// iroh-user-data: cloud-init for a socialweb.computer guest that serves ssh over iroh, the guest half of // swck's irohModule (internal/atproto/cloudinit/modules.go). The bidder's onNetwork reporter reads // /run/guest-fqdn and publishes it as vm.onNetwork. // iroh_user_data computer.socialweb.vm.irohUserData {ssh_key, dumbpipe?, dumbpipe_version?, write_files?} // -> {user_data}: root ssh with that key, dumbpipe in front of sshd (dumbpipe: false: ssh // only), write_files appended after its own use abi::{Handle, Run, entries}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; /// swck cloudinit.DumbpipeVersionDefault pub const DUMBPIPE_VERSION: &str = "0.39.0"; const LISTENER: &str = r##"#!/bin/bash set -euo pipefail SECRET_FILE=/root/.iroh-secret if [ ! -s "${SECRET_FILE}" ]; then umask 077 head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > "${SECRET_FILE}" fi export IROH_SECRET="$(cat "${SECRET_FILE}")" LOG=/run/guest-iroh.log rm -f /run/guest-iroh-ticket "${LOG}" /usr/local/bin/dumbpipe listen-tcp --host 127.0.0.1:22 2>>"${LOG}" & PID=$! TICKET="" for _ in $(seq 1 150); do TICKET="$(grep -a 'connect-tcp' "${LOG}" 2>/dev/null | tail -n 1 | awk '{print $NF}')" || true [ -n "${TICKET}" ] && break kill -0 "${PID}" 2>/dev/null || break sleep 1 done if [ -n "${TICKET}" ]; then printf 'iroh://%s\n' "${TICKET}" > /run/guest-iroh-ticket printf 'iroh://%s\n' "${TICKET}" > /run/guest-fqdn.tmp mv -f /run/guest-fqdn.tmp /run/guest-fqdn fi wait "${PID}" "##; const SETUP: &str = r##"#!/bin/bash set -euo pipefail [ -x /usr/local/bin/dumbpipe ] && exit 0 case "$(uname -m)" in x86_64|amd64) A=x86_64 ;; aarch64|arm64) A=aarch64 ;; *) exit 1 ;; esac URL="https://github.com/n0-computer/dumbpipe/releases/download/v{version}/dumbpipe-v{version}-linux-${A}.tar.gz" T="$(mktemp -d)" for i in 1 2 3 4 5; do curl -fsSL "${URL}" | tar -xz -C "${T}" && install -m 0755 "${T}/dumbpipe" /usr/local/bin/dumbpipe && exit 0 sleep 5 done exit 1 "##; const UNIT: &str = "[Unit] Description=iroh dumbpipe listener in front of local sshd After=network-online.target Wants=network-online.target [Service] ExecStart=/usr/local/bin/iroh-listener.sh Restart=always RestartSec=5 [Install] WantedBy=multi-user.target "; const SSHD: &str = "systemctl enable --now --no-block ssh || systemctl enable --now --no-block sshd"; /// A cloud-config document; fields serialize in this order, empty ones left out (write_files always there). #[derive(Serialize, Default, Clone, Debug, PartialEq)] pub struct CloudConfig { #[serde(skip_serializing_if = "Vec::is_empty")] pub packages: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub disable_root: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ssh_pwauth: Option, pub write_files: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub runcmd: Vec, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct WriteFile { pub path: String, pub owner: String, pub permissions: String, pub content: String, } impl WriteFile { /// A root:root file. pub fn root(path: &str, content: impl Into, permissions: &str) -> Self { WriteFile { path: path.into(), owner: "root:root".into(), permissions: permissions.into(), content: content.into() } } } impl CloudConfig { /// As user_data: `#cloud-config` and the document as JSON (JSON is YAML), one-space indent. pub fn user_data(&self) -> String { let mut out = b"#cloud-config\n".to_vec(); let mut ser = serde_json::Serializer::with_formatter(&mut out, serde_json::ser::PrettyFormatter::with_indent(b" ")); self.serialize(&mut ser).expect("strings and bools serialize"); out.push(b'\n'); String::from_utf8(out).expect("JSON is UTF-8") } } /// Root ssh with this public key only (no passwords). pub fn ssh_config(pubkey: &str) -> CloudConfig { CloudConfig { packages: vec!["openssh-server".into()], disable_root: Some(false), ssh_pwauth: Some(false), write_files: vec![ WriteFile::root("/root/.ssh/authorized_keys", format!("{}\n", pubkey.trim()), "0600"), WriteFile::root("/etc/ssh/sshd_config.d/10-iroh.conf", "PermitRootLogin prohibit-password\nPasswordAuthentication no\n", "0644"), ], runcmd: vec![SSHD.into()], } } /// ssh_config plus the dumbpipe listener in front of sshd (its ticket becomes vm.onNetwork). pub fn iroh_config(pubkey: &str, version: &str) -> CloudConfig { let mut config = ssh_config(pubkey); config.write_files.extend([ WriteFile::root("/usr/local/bin/setup-iroh.sh", SETUP.replace("{version}", version), "0755"), WriteFile::root("/usr/local/bin/iroh-listener.sh", LISTENER, "0755"), WriteFile::root("/etc/systemd/system/iroh.service", UNIT, "0644"), ]); config.runcmd = ["/usr/local/bin/setup-iroh.sh", "systemctl daemon-reload", "systemctl enable --now --no-block iroh.service", SSHD].map(String::from).to_vec(); config } fn handler(run: Run) -> Result { let a = &run.args; let key = a["ssh_key"].as_str().map(str::trim).filter(|k| !k.contains(['\n', '\r']) && k.split_once(' ').is_some_and(|(t, b)| !t.is_empty() && !b.trim_start().is_empty())); let key = key.ok_or("ssh_key must be an OpenSSH public key")?; let version = match &a["dumbpipe_version"] { Value::Null => DUMBPIPE_VERSION, Value::String(v) if !v.is_empty() && v.len() <= 32 && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') => v, _ => return Err("dumbpipe_version must be a version like 0.39.0".into()), }; let mut config = match &a["dumbpipe"] { Value::Null | Value::Bool(true) => iroh_config(key, version), Value::Bool(false) => ssh_config(key), _ => return Err("dumbpipe must be a boolean".into()), }; if !a["write_files"].is_null() { config.write_files.extend(Vec::::deserialize(&a["write_files"]).map_err(|e| format!("write_files: {e}"))?); } Ok(Handle::new().result(json!({ "user_data": config.user_data() }))) } entries! { iroh_user_data => handler }