diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 254eb10..0b48be5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,6 +78,7 @@ crates/ ├── allmystuff-term # `amst` — the command-line terminal (a mesh PTY in your own terminal) ├── allmystuff-cec-protocol # CEC Support's wire types: support ids, consent control, the help beacon ├── allmystuff-cec-consent # a customer's standing technician approvals (once / 3 hours / forever) +├── allmystuff-ashlar # `allmystuff-ashlar` — answer an Ashlar program's `mesh.sites` space over JSON Lines ├── allmystuff-mobile-core # the phone's model: viewer/controller capability set + NodeProfile (docs/MOBILE.md) ├── allmystuff-updater # self-update: release feed, SHA-256 verify, stage-then-apply ├── allmystuff-service # install/manage the OS background service (systemd / launchd / Windows SCM) @@ -218,6 +219,31 @@ Everything AllMyStuff puts on a wire, with no dependency heavier than per-device identities (no shared secrets); see MyOwnMesh `docs/NETWORK-TYPES.md` for the governance contract. +### allmystuff-ashlar + +The seam an [Ashlar](https://github.com/mrjeeves/ashlar) site reaches this +machine through. Ashlar has one boundary for everything outside its builtin +set, and two space names derive to a co-process rather than to a library the +project supplies: `mesh` — the roster, answered by `myownmesh ashlar` — and +`mesh.sites`, answered by the `allmystuff-ashlar` binary here. Sites are the +half that needs a proxy able to carry a TCP connection to a peer, which is +exactly what this node has and MyOwnMesh alone does not. + +Four calls over JSON Lines on stdin/stdout — `expose`, `unexpose`, +`published`, `nearby` — driving ops the node already had (`site_exposed`, +`site_set_exposed`, `site_mappings`, `site_map`, `session_snapshot`, +`mesh_networks`). Publishing stays **opt-in at the node**: `expose` adds one +port to the owner's exposed selection, the proxy still refuses every port +outside it, and `nearby` reads what peers *advertise* rather than scanning +anyone. + +It lives in the light workspace and speaks the node's control wire +(`[u32 BE len][tag][JSON]`) instead of linking `allmystuff-node`, so it builds +in seconds and `cargo test --workspace` covers it. That is a real coupling: +the frame format is defined in `node/src/node_control.rs` and re-implemented +here, pinned by `frame_roundtrips_the_node_wire`. Change that wire and change +both. + ### allmystuff-bridge The one place hardware vocabulary meets graph vocabulary. Turns an diff --git a/Cargo.lock b/Cargo.lock index dbdefee..04fad26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "allmystuff-ashlar" +version = "0.2.49" +dependencies = [ + "dirs", + "interprocess", + "serde_json", +] + [[package]] name = "allmystuff-bridge" version = "0.2.49" diff --git a/Cargo.toml b/Cargo.toml index 783a0e4..b7c624e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ members = [ # mesh) and by the standalone CEC Support client app. "crates/allmystuff-cec-protocol", "crates/allmystuff-cec-consent", + # The Ashlar seam: a co-process answering an Ashlar program's `mesh.sites` + # space. It speaks the node's control wire rather than linking the node, so + # it belongs in this fast workspace and not beside the media stack. + "crates/allmystuff-ashlar", ] # The Tauri desktop app (`gui/src-tauri`) and the headless mesh node # (`node/` — the `allmystuff-node` engine + the `allmystuff-serve` binary) @@ -49,6 +53,7 @@ allmystuff-service = { path = "crates/allmystuff-service", version = "0.2.49" } allmystuff-mobile-core = { path = "crates/allmystuff-mobile-core", version = "0.2.49" } allmystuff-cec-protocol = { path = "crates/allmystuff-cec-protocol", version = "0.2.49" } allmystuff-cec-consent = { path = "crates/allmystuff-cec-consent", version = "0.2.49" } +allmystuff-ashlar = { path = "crates/allmystuff-ashlar", version = "0.2.49" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index 0a993bf..762e597 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ It's free, open source, and runs on macOS, Linux, and Windows. - **Sites.** Open a machine's local web service in your browser — even one bound to loopback — through a port mapped on the machine you're sitting at. Only ports the host advertises are ever proxied. +- **Ashlar sites.** An [Ashlar](https://github.com/mrjeeves/ashlar) program + reaches this node across its own foreign boundary: `ashlar run --mesh` + publishes the port it is serving to a private mesh, and the peers on that + mesh open it in a browser through a locally mapped port. No forwarded port, + no public address, nothing vendored — the site names a capability and this + node answers it (`allmystuff-ashlar`). - **KVM appliances.** Point a NanoKVM-class device at a machine and you can reach its screen and keyboard even when its OS is down — BIOS included — with power and reset a click away; the KVM's own web UI opens through the mesh. diff --git a/crates/allmystuff-ashlar/Cargo.toml b/crates/allmystuff-ashlar/Cargo.toml new file mode 100644 index 0000000..483b4a8 --- /dev/null +++ b/crates/allmystuff-ashlar/Cargo.toml @@ -0,0 +1,31 @@ +# The Ashlar seam: a co-process that answers an Ashlar program's `mesh.sites` +# space over JSON Lines, driving this machine's AllMyStuff node. +# +# It lives in the LIGHT workspace on purpose. The node engine carries the media +# stack (xcap / cpal / openh264 / …) and takes minutes to build; this speaks the +# node's control wire instead of linking it, so the seam compiles and tests in +# seconds and a `cargo test --workspace` covers it. The wire it re-implements is +# small and fixed — `[u32 BE len][tag][JSON]`, defined in +# `node/src/node_control.rs` — and `frame_roundtrips_the_node_wire` pins it. +[package] +name = "allmystuff-ashlar" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Answer an Ashlar program's `mesh.sites` space — publish this machine's site to the mesh, and reach the peers'." + +[lib] +name = "allmystuff_ashlar" +path = "src/lib.rs" + +[[bin]] +name = "allmystuff-ashlar" +path = "src/main.rs" + +[dependencies] +serde_json = { workspace = true } +interprocess = { workspace = true } +dirs = { workspace = true } diff --git a/crates/allmystuff-ashlar/src/lib.rs b/crates/allmystuff-ashlar/src/lib.rs new file mode 100644 index 0000000..3ba8d61 --- /dev/null +++ b/crates/allmystuff-ashlar/src/lib.rs @@ -0,0 +1,364 @@ +//! The Ashlar seam: answer an Ashlar program's `mesh.sites` space. +//! +//! Ashlar reaches everything outside its builtin set across one boundary. Two +//! space names derive to a co-process rather than to a library the project +//! supplies, because what they name belongs to the machine: `mesh` — who else +//! is on the private network this machine joined, answered by +//! `myownmesh ashlar` — and `mesh.sites`, answered here. Sites are the half +//! that needs a proxy able to carry a TCP connection to a peer, which is why +//! it is a separate space with a separate binding: a box can answer the roster +//! perfectly well and genuinely be unable to publish a site. +//! +//! Four calls, JSON Lines on stdin/stdout, shapes fixed by Ashlar's +//! `mesh.sites.Site` and `mesh.sites.Published`: +//! +//! | call | does | +//! |---|---| +//! | `expose(port, label, network)` | publish a local port to the mesh's members | +//! | `unexpose(port)` | take it back off | +//! | `published()` | what this machine is publishing | +//! | `nearby()` | the peers' sites, each with an address this machine can open | +//! +//! Publishing is **opt-in at the node**, and this does not change that: the +//! node advertises only the listening services its owner selected, and its +//! proxy refuses any port not on that list. `expose` adds one port to that +//! selection — the port an Ashlar program is serving, which its operator just +//! asked to publish — and `unexpose` removes it. Nothing here can reach a +//! service the owner never exposed, including through a peer. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +/// The mesh an Ashlar site lands on when nothing named one. It matches the +/// default in Ashlar's `lib/mesh` and in `myownmesh ashlar`, so a program that +/// says nothing and a machine told nothing meet on one area rather than two. +pub const DEFAULT_ASHLAR_NETWORK: &str = "ashlar"; + +/// What a node calls the listening service on `port`. The node's own scan +/// spells ids this way (`tcp:8080`), and `expose` needs the id before the scan +/// has necessarily noticed a port that came up a moment ago. +pub fn service_id(port: u16) -> String { + format!("tcp:{port}") +} + +/// The port an id names, if it names one. The inverse of [`service_id`], used +/// to answer `published` from the node's own selection rather than from +/// anything this process remembered. +pub fn port_of(id: &str) -> Option { + id.strip_prefix("tcp:").and_then(|p| p.parse().ok()) +} + +/// The address this machine reaches a port at. Ashlar source may not write a +/// location down (its B5), so every URL a page renders arrives from out here, +/// at runtime. +pub fn local_url(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +/// Add one port to the node's exposed selection, leaving every other entry +/// alone. The node's map is `service id -> display label`; an empty label +/// means "use the scan's own name", so a program that named its site keeps +/// that name and one that did not is still exposed. +pub fn with_exposed( + current: &BTreeMap, + port: u16, + label: &str, +) -> BTreeMap { + let mut next = current.clone(); + next.insert(service_id(port), label.to_string()); + next +} + +/// Remove one port from the selection. Removing what was never there is not an +/// error: `unexpose` runs on the way out of a run that may never have +/// published, and a shutdown path that can fail for that is worse than one +/// that cannot. +pub fn without_exposed(current: &BTreeMap, port: u16) -> BTreeMap { + let mut next = current.clone(); + next.remove(&service_id(port)); + next +} + +/// Read the node's exposed map out of a control answer. +pub fn exposed_map(value: &Value) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(obj) = value.as_object() { + for (k, v) in obj { + out.insert(k.clone(), v.as_str().unwrap_or("").to_string()); + } + } + out +} + +/// One site on the mesh, in the shape Ashlar declared. +pub fn site(peer: &str, label: &str, url: &str) -> Value { + json!({ "peer": peer, "label": label, "url": url }) +} + +/// Every site the peers in a node snapshot advertise, as `(node, port, label)`. +/// The snapshot carries each peer's published profile, and a profile's `sites` +/// are exactly what that peer chose to expose — so this reads an advert, never +/// a scan of somebody else's machine. +pub fn peer_sites(snapshot: &Value) -> Vec<(String, u16, String)> { + let mut out = Vec::new(); + let Some(peers) = snapshot.get("peers").and_then(Value::as_array) else { + return out; + }; + for peer in peers { + let node = peer + .get("node") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let peer_label = peer.get("label").and_then(Value::as_str).unwrap_or(""); + if node.is_empty() { + continue; + } + for advert in peer + .get("sites") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + let Some(port) = advert.get("port").and_then(Value::as_u64) else { + continue; + }; + let label = advert + .get("label") + .and_then(Value::as_str) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{peer_label} :{port}")); + out.push((node.clone(), port as u16, label)); + } + } + out +} + +/// The local port a node's site is already mapped to, if it is. Mapping is +/// what makes a peer's site openable from here, and asking first keeps +/// `nearby` from re-binding a port on every render. +pub fn existing_mapping(mappings: &Value, node: &str, port: u16) -> Option { + mappings + .as_array()? + .iter() + .find(|m| { + m.get("node").and_then(Value::as_str) == Some(node) + && m.get("port").and_then(Value::as_u64) == Some(port as u64) + }) + .and_then(|m| m.get("localPort").and_then(Value::as_u64)) + .map(|p| p as u16) +} + +/// Whether the node is already on this mesh, from a `mesh_networks` answer. +/// Joining is idempotent at the daemon, but asking first keeps a program's +/// every start from writing a config it already has. +pub fn already_joined(networks: &Value, network: &str) -> bool { + let list = networks + .get("networks") + .and_then(Value::as_array) + .cloned() + .or_else(|| networks.as_array().cloned()) + .unwrap_or_default(); + list.iter().any(|n| { + n.get("network_id").and_then(Value::as_str) == Some(network) + || n.get("id").and_then(Value::as_str) == Some(network) + }) +} + +/// The mesh a call named, or the default when it named none. +pub fn network_or_default(named: &str) -> String { + let named = named.trim(); + if named.is_empty() { + DEFAULT_ASHLAR_NETWORK.to_string() + } else { + named.to_string() + } +} + +// --------------------------------------------------------------------------- +// The node's control wire +// --------------------------------------------------------------------------- + +/// A JSON-bodied frame. The node's wire is `[u32 BE len][1 tag byte][payload]`, +/// where `len` counts the tag — defined in `node/src/node_control.rs`, and +/// re-implemented here rather than linked so this seam stays out of the media +/// stack's build. Only the JSON tag is used: the byte-tagged frames carry media +/// batches, which no Ashlar call asks for. +pub const TAG_JSON: u8 = 0; + +/// Frame one request for the node. +pub fn frame(cmd: &str, args: Value) -> Vec { + let body = serde_json::to_vec(&json!({ "cmd": cmd, "args": args })) + .expect("a JSON object always serializes"); + let len = (body.len() as u32) + 1; + let mut out = Vec::with_capacity(body.len() + 5); + out.extend_from_slice(&len.to_be_bytes()); + out.push(TAG_JSON); + out.extend_from_slice(&body); + out +} + +/// Unwrap the node's `{ok, result, error}` answer, keeping the node's own +/// words on failure: the message an Ashlar call site raises should be the one +/// the node wrote, not a paraphrase of it. +pub fn unwrap_answer(payload: &[u8]) -> Result { + let value: Value = serde_json::from_slice(payload) + .map_err(|e| format!("the node's answer was not JSON: {e}"))?; + if value.get("ok").and_then(Value::as_bool) == Some(true) { + return Ok(value.get("result").cloned().unwrap_or(Value::Null)); + } + Err(value + .get("error") + .and_then(Value::as_str) + .unwrap_or("the node refused without saying why") + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_service_id_and_its_port_are_inverses() { + assert_eq!(service_id(8080), "tcp:8080"); + assert_eq!(port_of("tcp:8080"), Some(8080)); + assert_eq!(port_of("udp:8080"), None); + assert_eq!(port_of("tcp:not-a-port"), None); + } + + #[test] + fn exposing_leaves_every_other_selection_alone() { + // The node's exposed map is its owner's choice about the WHOLE + // machine. Publishing an Ashlar site adds one port to it; replacing + // the map would silently unpublish whatever else was there. + let mut current = BTreeMap::new(); + current.insert("tcp:3000".to_string(), "dev server".to_string()); + let after = with_exposed(¤t, 8080, "enclave.app"); + assert_eq!( + after.get("tcp:3000").map(String::as_str), + Some("dev server") + ); + assert_eq!( + after.get("tcp:8080").map(String::as_str), + Some("enclave.app") + ); + + let back = without_exposed(&after, 8080); + assert_eq!(back, current, "withdrawing restores exactly what was there"); + assert_eq!( + without_exposed(¤t, 9999), + current, + "withdrawing what was never published is not an error" + ); + } + + #[test] + fn peer_sites_read_the_advert_not_a_scan() { + let snapshot = json!({ + "peers": [ + { "node": "n1", "label": "ada", "sites": [ + { "id": "tcp:8080", "label": "ada's pad", "port": 8080, "scheme": "http" }, + { "id": "tcp:9000", "label": "", "port": 9000, "scheme": "http" } + ]}, + { "node": "n2", "label": "grace", "sites": [] }, + { "label": "no id at all", "sites": [{ "port": 1 }] } + ] + }); + let sites = peer_sites(&snapshot); + assert_eq!( + sites.len(), + 2, + "a peer with no node id is skipped: {sites:?}" + ); + assert_eq!(sites[0], ("n1".to_string(), 8080, "ada's pad".to_string())); + assert_eq!( + sites[1], + ("n1".to_string(), 9000, "ada :9000".to_string()), + "an advert with no label falls back to the peer and port" + ); + } + + #[test] + fn an_empty_snapshot_yields_no_sites() { + assert!(peer_sites(&json!({ "ready": false })).is_empty()); + assert!(peer_sites(&json!({ "peers": [] })).is_empty()); + } + + #[test] + fn a_mapping_is_reused_rather_than_rebound() { + let mappings = json!([ + { "node": "n1", "port": 8080, "localPort": 47001 }, + { "node": "n2", "port": 8080, "localPort": 47002 } + ]); + assert_eq!(existing_mapping(&mappings, "n1", 8080), Some(47001)); + assert_eq!(existing_mapping(&mappings, "n2", 8080), Some(47002)); + assert_eq!(existing_mapping(&mappings, "n3", 8080), None); + assert_eq!(existing_mapping(&json!([]), "n1", 8080), None); + } + + #[test] + fn the_default_mesh_matches_what_ashlar_ships_with() { + assert_eq!(DEFAULT_ASHLAR_NETWORK, "ashlar"); + assert_eq!(network_or_default(""), "ashlar"); + assert_eq!(network_or_default(" "), "ashlar"); + assert_eq!(network_or_default(" enclave "), "enclave"); + } + + #[test] + fn a_joined_network_is_recognised_either_way_it_is_keyed() { + let networks = json!({ "networks": [{ "id": "home", "network_id": "abc123" }] }); + assert!(already_joined(&networks, "abc123")); + assert!(already_joined(&networks, "home")); + assert!(!already_joined(&networks, "elsewhere")); + assert!(!already_joined(&json!({}), "abc123")); + } + + #[test] + fn frame_roundtrips_the_node_wire() { + // The length counts the tag byte, big-endian, and the body is the + // node's `{cmd, args}` request. This is the whole contract with + // `node/src/node_control.rs`; if it drifts, this fails rather than a + // user's first request. + let bytes = frame("site_exposed", json!({})); + let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + assert_eq!(len, bytes.len() - 4, "len covers tag + payload"); + assert_eq!(bytes[4], TAG_JSON); + let body: Value = serde_json::from_slice(&bytes[5..]).unwrap(); + assert_eq!(body["cmd"], "site_exposed"); + assert_eq!(body["args"], json!({})); + } + + #[test] + fn a_refusal_keeps_the_nodes_own_words() { + let ok = unwrap_answer(br#"{"ok":true,"result":{"localPort":47001}}"#).unwrap(); + assert_eq!(ok["localPort"], 47001); + let e = unwrap_answer(br#"{"ok":false,"error":"port is not advertised"}"#).unwrap_err(); + assert_eq!(e, "port is not advertised"); + let e = unwrap_answer(br#"{"ok":false}"#).unwrap_err(); + assert!(e.contains("without saying why"), "{e}"); + let e = unwrap_answer(b"not json").unwrap_err(); + assert!(e.contains("was not JSON"), "{e}"); + } + + #[test] + fn exposed_map_reads_the_nodes_selection() { + let value = json!({ "tcp:8080": "enclave.app", "tcp:3000": "" }); + let map = exposed_map(&value); + assert_eq!(map.get("tcp:8080").map(String::as_str), Some("enclave.app")); + assert_eq!(map.get("tcp:3000").map(String::as_str), Some("")); + assert!(exposed_map(&json!(null)).is_empty()); + } + + #[test] + fn a_url_is_built_out_here_never_in_ashlar_source() { + assert_eq!(local_url(47001), "http://127.0.0.1:47001"); + assert_eq!( + site("ada", "pad", &local_url(1)), + json!({ + "peer": "ada", "label": "pad", "url": "http://127.0.0.1:1" + }) + ); + } +} diff --git a/crates/allmystuff-ashlar/src/main.rs b/crates/allmystuff-ashlar/src/main.rs new file mode 100644 index 0000000..c6bece2 --- /dev/null +++ b/crates/allmystuff-ashlar/src/main.rs @@ -0,0 +1,322 @@ +//! `allmystuff-ashlar` — the co-process Ashlar's `mesh.sites` space derives to. +//! +//! Not meant to be typed: the Ashlar runtime spawns it and speaks JSON Lines +//! on stdin/stdout. It is an ordinary binary all the same, so +//! `printf '{"call":"published","args":[]}' | allmystuff-ashlar` shows what a +//! site would see. +//! +//! Every answer is one line. A failure crosses as `{"error": …}` and Ashlar +//! raises it at the call site with the message intact — which is worth more +//! than a dead co-process, so this exits zero for a failed call and non-zero +//! only when stdin itself breaks. + +use std::io::{BufRead, BufReader, Read, Write}; + +use allmystuff_ashlar::*; +use interprocess::local_socket::prelude::*; +#[cfg(unix)] +use interprocess::local_socket::GenericFilePath; +#[cfg(not(unix))] +use interprocess::local_socket::GenericNamespaced; +use interprocess::local_socket::Stream; +use serde_json::{json, Value}; + +fn main() { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + eprintln!("allmystuff-ashlar: stdin closed badly: {e}"); + std::process::exit(1); + } + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + let answer = match serde_json::from_str::(line) { + Ok(call) => match dispatch(&call) { + Ok(value) => json!({ "ok": value }), + Err(e) => json!({ "error": e }), + }, + Err(e) => json!({ "error": format!("not a call envelope: {e}") }), + }; + if writeln!(stdout, "{answer}") + .and_then(|_| stdout.flush()) + .is_err() + { + // Ashlar hung up. Nothing to report to, and nothing left to do. + return; + } + } +} + +fn dispatch(call: &Value) -> Result { + let name = call + .get("call") + .and_then(Value::as_str) + .ok_or("a call envelope needs a `call` name")?; + let args = call + .get("args") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + match name { + "expose" => { + let port = port_arg(&args, 0)?; + let label = text_arg(&args, 1)?; + let network = network_or_default(&text_arg(&args, 2).unwrap_or_default()); + expose(port, &label, &network) + } + "unexpose" => { + let port = port_arg(&args, 0)?; + let exposed = exposed_map(&node("site_exposed", json!({}))?); + node( + "site_set_exposed", + json!({ "exposed": without_exposed(&exposed, port) }), + )?; + Ok(json!(true)) + } + "published" => published(), + "nearby" => nearby(), + other => Err(format!( + "no such call: `{other}`. This command answers Ashlar's `mesh.sites` \ + space: expose, unexpose, published, nearby. The roster itself is \ + `mesh`, which `myownmesh ashlar` answers." + )), + } +} + +/// Publish a local port to the mesh's members: make sure this node is on that +/// mesh, then add the port to the node's exposed selection. The node is what +/// enforces the selection — its proxy dials nothing outside it — so this is +/// the whole of publishing, and unexposing is the whole of taking it back. +fn expose(port: u16, label: &str, network: &str) -> Result { + let networks = node("mesh_networks", json!({}))?; + if !already_joined(&networks, network) { + // Open and auto-approving: everyone running the program should see + // everyone else without a human approving each arrival, which makes + // the mesh id itself the secret rather than the roster. + node( + "mesh_network_add", + json!({ "config": { + "id": network, + "network_id": network, + "label": label, + "kind": "open", + "auto_approve": true, + }}), + )?; + } + let exposed = exposed_map(&node("site_exposed", json!({}))?); + node( + "site_set_exposed", + json!({ "exposed": with_exposed(&exposed, port, label) }), + )?; + let identity = node("mesh_identity", json!({})).unwrap_or(Value::Null); + Ok(json!({ + "node": identity.get("device_id").and_then(Value::as_str).unwrap_or("this node"), + "network": network, + "label": label, + })) +} + +/// What this machine publishes, read back from the node's own selection rather +/// than from anything this process remembered — a worker that restarted still +/// answers with what is actually exposed. +fn published() -> Result { + let exposed = exposed_map(&node("site_exposed", json!({}))?); + let me = node("mesh_identity", json!({})) + .ok() + .and_then(|v| { + v.get("device_id") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "this node".to_string()); + let mut out = Vec::new(); + for (id, label) in &exposed { + let Some(port) = port_of(id) else { continue }; + let shown = if label.is_empty() { + id.clone() + } else { + label.clone() + }; + out.push(site(&me, &shown, &local_url(port))); + } + Ok(Value::Array(out)) +} + +/// The peers' sites, each with an address this machine can open. A peer's +/// advert says what it serves; mapping it here binds a local port that the +/// node proxies over the mesh, so the link an Ashlar page renders is an +/// ordinary loopback URL. +fn nearby() -> Result { + let snapshot = node("session_snapshot", json!({}))?; + let mappings = node("site_mappings", json!({})).unwrap_or(Value::Array(vec![])); + let mut out = Vec::new(); + for (peer, port, label) in peer_sites(&snapshot) { + let local = match existing_mapping(&mappings, &peer, port) { + Some(p) => Some(p), + None => node("site_map", json!({ "node": peer, "port": port })) + .ok() + .and_then(|v| v.get("localPort").and_then(Value::as_u64)) + .map(|p| p as u16), + }; + // A site that would not map is still a site the peer is running, and + // saying so with no link beats dropping it: "there but unreachable + // from here" is a different fact from "not there". + let url = local.map(local_url).unwrap_or_default(); + out.push(site(&peer, &label, &url)); + } + Ok(Value::Array(out)) +} + +// -- the node's control socket ---------------------------------------------- + +/// One command to this machine's AllMyStuff node: connect, one frame out, one +/// frame in, close — the same short round trip its own GUI makes. +fn node(cmd: &str, args: Value) -> Result { + let mut stream = connect()?; + stream + .write_all(&frame(cmd, args)) + .map_err(|e| format!("could not send `{cmd}` to the node: {e}"))?; + stream + .flush() + .map_err(|e| format!("could not send `{cmd}` to the node: {e}"))?; + + let mut reader = BufReader::new(stream); + let mut len_buf = [0u8; 4]; + reader + .read_exact(&mut len_buf) + .map_err(|e| format!("the node did not answer `{cmd}`: {e}"))?; + let len = u32::from_be_bytes(len_buf) as usize; + if len == 0 { + return Err(format!("the node sent an empty frame for `{cmd}`")); + } + let mut body = vec![0u8; len]; + reader + .read_exact(&mut body) + .map_err(|e| format!("the node's answer to `{cmd}` was truncated: {e}"))?; + if body[0] != TAG_JSON { + return Err(format!( + "the node answered `{cmd}` with a {} frame, not JSON", + body[0] + )); + } + unwrap_answer(&body[1..]) +} + +/// Where this machine's node listens. Derived exactly as the node derives it +/// (`node/src/node_control.rs`), including the `MYOWNMESH_HOME` override that +/// lets a second stack — CEC Support's, say — run beside an AllMyStuff install +/// without either finding the other's socket. +fn connect() -> Result { + let missing = "no AllMyStuff node is listening. Start one with `allmystuff serve`, \ + or open the app — the mesh's roster is a separate space (`mesh`) and \ + works without it."; + #[cfg(unix)] + { + let home = std::env::var_os("MYOWNMESH_HOME") + .map(std::path::PathBuf::from) + .or_else(dirs::home_dir) + .ok_or("could not resolve a home directory for the node socket")? + .join(".myownmesh"); + let path = home.join("allmystuff-node.sock"); + let name = path + .as_path() + .to_fs_name::() + .map_err(|e| format!("{}: {e}", path.display()))?; + Stream::connect(name).map_err(|e| format!("{missing} ({e})")) + } + #[cfg(not(unix))] + { + let name = "allmystuff-node" + .to_ns_name::() + .map_err(|e| format!("named pipe: {e}"))?; + Stream::connect(name).map_err(|e| format!("{missing} ({e})")) + } +} + +fn text_arg(args: &[Value], index: usize) -> Result { + match args.get(index) { + Some(Value::String(s)) => Ok(s.clone()), + Some(other) => Err(format!( + "argument {} must be a text, not {other}", + index + 1 + )), + None => Err(format!( + "this call wants at least {} argument(s)", + index + 1 + )), + } +} + +fn port_arg(args: &[Value], index: usize) -> Result { + match args.get(index) { + Some(Value::Number(n)) => n + .as_u64() + .filter(|p| *p > 0 && *p <= u16::MAX as u64) + .map(|p| p as u16) + .ok_or_else(|| format!("argument {} must be a port number", index + 1)), + Some(other) => Err(format!( + "argument {} must be a port number, not {other}", + index + 1 + )), + None => Err(format!( + "this call wants at least {} argument(s)", + index + 1 + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_unknown_call_names_both_halves_of_the_mesh() { + // Reaching for the roster here is a real mistake to make, so the + // message says which command answers it rather than just refusing. + let e = dispatch(&json!({ "call": "peers", "args": [] })).unwrap_err(); + assert!(e.contains("no such call"), "{e}"); + assert!(e.contains("expose, unexpose, published, nearby"), "{e}"); + assert!(e.contains("myownmesh ashlar"), "{e}"); + } + + #[test] + fn an_envelope_without_a_call_says_so() { + let e = dispatch(&json!({ "args": [] })).unwrap_err(); + assert!(e.contains("needs a `call` name"), "{e}"); + } + + #[test] + fn arguments_are_checked_before_the_node_is_touched() { + // A bad argument must not reach the socket: the message an Ashlar + // call site sees should be about the argument, not about a daemon. + let e = dispatch(&json!({ "call": "expose", "args": ["8080"] })).unwrap_err(); + assert!(e.contains("must be a port number"), "{e}"); + let e = dispatch(&json!({ "call": "unexpose", "args": [] })).unwrap_err(); + assert!(e.contains("at least 1 argument"), "{e}"); + let e = dispatch(&json!({ "call": "expose", "args": [0] })).unwrap_err(); + assert!(e.contains("must be a port number"), "{e}"); + let e = dispatch(&json!({ "call": "expose", "args": [70000] })).unwrap_err(); + assert!(e.contains("must be a port number"), "{e}"); + } + + #[test] + fn a_port_and_a_label_are_read_in_that_order() { + assert_eq!(port_arg(&[json!(8080)], 0), Ok(8080)); + assert_eq!( + text_arg(&[json!(8080), json!("app")], 1), + Ok("app".to_string()) + ); + assert_eq!( + text_arg(&[json!(8080)], 2), + Err("this call wants at least 3 argument(s)".to_string()) + ); + } +}