Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 28 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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 <protocol>
Expand Down Expand Up @@ -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]]
Expand All @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
41 changes: 37 additions & 4 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading