From dd61a20413b568302aaded4e8259cf683acdb4aa Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 8 Jul 2026 20:29:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(security):=20bind=20Bitcoin=20RPC/ZMQ=20pu?= =?UTF-8?q?blishes=20to=20loopback=20+=20archy-net=20gateway=20(=C2=A7C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=:: 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 --- apps/bitcoin-core/manifest.yml | 9 +++ apps/bitcoin-knots/manifest.yml | 9 +++ .../archipelago/src/api/rpc/package/config.rs | 24 ++++-- core/archipelago/src/container/companion.rs | 2 +- .../src/container/prod_orchestrator.rs | 1 + core/archipelago/src/container/quadlet.rs | 49 +++++++++++-- core/container/src/manifest.rs | 73 ++++++++++++++++++- core/container/src/podman_client.rs | 8 +- 8 files changed, 157 insertions(+), 18 deletions(-) diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index 7bd9d9f1..7ff6061a 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -71,9 +71,18 @@ 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. - 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 a54e0a54..a5bf220c 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -71,9 +71,18 @@ 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. - 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 c41f63dd..b11609ad 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -660,10 +660,16 @@ pub(super) async fn get_app_config( ), "bitcoin-core" => ( vec![ - "8332:8332".to_string(), + // RPC + ZMQ are auth-only/unauthenticated: host-local + the + // archy-net gateway (host.archipelago) only — never the LAN. + // P2P 8333 stays public. + "127.0.0.1:8332:8332".to_string(), + "10.89.0.1:8332:8332".to_string(), "8333:8333".to_string(), - "28332:28332".to_string(), - "28333:28333".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![], @@ -702,10 +708,16 @@ pub(super) async fn get_app_config( ), "bitcoin" | "bitcoin-knots" => ( vec![ - "8332:8332".to_string(), + // RPC + ZMQ are auth-only/unauthenticated: host-local + the + // archy-net gateway (host.archipelago) only — never the LAN. + // P2P 8333 stays public. + "127.0.0.1:8332:8332".to_string(), + "10.89.0.1:8332:8332".to_string(), "8333:8333".to_string(), - "28332:28332".to_string(), - "28333:28333".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/container/companion.rs b/core/archipelago/src/container/companion.rs index 71423380..51bd32db 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -424,7 +424,7 @@ fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit { ports: spec .ports .iter() - .map(|(host, container)| (*host, *container, "tcp".into())) + .map(|(host, container)| (*host, *container, "tcp".into(), String::new())) .collect(), extra_podman_args: vec![], depends_on: vec![], diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 41dbfb47..9af0d518 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3990,6 +3990,7 @@ mod tests { host, container, protocol: "tcp".to_string(), + bind: String::new(), } } diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index cfe1f42b..993f8378 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -140,7 +140,8 @@ pub struct QuadletUnit { // Backend-manifest extensions (Phase 3.1). Companion units leave // these defaulted; the renderer skips empty/false directives so a // companion's rendered bytes are unchanged from before this PR. - pub ports: Vec<(u16, u16, String)>, + /// (host_port, container_port, protocol, bind). `bind` empty = 0.0.0.0. + pub ports: Vec<(u16, u16, String, String)>, pub environment: Vec, /// Secret-backed env: (env_key, podman secret name). Rendered as /// `Secret=,type=env,target=` so the VALUE never lands in @@ -241,13 +242,19 @@ impl QuadletUnit { // (exit 125). Skip publishing in host mode — matches the NetworkMode // doc note that Podman discards port mappings under host networking. if !matches!(self.network, NetworkMode::Host) { - for (host, container, proto) in &self.ports { + for (host, container, proto, bind) in &self.ports { let p = if proto.is_empty() { "tcp" } else { proto.as_str() }; - let _ = writeln!(s, "PublishPort={host}:{container}/{p}"); + // Keep the rendered directive byte-identical for unbound + // ports so existing units don't read as drifted. + if bind.is_empty() { + let _ = writeln!(s, "PublishPort={host}:{container}/{p}"); + } else { + let _ = writeln!(s, "PublishPort={bind}:{host}:{container}/{p}"); + } } } for env in &self.environment { @@ -426,7 +433,7 @@ impl QuadletUnit { ports: app .ports .iter() - .map(|p| (p.host, p.container, p.protocol.clone())) + .map(|p| (p.host, p.container, p.protocol.clone(), p.bind.clone())) .collect(), environment: app.environment.clone(), secret_env: app @@ -957,7 +964,7 @@ mod tests { // Podman rejects PublishPort with Network=host (crash-loop exit 125). let mut u = sample_unit(); u.network = NetworkMode::Host; - u.ports = vec![(3000, 3000, "tcp".into())]; + u.ports = vec![(3000, 3000, "tcp".into(), String::new())]; let s = u.render(); assert!(s.contains("Network=host")); assert!(!s.contains("PublishPort")); @@ -967,7 +974,7 @@ mod tests { fn render_non_host_network_emits_publish_ports() { let mut u = sample_unit(); u.network = NetworkMode::Bridge("archy-net".into()); - u.ports = vec![(3000, 3000, "tcp".into())]; + u.ports = vec![(3000, 3000, "tcp".into(), String::new())]; let s = u.render(); assert!(s.contains("PublishPort=3000:3000/tcp")); } @@ -1085,6 +1092,29 @@ mod tests { assert_eq!(RestartPolicy::OnFailure.as_systemd(), "on-failure"); } + #[test] + fn render_bound_ports_prefix_the_bind_address() { + // Bitcoin RPC hardening: the same host port published on loopback + + // the archy-net gateway; an unbound port must render byte-identical + // to the legacy form so existing units don't read as drifted. + let u = QuadletUnit { + name: "bitcoin-knots".into(), + image: "registry/bitcoin-knots:latest".into(), + network: NetworkMode::Bridge("archy-net".into()), + ports: vec![ + (8332, 8332, "tcp".into(), "127.0.0.1".into()), + (8332, 8332, "tcp".into(), "10.89.0.1".into()), + (8333, 8333, "tcp".into(), String::new()), + ], + ..QuadletUnit::default() + }; + let s = u.render(); + assert!(s.contains("PublishPort=127.0.0.1:8332:8332/tcp")); + assert!(s.contains("PublishPort=10.89.0.1:8332:8332/tcp")); + assert!(s.contains("PublishPort=8333:8333/tcp")); + assert!(!s.contains("PublishPort=8332:8332/tcp")); + } + #[test] fn render_emits_backend_directives_when_set() { let u = QuadletUnit { @@ -1094,7 +1124,10 @@ mod tests { network: NetworkMode::Bridge("archy-net".into()), cap_drop_all: true, cap_add: vec!["NET_BIND_SERVICE".into()], - ports: vec![(8332, 8332, "tcp".into()), (8333, 8333, "tcp".into())], + ports: vec![ + (8332, 8332, "tcp".into(), String::new()), + (8333, 8333, "tcp".into(), String::new()), + ], environment: vec![ "BITCOIN_RPC_USER=archipelago".into(), "BITCOIN_RPC_PASS=secret".into(), @@ -1189,7 +1222,7 @@ app: assert!(u.read_only_root); assert!(u.no_new_privileges); assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]); - assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string())]); + assert_eq!(u.ports, vec![(8332, 8332, "tcp".to_string(), String::new())]); assert_eq!(u.environment, vec!["BITCOIN_NETWORK=mainnet"]); assert_eq!(u.bind_mounts.len(), 1); assert_eq!( diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index d365ac32..d970e8e9 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -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::().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#" diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 1a965ea5..9e62ed19 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -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();