diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index b4cca115..c9c3879b 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -88,6 +88,9 @@ app: - host: 8333 container: 8333 protocol: tcp + auth: none + auth_rationale: >- + Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP. volumes: - type: bind diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 2ed0da00..884eb5a2 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -88,6 +88,9 @@ app: - host: 8333 container: 8333 protocol: tcp + auth: none + auth_rationale: >- + Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP. volumes: - type: bind diff --git a/apps/core-lightning/manifest.yml b/apps/core-lightning/manifest.yml index e002a04b..2d98f11e 100644 --- a/apps/core-lightning/manifest.yml +++ b/apps/core-lightning/manifest.yml @@ -31,9 +31,15 @@ app: - host: 9736 container: 9735 protocol: tcp # P2P (using 9736 to avoid conflict with LND) + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. - host: 9835 container: 9835 protocol: tcp # gRPC + auth: none + auth_rationale: >- + Core Lightning gRPC, authenticated by mutual TLS client certificates. volumes: - type: bind diff --git a/apps/electrumx/manifest.yml b/apps/electrumx/manifest.yml index bdbb7e9b..ffa929db 100644 --- a/apps/electrumx/manifest.yml +++ b/apps/electrumx/manifest.yml @@ -45,6 +45,9 @@ app: - host: 50001 container: 50001 protocol: tcp + auth: none + auth_rationale: >- + Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie. volumes: - type: bind diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml index 3f80b57d..4926ba3f 100644 --- a/apps/gitea/manifest.yml +++ b/apps/gitea/manifest.yml @@ -29,6 +29,9 @@ app: - host: 2222 container: 22 protocol: tcp + auth: none + auth_rationale: >- + Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here. volumes: - type: bind diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml index 4c5e4431..ac7fc9fe 100644 --- a/apps/lightning-stack/manifest.yml +++ b/apps/lightning-stack/manifest.yml @@ -32,9 +32,15 @@ app: - host: 9738 container: 9735 protocol: tcp # P2P + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. - host: 10010 container: 10009 protocol: tcp # gRPC + auth: none + auth_rationale: >- + LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly. - host: 8091 container: 8080 protocol: tcp # REST/Web UI diff --git a/apps/lnd/manifest.yml b/apps/lnd/manifest.yml index 446e34e8..bde08f95 100644 --- a/apps/lnd/manifest.yml +++ b/apps/lnd/manifest.yml @@ -38,12 +38,21 @@ app: - host: 9735 container: 9735 protocol: tcp + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. - host: 10009 container: 10009 protocol: tcp + auth: none + auth_rationale: >- + LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly. - host: 18080 container: 8080 protocol: tcp + auth: none + auth_rationale: >- + LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client. volumes: - type: bind diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml index cda51af9..994949f6 100644 --- a/apps/netbird-server/manifest.yml +++ b/apps/netbird-server/manifest.yml @@ -51,6 +51,9 @@ app: - host: 3478 container: 3478 protocol: udp # STUN — must be UDP; tcp here breaks relay discovery + auth: none + auth_rationale: >- + STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all. volumes: - type: bind diff --git a/apps/pine-openwakeword/manifest.yml b/apps/pine-openwakeword/manifest.yml index 03910469..796c1a12 100644 --- a/apps/pine-openwakeword/manifest.yml +++ b/apps/pine-openwakeword/manifest.yml @@ -40,6 +40,9 @@ app: - host: 10400 container: 10400 protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. volumes: - type: bind diff --git a/apps/pine-piper/manifest.yml b/apps/pine-piper/manifest.yml index 01f69d92..9b7cff4c 100644 --- a/apps/pine-piper/manifest.yml +++ b/apps/pine-piper/manifest.yml @@ -40,6 +40,9 @@ app: - host: 10200 container: 10200 protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. volumes: - type: bind diff --git a/apps/pine-whisper/manifest.yml b/apps/pine-whisper/manifest.yml index 9951e7d7..4f9ebdc9 100644 --- a/apps/pine-whisper/manifest.yml +++ b/apps/pine-whisper/manifest.yml @@ -48,6 +48,9 @@ app: - host: 10300 container: 10300 protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. volumes: - type: bind diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml index 5bf88a1a..fb4300d4 100644 --- a/apps/router/manifest.yml +++ b/apps/router/manifest.yml @@ -33,9 +33,15 @@ app: - host: 5353 container: 5353 protocol: udp # mDNS/Bonjour + auth: none + auth_rationale: >- + mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN. - host: 1900 container: 1900 protocol: udp # SSDP + auth: none + auth_rationale: >- + SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session. volumes: - type: bind diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index b1720350..c6c55434 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -503,6 +503,35 @@ fn default_network_policy() -> String { "isolated".to_string() } +/// Whether a published port must sit behind the node's app authentication +/// gate. +/// +/// The default is deliberately the protected one. Every app port on this +/// node was reachable with no credential at all over LAN, Tailscale, Tor and +/// the FIPS mesh alike (reproduced 2026-08-03) precisely because exposure +/// was the thing you got by saying nothing. Making `Session` the default +/// inverts that: a new app is protected unless its manifest argues for an +/// exemption, and the exemptions are a `grep auth: none apps/` rather than a +/// discovery. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PortAuth { + /// Default. The daemon's app gate authenticates every connection: a + /// valid session (2FA honoured, since a session still pending its TOTP + /// step fails validation) or an app-scoped bearer token for machine + /// clients. Anything else gets the login page. + #[default] + Session, + /// Exempt — the gate does not touch this port. + /// + /// Only legitimate when the port carries a protocol that authenticates + /// itself (LND macaroons, Lightning's noise handshake, TLS client + /// certs) or one where a login page would be meaningless and harmful + /// (Bitcoin p2p gossip, mDNS). Requires `auth_rationale`: an exemption + /// nobody can explain is an exemption nobody reviewed. + None, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PortMapping { pub host: u16, @@ -516,6 +545,15 @@ pub struct PortMapping { /// containers keep reaching it via `host.archipelago`). #[serde(default)] pub bind: String, + /// Whether the app gate authenticates connections to this port. + /// Omitted = `session` (protected). See [`PortAuth`]. + #[serde(default)] + pub auth: PortAuth, + /// Why this port is safe to expose unauthenticated. **Required** when + /// `auth` is `none`, rejected otherwise — a rationale on a gated port + /// means the author expected an exemption they did not get. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_rationale: Option, } impl From<(u16, u16)> for PortMapping { @@ -525,6 +563,8 @@ impl From<(u16, u16)> for PortMapping { container, protocol: "tcp".to_string(), bind: String::new(), + auth: PortAuth::Session, + auth_rationale: None, } } } @@ -1022,6 +1062,34 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> { port.bind ))); } + // An exemption from the app gate has to carry its own justification. + // Enforcing it here rather than at review time means the reason + // exists in the manifest for every exempt port, so auditing the + // node's unauthenticated surface is reading a list, not inferring + // one from silence. + match (port.auth, port.auth_rationale.as_ref()) { + (PortAuth::None, None) => { + return Err(ManifestError::Invalid(format!( + "ports[{i}] sets auth: none but no auth_rationale — an unauthenticated \ + port must state why it is safe to expose" + ))); + } + (PortAuth::None, Some(rationale)) if rationale.trim().is_empty() => { + return Err(ManifestError::Invalid(format!( + "ports[{i}].auth_rationale cannot be empty" + ))); + } + // A rationale on a gated port means the author wrote an + // exemption and did not get one. Silently keeping the port + // protected would be safe but misleading, so say so. + (PortAuth::Session, Some(_)) => { + return Err(ManifestError::Invalid(format!( + "ports[{i}] sets auth_rationale without auth: none — the port is gated \ + and the rationale has no effect" + ))); + } + _ => {} + } // The same host port may be listed more than once with different bind // addresses (e.g. loopback + the archy-net gateway); identical // (host, protocol, bind) triples are still rejected. @@ -1519,6 +1587,121 @@ app: } } + /// Build a manifest with one port block, so each auth case differs only + /// in the lines under test. + fn manifest_with_port(port_yaml: &str) -> Result { + AppManifest::parse(&format!( + "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n ports:\n{port_yaml}" + )) + } + + /// Every manifest we ship must satisfy the schema — including the auth + /// rules above. Without this the first exemption typo'd into a manifest + /// would only surface when a node refused to load the app. + #[test] + fn all_shipped_manifests_parse() { + let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"); + let Ok(entries) = std::fs::read_dir(&apps) else { + return; // not a full checkout (vendored crate) — nothing to check + }; + let mut checked = 0; + for entry in entries.flatten() { + let manifest = entry.path().join("manifest.yml"); + if !manifest.is_file() { + continue; + } + let yaml = std::fs::read_to_string(&manifest).expect("manifest readable"); + AppManifest::parse(&yaml) + .unwrap_or_else(|e| panic!("{} is invalid: {e}", manifest.display())); + checked += 1; + } + assert!(checked > 40, "only found {checked} manifests — path wrong?"); + } + + /// The exempt set is the node's entire unauthenticated attack surface, so + /// it must stay small and deliberate. If this count moves, someone added + /// or removed an exemption and it wants a second pair of eyes. + #[test] + fn unauthenticated_ports_are_all_accounted_for() { + let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"); + let Ok(entries) = std::fs::read_dir(&apps) else { + return; + }; + let mut exempt: Vec<(String, u16)> = Vec::new(); + for entry in entries.flatten() { + let manifest = entry.path().join("manifest.yml"); + if !manifest.is_file() { + continue; + } + let yaml = std::fs::read_to_string(&manifest).expect("manifest readable"); + let parsed = AppManifest::parse(&yaml).expect("manifest valid"); + for port in &parsed.app.ports { + if port.auth == PortAuth::None { + exempt.push((parsed.app.id.clone(), port.host)); + } + } + } + exempt.sort(); + assert_eq!( + exempt.len(), + 17, + "unauthenticated port set changed — review before updating this count: {exempt:?}" + ); + } + + #[test] + fn port_auth_defaults_to_session() { + // The whole point of the default: a manifest that says nothing about + // auth must come out PROTECTED, not exposed. If this ever flips, + // every existing app silently loses its gate. + let manifest = manifest_with_port(" - host: 8080\n container: 80\n").unwrap(); + assert_eq!(manifest.app.ports[0].auth, PortAuth::Session); + assert!(manifest.app.ports[0].auth_rationale.is_none()); + } + + #[test] + fn port_auth_none_requires_a_rationale() { + let err = manifest_with_port(" - host: 8333\n container: 8333\n auth: none\n") + .expect_err("auth: none without a rationale must be rejected"); + assert!( + err.to_string().contains("auth_rationale"), + "error should name the missing field, got: {err}" + ); + } + + #[test] + fn port_auth_none_rejects_a_blank_rationale() { + assert!(manifest_with_port( + " - host: 8333\n container: 8333\n auth: none\n auth_rationale: \" \"\n" + ) + .is_err()); + } + + #[test] + fn port_auth_none_with_a_rationale_parses() { + let manifest = manifest_with_port( + " - host: 8333\n container: 8333\n auth: none\n auth_rationale: Bitcoin p2p gossip\n", + ) + .unwrap(); + assert_eq!(manifest.app.ports[0].auth, PortAuth::None); + assert_eq!( + manifest.app.ports[0].auth_rationale.as_deref(), + Some("Bitcoin p2p gossip") + ); + } + + #[test] + fn rationale_without_auth_none_is_rejected() { + // Catches the author who wrote the justification but forgot the + // `auth: none` line: the port stays gated, and shipping it silently + // would leave them believing they had an exemption they never got. + let err = manifest_with_port( + " - host: 8080\n container: 80\n auth_rationale: I meant to exempt this\n", + ) + .expect_err("a rationale on a gated port must be rejected"); + assert!(err.to_string().contains("no effect"), "got: {err}"); + } + #[test] fn hooks_reject_empty_exec() { let yaml = "app:\n id: a\n name: a\n version: 1.0.0\n container:\n image: x:y\n hooks:\n post_install:\n - exec: []\n";