feat(security): bind Bitcoin RPC/ZMQ publishes to loopback + archy-net gateway (§C)

USER DECISION 2026-07-08: accept breaking external wallets pointed at
nodeIP:8332. The 0.0.0.0 publish exposed auth-only RPC (and unauthenticated
ZMQ 28332/28333 on the legacy path) to the whole LAN.

- New `bind` field on manifest port mappings (validated as an IP; the same
  host port may repeat with distinct binds). Rendered as
  PublishPort=<ip>:<host>:<container> in quadlet units and host_ip in the
  podman API create — unbound ports render byte-identical to before, so no
  fleet-wide false-drift wave.
- bitcoin-knots/-core manifests publish 8332 on 127.0.0.1 + 10.89.0.1 only.
  In-node consumers (lnd, fedimint-gateway, btcpay/nbxplorer) are unaffected:
  they dial host.archipelago / host.containers.internal, which the
  orchestrator pins to the archy-net gateway 10.89.0.1. P2P 8333 stays public.
- Legacy config.rs port strings get the same treatment incl. ZMQ.

Tests: new quadlet bind-render + manifest bind-validation tests;
container:: suite 167/167, archipelago-container 63/63.

DEPLOY NOTE: PublishPort strings change for bitcoin containers → one
planned recreate per node; restart lnd afterwards (it caches the backend
IP — see the backend-recreate cascade tracker item). Catalog manifests for
bitcoin apps must be regenerated + re-signed for catalog-covered nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-08 20:29:02 -04:00
co-authored by Claude Fable 5
parent 81743874a1
commit dd61a20413
8 changed files with 157 additions and 18 deletions
+72 -1
View File
@@ -503,6 +503,13 @@ pub struct PortMapping {
pub container: u16,
#[serde(default)]
pub protocol: String,
/// Host address to bind the publish to. Empty = all interfaces
/// (0.0.0.0). Set `127.0.0.1` to keep a port host-local; list the same
/// host/container pair twice with different binds to serve several
/// addresses (e.g. loopback + the archy-net gateway `10.89.0.1` so
/// containers keep reaching it via `host.archipelago`).
#[serde(default)]
pub bind: String,
}
impl From<(u16, u16)> for PortMapping {
@@ -511,6 +518,7 @@ impl From<(u16, u16)> for PortMapping {
host,
container,
protocol: "tcp".to_string(),
bind: String::new(),
}
}
}
@@ -975,7 +983,16 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
"ports[{i}].protocol must be tcp or udp"
)));
}
if !seen_host.insert((port.host, protocol.to_string())) {
if !port.bind.is_empty() && port.bind.parse::<std::net::IpAddr>().is_err() {
return Err(ManifestError::Invalid(format!(
"ports[{i}].bind must be an IP address, got '{}'",
port.bind
)));
}
// 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.
if !seen_host.insert((port.host, protocol.to_string(), port.bind.clone())) {
return Err(ManifestError::Invalid(format!(
"ports contains duplicate host binding {}/{}",
port.host, protocol
@@ -2126,6 +2143,35 @@ app:
"#,
"duplicate host binding",
),
(
"duplicate host port with same bind",
r#"
app:
id: bad-port-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
- { host: 8332, container: 8332, protocol: tcp, bind: 127.0.0.1 }
"#,
"duplicate host binding",
),
(
"non-IP bind address",
r#"
app:
id: bad-bind
name: Bad
version: 1.0.0
container:
image: test/image:latest
ports:
- { host: 8332, container: 8332, protocol: tcp, bind: localhost }
"#,
"bind must be an IP address",
),
(
"bad device",
r#"
@@ -2165,6 +2211,31 @@ app:
}
}
#[test]
fn same_host_port_with_distinct_binds_is_valid() {
// Bitcoin RPC hardening: 8332 published on loopback + the archy-net
// gateway as two mappings of the same host port.
let m = AppManifest::parse(
r#"
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 1.0.0
container:
image: test/bitcoin:latest
ports:
- { 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 }
"#,
)
.expect("distinct binds on one host port must validate");
assert_eq!(m.app.ports.len(), 3);
assert_eq!(m.app.ports[0].bind, "127.0.0.1");
assert_eq!(m.app.ports[1].bind, "10.89.0.1");
assert_eq!(m.app.ports[2].bind, "");
}
#[test]
fn reviewed_host_bind_exceptions_parse() {
let yaml = r#"
+6 -2
View File
@@ -299,11 +299,15 @@ impl PodmanClient {
"sctp" => "sctp",
_ => "tcp",
};
port_mappings.push(serde_json::json!({
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
}));
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
port_mappings.push(mapping);
}
let mut mounts = Vec::new();