fix(security): drop unbindable publish binds instead of crash-looping the app
The dd61a204 bind hardening published Bitcoin RPC on the archy-net gateway 10.89.0.1 — but rootlessport binds in the HOST netns, where that address does not exist. First real deploy (.228, 2026-07-09) crash-looped bitcoin-knots AND bitcoin-core the moment the gate's stop→start regenerated the unit from the re-signed catalog: 'rootlessport listen tcp 10.89.0.1:8332: bind: cannot assign requested address', restart counter 132. Hand-edits to the unit don't survive — the orchestrator regenerates it from the signed catalog manifest within seconds. - New archipelago_container::manifest::host_can_bind_publish_ip(): empty/ wildcard/loopback accepted without probing, anything else ephemeral-bind probed. Applied at all three publish paths — quadlet from_manifest (PublishPort), podman API create (host_ip), and the legacy -p string table loop — each dropping the publish with a warn instead of taking the container down. This neutralizes the bad binds already in the SIGNED catalog, so nodes recover on binary deploy alone (no ceremony needed). - Remove the gateway publishes from bitcoin-knots/-core manifests and the legacy config.rs tables: verified on .228 that every in-node consumer (lnd, btcpay/nbxplorer, fedimint, mempool-api) dials the container's archy-net alias directly (bitcoin-knots:8332) and lnd uses RPC polling (no ZMQ) — the gateway publish had zero consumers. Loopback-only RPC/ZMQ (the approved LAN lockdown) stands; P2P 8333 stays public. Catalog still ships the gateway binds until the next signing ceremony regenerates it from these manifests; the guard makes that non-urgent. Tests: container crate 65/65 (2 new guard tests), quadlet 40/40. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
922b79bf95
commit
eb6ec71a56
@ -71,18 +71,17 @@ app:
|
|||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# RPC is auth-only: publish host-local + the archy-net gateway
|
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
|
||||||
# (host.archipelago / host.containers.internal -> 10.89.0.1) so in-node
|
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
|
||||||
# consumers (lnd, fedimint-gateway, btcpay) keep their backend-agnostic
|
# dial the container's archy-net alias directly (bitcoin-core:8332),
|
||||||
# endpoint - but the LAN can no longer reach nodeIP:8332. P2P stays public.
|
# 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
|
- host: 8332
|
||||||
container: 8332
|
container: 8332
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
bind: 127.0.0.1
|
bind: 127.0.0.1
|
||||||
- host: 8332
|
|
||||||
container: 8332
|
|
||||||
protocol: tcp
|
|
||||||
bind: 10.89.0.1
|
|
||||||
- host: 8333
|
- host: 8333
|
||||||
container: 8333
|
container: 8333
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
|||||||
@ -71,18 +71,17 @@ app:
|
|||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
# RPC is auth-only: publish host-local + the archy-net gateway
|
# RPC is auth-only: publish host-local ONLY - the LAN cannot reach
|
||||||
# (host.archipelago / host.containers.internal -> 10.89.0.1) so in-node
|
# nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api)
|
||||||
# consumers (lnd, fedimint-gateway, btcpay) keep their backend-agnostic
|
# dial the container's archy-net alias directly (bitcoin-knots:8332),
|
||||||
# endpoint - but the LAN can no longer reach nodeIP:8332. P2P stays public.
|
# 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
|
- host: 8332
|
||||||
container: 8332
|
container: 8332
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
bind: 127.0.0.1
|
bind: 127.0.0.1
|
||||||
- host: 8332
|
|
||||||
container: 8332
|
|
||||||
protocol: tcp
|
|
||||||
bind: 10.89.0.1
|
|
||||||
- host: 8333
|
- host: 8333
|
||||||
container: 8333
|
container: 8333
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
|||||||
@ -660,16 +660,15 @@ pub(super) async fn get_app_config(
|
|||||||
),
|
),
|
||||||
"bitcoin-core" => (
|
"bitcoin-core" => (
|
||||||
vec![
|
vec![
|
||||||
// RPC + ZMQ are auth-only/unauthenticated: host-local + the
|
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||||
// archy-net gateway (host.archipelago) only — never the LAN.
|
// never the LAN. In-node consumers dial the container's
|
||||||
// P2P 8333 stays public.
|
// 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(),
|
"127.0.0.1:8332:8332".to_string(),
|
||||||
"10.89.0.1:8332:8332".to_string(),
|
|
||||||
"8333:8333".to_string(),
|
"8333:8333".to_string(),
|
||||||
"127.0.0.1:28332:28332".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(),
|
"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!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
@ -708,16 +707,15 @@ pub(super) async fn get_app_config(
|
|||||||
),
|
),
|
||||||
"bitcoin" | "bitcoin-knots" => (
|
"bitcoin" | "bitcoin-knots" => (
|
||||||
vec![
|
vec![
|
||||||
// RPC + ZMQ are auth-only/unauthenticated: host-local + the
|
// RPC + ZMQ are auth-only/unauthenticated: host-local ONLY —
|
||||||
// archy-net gateway (host.archipelago) only — never the LAN.
|
// never the LAN. In-node consumers dial the container's
|
||||||
// P2P 8333 stays public.
|
// 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(),
|
"127.0.0.1:8332:8332".to_string(),
|
||||||
"10.89.0.1:8332:8332".to_string(),
|
|
||||||
"8333:8333".to_string(),
|
"8333:8333".to_string(),
|
||||||
"127.0.0.1:28332:28332".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(),
|
"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!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
|
|||||||
@ -720,6 +720,24 @@ impl RpcHandler {
|
|||||||
self.create_data_dirs(package_id, &volumes).await;
|
self.create_data_dirs(package_id, &volumes).await;
|
||||||
|
|
||||||
for port in &ports {
|
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("-p");
|
||||||
run_args.push(port);
|
run_args.push(port);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -433,6 +433,19 @@ impl QuadletUnit {
|
|||||||
ports: app
|
ports: app
|
||||||
.ports
|
.ports
|
||||||
.iter()
|
.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()))
|
.map(|p| (p.host, p.container, p.protocol.clone(), p.bind.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
environment: app.environment.clone(),
|
environment: app.environment.clone(),
|
||||||
|
|||||||
@ -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::<std::net::IpAddr>() 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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Volume {
|
pub struct Volume {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@ -1364,6 +1391,25 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
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]
|
#[test]
|
||||||
fn partition_taints_plain_entries_that_interpolate_secrets() {
|
fn partition_taints_plain_entries_that_interpolate_secrets() {
|
||||||
let plain = vec![
|
let plain = vec![
|
||||||
|
|||||||
@ -291,6 +291,15 @@ impl PodmanClient {
|
|||||||
// Build the container spec for the API
|
// Build the container spec for the API
|
||||||
let mut port_mappings = Vec::new();
|
let mut port_mappings = Vec::new();
|
||||||
for port in &manifest.app.ports {
|
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
|
// Honour the manifest's protocol (default tcp). netbird's STUN port
|
||||||
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
// is 3478/udp; forcing tcp here would publish the wrong protocol and
|
||||||
// silently break relay discovery.
|
// silently break relay discovery.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user