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
+46
View File
@@ -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)]
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![
+9
View File
@@ -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.