feat(security): declare which app ports may skip authentication
Groundwork for the app gate (item 1): before anything can enforce authentication on app ports, the node has to know which ports are *supposed* to be reachable without it. `PortMapping` grows `auth` (PortAuth::Session | None, defaulting to Session) and `auth_rationale`. The default is deliberately the protected one. Every app port on this node answered with no credential at all over LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 — precisely because exposure was what you got by saying nothing. Inverting the default means a new app is protected unless its manifest argues for an exemption. Validation makes the argument mandatory: `auth: none` without a rationale is rejected, and so is a rationale without `auth: none` (that combination means the author wrote an exemption and did not get one — shipping it silently would leave them believing otherwise). 17 ports across 12 apps are declared exempt, each with its reason. They are the ports that cannot sit behind an HTTP login page at all: Lightning p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS — Zeus and remote wallets dial these directly), Bitcoin p2p gossip, electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published ports — now defaults to gated. Bitcoin's RPC 8332 is deliberately NOT exempted: it is already `bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption it does not need would put a line in the audit list that means nothing. If the loopback bind is ever dropped, it fails closed. Two corpus tests keep this honest: every shipped manifest must parse under the new rules, and the exempt set is pinned at 17 so any change to the node's unauthenticated surface has to be a deliberate edit. Tests: 73/73 archipelago-container, workspace builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
24ce8b39e8
commit
0c4826f8cc
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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, ManifestError> {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user