diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index eb62dfb..7fc6592 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -122,6 +122,20 @@ async fn run(config_override: Option) -> Result<(), Box> { let events = LocalEventBus::new(); spawn_session_publisher(manager.clone(), events.clone()); + // Cross-host attach (T2) only works when both daemons advertise a matching + // perimeter. With none set, peers resolving this daemon's sessions get + // `no_perimeter_candidate` and the attach fails — warn loudly at startup so + // operators aren't left guessing (#39). + let perimeter = config.perimeter(); + if perimeter.is_none() { + tracing::warn!( + "no [daemon].perimeter_id/private_addr configured: cross-host attach (T2) is \ + unavailable — peers will get `no_perimeter_candidate`. Set a matching \ + perimeter_id on every host plus this host's reachable private_addr. \ + See docs/INSTALL.md § Cross-host attach." + ); + } + let client = RegistryClient::new_with_bus( config.registry_url.clone(), config.daemon_id.clone(), @@ -132,7 +146,7 @@ async fn run(config_override: Option) -> Result<(), Box> { manager.clone(), Some(events.clone()), ) - .with_perimeter(config.perimeter()); + .with_perimeter(perimeter); let catalog = client.catalog(); let local_state = local_api::state_for( diff --git a/crates/maestro-web/Cargo.toml b/crates/maestro-web/Cargo.toml index 50d3787..002af59 100644 --- a/crates/maestro-web/Cargo.toml +++ b/crates/maestro-web/Cargo.toml @@ -24,6 +24,7 @@ include_dir.workspace = true maestro-config.workspace = true mime_guess.workspace = true reqwest.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } diff --git a/crates/maestro-web/src/proxy.rs b/crates/maestro-web/src/proxy.rs index be435ee..26d43b1 100644 --- a/crates/maestro-web/src/proxy.rs +++ b/crates/maestro-web/src/proxy.rs @@ -202,12 +202,24 @@ fn payload_too_large() -> Response { /// Open an upstream WS connection at `daemon_ws_base + path?query` and pump /// frames in both directions until either side closes. Used for /// `/api/attach/:id` and `/api/events`. -pub async fn pump_ws(client: WebSocket, upstream_url: String) { +pub async fn pump_ws(mut client: WebSocket, upstream_url: String) { let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await { Ok(pair) => pair, Err(e) => { tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed"); - // Drop the client socket — there's nothing useful to send it. + // The daemon rejects broker attaches it can't route (no shared + // perimeter, peer down, unknown session — NAP-247) with a structured + // HTTP error *before* the upgrade. We've already upgraded the browser + // socket, so a bare drop reaches it as an opaque 1006 close — the + // attach pane just flashes and vanishes with no reason. Relay the + // daemon's reason as an application close frame the UI can show (#39). + let (code, reason) = upstream_close_reason(&e); + let _ = client + .send(AxMessage::Close(Some(axum::extract::ws::CloseFrame { + code, + reason: reason.into(), + }))) + .await; return; } }; @@ -274,6 +286,48 @@ pub async fn pump_ws(client: WebSocket, upstream_url: String) { } } +/// Largest application close code we emit; the browser surfaces it as +/// `CloseEvent.code`. Codes in 4000-4999 are reserved for application use. +const CLOSE_UPSTREAM_REJECTED: u16 = 4502; + +/// Turn a failed upstream WS handshake into a browser-facing close code + +/// reason. When the daemon rejected the upgrade with its structured JSON error +/// body (`{"error":{"code","message"}}` — see `local_api::error_response`), +/// surface that message; otherwise fall back to a generic gateway reason. The +/// reason rides a WS control frame, so it's truncated to the 123-byte limit. +fn upstream_close_reason(e: &tokio_tungstenite::tungstenite::Error) -> (u16, String) { + use tokio_tungstenite::tungstenite::Error as WsError; + let msg = match e { + WsError::Http(resp) => resp + .body() + .as_ref() + .and_then(|b| serde_json::from_slice::(b).ok()) + .and_then(|v| { + v.get("error") + .and_then(|err| err.get("message")) + .and_then(|m| m.as_str()) + .map(str::to_owned) + }) + .unwrap_or_else(|| "the session's daemon rejected the attach".to_owned()), + _ => "could not reach the session's daemon".to_owned(), + }; + (CLOSE_UPSTREAM_REJECTED, truncate_reason(&msg)) +} + +/// Clamp a close reason to the WS control-frame budget (125 bytes total, 2 for +/// the code → 123 for the reason), respecting UTF-8 char boundaries. +fn truncate_reason(s: &str) -> String { + const MAX: usize = 123; + if s.len() <= MAX { + return s.to_owned(); + } + let mut end = MAX; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_owned() +} + /// Compose the upstream WS URL from the base + path + query of the inbound /// request. pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String { @@ -336,4 +390,44 @@ mod tests { let resp = proxy_http(State(state), req).await; assert_eq!(resp.status(), StatusCode::BAD_GATEWAY); } + + /// #39: a daemon rejection carrying the structured error body surfaces its + /// `message` as the close reason, under an application close code, so the + /// browser can show *why* the attach failed instead of an opaque 1006. + #[test] + fn upstream_close_reason_extracts_daemon_message() { + use tokio_tungstenite::tungstenite::{http, Error as WsError}; + let body = br#"{"error":{"code":"no_perimeter_candidate","message":"peer hasn't advertised a perimeter; T2 unavailable"}}"#.to_vec(); + let resp = http::Response::builder() + .status(502) + .body(Some(body)) + .unwrap(); + let (code, reason) = upstream_close_reason(&WsError::Http(resp)); + assert_eq!(code, CLOSE_UPSTREAM_REJECTED); + assert_eq!(reason, "peer hasn't advertised a perimeter; T2 unavailable"); + } + + /// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable + /// generic reason rather than a panic or empty string. + #[test] + fn upstream_close_reason_falls_back_for_non_http_errors() { + use tokio_tungstenite::tungstenite::Error as WsError; + let e = WsError::Io(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "nope")); + let (code, reason) = upstream_close_reason(&e); + assert_eq!(code, CLOSE_UPSTREAM_REJECTED); + assert!(!reason.is_empty()); + } + + /// Close reasons ride a control frame capped at 123 bytes; an over-long + /// message is truncated on a char boundary (no panic on multi-byte chars). + #[test] + fn truncate_reason_respects_control_frame_budget() { + let ascii = "x".repeat(200); + assert_eq!(truncate_reason(&ascii).len(), 123); + + let multibyte = "é".repeat(100); // 2 bytes each + let out = truncate_reason(&multibyte); + assert!(out.len() <= 123); + assert!(out.is_char_boundary(out.len())); + } } diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 91807ab..43c5032 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -190,6 +190,33 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes precedence over the file. +## Cross-host attach (perimeter setup) + +Attaching to a session that lives on **another** host is brokered over the "T2" +transport, which only works when both daemons advertise a **matching +`perimeter_id`** and the session's owner advertises a **reachable +`private_addr`**. These default to unset, and without them every cross-host +attach fails — the registry resolves the session but returns no perimeter +candidate, so the attaching daemon rejects it (`no_perimeter_candidate`) and the +browser pane shows the reason and closes. Same-host attach is unaffected. + +Set both on **every** host in `~/.config/maestro/config.toml`: + +```toml +[daemon] +# Identical on every daemon that should reach each other — name it after your +# VPN/LAN segment. Two daemons broker to each other only when these match. +perimeter_id = "wg-home" +# THIS host's address as its peers reach it on that perimeter (host:port of the +# daemon's bind_addr). Per-host — never the same value on two daemons. +private_addr = "10.0.0.7:7070" +``` + +It's both-or-neither: setting only one is ignored with a startup warning. A +daemon started with no perimeter configured logs a `WARN` saying cross-host +attach is unavailable. (T1 direct-WSS was dropped — NAP-235 — and T3 relay isn't +implemented — NAP-238 — so T2 is currently the only cross-host path.) + ## Peer attach trust model There is no per-peer allow-list. The operator's network perimeter (VPN/firewall) diff --git a/web/src/attach.ts b/web/src/attach.ts index ef7c4b5..526d2ae 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -245,6 +245,18 @@ export function createAttachConnection( }; ws.onclose = (ev) => { + // The web proxy relays a broker/daemon rejection (no shared perimeter, peer + // down, unknown session) as an application close code (4000+) carrying a + // human reason. Show it in the pane — both the header and the terminal body + // — and KEEP the pane up so the user can read why, instead of letting it + // flash and vanish (#39). Clean session-ends and ordinary drops keep the + // auto-close behavior below. + const reason = ev.reason?.trim(); + if (ev.code >= 4000 && reason && !disposed) { + setState(reason, "error"); + term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`); + return; + } setState(`closed (${ev.code})`, "closed"); // Only bubble up server/network closes — an intentional dispose has already // set `disposed`, and eviction/user-close handle their own pane teardown.