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:
parent
81743874a1
commit
dd61a20413
@ -71,9 +71,18 @@ app:
|
|||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
|
|
||||||
ports:
|
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
|
- host: 8332
|
||||||
container: 8332
|
container: 8332
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
- host: 8332
|
||||||
|
container: 8332
|
||||||
|
protocol: tcp
|
||||||
|
bind: 10.89.0.1
|
||||||
- host: 8333
|
- host: 8333
|
||||||
container: 8333
|
container: 8333
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
|||||||
@ -71,9 +71,18 @@ app:
|
|||||||
network_policy: isolated
|
network_policy: isolated
|
||||||
|
|
||||||
ports:
|
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
|
- host: 8332
|
||||||
container: 8332
|
container: 8332
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
bind: 127.0.0.1
|
||||||
|
- host: 8332
|
||||||
|
container: 8332
|
||||||
|
protocol: tcp
|
||||||
|
bind: 10.89.0.1
|
||||||
- host: 8333
|
- host: 8333
|
||||||
container: 8333
|
container: 8333
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
|||||||
@ -660,10 +660,16 @@ pub(super) async fn get_app_config(
|
|||||||
),
|
),
|
||||||
"bitcoin-core" => (
|
"bitcoin-core" => (
|
||||||
vec![
|
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(),
|
"8333:8333".to_string(),
|
||||||
"28332:28332".to_string(),
|
"127.0.0.1:28332:28332".to_string(),
|
||||||
"28333:28333".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!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
@ -702,10 +708,16 @@ pub(super) async fn get_app_config(
|
|||||||
),
|
),
|
||||||
"bitcoin" | "bitcoin-knots" => (
|
"bitcoin" | "bitcoin-knots" => (
|
||||||
vec![
|
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(),
|
"8333:8333".to_string(),
|
||||||
"28332:28332".to_string(),
|
"127.0.0.1:28332:28332".to_string(),
|
||||||
"28333:28333".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!["/var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin".to_string()],
|
||||||
vec![],
|
vec![],
|
||||||
|
|||||||
@ -424,7 +424,7 @@ fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
|
|||||||
ports: spec
|
ports: spec
|
||||||
.ports
|
.ports
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(host, container)| (*host, *container, "tcp".into()))
|
.map(|(host, container)| (*host, *container, "tcp".into(), String::new()))
|
||||||
.collect(),
|
.collect(),
|
||||||
extra_podman_args: vec![],
|
extra_podman_args: vec![],
|
||||||
depends_on: vec![],
|
depends_on: vec![],
|
||||||
|
|||||||
@ -3990,6 +3990,7 @@ mod tests {
|
|||||||
host,
|
host,
|
||||||
container,
|
container,
|
||||||
protocol: "tcp".to_string(),
|
protocol: "tcp".to_string(),
|
||||||
|
bind: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,7 +140,8 @@ pub struct QuadletUnit {
|
|||||||
// Backend-manifest extensions (Phase 3.1). Companion units leave
|
// Backend-manifest extensions (Phase 3.1). Companion units leave
|
||||||
// these defaulted; the renderer skips empty/false directives so a
|
// these defaulted; the renderer skips empty/false directives so a
|
||||||
// companion's rendered bytes are unchanged from before this PR.
|
// 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>,
|
pub environment: Vec<String>,
|
||||||
/// Secret-backed env: (env_key, podman secret name). Rendered as
|
/// Secret-backed env: (env_key, podman secret name). Rendered as
|
||||||
/// `Secret=<name>,type=env,target=<key>` so the VALUE never lands in
|
/// `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
|
// (exit 125). Skip publishing in host mode — matches the NetworkMode
|
||||||
// doc note that Podman discards port mappings under host networking.
|
// doc note that Podman discards port mappings under host networking.
|
||||||
if !matches!(self.network, NetworkMode::Host) {
|
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() {
|
let p = if proto.is_empty() {
|
||||||
"tcp"
|
"tcp"
|
||||||
} else {
|
} else {
|
||||||
proto.as_str()
|
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 {
|
for env in &self.environment {
|
||||||
@ -426,7 +433,7 @@ impl QuadletUnit {
|
|||||||
ports: app
|
ports: app
|
||||||
.ports
|
.ports
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.host, p.container, p.protocol.clone()))
|
.map(|p| (p.host, p.container, p.protocol.clone(), p.bind.clone()))
|
||||||
.collect(),
|
.collect(),
|
||||||
environment: app.environment.clone(),
|
environment: app.environment.clone(),
|
||||||
secret_env: app
|
secret_env: app
|
||||||
@ -957,7 +964,7 @@ mod tests {
|
|||||||
// Podman rejects PublishPort with Network=host (crash-loop exit 125).
|
// Podman rejects PublishPort with Network=host (crash-loop exit 125).
|
||||||
let mut u = sample_unit();
|
let mut u = sample_unit();
|
||||||
u.network = NetworkMode::Host;
|
u.network = NetworkMode::Host;
|
||||||
u.ports = vec![(3000, 3000, "tcp".into())];
|
u.ports = vec![(3000, 3000, "tcp".into(), String::new())];
|
||||||
let s = u.render();
|
let s = u.render();
|
||||||
assert!(s.contains("Network=host"));
|
assert!(s.contains("Network=host"));
|
||||||
assert!(!s.contains("PublishPort"));
|
assert!(!s.contains("PublishPort"));
|
||||||
@ -967,7 +974,7 @@ mod tests {
|
|||||||
fn render_non_host_network_emits_publish_ports() {
|
fn render_non_host_network_emits_publish_ports() {
|
||||||
let mut u = sample_unit();
|
let mut u = sample_unit();
|
||||||
u.network = NetworkMode::Bridge("archy-net".into());
|
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();
|
let s = u.render();
|
||||||
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
assert!(s.contains("PublishPort=3000:3000/tcp"));
|
||||||
}
|
}
|
||||||
@ -1085,6 +1092,29 @@ mod tests {
|
|||||||
assert_eq!(RestartPolicy::OnFailure.as_systemd(), "on-failure");
|
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]
|
#[test]
|
||||||
fn render_emits_backend_directives_when_set() {
|
fn render_emits_backend_directives_when_set() {
|
||||||
let u = QuadletUnit {
|
let u = QuadletUnit {
|
||||||
@ -1094,7 +1124,10 @@ mod tests {
|
|||||||
network: NetworkMode::Bridge("archy-net".into()),
|
network: NetworkMode::Bridge("archy-net".into()),
|
||||||
cap_drop_all: true,
|
cap_drop_all: true,
|
||||||
cap_add: vec!["NET_BIND_SERVICE".into()],
|
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![
|
environment: vec![
|
||||||
"BITCOIN_RPC_USER=archipelago".into(),
|
"BITCOIN_RPC_USER=archipelago".into(),
|
||||||
"BITCOIN_RPC_PASS=secret".into(),
|
"BITCOIN_RPC_PASS=secret".into(),
|
||||||
@ -1189,7 +1222,7 @@ app:
|
|||||||
assert!(u.read_only_root);
|
assert!(u.read_only_root);
|
||||||
assert!(u.no_new_privileges);
|
assert!(u.no_new_privileges);
|
||||||
assert_eq!(u.cap_add, vec!["NET_BIND_SERVICE"]);
|
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.environment, vec!["BITCOIN_NETWORK=mainnet"]);
|
||||||
assert_eq!(u.bind_mounts.len(), 1);
|
assert_eq!(u.bind_mounts.len(), 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@ -503,6 +503,13 @@ pub struct PortMapping {
|
|||||||
pub container: u16,
|
pub container: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub protocol: String,
|
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 {
|
impl From<(u16, u16)> for PortMapping {
|
||||||
@ -511,6 +518,7 @@ impl From<(u16, u16)> for PortMapping {
|
|||||||
host,
|
host,
|
||||||
container,
|
container,
|
||||||
protocol: "tcp".to_string(),
|
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"
|
"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!(
|
return Err(ManifestError::Invalid(format!(
|
||||||
"ports contains duplicate host binding {}/{}",
|
"ports contains duplicate host binding {}/{}",
|
||||||
port.host, protocol
|
port.host, protocol
|
||||||
@ -2126,6 +2143,35 @@ app:
|
|||||||
"#,
|
"#,
|
||||||
"duplicate host binding",
|
"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",
|
"bad device",
|
||||||
r#"
|
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]
|
#[test]
|
||||||
fn reviewed_host_bind_exceptions_parse() {
|
fn reviewed_host_bind_exceptions_parse() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
@ -299,11 +299,15 @@ impl PodmanClient {
|
|||||||
"sctp" => "sctp",
|
"sctp" => "sctp",
|
||||||
_ => "tcp",
|
_ => "tcp",
|
||||||
};
|
};
|
||||||
port_mappings.push(serde_json::json!({
|
let mut mapping = serde_json::json!({
|
||||||
"container_port": port.container,
|
"container_port": port.container,
|
||||||
"host_port": port.host,
|
"host_port": port.host,
|
||||||
"protocol": protocol,
|
"protocol": protocol,
|
||||||
}));
|
});
|
||||||
|
if !port.bind.is_empty() {
|
||||||
|
mapping["host_ip"] = serde_json::json!(port.bind);
|
||||||
|
}
|
||||||
|
port_mappings.push(mapping);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut mounts = Vec::new();
|
let mut mounts = Vec::new();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user