diff --git a/core/Cargo.lock b/core/Cargo.lock index 5ab2ea49..6d572135 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -145,6 +145,7 @@ dependencies = [ "serde_yaml", "serial2-tokio", "sha2 0.10.9", + "socket2 0.5.10", "tar", "tempfile", "thiserror 1.0.69", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 9e1cd48a..1db46696 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"] [dependencies] # Core dependencies tokio = { version = "1", features = ["full"] } +# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with +# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt). +socket2 = "0.5" libc = "0.2" # process-group signalling for the supervised reticulum daemon serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 11f943ba..4a7d0bbd 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -34,6 +34,7 @@ mod bitcoin_rpc; mod bitcoin_status; mod blobs; mod bootstrap; +mod mesh_ports; mod ceremony; mod config; mod constants; @@ -395,6 +396,10 @@ async fn main() -> Result<()> { // iframe on kiosk nodes (docs/tv-input-iframe-apps.md). tokio::spawn(bootstrap::ensure_gamepad_keys()); + // Mesh access: mirror IPv4-published app ports onto [::] so direct-port + // app URLs (http://[]:) work from the companion. + tokio::spawn(mesh_ports::run_mesh_port_mirror()); + // Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when // DHCP renumbering strands them — HA never re-resolves on its own. tokio::spawn(api::rpc::wyoming_satellite_keeper()); diff --git a/core/archipelago/src/mesh_ports.rs b/core/archipelago/src/mesh_ports.rs new file mode 100644 index 00000000..a22f6236 --- /dev/null +++ b/core/archipelago/src/mesh_ports.rs @@ -0,0 +1,145 @@ +//! Mirror IPv4-published app ports onto IPv6 for mesh access. +//! +//! The companion (and anything else on the FIPS mesh) reaches this node at +//! its fips0 ULA. The web UI builds app links from the current host + each +//! app's DIRECT port (Direct Port Rule) — but rootless-podman published +//! ports bind 0.0.0.0 only, so `http://[]:8334` was refused for every +//! catalog app even after nginx :80 learned IPv6 (2026-07-23, third +//! "the node wasn't listening on v6" bug of the night). +//! +//! Instead of touching the container layer (port-publish changes would +//! recreate every container), a reconcile loop mirrors the host's public +//! IPv4 listeners: any port ≥1024 listening on 0.0.0.0 without an IPv6 +//! any-address listener gets a v6-only `[::]:` forwarder to +//! `127.0.0.1:`. Ports appear/disappear with app install/remove, so +//! the mirror follows `/proc/net/tcp*` rather than the catalog — companion +//! containers with hardcoded ports (bitcoin-ui :8334) are covered the same +//! as manifest-declared ones. Nothing is exposed that IPv4 didn't already +//! expose to the LAN; the ULA is the established remote-access surface. + +use std::collections::{HashMap, HashSet}; +use std::net::{Ipv6Addr, SocketAddrV6}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; + +const RECONCILE_INTERVAL: Duration = Duration::from_secs(15); +/// Never mirror system ports; nginx (80/443) handles its own v6 listeners. +const MIN_PORT: u16 = 1024; + +/// Entry point — spawned from main; runs for the process lifetime. +pub async fn run_mesh_port_mirror() { + let mut active: HashMap> = HashMap::new(); + loop { + match reconcile(&mut active).await { + Ok(()) => {} + Err(e) => debug!("mesh-ports: reconcile skipped: {e:#}"), + } + tokio::time::sleep(RECONCILE_INTERVAL).await; + } +} + +async fn reconcile(active: &mut HashMap>) -> Result<()> { + let v4 = listening_ports("/proc/net/tcp", 8).await?; + let v6 = listening_ports("/proc/net/tcp6", 32).await?; + + // Drop mirrors whose backing IPv4 listener vanished (app removed/stopped). + active.retain(|port, handle| { + if v4.contains(port) && !handle.is_finished() { + true + } else { + handle.abort(); + info!("mesh-ports: released [::]:{port} (backing 0.0.0.0 listener gone)"); + false + } + }); + + for port in v4 { + if port < MIN_PORT || active.contains_key(&port) { + continue; + } + // A foreign IPv6 any-listener already serves this port (our own + // mirrors are excluded above via `active`). + if v6.contains(&port) { + continue; + } + match spawn_forwarder(port) { + Ok(handle) => { + info!("mesh-ports: mirroring [::]:{port} -> 127.0.0.1:{port}"); + active.insert(port, handle); + } + Err(e) => debug!("mesh-ports: cannot mirror port {port}: {e:#}"), + } + } + Ok(()) +} + +/// Ports in LISTEN state bound to the any-address, parsed from /proc/net/tcp +/// (`addr_hex_len` 8) or /proc/net/tcp6 (32). +async fn listening_ports(path: &str, addr_hex_len: usize) -> Result> { + let raw = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("read {path}"))?; + let any_addr = "0".repeat(addr_hex_len); + let mut ports = HashSet::new(); + for line in raw.lines().skip(1) { + let mut cols = line.split_whitespace(); + let (Some(_sl), Some(local), Some(_rem), Some(state)) = + (cols.next(), cols.next(), cols.next(), cols.next()) + else { + continue; + }; + if state != "0A" { + continue; // not LISTEN + } + let Some((addr, port_hex)) = local.split_once(':') else { + continue; + }; + if addr != any_addr { + continue; + } + if let Ok(port) = u16::from_str_radix(port_hex, 16) { + ports.insert(port); + } + } + Ok(ports) +} + +/// A v6-only listener on [::]:port forwarding each connection to 127.0.0.1:port. +fn spawn_forwarder(port: u16) -> Result> { + use socket2::{Domain, Protocol, Socket, Type}; + let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP)) + .context("create v6 socket")?; + // v6only so we coexist with the app's own 0.0.0.0: bind. + socket.set_only_v6(true).context("set v6only")?; + socket.set_reuse_address(true).ok(); + socket.set_nonblocking(true).context("set nonblocking")?; + let addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0); + socket.bind(&addr.into()).context("bind [::]")?; + socket.listen(128).context("listen")?; + let listener = tokio::net::TcpListener::from_std(socket.into()) + .context("register with tokio")?; + + Ok(tokio::spawn(async move { + loop { + let (mut inbound, _peer) = match listener.accept().await { + Ok(conn) => conn, + Err(e) => { + warn!("mesh-ports: accept failed on [::]:{port}: {e}"); + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + }; + tokio::spawn(async move { + match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { + Ok(mut upstream) => { + let _ = tokio::io::copy_bidirectional(&mut inbound, &mut upstream).await; + } + Err(e) => debug!("mesh-ports: upstream 127.0.0.1:{port} refused: {e}"), + } + }); + } + })) +}