johnandersen777.bsky.social/atlibs-socialweb-computer-requester
socialweb.computer market requester and policy modules for atlibs, with its web pages and lexicons
0
0
// 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 }