//! atlibs-iroh: com.fedfork.exec.iroh.connect and the iroh: scheme fetcher (HTTP/1.1 to a guest's unix //! socket over `ssh root@guest socialweb-guest-agent relay `) for atlibs hosts, on atlibs-host's //! transport seam (atlibs_host::transport) and nothing else of it. The atlibs CLI registers it behind its //! cargo feature `iroh` (default on). use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use atlibs_host::host::Response; use atlibs_host::run::{Call, Request, Sub, SubCmd}; use atlibs_host::transport::Transport; use atlibs_host::{BoxFut, Signal, js_string}; use serde_json::{Value, json}; pub const NSID: &str = "com.fedfork.exec.iroh.connect"; /// The URL schemes (no colon) the fetcher serves: Call::schemes (js/index.js fetchers' "iroh:"). pub const SCHEMES: &[&str] = &["iroh"]; /// The guest agent's shell service socket, the URL's socket "shell" (vm-mcp contract v2.2 §7). pub const SHELL_SOCKET: &str = "/run/socialweb-guest-agent/shell.sock"; // One request's cap (a guest exec runs up to 600 s); a response's head and body caps. const FETCH_MS: u64 = 660_000; const HEAD_MAX: usize = 1 << 16; pub const BODY_MAX: usize = 1 << 24; /// The transport: com.fedfork.exec.iroh.connect and the iroh: fetch with the host's ssh private key (a /// path; the key never enters wasm), shelling out to dumbpipe and ssh as js/index.js irohCall and irohFetch /// do. A connect request is a one-shot exchange (iroh), subscribe a long-lived session (iroh_session); a /// fetch goes over an idle Relay of its (ticket, hostKeys, socket), or a new one. pub struct Iroh { pub key: PathBuf, // the ssh binary ("ssh"; tests: js/fake-bin/ssh) ssh: String, // idle relays by " " relays: Mutex>>, } impl Iroh { pub fn new(key: PathBuf) -> Self { Self { key, ssh: "ssh".into(), relays: Default::default() } } fn input(&self, nsid: &str, input: Option) -> Result { if nsid != NSID { return Err(format!("{nsid} is not available on this host")); } let input = input.unwrap_or_else(|| json!({})); if input.get("alpn").is_none() && std::fs::metadata(&self.key).is_err() { return Err(format!("{nsid} ssh needs the host's ssh private key: set ATLIBS_IROH_KEY or put it at {}", self.key.display())); } Ok(input) } /// iroh:///// (js/index.js irohFetch): relays reused per (ref, socket) with /// HTTP keep-alive, one request in flight on each; a request whose relay failed before any response /// byte is retried once on a fresh one. Response headers: lowercase names, in order. pub async fn http(&self, method: &str, url: &str, headers: Vec<(String, String)>, body: Vec) -> Result { if !token(method) { return Err(format!("iroh: bad method {method}")); } let to = match raw_url(url) { Some(to) => to?, None => iroh_url(url)?, }; let mut sent = vec![]; for (k, v) in headers { if !token(&k) || v.contains(['\r', '\n', '\0']) { return Err(format!("iroh: bad header {k}")); } if !["host", "content-length", "transfer-encoding", "connection"].iter().any(|h| h.eq_ignore_ascii_case(&k)) { sent.push((k, v)); } } if !to.raw && std::fs::metadata(&self.key).is_err() { return Err(format!("iroh: needs the host's ssh private key: set ATLIBS_IROH_KEY or put it at {}", self.key.display())); } let name = if to.raw { format!("raw {}", to.ticket) } else { format!("{} {} {}", to.ticket, to.host_keys, to.socket) }; let mut fresh = false; loop { let idle = if fresh { None } else { self.idle(&name) }; let mut relay = match idle { Some(relay) => relay, None => Relay::open(&self.ssh, &self.key, &to)?, }; match tokio::time::timeout(Duration::from_millis(FETCH_MS), relay.request(method, &to.target, &sent, &body)).await { Err(_) => return Err(format!("iroh: no response in {} s", FETCH_MS / 1000)), Ok(Ok((response, keep))) => { if keep { self.relays.lock().unwrap().entry(name).or_default().push(relay); } return Ok(response); } Ok(Err(why)) => { let answered = relay.got; let why = relay.failed(why).await; if fresh || answered { return Err(why); } fresh = true; } } } } // An idle relay of name still running. fn idle(&self, name: &str) -> Option { let mut relays = self.relays.lock().unwrap(); let pool = relays.get_mut(name)?; while let Some(mut relay) = pool.pop() { if matches!(relay.child.try_wait(), Ok(None)) { return Some(relay); } } None } } impl Call for Iroh { fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result, String>> { Box::pin(async move { let input = self.input(&nsid, request.input)?; iroh(&self.key, &input).await.map(Some) }) } fn subscribe(&self, nsid: String, request: Request) -> BoxFut<'_, Result> { Box::pin(async move { let input = self.input(&nsid, request.input)?; iroh_session(&self.key, &input, request.signal).await }) } /// The iroh: scheme fetch (vm-mcp contract v2.2 §3): com.fedfork.exec.http.fetch to iroh:/// URLs. fn schemes(&self) -> &'static [&'static str] { SCHEMES } fn fetch(&self, method: &str, url: &str, headers: Vec<(String, String)>, body: Option>) -> BoxFut<'_, Result> { let (method, url) = (method.to_string(), url.to_string()); Box::pin(async move { self.http(&method, &url, headers, body.unwrap_or_default()).await }) } } /// The transport a host registers (atlibs_host::transport): NSID, and iroh: URLs for http.fetch /// (transport::fetchers), served with the ssh key at `key`. pub fn transport(key: PathBuf) -> Transport { Transport { provides: &[NSID], call: Arc::new(Iroh::new(key)) } } fn token(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+.^_`|~-".contains(c)) } /// Where an iroh: URL goes: the guest (ticket, pinned host keys), its unix socket, the HTTP request target. #[derive(Debug, PartialEq)] pub struct Target { pub ticket: String, pub host_keys: Value, pub socket: String, pub target: String, /// iroh:///: HTTP/1.1 over `dumbpipe connect ` itself (a market endpoint), no ssh. pub raw: bool, } /// iroh:/// (js/index.js rawUrl): a market endpoint's short ticket; None for any other URL. pub fn raw_url(url: &str) -> Option> { let rest = url.strip_prefix("iroh://")?.split('#').next().unwrap_or_default(); let (t, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); let body = t.strip_prefix("endpoint")?; if body.is_empty() || t.len() > 4096 || !body.bytes().all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)) { return None; } let target = if path.is_empty() { "/".to_string() } else { path.to_string() }; if target.bytes().any(|b| b <= 0x20 || b == 0x7f) { return Some(Err(format!("{url}: the path has spaces or control characters"))); } Some(Ok(Target { ticket: t.to_string(), host_keys: json!([]), socket: String::new(), target, raw: true })) } /// iroh://///: ref base64url JSON {ticket, hostKeys}; socket "shell" (SHELL_SOCKET) or a /// percent-encoded absolute unix path; target "/" + path, query kept (js/index.js irohUrl). pub fn iroh_url(url: &str) -> Result { use base64::Engine; let bad = || format!("not an iroh:///// URL: {url}"); let rest = url.strip_prefix("iroh://").ok_or_else(bad)?; let rest = rest.strip_prefix('/').unwrap_or(rest).split('#').next().unwrap_or_default(); let (r, rest) = rest.split_once('/').ok_or_else(bad)?; let (sock, target) = rest.split_at(rest.find(['/', '?']).unwrap_or(rest.len())); let b64 = r.trim_end_matches('='); if b64.is_empty() || r.len() - b64.len() > 2 || sock.is_empty() || !b64.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { return Err(bad()); } let refs: Value = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(b64) .ok() .and_then(|j| serde_json::from_slice(&j).ok()) .ok_or_else(|| format!("{url}: the ref is not base64url JSON"))?; let socket = match sock { "shell" => SHELL_SOCKET.to_string(), s => percent_encoding::percent_decode_str(s).decode_utf8().map_err(|_| format!("{url}: bad percent-encoding in the socket"))?.into_owned(), }; if !socket.starts_with('/') || socket.chars().any(|c| c.is_ascii_control()) { return Err(format!("{url}: the socket must be \"shell\" or an absolute unix path")); } let target = if target.starts_with('/') { target.to_string() } else { format!("/{target}") }; if target.bytes().any(|b| b <= 0x20 || b == 0x7f) { return Err(format!("{url}: the path has spaces or control characters")); } Ok(Target { ticket: ticket(refs.get("ticket"))?, host_keys: refs.get("hostKeys").cloned().unwrap_or(json!([])), socket, target, raw: false }) } /// The guest's login shell parses ssh's command: the path single-quoted. pub fn relay_command(path: &str) -> String { format!("socialweb-guest-agent relay '{}'", path.replace('\'', "'\\''")) } // A read that hit the relay's end (stdout closed): failed() reports its exit instead. const EOF: &str = "eof"; /// One `ssh root@guest socialweb-guest-agent relay ` through dumbpipe (js/index.js relay): an /// HTTP/1.1 connection to the guest's unix socket, one request at a time (its holder's). Dropped: ssh is /// killed (kill_on_drop), and its temp dir removed. struct Relay { child: tokio::process::Child, stdin: tokio::process::ChildStdin, stdout: tokio::io::BufReader, stderr: Arc>, errors: tokio::task::JoinHandle<()>, // a response byte arrived for the current request got: bool, _dir: Option, } fn io(e: std::io::Error) -> String { if e.kind() == std::io::ErrorKind::UnexpectedEof { EOF.into() } else { e.to_string() } } impl Relay { fn open(ssh: &str, key: &Path, to: &Target) -> Result { use tokio::io::AsyncReadExt; let (cmd, args, dir) = if to.raw { ("dumbpipe", vec!["connect".to_string(), to.ticket.clone()], None) } else { let (_, args, dir) = session_command(key, &json!({ "ticket": to.ticket, "hostKeys": to.host_keys, "command": relay_command(&to.socket) }))?; (ssh, args, dir) }; let mut child = iroh_command(cmd, &args).spawn().map_err(|e| format!("spawn {cmd} {e}"))?; let (stdin, stdout, mut err) = (child.stdin.take().expect("piped"), child.stdout.take().expect("piped"), child.stderr.take().expect("piped")); let stderr = Arc::new(Mutex::new(String::new())); let tail_of = stderr.clone(); let errors = tokio::spawn(async move { let (mut buf, mut carry) = (vec![0u8; 8192], Vec::new()); while let Ok(n @ 1..) = err.read(&mut buf).await { let mut text = tail_of.lock().unwrap(); text.push_str(&decode_utf8(&mut carry, &buf[..n])); tail(&mut text); } }); Ok(Self { child, stdin, stdout: tokio::io::BufReader::new(stdout), stderr, errors, got: false, _dir: dir }) } // A CRLF-terminated line, without it. async fn line(&mut self) -> Result { use tokio::io::{AsyncBufReadExt, AsyncReadExt}; let mut line = Vec::new(); (&mut self.stdout).take(HEAD_MAX as u64 + 2).read_until(b'\n', &mut line).await.map_err(io)?; self.got |= !line.is_empty(); if !line.ends_with(b"\r\n") { return Err(if line.len() > HEAD_MAX { format!("iroh: a response line over {HEAD_MAX} bytes") } else { EOF.into() }); } line.truncate(line.len() - 2); Ok(String::from_utf8_lossy(&line).into_owned()) } async fn take(&mut self, n: usize) -> Result, String> { use tokio::io::AsyncReadExt; let mut bytes = vec![0u8; n]; self.stdout.read_exact(&mut bytes).await.map_err(io)?; Ok(bytes) } // -> (response, keep): keep, the connection can take another request async fn request(&mut self, method: &str, target: &str, headers: &[(String, String)], body: &[u8]) -> Result<(Response, bool), String> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; self.got = false; let mut head = format!("{method} {target} HTTP/1.1\r\nhost: guest\r\n"); for (k, v) in headers { head.push_str(&format!("{k}: {v}\r\n")); } head.push_str(&format!("content-length: {}\r\n\r\n", body.len())); let mut bytes = head.into_bytes(); bytes.extend_from_slice(body); self.stdin.write_all(&bytes).await.map_err(io)?; self.stdin.flush().await.map_err(io)?; let (minor, status, fields) = loop { let line = self.line().await?; let status = line.strip_prefix("HTTP/1.").and_then(|s| Some((s.get(..1)?.to_string(), s.get(2..5)?.parse::().ok()?))); let Some((minor, status)) = status.filter(|_| line.as_bytes().get(8) == Some(&b' ')) else { return Err("iroh: not an HTTP/1.x response".into()); }; let (mut fields, mut size) = (vec![], 0); loop { let line = self.line().await?; if line.is_empty() { break; } size += line.len(); if size > HEAD_MAX { return Err(format!("iroh: response headers over {HEAD_MAX} bytes")); } if let Some((k, v)) = line.split_once(':').filter(|(k, _)| !k.trim().is_empty()) { fields.push((k.trim().to_ascii_lowercase(), v.trim().to_string())); } } if !(100..200).contains(&status) { break (minor, status, fields); } }; let field = |name: &str| fields.iter().filter(|(k, _)| k == name).map(|(_, v)| v.as_str()).collect::>().join(", "); let has = |name: &str, word: &str| field(name).split(',').any(|w| w.trim().eq_ignore_ascii_case(word)); let mut keep = minor == "1" && !has("connection", "close"); let body = if method == "HEAD" || status == 204 || status == 304 { vec![] } else if has("transfer-encoding", "chunked") { let mut body = vec![]; loop { let size = self.line().await?; let size = size.split(';').next().unwrap_or_default().trim(); let n = Some(size).filter(|s| (1..=8).contains(&s.len())).and_then(|s| usize::from_str_radix(s, 16).ok()); let Some(n) = n.filter(|n| body.len() + n <= BODY_MAX) else { return Err(format!("iroh: a bad chunk size, or a response body over {BODY_MAX} bytes")); }; if n == 0 { break; } body.extend(self.take(n).await?); self.line().await?; } while !self.line().await?.is_empty() {} // trailers body } else if !field("content-length").is_empty() { let n = field("content-length").split(',').next().unwrap_or_default().trim().parse::().ok().filter(|n| *n <= BODY_MAX); self.take(n.ok_or_else(|| format!("iroh: a bad content-length, or a response body over {BODY_MAX} bytes"))?).await? } else { keep = false; let mut body = vec![]; (&mut self.stdout).take(BODY_MAX as u64 + 1).read_to_end(&mut body).await.map_err(io)?; if body.len() > BODY_MAX { return Err(format!("iroh: a response body over {BODY_MAX} bytes")); } body }; Ok((Response { status, headers: fields, body }, keep)) } // The error for a failed request: the relay stopped, its exit (at its end) and stderr tail added. async fn failed(mut self, why: String) -> String { let why = match why.as_str() { EOF => match tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await { Ok(Ok(status)) => format!("exit {}", status.code().map_or("by a signal".into(), |c| c.to_string())), _ => "its output ended".into(), }, _ => why, }; stop(&mut self.child).await; let _ = tokio::time::timeout(Duration::from_millis(500), &mut self.errors).await; let stderr = self.stderr.lock().unwrap().trim().to_string(); format!("iroh: the relay ended ({why}){}", if stderr.is_empty() { String::new() } else { format!(": {stderr}") }) } } /// vm.onNetwork address -> dumbpipe ticket. pub fn ticket(address: Option<&Value>) -> Result { let raw = match address { None | Some(Value::Null) => String::new(), Some(v) => js_string(Some(v)), }; let t = raw.trim().trim_start_matches("iroh://").to_string(); if t.is_empty() || t.len() > 4096 || !t .bytes() .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) { return Err(format!("not an iroh ticket: {}", js_string(address))); } Ok(t) } /// vm.hostKeys -> known_hosts lines (bare base64 with a type, or authorized_keys strings). pub fn known_hosts(alias: &str, host_keys: Option<&Value>) -> Result { let lines: Vec = host_keys .and_then(Value::as_array) .map(|keys| { keys.iter() .map(|k| { let text = k .as_str() .map(String::from) .unwrap_or_else(|| js_string(k.get("publicKey"))); let f: Vec<&str> = text.split_whitespace().collect(); if f.len() > 1 { f[..2].join(" ") } else { format!("{} {}", js_string(k.get("type")), f.first().unwrap_or(&"")) } }) .collect() }) .unwrap_or_default(); if lines.is_empty() { return Err("no host keys: refusing to trust an unpinned guest".into()); } Ok(lines.iter().map(|l| format!("{alias} {l}\n")).collect()) } // stderr is kept only for an error message: its last STDERR_MAX bytes, however long the process runs. const STDERR_MAX: usize = 16384; // A one-shot exchange keeps at most OUTPUT_MAX bytes of stdout (half a module's 1 MiB input, room for the // event around it): past it the process is killed and the result says truncated. pub const OUTPUT_MAX: usize = 1 << 19; fn random_hex() -> String { let mut bytes = [0u8; N]; rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut bytes); bytes.iter().map(|b| format!("{b:02x}")).collect() } fn tail(text: &mut String) { if text.len() > STDERR_MAX { let mut cut = text.len() - STDERR_MAX; while !text.is_char_boundary(cut) { cut += 1; } text.drain(..cut); } } // dumbpipe without IROH_SECRET makes a key and prints "using secret key " to stderr, which would // reach the module. A fresh key per process (ssh's ProxyCommand inherits the env) keeps it quiet. // The rest of the environment stays out (ATPROTO_PASSWORD, SSH_AUTH_SOCK, ...): PATH, HOME and LANG only. fn iroh_command(cmd: &str, args: &[String]) -> tokio::process::Command { let mut command = tokio::process::Command::new(cmd); command.env_clear(); for key in ["PATH", "HOME", "LANG"] { if let Some(value) = std::env::var_os(key) { command.env(key, value); } } command .args(args) .env("IROH_SECRET", random_hex::<32>()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); command } /// A temp dir removed when dropped: on every exit path, and when the runtime drops the task. #[derive(Debug)] struct TempDir(PathBuf); impl TempDir { fn new() -> Result { let dir = std::env::temp_dir().join(format!("iroh-ssh-{}", random_hex::<16>())); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(Self(dir)) } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } // SIGTERM (ssh then takes its dumbpipe ProxyCommand down with it), SIGKILL if still up after 2 s. // ponytail: `kill -TERM` as a process, so no libc dependency for one signal. async fn stop(child: &mut tokio::process::Child) { if let Some(pid) = child.id() { let _ = tokio::process::Command::new("kill").args(["-TERM", &pid.to_string()]).status().await; if tokio::time::timeout(Duration::from_secs(2), child.wait()).await.is_ok() { return; } } let _ = child.start_kill(); let _ = child.wait().await; } /// What a one-shot exchange read: code None when it was killed (`timed_out` after ms, `truncated` at OUTPUT_MAX). struct Exchanged { stdout: String, stderr: String, code: Option, timed_out: bool, truncated: bool, } // Runs cmd with input on stdin; on timeout, or past OUTPUT_MAX bytes of stdout, the process is killed and // what it printed (up to OUTPUT_MAX) is kept. async fn exchange(cmd: &str, args: &[String], input: &str, ms: u64) -> Result { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut child = iroh_command(cmd, args).spawn().map_err(|e| format!("spawn {cmd} {e}"))?; let mut stdin = child.stdin.take().expect("piped"); let input = input.to_string(); tokio::spawn(async move { let _ = stdin.write_all(input.as_bytes()).await; // the peer may close before reading everything }); let (mut out_pipe, mut err_pipe) = (child.stdout.take().expect("piped"), child.stderr.take().expect("piped")); let (mut stdout, mut stderr, mut truncated) = (Vec::new(), String::new(), false); let read = async { let (mut out_buf, mut err_buf, mut open) = (vec![0u8; 8192], vec![0u8; 8192], (true, true)); let mut carry = Vec::new(); while open.0 || open.1 { tokio::select! { n = out_pipe.read(&mut out_buf), if open.0 => match n { Ok(n) if n > 0 => { stdout.extend_from_slice(&out_buf[..n]); if stdout.len() > OUTPUT_MAX { stdout.truncate(OUTPUT_MAX); truncated = true; let _ = child.start_kill(); break; } } _ => open.0 = false, }, n = err_pipe.read(&mut err_buf), if open.1 => match n { Ok(n) if n > 0 => { stderr.push_str(&decode_utf8(&mut carry, &err_buf[..n])); tail(&mut stderr); } _ => open.1 = false, }, } } child.wait().await.map_err(|e| e.to_string()) }; let (code, timed_out) = match tokio::time::timeout(Duration::from_millis(ms), read).await { Ok(status) => (status?.code(), false), Err(_) => { let _ = child.start_kill(); let _ = child.wait().await; (None, true) } }; Ok(Exchanged { stdout: String::from_utf8_lossy(&stdout).into(), stderr, code, timed_out, truncated }) } pub fn ssh_args(key: &Path, ticket: &str, known_hosts_file: &Path, command: &str) -> Vec { [ // no ~/.ssh/config or system ssh_config: no ForwardAgent, RemoteForward, LocalCommand or ControlMaster // toward a guest the bidder controls; only these options "-F", "/dev/null", "-i", &key.display().to_string(), "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", &format!("ProxyCommand=dumbpipe connect {ticket}"), "-o", "HostKeyAlias=guest", "-o", &format!("UserKnownHostsFile={}", known_hosts_file.display()), "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=60", "--", // ssh re-parses options after the host otherwise: a command "-oProxyCommand=..." would run locally "root@guest", command, ] .iter() .map(|s| s.to_string()) .collect() } /// ssh only: what the command reads on stdin. Secrets go here, never in `command`: the command is /// an argument of ssh on the host and of the login shell on the guest, so `ps` shows it on both. fn ssh_stdin(input: &Value) -> Result { match input.get("stdin") { None | Some(Value::Null) => Ok(String::new()), Some(Value::String(s)) if s.len() <= 65536 => Ok(s.clone()), Some(_) => Err("stdin must be a string of at most 65536 bytes".into()), } } // The connect input -> (cmd, args, temp dir): dumbpipe for a raw ALPN, else ssh through dumbpipe with // the host keys pinned (command optional for a session: none is a remote shell reading stdin). fn session_command( key: &Path, input: &Value, ) -> Result<(&'static str, Vec, Option), String> { let t = ticket(input.get("ticket"))?; if let Some(alpn) = input.get("alpn") { let alpn = alpn .as_str() .filter(|a| { (1..=64).contains(&a.len()) && a.bytes().all(|b| (0x21..=0x7e).contains(&b)) }) .ok_or("alpn must be 1 to 64 printable ASCII bytes")?; let args = vec!["connect".into(), "--custom-alpn".into(), format!("utf8:{alpn}"), t]; return Ok(("dumbpipe", args, None)); } let command = match input.get("command") { None => None, Some(Value::String(c)) if !c.is_empty() => Some(c.as_str()), Some(_) => return Err("command must be a non-empty string".into()), }; let lines = known_hosts("guest", input.get("hostKeys"))?; let dir = TempDir::new()?; std::fs::write(dir.0.join("known_hosts"), lines).map_err(|e| e.to_string())?; let mut args = ssh_args(key, &t, &dir.0.join("known_hosts"), command.unwrap_or_default()); if command.is_none() { args.pop(); } Ok(("ssh", args, Some(dir))) } // Streaming UTF-8: the valid prefix of carry + bytes; an incomplete trailing sequence waits for more. fn decode_utf8(carry: &mut Vec, bytes: &[u8]) -> String { carry.extend_from_slice(bytes); let valid = match std::str::from_utf8(carry) { Ok(_) => carry.len(), Err(e) if e.error_len().is_none() => e.valid_up_to(), Err(_) => { let text = String::from_utf8_lossy(carry).into_owned(); carry.clear(); return text; } }; let text = String::from_utf8_lossy(&carry[..valid]).into_owned(); carry.drain(..valid); text } /// com.fedfork.exec.iroh.connect with subscribe: true: the one-shot input, /// but the process stays up. Each stdout / stderr chunk is a message { stdout } / { stderr }, the exit /// a last message { exitCode }, then the stream ends; it fails instead when ssh itself fails (255) or /// dumbpipe exits non-zero having printed nothing. Send writes stdin (a string as is, else JSON); /// Close and the signal kill the process. Same as js/index.js irohSession. pub async fn iroh_session(key: &Path, input: &Value, signal: Signal) -> Result { use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::mpsc; let (cmd, args, dir) = session_command(key, input)?; let mut child = iroh_command(cmd, &args).spawn().map_err(|e| format!("spawn {cmd} {e}"))?; let (msg_tx, messages) = mpsc::unbounded_channel(); let (commands, mut cmd_rx) = mpsc::unbounded_channel::(); let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<(&'static str, Vec)>(); let readers: [(&'static str, Option>); 2] = [ ("stdout", child.stdout.take().map(|r| Box::new(r) as _)), ("stderr", child.stderr.take().map(|r| Box::new(r) as _)), ]; for (name, reader) in readers { let (Some(mut reader), tx) = (reader, chunk_tx.clone()) else { continue }; tokio::spawn(async move { let mut buf = vec![0u8; 8192]; while let Ok(n) = reader.read(&mut buf).await { if n == 0 || tx.send((name, buf[..n].to_vec())).is_err() { break; } } }); } drop(chunk_tx); // stdin has its own writer: a write blocked on a peer that stopped reading never holds up Close let (write_tx, mut write_rx) = mpsc::unbounded_channel::<(String, tokio::sync::oneshot::Sender>)>(); if let Some(mut w) = child.stdin.take() { tokio::spawn(async move { while let Some((data, ack)) = write_rx.recv().await { let sent = match w.write_all(data.as_bytes()).await { Ok(()) => w.flush().await.map_err(|e| e.to_string()), Err(e) => Err(e.to_string()), }; let _ = ack.send(sent); } }); } let stdin = Some(write_tx); tokio::spawn(async move { let _dir = dir; // removed when this task ends, or is dropped with the runtime let (mut out_carry, mut err_carry) = (Vec::new(), Vec::new()); let (mut printed, mut killed, mut stderr) = (false, false, String::new()); loop { tokio::select! { chunk = chunk_rx.recv() => { let Some((name, bytes)) = chunk else { break }; let text = if name == "stdout" { printed = true; decode_utf8(&mut out_carry, &bytes) } else { let text = decode_utf8(&mut err_carry, &bytes); stderr.push_str(&text); tail(&mut stderr); text }; if !text.is_empty() { let _ = msg_tx.send(Ok(json!({ name: text }))); } } command = cmd_rx.recv() => match command { Some(SubCmd::Send(data, ack)) => { let data = match data { Value::String(s) => s, other => other.to_string() }; let refused = match &stdin { Some(w) => w.send((data, ack)).err().map(|e| e.0.1), None => Some(ack) }; if let Some(ack) = refused { let _ = ack.send(Err(format!("{NSID} session is closed"))); } } Some(SubCmd::Close) | None => { killed = true; break; } }, _ = signal.aborted() => { killed = true; break; } } } if killed { drop(stdin); stop(&mut child).await; } else { drop(stdin); let code = child.wait().await.ok().and_then(|s| s.code()); let message = match (cmd, code) { ("ssh", Some(255)) => Err(format!("ssh failed: {}", stderr.trim())), ("dumbpipe", Some(c)) if c != 0 && !printed => { Err(format!("dumbpipe exited {c}: {}", stderr.trim())) } _ => Ok(json!({ "exitCode": code })), }; let _ = msg_tx.send(message); } }); Ok(Sub { messages, commands }) } async fn iroh(key: &Path, input: &Value) -> Result { let t = ticket(input.get("ticket"))?; if let Some(alpn) = input.get("alpn") { let alpn = alpn .as_str() .filter(|a| { (1..=64).contains(&a.len()) && a.bytes().all(|b| (0x21..=0x7e).contains(&b)) }) .ok_or("alpn must be 1 to 64 printable ASCII bytes")?; let send = input .get("send") .map(|s| js_string(Some(s))) .unwrap_or_default(); let args = ["connect".into(), "--custom-alpn".into(), format!("utf8:{alpn}"), t]; let out = exchange("dumbpipe", &args, &send, 30_000).await?; if let Some(c) = out.code.filter(|c| *c != 0) && out.stdout.is_empty() { return Err(format!("dumbpipe exited {c}: {}", out.stderr.trim())); } // a peer that never closes, or sends too much: what it sent so far, marked let mut output = json!({ "output": out.stdout }); if out.truncated { output["truncated"] = json!(true); } else if out.timed_out { output["timedOut"] = json!(true); } return Ok(output); } let command = input .get("command") .and_then(Value::as_str) .filter(|c| !c.is_empty()) .ok_or("command is required for ssh")?; let stdin = ssh_stdin(input)?; let dir = TempDir::new()?; std::fs::write(dir.0.join("known_hosts"), known_hosts("guest", input.get("hostKeys"))?) .map_err(|e| e.to_string())?; let out = exchange("ssh", &ssh_args(key, &t, &dir.0.join("known_hosts"), command), &stdin, 120_000).await?; if out.truncated { return Ok(json!({ "output": out.stdout, "truncated": true })); } // 255 is ssh's own failure (host key mismatch, auth, dial); None is the timeout. match out.code { Some(255) => Err(format!("ssh failed: {}", out.stderr.trim())), None => Err(format!("ssh timed out: {}", out.stderr.trim())), Some(code) => Ok(json!({ "output": out.stdout, "exitCode": code })), } } #[cfg(test)] mod tests { use super::*; // The loopback rule: real dumbpipe, a local HTTP service behind `dumbpipe listen-tcp`, the raw fetch (no // ssh, no key) over `dumbpipe connect `; skipped without dumbpipe on PATH. #[tokio::test] async fn a_market_endpoint_fetch_goes_over_raw_dumbpipe() { use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; if std::process::Command::new("dumbpipe").arg("--version").output().is_err() { eprintln!("skipped: no dumbpipe"); return; } let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { while let Ok((mut tcp, _)) = listener.accept().await { tokio::spawn(async move { let mut buf = vec![0u8; 8192]; let mut got = Vec::new(); while !String::from_utf8_lossy(&got).contains("{\"a\":1}") { let n = tcp.read(&mut buf).await.unwrap_or(0); if n == 0 { return; } got.extend_from_slice(&buf[..n]); } let text = String::from_utf8_lossy(&got).to_string(); let body = json!({ "line": text.lines().next(), "auth": text.contains("authorization: Bearer jwt1") }).to_string(); let _ = tcp.write_all(format!("HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", body.len()).as_bytes()).await; }); } }); let mut listen = iroh_command("dumbpipe", &["listen-tcp".into(), "--host".into(), format!("127.0.0.1:{port}")]).spawn().unwrap(); let mut lines = tokio::io::BufReader::new(listen.stderr.take().unwrap()).lines(); let ticket = loop { let line = lines.next_line().await.unwrap().expect("dumbpipe printed no ticket"); if let Some(t) = line.split_whitespace().find(|w| w.starts_with("endpoint")) { break t.to_string(); } }; let iroh = Iroh::new("/nonexistent".into()); let url = format!("iroh://{ticket}/xrpc/com.publicdomainrelay.temp.market.submitRfp"); let headers = vec![("authorization".to_string(), "Bearer jwt1".to_string()), ("content-type".into(), "application/json".into())]; let response = iroh.http("POST", &url, headers, br#"{"a":1}"#.to_vec()).await.unwrap(); let answer: Value = serde_json::from_slice(&response.body).unwrap(); assert_eq!((response.status, answer), (200, json!({ "line": "POST /xrpc/com.publicdomainrelay.temp.market.submitRfp HTTP/1.1", "auth": true }))); let _ = listen.kill().await; } #[test] fn tickets_and_known_hosts() { assert_eq!(ticket(Some(&json!("iroh://abc123\n"))).unwrap(), "abc123"); assert!(ticket(Some(&json!("ABC"))).is_err()); assert_eq!( known_hosts( "guest", Some(&json!([{ "type": "ssh-ed25519", "publicKey": "AAAA" }, "ssh-rsa BBBB c"])) ) .unwrap(), "guest ssh-ed25519 AAAA\nguest ssh-rsa BBBB\n" ); assert!(known_hosts("guest", Some(&json!([]))).is_err()); } #[tokio::test] async fn ssh_secrets_go_over_stdin() { assert_eq!(ssh_stdin(&json!({})).unwrap(), ""); assert_eq!(ssh_stdin(&json!({ "stdin": "tok\n" })).unwrap(), "tok\n"); assert!(ssh_stdin(&json!({ "stdin": 5 })).is_err()); assert!(ssh_stdin(&json!({ "stdin": "x".repeat(65537) })).is_err()); // what the guest runs: a builtin read, so the token is in no process's argv let args = ["-c".to_string(), "IFS= read -r t; printf 'got:%s' \"$t\"".to_string()]; let out = exchange("sh", &args, "tok\n", 5000).await.unwrap(); assert_eq!((out.stdout.as_str(), out.code), ("got:tok", Some(0))); } #[tokio::test] async fn exchanges_keep_partial_output_on_timeout_a_stderr_tail_and_a_fresh_iroh_secret() { let sh = |script: &str| ["-c".to_string(), script.to_string()]; let out = exchange("sh", &sh("printf partial; sleep 5"), "", 300).await.unwrap(); assert_eq!((out.stdout.as_str(), out.code, out.timed_out), ("partial", None, true)); let out = exchange("sh", &sh("head -c 1000000 /dev/zero | tr '\\0' x >&2; printf '\\nlast' >&2; exit 1"), "", 5000).await.unwrap(); assert_eq!((out.stderr.len(), out.code), (STDERR_MAX, Some(1))); assert!(out.stderr.ends_with("x\nlast")); let out = exchange("sh", &sh("printf %s \"$IROH_SECRET\""), "", 5000).await.unwrap(); assert!(out.stdout.len() == 64 && out.stdout.bytes().all(|b| b.is_ascii_hexdigit()), "{}", out.stdout); assert_ne!(exchange("sh", &sh("printf %s \"$IROH_SECRET\""), "", 5000).await.unwrap().stdout, out.stdout, "one per process"); } #[tokio::test] async fn children_get_a_cleared_env_and_at_most_output_max_of_stdout() { // env itself (no shell adding PWD, SHLVL): this test process's ATPROTO_*, CARGO_*, SSH_AUTH_SOCK stay out let out = exchange("/usr/bin/env", &[], "", 5000).await.unwrap(); let keys: Vec<&str> = out.stdout.lines().filter_map(|l| l.split_once('=').map(|(k, _)| k)).collect(); assert!(keys.contains(&"IROH_SECRET") && keys.iter().all(|k| ["PATH", "HOME", "LANG", "IROH_SECRET"].contains(k)), "{keys:?}"); let sh = |script: &str| ["-c".to_string(), script.to_string()]; let out = exchange("sh", &sh("head -c 10000000 /dev/zero; sleep 5"), "", 5000).await.unwrap(); assert_eq!((out.stdout.len(), out.truncated, out.timed_out), (OUTPUT_MAX, true, false)); let out = exchange("sh", &sh(&format!("head -c {OUTPUT_MAX} /dev/zero")), "", 5000).await.unwrap(); assert_eq!((out.stdout.len(), out.truncated, out.code), (OUTPUT_MAX, false, Some(0)), "exactly the cap is all of it"); } #[test] fn utf8_split_across_chunks_waits_for_the_rest() { let mut carry = vec![]; let bytes = "héllo".as_bytes(); assert_eq!(decode_utf8(&mut carry, &bytes[..2]), "h"); assert_eq!(decode_utf8(&mut carry, &bytes[2..]), "éllo"); assert!(carry.is_empty()); } #[test] fn session_command_matches_the_one_shot_inputs() { let t = "abc123"; let (cmd, args, dir) = session_command(Path::new("/k"), &json!({ "ticket": t, "alpn": "echo/1" })).unwrap(); assert_eq!((cmd, args, dir.is_none()), ("dumbpipe", vec!["connect".to_string(), "--custom-alpn".into(), "utf8:echo/1".into(), t.into()], true)); let keys = json!([{ "type": "ssh-ed25519", "publicKey": "AAAA" }]); let (cmd, args, dir) = session_command(Path::new("/k"), &json!({ "ticket": t, "hostKeys": keys })).unwrap(); assert_eq!(cmd, "ssh"); assert_eq!(args.last().map(String::as_str), Some("root@guest"), "no command: a remote shell"); let dir = dir.unwrap(); assert!(std::fs::read_to_string(dir.0.join("known_hosts")).unwrap().starts_with("guest ssh-ed25519 AAAA")); let path = dir.0.clone(); drop(dir); assert!(!path.exists(), "the temp dir goes with its guard"); assert_eq!(session_command(Path::new("/k"), &json!({ "ticket": t })).unwrap_err(), "no host keys: refusing to trust an unpinned guest"); assert_eq!(session_command(Path::new("/k"), &json!({ "ticket": t, "hostKeys": keys, "command": "" })).unwrap_err(), "command must be a non-empty string"); } // js/fetch.test.mjs's cases against the same stand-ins (js/fake-bin/ssh runs relay.mjs; http-stub.mjs) #[tokio::test] async fn fetches_reuse_relays_open_more_for_concurrency_and_retry_once() { use tokio::io::AsyncBufReadExt; let dir = TempDir::new().unwrap(); let key = dir.0.join("key"); std::fs::write(&key, "not a real key\n").unwrap(); let sock = dir.0.join("it's.sock").display().to_string(); let fake = concat!(env!("CARGO_MANIFEST_DIR"), "/js/fake-bin"); let mut stub = tokio::process::Command::new("node").args([format!("{fake}/http-stub.mjs"), sock.clone()]).stdout(std::process::Stdio::piped()).kill_on_drop(true).spawn().unwrap(); tokio::io::BufReader::new(stub.stdout.take().unwrap()).read_line(&mut String::new()).await.unwrap(); let iroh = Iroh { key, ssh: format!("{fake}/ssh"), relays: Default::default() }; let refs = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, json!({ "ticket": "abc123", "hostKeys": [{ "type": "ssh-ed25519", "publicKey": "AAAAC3Bare" }] }).to_string()); let base = format!("iroh:///{refs}/{}", percent_encoding::utf8_percent_encode(&sock, percent_encoding::NON_ALPHANUMERIC)); let shared = &iroh; let get = |path: &str| { let url = format!("{base}{path}"); async move { shared.http("GET", &url, vec![], vec![]).await } }; let body = |r: Response| serde_json::from_slice::(&r.body).unwrap(); let idle = |iroh: &Iroh| iroh.relays.lock().unwrap().values().map(Vec::len).sum::(); let posted = iroh.http("POST", &format!("{base}/mcp"), vec![("content-type".into(), "application/json".into()), ("Host".into(), "evil".into())], b"{}".to_vec()).await.unwrap(); assert_eq!(posted.status, 200); assert!(posted.headers.contains(&("content-type".into(), "application/json".into()))); let posted = body(posted); assert_eq!((posted["method"].clone(), posted["url"].clone(), posted["body"].clone(), posted["headers"]["host"].clone(), posted["n"].clone()), (json!("POST"), json!("/mcp"), json!("{}"), json!("guest"), json!(1))); assert_eq!(body(get("/x").await.unwrap())["n"], 2, "keep-alive: the same relay"); // the host's way in: transport::fetchers (run::Http::schemes) -> Transport -> Call::fetch, its own relays let host = atlibs_host::transport::fetchers(&[Transport { provides: &[NSID], call: Arc::new(Iroh { key: iroh.key.clone(), ssh: iroh.ssh.clone(), relays: Default::default() }) }]); let via = host["iroh:"].fetch("PUT", &format!("{base}/put"), vec![], Some(b"x".to_vec())).await.ok().unwrap(); assert_eq!((body(via)["method"].clone(), host.len()), (json!("PUT"), 1)); let (slow, fast) = tokio::join!(get("/slow?ms=300"), get("/fast?chunked=1")); let (slow, fast) = (body(slow.unwrap()), body(fast.unwrap())); assert_eq!((slow["url"].clone(), fast["url"].clone()), (json!("/slow?ms=300"), json!("/fast?chunked=1"))); assert_eq!(idle(&iroh), 2, "a second relay for the second in-flight request"); assert_eq!(get("/c?close=1").await.unwrap().status, 200); assert_eq!(idle(&iroh), 1, "connection: close is not kept"); let marker = dir.0.join("died").display().to_string(); let again = get(&format!("/mcp?die={}", percent_encoding::utf8_percent_encode(&marker, percent_encoding::NON_ALPHANUMERIC))).await.unwrap(); assert_eq!(body(again)["n"], 1, "retried on a fresh relay"); assert!(std::path::Path::new(&marker).exists()); let absent = format!("iroh:///{refs}/{}/", percent_encoding::utf8_percent_encode(&format!("{}/absent.sock", dir.0.display()), percent_encoding::NON_ALPHANUMERIC)); let failed = iroh.http("GET", &absent, vec![], vec![]).await.err().unwrap(); assert!(failed.starts_with("iroh: the relay ended (exit 1)") && failed.contains("ENOENT"), "{failed}"); assert!(iroh.http("G T", &base, vec![], vec![]).await.err().unwrap().contains("bad method")); assert!(iroh.http("GET", &format!("{base}/"), vec![("x".into(), "a\r\nb: c".into())], vec![]).await.err().unwrap().contains("bad header")); let no_keys = base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, r#"{"ticket":"abc"}"#); assert_eq!(iroh.http("GET", &format!("iroh:///{no_keys}/shell/mcp"), vec![], vec![]).await.err().unwrap(), "no host keys: refusing to trust an unpinned guest"); } #[test] fn iroh_urls_and_the_relay_command() { let refs = |v: Value| base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, v.to_string()); let keys = json!([{ "type": "ssh-ed25519", "publicKey": "AAAA" }]); let r = refs(json!({ "ticket": "iroh://abc", "hostKeys": keys })); assert_eq!(iroh_url(&format!("iroh:///{r}/shell/mcp?x=1#f")).unwrap(), Target { ticket: "abc".into(), host_keys: keys.clone(), socket: SHELL_SOCKET.into(), target: "/mcp?x=1".into(), raw: false }); let raw = raw_url("iroh://endpointabc234/xrpc/a.b.c?x=1").unwrap().unwrap(); assert_eq!((raw.ticket.as_str(), raw.target.as_str(), raw.raw), ("endpointabc234", "/xrpc/a.b.c?x=1", true)); assert!(raw_url(&format!("iroh://{r}/shell/mcp")).is_none()); assert_eq!(iroh_url(&format!("iroh://{r}/%2Ftmp%2Fs.sock")).unwrap().socket, "/tmp/s.sock"); assert_eq!(iroh_url(&format!("iroh://{r}/%2Ftmp%2Fs.sock")).unwrap().target, "/"); assert!(iroh_url("iroh:///abc").unwrap_err().starts_with("not an iroh")); assert!(iroh_url("iroh:///!!/shell/mcp").unwrap_err().starts_with("not an iroh")); assert!(iroh_url(&format!("iroh:///{r}/tmp/x")).unwrap_err().contains("absolute unix path")); assert!(iroh_url(&format!("iroh:///{}/shell/mcp", refs(json!({ "ticket": "ABC" })))).unwrap_err().starts_with("not an iroh ticket")); assert!(iroh_url(&format!("iroh:///{r}/shell/a b")).unwrap_err().contains("spaces")); assert_eq!(relay_command("/a'b"), "socialweb-guest-agent relay '/a'\\''b'"); } #[test] fn ssh_command_cannot_be_an_option() { let evil = "-oKnownHostsCommand=/nonexistent/pwned"; let args = ssh_args(Path::new("/k/key"), "abc", Path::new("/k/kh"), evil); assert_eq!(&args[args.len() - 3..], ["--", "root@guest", evil]); assert_eq!(&args[..2], ["-F", "/dev/null"], "no user or system ssh config"); // ssh -G resolves the config without connecting let Ok(out) = std::process::Command::new("ssh").arg("-G").args(&args).output() else { return; // no ssh binary here }; let out = String::from_utf8_lossy(&out.stdout); assert!(out.contains("proxycommand dumbpipe connect abc"), "{out}"); assert!(!out.contains("pwned"), "{out}"); } }