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:
archipelago
2026-07-08 23:41:39 -04:00
co-authored by Claude Fable 5
parent 922b79bf95
commit eb6ec71a56
7 changed files with 110 additions and 28 deletions
+10 -12
View File
@@ -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![],
@@ -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);
}
+13
View File
@@ -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(),