diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 7ff6061a..55cbb492 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -71,18 +71,17 @@ app: network_policy: isolated ports: - # RPC is auth-only: publish host-local + the archy-net gateway - # (host.archipelago / host.containers.internal -> 10.89.0.1) so in-node - # consumers (lnd, fedimint-gateway, btcpay) keep their backend-agnostic - # endpoint - but the LAN can no longer reach nodeIP:8332. P2P stays public. + # RPC is auth-only: publish host-local ONLY - the LAN cannot reach + # nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api) + # dial the container's archy-net alias directly (bitcoin-core:8332), + # which needs no publish at all. Do NOT bind the archy-net gateway + # (10.89.0.1): rootlessport binds in the HOST netns where that address + # does not exist, and the whole unit crash-loops (2026-07-09, .228). + # P2P 8333 stays public. - host: 8332 container: 8332 protocol: tcp bind: 127.0.0.1 - - host: 8332 - container: 8332 - protocol: tcp - bind: 10.89.0.1 - host: 8333 container: 8333 protocol: tcp diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index a5bf220c..4c3f9257 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -71,18 +71,17 @@ app: network_policy: isolated ports: - # RPC is auth-only: publish host-local + the archy-net gateway - # (host.archipelago / host.containers.internal -> 10.89.0.1) so in-node - # consumers (lnd, fedimint-gateway, btcpay) keep their backend-agnostic - # endpoint - but the LAN can no longer reach nodeIP:8332. P2P stays public. + # RPC is auth-only: publish host-local ONLY - the LAN cannot reach + # nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api) + # dial the container's archy-net alias directly (bitcoin-knots:8332), + # which needs no publish at all. Do NOT bind the archy-net gateway + # (10.89.0.1): rootlessport binds in the HOST netns where that address + # does not exist, and the whole unit crash-loops (2026-07-09, .228). + # P2P 8333 stays public. - host: 8332 container: 8332 protocol: tcp bind: 127.0.0.1 - - host: 8332 - container: 8332 - protocol: tcp - bind: 10.89.0.1 - host: 8333 container: 8333 protocol: tcp diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index b11609ad..b6457bb0 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -660,16 +660,15 @@ pub(super) async fn get_app_config( ), "bitcoin-core" => ( vec![ - // RPC + ZMQ are auth-only/unauthenticated: host-local + the - // archy-net gateway (host.archipelago) only — never the LAN. - // P2P 8333 stays public. + // RPC + ZMQ are auth-only/unauthenticated: host-local ONLY — + // never the LAN. In-node consumers dial the container's + // archy-net alias directly; never bind the archy-net gateway + // (10.89.0.1) — rootlessport can't hold it and podman run + // fails outright (2026-07-09, .228). P2P 8333 stays public. "127.0.0.1:8332:8332".to_string(), - "10.89.0.1:8332:8332".to_string(), "8333:8333".to_string(), "127.0.0.1:28332:28332".to_string(), - "10.89.0.1:28332:28332".to_string(), "127.0.0.1:28333:28333".to_string(), - "10.89.0.1:28333:28333".to_string(), ], vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()], vec![], @@ -708,16 +707,15 @@ pub(super) async fn get_app_config( ), "bitcoin" | "bitcoin-knots" => ( vec![ - // RPC + ZMQ are auth-only/unauthenticated: host-local + the - // archy-net gateway (host.archipelago) only — never the LAN. - // P2P 8333 stays public. + // RPC + ZMQ are auth-only/unauthenticated: host-local ONLY — + // never the LAN. In-node consumers dial the container's + // archy-net alias directly; never bind the archy-net gateway + // (10.89.0.1) — rootlessport can't hold it and podman run + // fails outright (2026-07-09, .228). P2P 8333 stays public. "127.0.0.1:8332:8332".to_string(), - "10.89.0.1:8332:8332".to_string(), "8333:8333".to_string(), "127.0.0.1:28332:28332".to_string(), - "10.89.0.1:28332:28332".to_string(), "127.0.0.1:28333:28333".to_string(), - "10.89.0.1:28333:28333".to_string(), ], vec!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()], vec![], diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 8dc43714..70570b0a 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -720,6 +720,24 @@ impl RpcHandler { self.create_data_dirs(package_id, &volumes).await; for port in &ports { + // Drop publishes whose bind address the host can't hold (e.g. the + // archy-net gateway 10.89.0.1 under rootless podman) — passing + // them through makes `podman run` itself fail and takes the app + // down. "ip:host:container" has 2 colons; plain "host:container" + // has 1 (no IPv6 binds in the legacy string table). + let bind = if port.matches(':').count() == 2 { + port.split(':').next().unwrap_or("") + } else { + "" + }; + if !archipelago_container::manifest::host_can_bind_publish_ip(bind) { + warn!( + app = %package_id, + publish = %port, + "dropping publish: bind address not assignable on this host" + ); + continue; + } run_args.push("-p"); run_args.push(port); } diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 993f8378..c0ad61c5 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -433,6 +433,19 @@ impl QuadletUnit { ports: app .ports .iter() + .filter(|p| { + let ok = archipelago_container::manifest::host_can_bind_publish_ip(&p.bind); + if !ok { + tracing::warn!( + app = %app.id, + bind = %p.bind, + host_port = p.host, + "dropping publish: bind address not assignable on this host \ + (rootlessport would crash-loop the unit)" + ); + } + ok + }) .map(|p| (p.host, p.container, p.protocol.clone(), p.bind.clone())) .collect(), environment: app.environment.clone(), diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index d970e8e9..85a51cf8 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -523,6 +523,33 @@ impl From<(u16, u16)> for PortMapping { } } +/// True when the host can actually bind a publish to `ip` right now. +/// +/// Rootless podman forwards published ports via rootlessport, which listens +/// in the HOST network namespace — an address that only exists inside the +/// rootless netns (e.g. the archy-net bridge gateway `10.89.0.1`) fails with +/// "cannot assign requested address" and systemd crash-loops the whole unit +/// (bitcoin went down fleet-wide-capable this way, 2026-07-09 on .228). +/// Callers drop such publishes instead of passing them through: a publish +/// that cannot bind provides nothing, while the failed bind takes the +/// container down with it. +/// +/// Empty (= 0.0.0.0), wildcard, and loopback binds are always accepted +/// without probing. Anything else is probed with an ephemeral-port bind. +/// Unparseable addresses return false — podman would reject them anyway. +pub fn host_can_bind_publish_ip(ip: &str) -> bool { + if ip.is_empty() { + return true; + } + let Ok(addr) = ip.parse::() else { + return false; + }; + if addr.is_unspecified() || addr.is_loopback() { + return true; + } + std::net::TcpListener::bind((addr, 0)).is_ok() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Volume { #[serde(rename = "type")] @@ -1364,6 +1391,25 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; + #[test] + fn host_can_bind_accepts_empty_wildcard_and_loopback_without_probing() { + assert!(host_can_bind_publish_ip("")); + assert!(host_can_bind_publish_ip("0.0.0.0")); + assert!(host_can_bind_publish_ip("127.0.0.1")); + assert!(host_can_bind_publish_ip("::")); + assert!(host_can_bind_publish_ip("::1")); + } + + #[test] + fn host_can_bind_rejects_addresses_absent_from_the_host() { + // TEST-NET-1 (RFC 5737) is never assigned to a real interface. The + // production case is 10.89.0.1 (podman bridge gateway, exists only + // inside the rootless netns), but that could legitimately bind on a + // rootful host, so the test probes an address that can't. + assert!(!host_can_bind_publish_ip("192.0.2.1")); + assert!(!host_can_bind_publish_ip("not-an-ip")); + } + #[test] fn partition_taints_plain_entries_that_interpolate_secrets() { let plain = vec![ diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 9e62ed19..0c584e85 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -291,6 +291,15 @@ impl PodmanClient { // Build the container spec for the API let mut port_mappings = Vec::new(); for port in &manifest.app.ports { + if !crate::manifest::host_can_bind_publish_ip(&port.bind) { + tracing::warn!( + app = %manifest.app.id, + bind = %port.bind, + host_port = port.host, + "dropping publish: bind address not assignable on this host" + ); + continue; + } // Honour the manifest's protocol (default tcp). netbird's STUN port // is 3478/udp; forcing tcp here would publish the wrong protocol and // silently break relay discovery.