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:
co-authored by
Claude Fable 5
parent
6c48de20e3
commit
2ad57c63f1
Generated
+1
@@ -145,6 +145,7 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"serial2-tokio",
|
"serial2-tokio",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"socket2 0.5.10",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# Core dependencies
|
# Core dependencies
|
||||||
tokio = { version = "1", features = ["full"] }
|
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
|
libc = "0.2" # process-group signalling for the supervised reticulum daemon
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ mod bitcoin_rpc;
|
|||||||
mod bitcoin_status;
|
mod bitcoin_status;
|
||||||
mod blobs;
|
mod blobs;
|
||||||
mod bootstrap;
|
mod bootstrap;
|
||||||
|
mod mesh_ports;
|
||||||
mod ceremony;
|
mod ceremony;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
@@ -395,6 +396,10 @@ async fn main() -> Result<()> {
|
|||||||
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
|
||||||
tokio::spawn(bootstrap::ensure_gamepad_keys());
|
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
|
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
|
||||||
// DHCP renumbering strands them — HA never re-resolves on its own.
|
// DHCP renumbering strands them — HA never re-resolves on its own.
|
||||||
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
tokio::spawn(api::rpc::wyoming_satellite_keeper());
|
||||||
|
|||||||
@@ -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}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user