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
+18 -6
View File
@@ -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![],
+1 -1
View File
@@ -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![],
@@ -3990,6 +3990,7 @@ mod tests {
host,
container,
protocol: "tcp".to_string(),
bind: String::new(),
}
}
+41 -8
View File
@@ -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<String>,
/// Secret-backed env: (env_key, podman secret name). Rendered as
/// `Secret=<name>,type=env,target=<key>` 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!(