From 100af2226d27b071272945a9ce843b1128db6782 Mon Sep 17 00:00:00 2001 From: Chris Paul Date: Mon, 27 Jul 2026 21:42:46 +0000 Subject: [PATCH 1/2] feat(ashlar): publish an Ashlar site to the mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Ashlar program is an origin: it binds a port, and everything about reaching that port from elsewhere is a deployment fact. `ashlar run --mesh` makes this node the answer — the site goes to a private mesh's members and to nobody else, with no forwarded port and no public address. Ashlar reaches it across its one foreign boundary. Two space names derive to a co-process rather than a library: `mesh` — the roster, which `myownmesh ashlar` answers — and `mesh.sites`, answered by the new `allmystuff-ashlar` binary. They are separate because sites need a proxy able to carry a TCP connection to a peer, which this node has and MyOwnMesh alone does not; a machine can answer the roster perfectly well and genuinely be unable to publish, and an unknown call says which command answers what. Four calls over JSON Lines, all driving ops the node already had: `expose`, `unexpose`, `published`, `nearby`. Publishing stays opt-in at the node — `expose` adds one port to the owner's exposed selection and the proxy still refuses everything outside it, so nothing here can reach a service the owner never exposed. `nearby` reads what peers ADVERTISE, maps each site once, and reuses the mapping rather than re-binding a port on every render; a site that will not map is still listed, because "there but unreachable from here" is a different fact from "not there". The crate lives in the light workspace and speaks the node's control wire rather than linking `allmystuff-node`, so it builds in seconds and `cargo test --workspace` covers it. That is a real coupling and it is named in the crate docs and ARCHITECTURE.md: the frame format is defined in `node/src/node_control.rs`, re-implemented here, and pinned by `frame_roundtrips_the_node_wire`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013F9wx6nykjF97SeSfJkp4R --- ARCHITECTURE.md | 26 ++ Cargo.lock | 9 + Cargo.toml | 5 + README.md | 6 + crates/allmystuff-ashlar/Cargo.toml | 31 +++ crates/allmystuff-ashlar/src/lib.rs | 354 +++++++++++++++++++++++++++ crates/allmystuff-ashlar/src/main.rs | 296 ++++++++++++++++++++++ 7 files changed, 727 insertions(+) create mode 100644 crates/allmystuff-ashlar/Cargo.toml create mode 100644 crates/allmystuff-ashlar/src/lib.rs create mode 100644 crates/allmystuff-ashlar/src/main.rs 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..8a205c2 --- /dev/null +++ b/crates/allmystuff-ashlar/src/lib.rs @@ -0,0 +1,354 @@ +//! 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..3286372 --- /dev/null +++ b/crates/allmystuff-ashlar/src/main.rs @@ -0,0 +1,296 @@ +//! `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()) + ); + } +} From 4b022bc447f80cdec18df2434db4b9f2120ed23f Mon Sep 17 00:00:00 2001 From: Chris Paul Date: Mon, 27 Jul 2026 21:46:13 +0000 Subject: [PATCH 2/2] style: rustfmt the Ashlar seam CI checks `cargo fmt --all --check` on every platform; the seam was written by hand and did not match rustfmt's line breaking. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013F9wx6nykjF97SeSfJkp4R --- crates/allmystuff-ashlar/src/lib.rs | 34 ++++++++++++++-------- crates/allmystuff-ashlar/src/main.rs | 42 ++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/crates/allmystuff-ashlar/src/lib.rs b/crates/allmystuff-ashlar/src/lib.rs index 8a205c2..3ba8d61 100644 --- a/crates/allmystuff-ashlar/src/lib.rs +++ b/crates/allmystuff-ashlar/src/lib.rs @@ -74,10 +74,7 @@ pub fn with_exposed( /// 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 { +pub fn without_exposed(current: &BTreeMap, port: u16) -> BTreeMap { let mut next = current.clone(); next.remove(&service_id(port)); next @@ -207,8 +204,8 @@ pub fn frame(cmd: &str, args: Value) -> Vec { /// 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}"))?; + 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)); } @@ -239,8 +236,14 @@ mod tests { 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")); + 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"); @@ -264,7 +267,11 @@ mod tests { ] }); let sites = peer_sites(&snapshot); - assert_eq!(sites.len(), 2, "a peer with no node id is skipped: {sites:?}"); + 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], @@ -347,8 +354,11 @@ mod tests { #[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" - })); + 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 index 3286372..c6bece2 100644 --- a/crates/allmystuff-ashlar/src/main.rs +++ b/crates/allmystuff-ashlar/src/main.rs @@ -43,7 +43,10 @@ fn main() { }, Err(e) => json!({ "error": format!("not a call envelope: {e}") }), }; - if writeln!(stdout, "{answer}").and_then(|_| stdout.flush()).is_err() { + if writeln!(stdout, "{answer}") + .and_then(|_| stdout.flush()) + .is_err() + { // Ashlar hung up. Nothing to report to, and nothing left to do. return; } @@ -128,12 +131,20 @@ 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)) + .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() }; + let shown = if label.is_empty() { + id.clone() + } else { + label.clone() + }; out.push(site(&me, &shown, &local_url(port))); } Ok(Value::Array(out)) @@ -233,8 +244,14 @@ fn connect() -> Result { 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)), + 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 + )), } } @@ -245,8 +262,14 @@ fn port_arg(args: &[Value], index: usize) -> Result { .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)), + 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 + )), } } @@ -287,7 +310,10 @@ mod tests { #[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), 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())