From c4109452228182c9b187744cb28d2d4cd4bb8084 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 16:45:46 +0200 Subject: [PATCH 1/6] add LAN service discovery via UDP multicast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Numa instances on the same network auto-discover each other's .numa services. No config, no cloud — just multicast on 239.255.70.78:5390. - PeerStore with lazy expiry (90s timeout, 30s broadcast interval) - DNS resolves remote .numa services to peer's LAN IP (not localhost) - Proxy forwards to peer IP for remote services - Graceful degradation if multicast bind fails Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 19 ++++- Cargo.toml | 1 + src/config.rs | 44 +++++++++++ src/ctx.rs | 29 +++++++- src/lan.rs | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 14 ++++ src/proxy.rs | 15 ++-- 8 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 src/lan.rs diff --git a/Cargo.lock b/Cargo.lock index be1edf2..57a030e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -621,7 +621,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -946,6 +946,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "socket2 0.5.10", "time", "tokio", "tokio-rustls", @@ -1062,7 +1063,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -1099,7 +1100,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -1407,6 +1408,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1566,7 +1577,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 990a6b6..31d6481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ hyper = { version = "1", features = ["client", "http1", "server"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" +socket2 = "0.5" rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" diff --git a/src/config.rs b/src/config.rs index 8359983..d7a9a19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,6 +25,8 @@ pub struct Config { pub proxy: ProxyConfig, #[serde(default)] pub services: Vec, + #[serde(default)] + pub lan: LanConfig, } #[derive(Deserialize)] @@ -202,6 +204,48 @@ pub struct ServiceConfig { pub target_port: u16, } +#[derive(Deserialize, Clone)] +pub struct LanConfig { + #[serde(default = "default_lan_enabled")] + pub enabled: bool, + #[serde(default = "default_lan_multicast_group")] + pub multicast_group: String, + #[serde(default = "default_lan_port")] + pub port: u16, + #[serde(default = "default_lan_broadcast_interval")] + pub broadcast_interval_secs: u64, + #[serde(default = "default_lan_peer_timeout")] + pub peer_timeout_secs: u64, +} + +impl Default for LanConfig { + fn default() -> Self { + LanConfig { + enabled: default_lan_enabled(), + multicast_group: default_lan_multicast_group(), + port: default_lan_port(), + broadcast_interval_secs: default_lan_broadcast_interval(), + peer_timeout_secs: default_lan_peer_timeout(), + } + } +} + +fn default_lan_enabled() -> bool { + true +} +fn default_lan_multicast_group() -> String { + "239.255.70.78".to_string() +} +fn default_lan_port() -> u16 { + 5390 +} +fn default_lan_broadcast_interval() -> u64 { + 30 +} +fn default_lan_peer_timeout() -> u64 { + 90 +} + pub fn load_config(path: &str) -> Result { if !Path::new(path).exists() { return Ok(Config::default()); diff --git a/src/ctx.rs b/src/ctx.rs index cf485bd..d555fad 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -16,6 +16,7 @@ use crate::packet::DnsPacket; use crate::query_log::{QueryLog, QueryLogEntry}; use crate::question::QueryType; use crate::record::DnsRecord; +use crate::lan::PeerStore; use crate::service_store::ServiceStore; use crate::stats::{QueryPath, ServerStats}; use crate::system_dns::ForwardingRule; @@ -29,6 +30,7 @@ pub struct ServerCtx { pub blocklist: Mutex, pub query_log: Mutex, pub services: Mutex, + pub lan_peers: Mutex, pub forwarding_rules: Vec, pub upstream: SocketAddr, pub timeout: Duration, @@ -67,16 +69,39 @@ pub async fn handle_query( } else if !ctx.proxy_tld_suffix.is_empty() && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) { + // Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP + let service_name = qname + .strip_suffix(&ctx.proxy_tld_suffix) + .unwrap_or(&qname); + let resolve_ip = { + let local = ctx.services.lock().unwrap(); + if local.lookup(service_name).is_some() { + std::net::Ipv4Addr::LOCALHOST + } else { + let mut peers = ctx.lan_peers.lock().unwrap(); + peers + .lookup(service_name) + .and_then(|(ip, _)| match ip { + std::net::IpAddr::V4(v4) => Some(v4), + _ => None, + }) + .unwrap_or(std::net::Ipv4Addr::LOCALHOST) + } + }; let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { domain: qname.clone(), - addr: std::net::Ipv6Addr::LOCALHOST, + addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST { + std::net::Ipv6Addr::LOCALHOST + } else { + resolve_ip.to_ipv6_mapped() + }, ttl: 300, }), _ => resp.answers.push(DnsRecord::A { domain: qname.clone(), - addr: std::net::Ipv4Addr::LOCALHOST, + addr: resolve_ip, ttl: 300, }), } diff --git a/src/lan.rs b/src/lan.rs new file mode 100644 index 0000000..f693f71 --- /dev/null +++ b/src/lan.rs @@ -0,0 +1,198 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; + +use crate::config::LanConfig; +use crate::ctx::ServerCtx; + +// --- Peer Store --- + +pub struct PeerStore { + peers: HashMap, + timeout: Duration, +} + +impl PeerStore { + pub fn new(timeout_secs: u64) -> Self { + PeerStore { + peers: HashMap::new(), + timeout: Duration::from_secs(timeout_secs), + } + } + + pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) { + let now = Instant::now(); + for (name, port) in services { + self.peers + .insert(name.to_lowercase(), (host, *port, now)); + } + } + + pub fn lookup(&mut self, name: &str) -> Option<(IpAddr, u16)> { + let key = name.to_lowercase(); + let entry = self.peers.get(&key)?; + if entry.2.elapsed() > self.timeout { + self.peers.remove(&key); + return None; + } + Some((entry.0, entry.1)) + } + + pub fn list(&mut self) -> Vec<(String, IpAddr, u16, u64)> { + let now = Instant::now(); + self.peers.retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); + self.peers + .iter() + .map(|(name, (ip, port, seen))| { + (name.clone(), *ip, *port, now.duration_since(*seen).as_secs()) + }) + .collect() + } +} + +// --- Multicast --- + +#[derive(Serialize, Deserialize)] +struct Announcement { + host: String, + services: Vec, +} + +#[derive(Serialize, Deserialize)] +struct AnnouncedService { + name: String, + port: u16, +} + +pub fn detect_lan_ip() -> Option { + let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("8.8.8.8:80").ok()?; + match socket.local_addr().ok()? { + SocketAddr::V4(addr) => Some(*addr.ip()), + _ => None, + } +} + +pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { + let multicast_group: Ipv4Addr = match config.multicast_group.parse() { + Ok(g) => g, + Err(e) => { + warn!("LAN: invalid multicast group {}: {}", config.multicast_group, e); + return; + } + }; + let port = config.port; + let interval = Duration::from_secs(config.broadcast_interval_secs); + + let local_ip = detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); + info!("LAN discovery on {}:{}, local IP {}", multicast_group, port, local_ip); + + // Create socket with SO_REUSEADDR for multicast + let std_socket = match create_multicast_socket(multicast_group, port) { + Ok(s) => s, + Err(e) => { + warn!("LAN: could not bind multicast socket: {} — LAN discovery disabled", e); + return; + } + }; + let socket = match tokio::net::UdpSocket::from_std(std_socket) { + Ok(s) => s, + Err(e) => { + warn!("LAN: tokio socket conversion failed: {}", e); + return; + } + }; + let socket = Arc::new(socket); + + // Spawn sender + let sender_ctx = Arc::clone(&ctx); + let sender_socket = Arc::clone(&socket); + let local_ip_str = local_ip.to_string(); + let self_filter = local_ip_str.clone(); + let dest = SocketAddr::new(IpAddr::V4(multicast_group), port); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + let services: Vec = { + let store = sender_ctx.services.lock().unwrap(); + store + .list() + .iter() + .map(|e| AnnouncedService { + name: e.name.clone(), + port: e.target_port, + }) + .collect() + }; + if services.is_empty() { + continue; + } + let announcement = Announcement { + host: local_ip_str.clone(), + services, + }; + if let Ok(json) = serde_json::to_vec(&announcement) { + let _ = sender_socket.send_to(&json, dest).await; + } + } + }); + + // Receiver loop + let mut buf = vec![0u8; 4096]; + loop { + let (len, src) = match socket.recv_from(&mut buf).await { + Ok(r) => r, + Err(e) => { + debug!("LAN recv error: {}", e); + continue; + } + }; + let announcement: Announcement = match serde_json::from_slice(&buf[..len]) { + Ok(a) => a, + Err(_) => continue, + }; + // Skip self-announcements + if announcement.host == self_filter { + continue; + } + let peer_ip: IpAddr = match announcement.host.parse() { + Ok(ip) => ip, + Err(_) => continue, + }; + let services: Vec<(String, u16)> = announcement + .services + .iter() + .map(|s| (s.name.clone(), s.port)) + .collect(); + let count = services.len(); + ctx.lan_peers.lock().unwrap().update(peer_ip, &services); + debug!( + "LAN: {} services from {} (via {})", + count, announcement.host, src + ); + } +} + +fn create_multicast_socket( + group: Ipv4Addr, + port: u16, +) -> std::io::Result { + use std::net::SocketAddrV4; + + let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port); + let socket = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::DGRAM, + Some(socket2::Protocol::UDP), + )?; + socket.set_reuse_address(true)?; + socket.set_nonblocking(true)?; + socket.bind(&socket2::SockAddr::from(addr))?; + socket.join_multicast_v4(&group, &Ipv4Addr::UNSPECIFIED)?; + Ok(socket.into()) +} diff --git a/src/lib.rs b/src/lib.rs index ad9355e..1d41c04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod ctx; pub mod forward; pub mod header; +pub mod lan; pub mod override_store; pub mod packet; pub mod proxy; diff --git a/src/main.rs b/src/main.rs index 9c1a543..dd6cead 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,6 +127,7 @@ async fn main() -> numa::Result<()> { blocklist: Mutex::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), services: Mutex::new(service_store), + lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), forwarding_rules, upstream, timeout: Duration::from_millis(config.upstream.timeout_ms), @@ -161,6 +162,10 @@ async fn main() -> numa::Result<()> { }; eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", schemes); } + if config.lan.enabled { + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mLAN\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", + format!("{}:{}", config.lan.multicast_group, config.lan.port)); + } if !ctx.forwarding_rules.is_empty() { eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} conditional rules", ctx.forwarding_rules.len())); @@ -235,6 +240,15 @@ async fn main() -> numa::Result<()> { } } + // Spawn LAN service discovery + if config.lan.enabled { + let lan_ctx = Arc::clone(&ctx); + let lan_config = config.lan.clone(); + tokio::spawn(async move { + numa::lan::start_lan_discovery(lan_ctx, &lan_config).await; + }); + } + // UDP DNS listener #[allow(clippy::infinite_loop)] loop { diff --git a/src/proxy.rs b/src/proxy.rs index 761a0d1..af9ca5c 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -135,11 +135,15 @@ async fn proxy_handler(State(state): State, req: Request) -> axum::r } }; - let target_port = { + let (target_host, target_port) = { let store = state.ctx.services.lock().unwrap(); - match store.lookup(&service_name) { - Some(entry) => entry.target_port, - None => { + if let Some(entry) = store.lookup(&service_name) { + ("localhost".to_string(), entry.target_port) + } else { + let mut peers = state.ctx.lan_peers.lock().unwrap(); + match peers.lookup(&service_name) { + Some((ip, port)) => (ip.to_string(), port), + None => { return ( StatusCode::NOT_FOUND, [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], @@ -259,6 +263,7 @@ pre .str {{ color: #d48a5a }} ), ) .into_response() + } } } }; @@ -268,7 +273,7 @@ pre .str {{ color: #d48a5a }} .path_and_query() .map(|pq| pq.as_str()) .unwrap_or("/"); - let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query) + let target_uri: hyper::Uri = format!("http://{}:{}{}", target_host, target_port, path_and_query) .parse() .unwrap(); -- 2.34.1 From d355f8d005d4a45b1250066bc6726c4294cdf69a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 16:54:03 +0200 Subject: [PATCH 2/6] fix rustfmt formatting Co-Authored-By: Claude Opus 4.6 --- src/ctx.rs | 6 ++---- src/lan.rs | 33 ++++++++++++++++++++++----------- src/proxy.rs | 7 ++++--- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/ctx.rs b/src/ctx.rs index d555fad..1a2f424 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -11,12 +11,12 @@ use crate::cache::DnsCache; use crate::config::ZoneMap; use crate::forward::forward_query; use crate::header::ResultCode; +use crate::lan::PeerStore; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; use crate::query_log::{QueryLog, QueryLogEntry}; use crate::question::QueryType; use crate::record::DnsRecord; -use crate::lan::PeerStore; use crate::service_store::ServiceStore; use crate::stats::{QueryPath, ServerStats}; use crate::system_dns::ForwardingRule; @@ -70,9 +70,7 @@ pub async fn handle_query( && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) { // Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP - let service_name = qname - .strip_suffix(&ctx.proxy_tld_suffix) - .unwrap_or(&qname); + let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname); let resolve_ip = { let local = ctx.services.lock().unwrap(); if local.lookup(service_name).is_some() { diff --git a/src/lan.rs b/src/lan.rs index f693f71..1cc1484 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -27,8 +27,7 @@ impl PeerStore { pub fn update(&mut self, host: IpAddr, services: &[(String, u16)]) { let now = Instant::now(); for (name, port) in services { - self.peers - .insert(name.to_lowercase(), (host, *port, now)); + self.peers.insert(name.to_lowercase(), (host, *port, now)); } } @@ -44,11 +43,17 @@ impl PeerStore { pub fn list(&mut self) -> Vec<(String, IpAddr, u16, u64)> { let now = Instant::now(); - self.peers.retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); + self.peers + .retain(|_, (_, _, seen)| now.duration_since(*seen) < self.timeout); self.peers .iter() .map(|(name, (ip, port, seen))| { - (name.clone(), *ip, *port, now.duration_since(*seen).as_secs()) + ( + name.clone(), + *ip, + *port, + now.duration_since(*seen).as_secs(), + ) }) .collect() } @@ -81,7 +86,10 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { let multicast_group: Ipv4Addr = match config.multicast_group.parse() { Ok(g) => g, Err(e) => { - warn!("LAN: invalid multicast group {}: {}", config.multicast_group, e); + warn!( + "LAN: invalid multicast group {}: {}", + config.multicast_group, e + ); return; } }; @@ -89,13 +97,19 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { let interval = Duration::from_secs(config.broadcast_interval_secs); let local_ip = detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); - info!("LAN discovery on {}:{}, local IP {}", multicast_group, port, local_ip); + info!( + "LAN discovery on {}:{}, local IP {}", + multicast_group, port, local_ip + ); // Create socket with SO_REUSEADDR for multicast let std_socket = match create_multicast_socket(multicast_group, port) { Ok(s) => s, Err(e) => { - warn!("LAN: could not bind multicast socket: {} — LAN discovery disabled", e); + warn!( + "LAN: could not bind multicast socket: {} — LAN discovery disabled", + e + ); return; } }; @@ -178,10 +192,7 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { } } -fn create_multicast_socket( - group: Ipv4Addr, - port: u16, -) -> std::io::Result { +fn create_multicast_socket(group: Ipv4Addr, port: u16) -> std::io::Result { use std::net::SocketAddrV4; let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port); diff --git a/src/proxy.rs b/src/proxy.rs index af9ca5c..414a53e 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -273,9 +273,10 @@ pre .str {{ color: #d48a5a }} .path_and_query() .map(|pq| pq.as_str()) .unwrap_or("/"); - let target_uri: hyper::Uri = format!("http://{}:{}{}", target_host, target_port, path_and_query) - .parse() - .unwrap(); + let target_uri: hyper::Uri = + format!("http://{}:{}{}", target_host, target_port, path_and_query) + .parse() + .unwrap(); // Check for upgrade request (WebSocket, etc.) let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); -- 2.34.1 From a29e4aeb966300cfd1fe3124557dddd72a23108c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 00:20:33 +0200 Subject: [PATCH 3/6] fix LAN discovery: instance-based self-filter and multicast port reuse Replace IP-based self-announcement filtering with a per-process instance ID (pid ^ timestamp) so multiple instances on the same host can discover each other. Enable SO_REUSEPORT for multicast socket binding on Unix. Add multicast address validation on configured group. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 2 +- src/lan.rs | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 31d6481..bda0a80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ hyper = { version = "1", features = ["client", "http1", "server"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" -socket2 = "0.5" +socket2 = { version = "0.5", features = ["all"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" diff --git a/src/lan.rs b/src/lan.rs index 1cc1484..9a24b1e 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -63,6 +63,7 @@ impl PeerStore { #[derive(Serialize, Deserialize)] struct Announcement { + instance_id: u64, host: String, services: Vec, } @@ -83,8 +84,12 @@ pub fn detect_lan_ip() -> Option { } pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { - let multicast_group: Ipv4Addr = match config.multicast_group.parse() { - Ok(g) => g, + let multicast_group: Ipv4Addr = match config.multicast_group.parse::() { + Ok(g) if g.is_multicast() => g, + Ok(g) => { + warn!("LAN: {} is not a multicast address (224.0.0.0/4)", g); + return; + } Err(e) => { warn!( "LAN: invalid multicast group {}: {}", @@ -96,10 +101,18 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { let port = config.port; let interval = Duration::from_secs(config.broadcast_interval_secs); + let instance_id: u64 = { + let pid = std::process::id() as u64; + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + pid ^ ts + }; let local_ip = detect_lan_ip().unwrap_or(Ipv4Addr::LOCALHOST); info!( - "LAN discovery on {}:{}, local IP {}", - multicast_group, port, local_ip + "LAN discovery on {}:{}, local IP {}, instance {:016x}", + multicast_group, port, local_ip, instance_id ); // Create socket with SO_REUSEADDR for multicast @@ -126,7 +139,6 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { let sender_ctx = Arc::clone(&ctx); let sender_socket = Arc::clone(&socket); let local_ip_str = local_ip.to_string(); - let self_filter = local_ip_str.clone(); let dest = SocketAddr::new(IpAddr::V4(multicast_group), port); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -147,6 +159,7 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { continue; } let announcement = Announcement { + instance_id, host: local_ip_str.clone(), services, }; @@ -171,7 +184,7 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { Err(_) => continue, }; // Skip self-announcements - if announcement.host == self_filter { + if announcement.instance_id == instance_id { continue; } let peer_ip: IpAddr = match announcement.host.parse() { @@ -202,6 +215,8 @@ fn create_multicast_socket(group: Ipv4Addr, port: u16) -> std::io::Result Date: Sun, 22 Mar 2026 06:35:12 +0200 Subject: [PATCH 4/6] add LAN accessibility indicator for services Show whether each service is reachable from the network or bound to localhost only. Dashboard displays green "LAN" or amber "local only" badge next to each healthy service. Unified TCP check function, concurrent health+LAN probes. Co-Authored-By: Claude Opus 4.6 (1M context) --- site/dashboard.html | 21 +++++++++++++++--- src/api.rs | 54 +++++++++++++++++++++++++++++++++------------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index ccbb4b5..41f9ce3 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -382,6 +382,15 @@ body { } .health-dot.up { background: var(--emerald); } .health-dot.down { background: var(--rose); } +.lan-badge { + font-family: var(--font-mono); + font-size: 0.58rem; + padding: 1px 5px; + border-radius: 3px; + margin-left: 0.3rem; +} +.lan-badge.shared { background: rgba(82, 122, 82, 0.12); color: var(--emerald); } +.lan-badge.local-only { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); } /* Override form */ .override-form { @@ -1082,16 +1091,22 @@ function renderServices(entries) { el.innerHTML = '
No services configured
'; return; } - el.innerHTML = entries.map(e => ` + el.innerHTML = entries.map(e => { + const lanBadge = e.healthy + ? (e.lan_accessible + ? 'LAN' + : 'local only') + : ''; + return `
- +
${e.name}.numa${lanBadge}
localhost:${e.target_port} → proxied
${e.name === 'numa' ? '' : ``}
- `).join(''); + `}).join(''); } async function addService(event) { diff --git a/src/api.rs b/src/api.rs index 9c25377..a9bd7ab 100644 --- a/src/api.rs +++ b/src/api.rs @@ -590,6 +590,7 @@ struct ServiceResponse { target_port: u16, url: String, healthy: bool, + lan_accessible: bool, } #[derive(Deserialize)] @@ -609,22 +610,38 @@ async fn list_services(State(ctx): State>) -> Json = entries + let lan_ip = crate::lan::detect_lan_ip(); + + let check_futures: Vec<_> = entries .iter() - .map(|(_, port)| check_health(*port)) + .map(|(_, port)| { + let port = *port; + let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port)); + async move { + let healthy = check_tcp(localhost).await; + let lan_accessible = match lan_addr { + Some(addr) => check_tcp(addr).await, + None => false, + }; + (healthy, lan_accessible) + } + }) .collect(); - let health_results = futures::future::join_all(health_futures).await; + let check_results = futures::future::join_all(check_futures).await; let results: Vec<_> = entries .into_iter() - .zip(health_results) - .map(|((name, port), healthy)| ServiceResponse { - url: format!("http://{}.{}", name, tld), - name, - target_port: port, - healthy, - }) + .zip(check_results) + .map( + |((name, port), (healthy, lan_accessible))| ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: port, + healthy, + lan_accessible, + }, + ) .collect(); Json(results) } @@ -655,7 +672,15 @@ async fn create_service( let tld = &ctx.proxy_tld; ctx.services.lock().unwrap().insert(&name, req.target_port); - let healthy = check_health(req.target_port).await; + let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], req.target_port)); + let lan_addr = + crate::lan::detect_lan_ip().map(|ip| std::net::SocketAddr::new(ip.into(), req.target_port)); + let (healthy, lan_accessible) = tokio::join!(check_tcp(localhost), async { + match lan_addr { + Some(a) => check_tcp(a).await, + None => false, + } + }); Ok(( StatusCode::CREATED, Json(ServiceResponse { @@ -663,6 +688,7 @@ async fn create_service( name, target_port: req.target_port, healthy, + lan_accessible, }), )) } @@ -679,10 +705,10 @@ async fn remove_service(State(ctx): State>, Path(name): Path bool { +async fn check_tcp(addr: std::net::SocketAddr) -> bool { tokio::time::timeout( std::time::Duration::from_millis(100), - tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), + tokio::net::TcpStream::connect(addr), ) .await .map(|r| r.is_ok()) -- 2.34.1 From 0ba2d3c72de6769654e1dcea4903ea2a4370e427 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 06:59:47 +0200 Subject: [PATCH 5/6] update README, dashboard layout, and version bump to 0.3.0 Add LAN discovery section to README with mesh and hub mode docs. Update comparison table and roadmap. Move Local Services panel above Blocking in dashboard for developer-first layout. Bump version from 0.1.0 to 0.3.0 to match release cadence. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 2 +- README.md | 39 +++++++++++++++++++++++++++++++++++- site/dashboard.html | 48 ++++++++++++++++++++++----------------------- 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bda0a80..fe34dad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "numa" -version = "0.1.0" +version = "0.3.0" authors = ["razvandimescu "] edition = "2021" description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done." diff --git a/README.md b/README.md index 27b7efc..de38ad5 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,10 @@ sudo ./target/release/numa - **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports. - **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with a dashboard and auto-revert. +- **LAN service discovery** — Numa instances on the same network find each other automatically via multicast. Access a teammate's `api.numa` from your machine, zero config. - **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 22 endpoints. - **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. -- **Live dashboard** — real-time stats, query log, blocking controls, service management. +- **Live dashboard** — real-time stats, query log, blocking controls, service management. LAN accessibility badges show which services are reachable from other devices. - **macOS + Linux** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service. ## Local Service Proxy @@ -59,6 +60,7 @@ open http://frontend.numa # → proxied to localhost:5173 - **HTTPS with green lock** — auto-generated local CA + per-service TLS certs - **WebSocket** — Vite/webpack HMR works through the proxy - **Health checks** — dashboard shows green/red status per service +- **LAN sharing** — services bound to `0.0.0.0` are automatically discoverable by other Numa instances on the network. Dashboard shows "LAN" or "local only" per service. - **Persistent** — services survive restarts - Or configure in `numa.toml`: @@ -68,6 +70,39 @@ name = "frontend" target_port = 5173 ``` +## LAN Service Discovery + +Run Numa on multiple machines. They find each other automatically: + +``` +Machine A (192.168.1.5) Machine B (192.168.1.20) +┌──────────────────────┐ ┌──────────────────────┐ +│ Numa │ multicast │ Numa │ +│ services: │◄───────────►│ services: │ +│ - api (port 8000) │ discovery │ - grafana (3000) │ +│ - frontend (5173) │ │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +From Machine B: +```bash +dig @127.0.0.1 api.numa # → 192.168.1.5 +curl http://api.numa # → proxied to Machine A's port 8000 +``` + +No configuration needed. Multicast announcements on `239.255.70.78:5390`, configurable via `[lan]` in `numa.toml`. + +**Hub mode** — don't want to install Numa on every machine? Run one instance as a shared DNS server and point other devices to it: + +```bash +# On the hub machine, bind to LAN interface +[server] +bind_addr = "0.0.0.0:53" + +# On other devices, set DNS to the hub's IP +# They get .numa resolution, ad blocking, caching — zero install +``` + ## How It Compares | | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa | @@ -76,6 +111,7 @@ target_port = 5173 | Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary | | Developer overrides | No | No | No | No | REST API + auto-expiry | | Local service proxy | No | No | No | No | `.numa` + HTTPS + WS | +| LAN service discovery | No | No | No | No | Multicast, zero config | | Data stays local | Yes | Yes | Cloud | Cloud | 100% local | | Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box | | Self-sovereign DNS | No | No | No | No | pkarr/DHT roadmap | @@ -97,6 +133,7 @@ No DNS libraries. The wire protocol — headers, labels, compression pointers, r - [x] Ad blocking — 385K+ domains, live dashboard, allowlist - [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery - [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket +- [x] LAN service discovery — multicast auto-discovery, cross-machine DNS + proxy - [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes) - [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served diff --git a/site/dashboard.html b/site/dashboard.html index 41f9ce3..f600a0a 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -577,22 +577,26 @@ body { - -
+ +
-
- Local Services -
Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.
-
+ Blocking +
-
+
- - + +
- -
-
-
No services configured
-
+ +
+
-- 2.34.1 From 990c865f41fc87ebbd99ebff26819f40f6e2b106 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sun, 22 Mar 2026 07:04:06 +0200 Subject: [PATCH 6/6] update demo script for new dashboard layout and LAN badges Reorder scenes to show services first (matching panel order), scroll to blocking panel for domain check scene. LAN badge now visible after adding a service. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 +- scripts/record-demo.sh | 46 +++++++++++++++++++++++++----------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57a030e..cf97456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,7 +932,7 @@ dependencies = [ [[package]] name = "numa" -version = "0.1.0" +version = "0.3.0" dependencies = [ "axum", "env_logger", diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh index 2e875db..bb84ead 100755 --- a/scripts/record-demo.sh +++ b/scripts/record-demo.sh @@ -8,8 +8,10 @@ # 1. Opens the dashboard in Chrome --app mode (clean, no address bar) # 2. Generates DNS traffic (forward, cache hit, blocked) # 3. Types "peekm" / "6419" into the Local Services form on camera -# 4. Opens peekm.numa to show the proxy working -# 5. Records via ffmpeg and converts to optimized GIF +# 4. Shows LAN accessibility badge ("local only" / "LAN") +# 5. Checks a blocked domain +# 6. Opens peekm.numa to show the proxy working +# 7. Records via ffmpeg and converts to optimized GIF set -euo pipefail @@ -228,18 +230,10 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1 dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 sleep 3 -# --------------- Scene 2: Check Domain blocker (3-6s) --------------- -log "Scene 2: Check Domain — blocked tracker..." -type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 -sleep 0.3 -# Click Check button -run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" -sleep 2 +# --------------- Scene 2: Add peekm service via UI (3-7s) --------------- +log "Scene 2: Adding peekm.numa service..." -# --------------- Scene 3: Add peekm service via UI (6-10s) --------------- -log "Scene 3: Adding peekm.numa service..." - -# Scroll to Local Services form +# Services panel is now first — scroll to it run_js " var svcPanel = document.getElementById('serviceForm'); if (svcPanel) svcPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); @@ -251,20 +245,34 @@ sleep 0.2 type_into "#svcPort" "6419" 0.1 sleep 0.3 -# Click "Add Service" +# Click "Add Service" — LAN badge ("local only" or "LAN") will appear run_js "document.querySelector('#serviceForm .btn-add').click();" -sleep 1.5 +sleep 2 -# --------------- Scene 4: Open peekm.numa (10-14s) --------------- -log "Scene 4: Opening peekm.numa in browser..." +# --------------- Scene 3: Open peekm.numa (7-11s) --------------- +log "Scene 3: Opening peekm.numa in browser..." open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true sleep 4 -# --------------- Scene 5: Back to dashboard (14-17s) --------------- -log "Scene 5: Back to dashboard — LOCAL queries visible..." +# --------------- Scene 4: Back to dashboard (11-14s) --------------- +log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..." osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true sleep 3 +# --------------- Scene 5: Check Domain blocker (14-17s) --------------- +log "Scene 5: Check Domain — blocked tracker..." +# Scroll down to blocking panel +run_js " + var blockPanel = document.getElementById('blockingPanel'); + if (blockPanel) blockPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); +" +sleep 0.5 +type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 +sleep 0.3 +# Click Check button +run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" +sleep 2 + # --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- log "Scene 6: dig proof overlay..." DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) -- 2.34.1