~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-socialweb-computer-requester

socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons

0
0
main
112 lines · 5.5 KB Raw History
// 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 <socket>`. 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<WriteFile>,
}

fn flag(args: &Value, key: &str, default: bool) -> Result<bool, String> {
    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<Vec<String>, 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<String>) -> Result<Vec<WriteFile>, String> {
    let tunnels: Vec<String> = [(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<Value>) -> Result<Handle, String> {
    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 }