// user-data: cloud-init for a VM whose guest agent (socialweb-computer-kcp socialweb-guest-agent) serves its // MCP shell. The bidder injects the agent when /etc/socialweb-guest-agent/services.json (or tunnels.json) // exists, and this writes them: shell, services.json's "shell" service (MCP over HTTP on a root-only unix // socket) and the "shell" datachannel tunnel to it; webrtc_ssh, the "ssh" tunnel to sshd; iroh, dumbpipe in // front of sshd, where the shell is `ssh root@guest socialweb-guest-agent relay `. ssh over either // is the irohUserData hook's (default iroh-user-data, atlibs-iroh), with these files appended. // tunnels, more datachannel tunnels to local TCP ports ({tag, port, transport: "webrtc"}), so a composition can reach // a service it starts on the VM (atirc's local listener, say). // user_data computer.socialweb.vm.userData {shell? (true), iroh?, webrtc_ssh?, ssh_key?, tunnels?} -> {user_data} use abi::{Call, Event, Handle, Run, entries}; use serde::Serialize; use serde_json::{Value, json}; pub const IROH_USER_DATA: &str = "computer.socialweb.vm.irohUserData"; const SERVICES_PATH: &str = "/etc/socialweb-guest-agent/services.json"; const TUNNELS_PATH: &str = "/etc/socialweb-guest-agent/tunnels.json"; const SHELL_SOCKET: &str = "/run/socialweb-guest-agent/shell.sock"; #[derive(Serialize)] struct WriteFile { path: &'static str, owner: &'static str, permissions: &'static str, content: String, } #[derive(Serialize)] struct Doc { write_files: Vec, } fn flag(args: &Value, key: &str, default: bool) -> Result { match &args[key] { Value::Null => Ok(default), Value::Bool(b) => Ok(*b), // a form's text Value::String(s) if s == "true" || s == "false" => Ok(s == "true"), _ => Err(format!("{key} must be a boolean")), } } fn json_file(path: &'static str, content: String) -> WriteFile { WriteFile { path, owner: "root:root", permissions: "0644", content: content + "\n" } } /// `tunnels`: [{tag, port, transport?}] -> tunnels.json entries; tags are [a-z0-9-], at most 32, not shell or ssh. fn extra(value: &Value) -> Result, String> { let list = match value { Value::Null => return Ok(vec![]), Value::Array(list) if list.len() <= 8 => list, _ => return Err("tunnels must be a list of at most 8 {tag, port, transport}".into()), }; let mut tags: Vec<&str> = vec![]; list.iter() .map(|t| { let tag = t["tag"].as_str().unwrap_or_default(); if tag.is_empty() || tag.len() > 32 || !tag.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') || ["shell", "ssh"].contains(&tag) || tags.contains(&tag) { return Err(format!("tunnels: tag {tag:?} must be [a-z0-9-], at most 32, unique, not shell or ssh")); } tags.push(tag); let port = t["port"].as_u64().filter(|p| (1..=65535).contains(p)).ok_or(format!("tunnels: {tag} needs a port 1-65535"))?; match &t["transport"] { Value::Null => {} Value::String(s) if s == "webrtc" => {} _ => return Err(format!("tunnels: {tag}: transport webrtc (the guest agent's datachannel tunnels)")), } Ok(format!(r#"{{"tag":"{tag}","port":{port}}}"#)) }) .collect() } /// services.json and tunnels.json, as the guest agent reads them. fn files(shell: bool, ssh: bool, more: Vec) -> Result, String> { let tunnels: Vec = [(shell, format!(r#"{{"tag":"shell","unix":"{SHELL_SOCKET}"}}"#)), (ssh, r#"{"tag":"ssh","port":22}"#.into())].into_iter().filter(|(on, _)| *on).map(|(_, t)| t).chain(more).collect(); if tunnels.is_empty() { return Err("the guest agent needs shell or webrtc_ssh".into()); } let services = shell.then(|| json_file(SERVICES_PATH, format!(r#"{{"shell":{{"unix":"{SHELL_SOCKET}"}}}}"#))); Ok(services.into_iter().chain([json_file(TUNNELS_PATH, format!(r#"{{"tunnels":[{}]}}"#, tunnels.join(",")))]).collect()) } /// `#cloud-config` and the document as JSON (JSON is YAML), one-space indent, as iroh-user-data writes it. fn cloud_config(doc: &impl Serialize) -> 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" ")); doc.serialize(&mut ser).expect("strings serialize"); out.push(b'\n'); String::from_utf8(out).expect("JSON is UTF-8") } fn handler(run: Run) -> Result { let a = &run.args; Ok(match run.event { Event::Start => { let (shell, iroh, ssh) = (flag(a, "shell", true)?, flag(a, "iroh", false)?, flag(a, "webrtc_ssh", false)?); let write_files = files(shell, ssh, extra(&a["tunnels"])?)?; if !(iroh || ssh) { return Ok(Handle::new().result(json!({ "user_data": cloud_config(&Doc { write_files }) }))); } let input = json!({ "ssh_key": a["ssh_key"], "dumbpipe": iroh, "write_files": write_files }); Handle::new().call(Call::new("iroh", IROH_USER_DATA).input(input)) } Event::Result { id, result, .. } if id == "iroh" => { let output = result.output().map_err(|e| format!("{IROH_USER_DATA}: {e}"))?; let user_data = output["user_data"].as_str().ok_or(format!("iroh and webrtc_ssh need a {IROH_USER_DATA} hook"))?; Handle::new().result(json!({ "user_data": user_data })) } _ => Handle::new(), }) } entries! { user_data => handler }