feat(mesh): app direct ports reachable over the mesh — IPv4 listeners mirrored onto [::]

The companion reaches the node at its fips0 ULA, and the web UI builds app
links as http://[<ULA>]:<direct port> — but rootless-podman published ports
bind 0.0.0.0 only, so every app URL was refused over the mesh (:8334 first).
A reconcile loop in the backend mirrors public IPv4 listeners: any port
>=1024 on 0.0.0.0 without an IPv6 any-listener gets a v6-ONLY [::] forwarder
to 127.0.0.1, following /proc/net/tcp* so install/remove and hardcoded
companion ports are covered without touching a single container. Purely
additive: IPv4/LAN/Tor access paths are untouched, v6only listeners cannot
collide with or intercept v4 traffic, and foreign IPv6 listeners win.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 19:09:14 -04:00
parent 6c48de20e3
commit 2ad57c63f1
4 changed files with 154 additions and 0 deletions

1
core/Cargo.lock generated
View File

@ -145,6 +145,7 @@ dependencies = [
"serde_yaml",
"serial2-tokio",
"sha2 0.10.9",
"socket2 0.5.10",
"tar",
"tempfile",
"thiserror 1.0.69",

View File

@ -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"

View File

@ -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://[<fips0 ULA>]:<port>) 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());

View File

@ -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://[<ULA>]: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 `[::]:<port>` forwarder to
//! `127.0.0.1:<port>`. 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<u16, JoinHandle<()>> = 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<u16, JoinHandle<()>>) -> 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<HashSet<u16>> {
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<JoinHandle<()>> {
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:<port> 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}"),
}
});
}
}))
}