diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ae95a6..c7fd63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,18 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice. ### Added +- **Legacy peer permissions can become explicit without narrowing access now.** + `fabric peers make-explicit` reads the running daemon's live exposures, adds + the five built-in service names, and writes that list to every peer whose + `allow` field is absent. Persisted and ephemeral exposures are both included. + Existing explicit lists stay unchanged. Access available now stays available; + a service exposed later becomes opt-in instead of being granted silently. + +- **A new unreachable exposure says so at creation time.** After `fabric expose` + succeeds, it warns when every trusted peer's explicit list denies the service. + The one-line warning names the peers that need the service added. The warning + does not refuse the exposure. + - **Durable connection telemetry.** `fabric status` now reports, per peer, how many times a session lost its transport, how many came back, how many gave up, and how long the reconnect took. The counters persist in diff --git a/README.md b/README.md index c5a3ecd..a464991 100644 --- a/README.md +++ b/README.md @@ -276,11 +276,10 @@ it has opted in. They are independent (allowing one does not allow the other): **Check what a daemon serves** by running `fabric status` on it — it prints `shell allowed` / `disabled` and `exec allowed` / `disabled`. -These flags are **daemon-global**: enabling `allow_shell` / `allow_exec` opens -that capability to **every** trusted peer, not a chosen subset. Restricting shell -or exec to specific peers is not supported today — it is all-or-nothing per -capability, gated only by the peer allow-list (who is trusted at all). If you need -per-peer scoping, keep the capability off and reach for it deliberately. +These flags are **daemon-global** and only subtract access. A peer also needs its +own `peers.toml` `allow` list to contain `shell` or `exec`. A legacy peer with no +`allow` field remains unrestricted at this gate for compatibility. It still +cannot use shell or exec unless the daemon-global flag enables that capability. Enable them with flags on `fabric service install`: @@ -650,6 +649,17 @@ fabric peers Read and list the entries in the authoritative `peers.toml`. +```sh +fabric peers make-explicit +``` + +Replace each legacy unrestricted peer with an explicit list that preserves all +services available now. The command reads the running daemon's status, so the +list includes the five built-ins and every persisted or ephemeral exposure on +this machine. It then writes `peers.toml` and reloads the daemon. Existing +explicit lists stay unchanged. A service exposed later is denied until it is +added to that peer's list. + ```sh fabric reload-peers ``` @@ -740,8 +750,10 @@ the daemon starts. That same file also stores shell policy; `fabric add` writes the separate authoritative `peers.toml`. Use `--ephemeral` for short-lived test exposes that should not survive a daemon restart. -Only allow-listed remote NodeIDs are accepted before the local socket is opened -or the local TCP connection / exec command is started. +Only permitted remote NodeIDs are accepted before the local socket is opened or +the local TCP connection or exec command starts. If no trusted peer can reach a +new exposure, `fabric expose` warns once and names the peers that need the new +service in their `allow` lists. ```sh fabric unexpose @@ -1149,18 +1161,20 @@ human-editable and can be provisioned before Fabric ever runs. Each `fabric ping workstation`. - `addr` (optional): an iroh `EndpointAddr` hint whose `id` must match the peer's `id`. +- `allow` (optional): the service names this peer may reach. Omit it for legacy + unrestricted behavior. An empty list permits no service. NodeIDs and names must be unique. Normal cross-machine setup should omit `addr`; NodeID-based iroh discovery supplies the current addresses. Trust is local and based on NodeID, not alias: `name` is only a command-line -label. Each machine must independently list the other NodeID. A trusted peer can -reach built-in Fabric protocols and explicitly exposed services; if this daemon -also enables the global `allow_shell` or `allow_exec` capability, every trusted -peer can use that enabled capability. Fabric does not currently support -per-peer shell or exec grants. +label. Each machine must independently list the other NodeID. An optional +`allow` list limits that peer to named services such as `sync`, `shell`, `exec`, +or an exposure name. A missing list keeps the legacy unrestricted behavior. The +daemon-global shell and exec flags still apply and cannot be overridden by a +peer entry. -The usual file contains only NodeIDs and optional names: +A file can mix legacy entries with explicit permissions: ```toml [[peers]] @@ -1170,6 +1184,7 @@ name = "workstation" [[peers]] id = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" name = "server" +allow = ["echo", "exec", "send-file", "shell", "sync", "web"] ``` An explicit address hint, mainly useful for deterministic tests, has this exact diff --git a/src/config.rs b/src/config.rs index bae156d..5f38d95 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,6 +506,25 @@ impl PeerBook { .sort_by_key(|peer| (peer.name.clone().unwrap_or_default(), peer.id.to_string())); } + /// Replace each legacy unrestricted entry with today's explicit services. + /// + /// Existing explicit entries stay unchanged. The caller must supply the + /// built-in names and the daemon's live exposure names. This preserves all + /// access that exists now while making a later service opt-in. + pub fn make_legacy_permissions_explicit(&mut self, services: &[String]) -> usize { + let mut explicit = services.to_vec(); + explicit.sort(); + explicit.dedup(); + let mut changed = 0; + for peer in &mut self.peers { + if peer.allow.is_none() { + peer.allow = Some(explicit.clone()); + changed += 1; + } + } + changed + } + pub fn remove(&mut self, peer: &str) -> bool { let before = self.peers.len(); if let Ok(id) = EndpointId::from_str(peer) { @@ -902,6 +921,93 @@ mod tests { assert_eq!(book.may(&hetz, "anything-exposed-later"), Ok(())); } + /// The 0.10 groundwork property, proven rather than asserted: writing a + /// legacy entry out as an explicit list of every service that exists TODAY + /// preserves every one of them, and changes exactly one thing — a service + /// exposed AFTER the transcription is no longer auto-granted. + /// + /// The second half is the half that matters. Every-current-service-still-Ok + /// would pass even if `may` ignored the allow field; the future service + /// being Ok under legacy and Denied under the explicit list is what proves + /// the gate is live and the transcription is real. This is the spec the + /// make-explicit helper must satisfy: the list it writes is exactly the + /// service names `service_name_for_alpn` produces plus this machine's + /// current exposures, and nothing narrows. + #[test] + fn an_explicit_list_of_todays_services_preserves_today_and_makes_tomorrow_opt_in() { + // The built-in service vocabulary the gate checks (see + // daemon::service_name_for_alpn). A real transcription also appends this + // machine's `fabric expose` names; omitting one of those would narrow by + // omission, which is why the helper reads them rather than hard-coding. + let today = ["shell", "exec", "sync", "echo", "send-file"]; + let id = an_id(1); + + let mut legacy = PeerBook::default(); + legacy.add(id, Some("hetz".into()), None); // allow = None, unrestricted + let mut explicit = PeerBook::default(); + explicit.add_with_allow( + id, + Some("hetz".into()), + None, + Some(today.iter().map(|s| s.to_string()).collect()), + ); + + for service in today { + assert_eq!( + legacy.may(&id, service), + Ok(()), + "legacy must reach {service} today" + ); + assert_eq!( + explicit.may(&id, service), + Ok(()), + "the transcription must still reach {service}; it narrowed by omission" + ); + } + + // The one real difference, made visible instead of hidden. + assert_eq!( + legacy.may(&id, "exposed-tomorrow"), + Ok(()), + "legacy auto-grants a future service" + ); + assert_eq!( + explicit.may(&id, "exposed-tomorrow"), + Err(Denied::NotPermitted { + service: "exposed-tomorrow".into() + }), + "the explicit list must make tomorrow opt-in — this is what proves the gate is live" + ); + } + + #[test] + fn make_explicit_changes_only_legacy_entries_and_keeps_every_named_service() { + let legacy = an_id(1); + let restricted = an_id(2); + let mut book = PeerBook::default(); + book.add(legacy, Some("legacy".into()), None); + book.add_with_allow( + restricted, + Some("restricted".into()), + None, + Some(vec!["sync".into()]), + ); + + let changed = book.make_legacy_permissions_explicit(&[ + "sync".into(), + "shell".into(), + "ephemeral-web".into(), + "sync".into(), + ]); + assert_eq!(changed, 1); + for service in ["sync", "shell", "ephemeral-web"] { + assert_eq!(book.may(&legacy, service), Ok(())); + } + assert!(book.may(&legacy, "exposed-tomorrow").is_err()); + assert_eq!(book.may(&restricted, "sync"), Ok(())); + assert!(book.may(&restricted, "shell").is_err()); + } + /// A peer WITH a list is deny by default, including for services this /// machine exposes later. That is the case worth having: trusting somebody /// today must not hand them whatever you publish next month. diff --git a/src/daemon.rs b/src/daemon.rs index dec232b..bc7dcb8 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -57,6 +57,22 @@ use crate::{ const BUILTIN_ECHO_ALPN: &[u8] = b"fabric/echo/0"; const SYNC_ALPN: &[u8] = b"fabric/sync/1"; +const ECHO_SERVICE: &str = "echo"; +const SHELL_SERVICE: &str = "shell"; +const EXEC_SERVICE: &str = "exec"; +const SYNC_SERVICE: &str = "sync"; + +/// Every built-in name accepted by a peer's explicit `allow` list. +/// +/// A permission transcription uses this list plus the daemon's live exposure +/// names. Keep it tied to `service_name_for_alpn`, which enforces the gate. +pub const BUILTIN_SERVICE_NAMES: [&str; 5] = [ + SHELL_SERVICE, + EXEC_SERVICE, + SYNC_SERVICE, + ECHO_SERVICE, + crate::sendfile::SERVICE, +]; const REACHABILITY_TIMEOUT: Duration = Duration::from_secs(3); const INCOMING_FAILURE_INITIAL_BACKOFF: Duration = Duration::from_millis(100); const INCOMING_FAILURE_MAX_BACKOFF: Duration = Duration::from_secs(5); @@ -3142,16 +3158,16 @@ impl DaemonState { /// service, not about which wire version negotiated it. fn service_name_for_alpn(alpn: &[u8]) -> String { if alpn == BUILTIN_ECHO_ALPN { - return "echo".to_string(); + return ECHO_SERVICE.to_string(); } if alpn == shell::SHELL_ALPN || alpn == shell::RESUMABLE_SHELL_ALPN { - return "shell".to_string(); + return SHELL_SERVICE.to_string(); } if alpn == exec::EXEC_ALPN { - return "exec".to_string(); + return EXEC_SERVICE.to_string(); } if alpn == SYNC_ALPN { - return "sync".to_string(); + return SYNC_SERVICE.to_string(); } if alpn == crate::sendfile::SEND_FILE_ALPN { return crate::sendfile::SERVICE.to_string(); @@ -6716,6 +6732,23 @@ mod tests { Ok(()) } + #[test] + fn explicit_acl_names_match_every_builtin_gate_name() { + let mapped = [ + service_name_for_alpn(shell::SHELL_ALPN), + service_name_for_alpn(exec::EXEC_ALPN), + service_name_for_alpn(SYNC_ALPN), + service_name_for_alpn(BUILTIN_ECHO_ALPN), + service_name_for_alpn(crate::sendfile::SEND_FILE_ALPN), + ]; + assert_eq!(mapped, BUILTIN_SERVICE_NAMES.map(str::to_string)); + assert_eq!( + service_name_for_alpn(shell::RESUMABLE_SHELL_ALPN), + SHELL_SERVICE, + "both shell wire versions must use one permission name" + ); + } + /// Finding 9 of the 2026-08-29 review. When the OS network monitor stops, /// the rehome loop must PARK, not return. `serve()` runs every background /// loop in one `select!` and shuts the daemon down when the first one diff --git a/src/main.rs b/src/main.rs index 8b0ff4b..6b66dce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, fs::{self, OpenOptions}, io::IsTerminal, path::PathBuf, @@ -17,7 +17,8 @@ use fabric::{ }, control::{ControlRequest, ControlResponse, PeerReachability}, daemon::{ - DaemonOptions, FabricNode, init_daemon_tracing, run_daemon_with_options, send_control, + BUILTIN_SERVICE_NAMES, DaemonOptions, FabricNode, init_daemon_tracing, + run_daemon_with_options, send_control, }, exec, service::{self, ServiceInstallOptions}, @@ -77,8 +78,11 @@ enum Commands { Addr, /// Show daemon state and echo-ping reachability for trusted peers. Status, - /// List trusted peers. - Peers, + /// List trusted peers or make legacy permissions explicit. + Peers { + #[command(subcommand)] + command: Option, + }, /// Reload peers.toml into the running daemon. ReloadPeers, /// Trust a peer NodeID and optionally assign a local name. @@ -329,6 +333,12 @@ enum Commands { }, } +#[derive(Debug, Subcommand)] +enum PeerCommands { + /// Preserve every service available now, then make later services opt-in. + MakeExplicit, +} + #[derive(Debug, Subcommand)] enum KeyCommands { /// Generate an identity file without starting a daemon. @@ -496,22 +506,51 @@ async fn main() -> Result<()> { response => bail!("unexpected daemon response: {response:?}"), } } - Commands::Peers => { - let book = PeerBook::load(&home)?; - for peer in book.peers() { - let name = peer.name.clone().unwrap_or_default(); - // The effective policy, shown rather than assumed. A - // peer written before permissions existed reads as - // `unrestricted (legacy)`, so nobody has to infer what - // an absent field means. - let policy = match &peer.allow { - None => "unrestricted (legacy)".to_string(), - Some(allow) if allow.is_empty() => "no services".to_string(), - Some(allow) => allow.join(","), - }; - println!("{}\t{}\t{}", peer.id, name, policy); + Commands::Peers { command } => match command { + None => { + let book = PeerBook::load(&home)?; + for peer in book.peers() { + let name = peer.name.clone().unwrap_or_default(); + // The effective policy, shown rather than assumed. A + // peer written before permissions existed reads as + // `unrestricted (legacy)`, so nobody has to infer what + // an absent field means. + let policy = match &peer.allow { + None => "unrestricted (legacy)".to_string(), + Some(allow) if allow.is_empty() => "no services".to_string(), + Some(allow) => allow.join(","), + }; + println!("{}\t{}\t{}", peer.id, name, policy); + } } - } + Some(PeerCommands::MakeExplicit) => { + let mut book = PeerBook::load(&home)?; + let legacy = book + .peers() + .iter() + .filter(|peer| peer.allow.is_none()) + .count(); + if legacy == 0 { + println!("updated\t0"); + } else { + // The daemon is the authority for exposures at this + // instant. config.toml omits ephemeral exposures, so + // reading only that file could narrow access now. + let exposed = match send_control(&home, ControlRequest::Status).await? { + ControlResponse::Status { + exposed_protocols, .. + } => exposed_protocols, + response => bail!("unexpected daemon response: {response:?}"), + }; + let services = explicit_service_names(&exposed); + let updated = book.make_legacy_permissions_explicit(&services); + book.save(&home)?; + send_control(&home, ControlRequest::ReloadPeers).await?; + println!("updated\t{updated}"); + println!("allow\t{}", services.join(",")); + } + } + }, Commands::ReloadPeers => { send_control(&home, ControlRequest::ReloadPeers).await?; println!("reloaded"); @@ -692,6 +731,7 @@ async fn main() -> Result<()> { ephemeral, command, } => { + let exposed_protocol = protocol.clone(); let request = expose_request( protocol, socket, @@ -702,6 +742,13 @@ async fn main() -> Result<()> { command, )?; send_control(&home, request).await?; + if let Err(error) = + warn_if_no_trusted_peer_can_reach(&home, &exposed_protocol) + { + eprintln!( + "fabric: exposure succeeded, but its peer permissions could not be checked: {error:#}" + ); + } println!("exposed"); } Commands::Unexpose { protocol } => { @@ -1287,6 +1334,107 @@ fn warn_if_permissions_would_stop_a_sync( Ok(()) } +/// The explicit permission list that preserves every service available now. +/// +/// Runtime status supplies custom exposures. This includes ephemeral services, +/// which config.toml cannot supply. A sorted set makes the file stable. +fn explicit_service_names(exposed_protocols: &[String]) -> Vec { + let mut names = BTreeSet::new(); + names.extend(BUILTIN_SERVICE_NAMES.iter().map(|name| (*name).to_string())); + names.extend(exposed_protocols.iter().cloned()); + names.into_iter().collect() +} + +/// Return every peer that needs `service` added when nobody can reach it. +fn peers_needing_new_service(book: &PeerBook, service: &str) -> Option> { + if book.peers().is_empty() { + return None; + } + let mut denied = book + .peers() + .iter() + .filter(|peer| book.may(&peer.id, service).is_err()) + .map(|peer| peer.name.clone().unwrap_or_else(|| peer.id.to_string())) + .collect::>(); + if denied.len() != book.peers().len() { + return None; + } + denied.sort(); + Some(denied) +} + +/// Warn after an exposure succeeds when its ACL makes it unreachable. +fn warn_if_no_trusted_peer_can_reach(home: &FabricHome, service: &str) -> Result<()> { + let book = PeerBook::load(home)?; + let Some(peers) = peers_needing_new_service(&book, service) else { + return Ok(()); + }; + eprintln!( + "fabric: no trusted peer may reach {service:?}; add it to allow for: {}", + peers.join(", ") + ); + Ok(()) +} + +#[cfg(test)] +mod permission_helpers_tests { + use super::*; + + fn peer_book(legacy: bool) -> PeerBook { + let mut book = PeerBook::default(); + let first = iroh::SecretKey::generate().public(); + let second = iroh::SecretKey::generate().public(); + book.add_with_allow( + first, + Some("droppy".into()), + None, + if legacy { None } else { Some(vec!["sync".into()]) }, + ); + book.add_with_allow( + second, + Some("hetz".into()), + None, + Some(vec!["sync".into()]), + ); + book + } + + #[test] + fn explicit_names_include_builtins_and_live_exposures_once() { + let names = explicit_service_names(&[ + "st-sync".into(), + "pty-remote".into(), + "st-sync".into(), + ]); + for required in [ + "shell", + "exec", + "sync", + "echo", + "send-file", + "st-sync", + "pty-remote", + ] { + assert!(names.iter().any(|name| name == required), "missing {required}"); + } + let unique = names.iter().collect::>(); + assert_eq!(unique.len(), names.len()); + } + + #[test] + fn a_new_exposure_warns_only_when_every_peer_is_denied() { + assert_eq!( + peers_needing_new_service(&peer_book(false), "web"), + Some(vec!["droppy".into(), "hetz".into()]) + ); + assert_eq!( + peers_needing_new_service(&peer_book(true), "web"), + None, + "a legacy peer can reach the exposure, so the warning would be false" + ); + } +} + /// The first 12 characters of the lattice-point digest, which is enough to /// compare two machines by eye. Scripts should read the full value from /// `sync ls --json` rather than this. diff --git a/tests/local_slice.rs b/tests/local_slice.rs index 1e768ca..762899c 100644 --- a/tests/local_slice.rs +++ b/tests/local_slice.rs @@ -42,6 +42,58 @@ impl Drop for LocalSliceGuard { } } +/// The ACL transcription must read the daemon's live exposure list. Reading +/// only config.toml would omit an ephemeral exposure and narrow access now. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn make_explicit_keeps_every_service_exposed_on_this_machine_now() -> Result<()> { + let _guard = local_slice_guard().await; + let node_a_dir = TempDir::new()?; + let node_b_dir = TempDir::new()?; + let node_a_home = FabricHome::new(node_a_dir.path()); + let node_b_home = FabricHome::new(node_b_dir.path()); + let node_a = FabricNode::start(node_a_home.clone()).await?; + let node_b = FabricNode::start(node_b_home.clone()).await?; + + trust_peer( + &node_a_home, + &node_a, + node_b.id(), + Some("node-b"), + Some(node_b.addr()), + ) + .await?; + + let socket = node_a_dir.path().join("ephemeral.sock"); + run_fabric( + &node_a_home, + &[ + "expose", + "ephemeral-web", + "--socket", + socket.to_str().context("the socket path is not UTF-8")?, + "--ephemeral", + ], + )?; + run_fabric(&node_a_home, &["peers", "make-explicit"])?; + + let book = PeerBook::load(&node_a_home)?; + for service in ["shell", "exec", "sync", "echo", "send-file", "ephemeral-web"] { + assert_eq!( + book.may(&node_b.id(), service), + Ok(()), + "the transcription omitted the live service {service:?}" + ); + } + assert!( + book.may(&node_b.id(), "exposed-tomorrow").is_err(), + "the legacy entry stayed unrestricted instead of becoming explicit" + ); + + node_b.shutdown().await?; + node_a.shutdown().await?; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn local_expose_dial_round_trips_and_acl_rejects_unknown_node() -> Result<()> { let _guard = local_slice_guard().await;