From 5e5a6544bc3ae3c6f817a62f297262f98df44619 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 06:56:31 +0200 Subject: [PATCH 01/13] LAN opt-in, mDNS migration, security hardening, path-based routing - LAN discovery disabled by default (opt-in via [lan] enabled = true) - Replace custom JSON multicast (239.255.70.78:5390) with standard mDNS (_numa._tcp.local on 224.0.0.251:5353) using existing DNS parser - Instance ID in TXT record for multi-instance self-filtering - API and proxy bind to 127.0.0.1 by default (0.0.0.0 when LAN enabled) - Path-based routing: longest prefix match with optional prefix stripping via [[services]] routes = [{path, port, strip?}] - REST API: GET/POST/DELETE /services/{name}/routes - Dashboard shows route lines per service when configured - Segment-boundary route matching (prevents /api matching /apiary) - Route path validation (rejects path traversal) Closes #11 Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/mdns_coexist.rs | 206 ++++++++++++++++++++++ numa.toml | 8 + site/dashboard.html | 8 + src/api.rs | 74 +++++++- src/config.rs | 30 ++-- src/lan.rs | 367 ++++++++++++++++++++++++++++----------- src/main.rs | 19 +- src/proxy.rs | 27 ++- src/service_store.rs | 70 +++++++- 9 files changed, 675 insertions(+), 134 deletions(-) create mode 100644 examples/mdns_coexist.rs diff --git a/examples/mdns_coexist.rs b/examples/mdns_coexist.rs new file mode 100644 index 0000000..6386cae --- /dev/null +++ b/examples/mdns_coexist.rs @@ -0,0 +1,206 @@ +/// Spike: can we bind to mDNS multicast (224.0.0.251:5353) alongside macOS mDNSResponder? +/// +/// Tests: +/// 1. Bind UDP socket to 0.0.0.0:5353 with SO_REUSEPORT + SO_REUSEADDR +/// 2. Join multicast group 224.0.0.251 +/// 3. Send a PTR query for _services._dns-sd._udp.local (standard browse) +/// 4. Listen for mDNS responses — do we see them alongside mDNSResponder? +/// 5. Send a _numa._tcp.local announcement — does it conflict? +/// +/// Run: cargo run --example mdns_coexist + +use std::mem::MaybeUninit; +use std::net::{Ipv4Addr, SocketAddrV4}; +use socket2::{Domain, Protocol, Socket, Type}; + +const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; + +fn main() -> std::io::Result<()> { + println!("=== mDNS coexistence spike ===\n"); + + // Step 1: Create UDP socket with SO_REUSEPORT + SO_REUSEADDR + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + println!("[OK] Socket created with SO_REUSEADDR + SO_REUSEPORT"); + + // Step 2: Bind to 0.0.0.0:5353 + let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT); + match socket.bind(&bind_addr.into()) { + Ok(()) => println!("[OK] Bound to 0.0.0.0:{}", MDNS_PORT), + Err(e) => { + println!("[FAIL] Cannot bind to port {}: {}", MDNS_PORT, e); + println!(" mDNSResponder may not allow port sharing"); + return Ok(()); + } + } + + // Step 3: Join multicast group + match socket.join_multicast_v4(&MDNS_ADDR, &Ipv4Addr::UNSPECIFIED) { + Ok(()) => println!("[OK] Joined multicast group {}", MDNS_ADDR), + Err(e) => { + println!("[FAIL] Cannot join multicast {}: {}", MDNS_ADDR, e); + return Ok(()); + } + } + + // Step 4: Send a PTR query for _services._dns-sd._udp.local + let query = build_mdns_query("_services._dns-sd._udp.local"); + let dest = SocketAddrV4::new(MDNS_ADDR, MDNS_PORT); + match socket.send_to(&query, &dest.into()) { + Ok(n) => println!("[OK] Sent mDNS browse query ({} bytes)", n), + Err(e) => { + println!("[FAIL] Cannot send to multicast: {}", e); + return Ok(()); + } + } + + // Step 5: Listen for responses (3 second timeout) + socket.set_read_timeout(Some(std::time::Duration::from_secs(3)))?; + let mut buf = [MaybeUninit::::zeroed(); 4096]; + let mut count = 0; + + println!("\nListening for mDNS responses (3s timeout)...\n"); + loop { + match socket.recv_from(&mut buf) { + Ok((n, addr)) => { + let data: &[u8] = unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; + count += 1; + let flags = u16::from_be_bytes([data[2], data[3]]); + let is_response = flags & 0x8000 != 0; + let qdcount = u16::from_be_bytes([data[4], data[5]]); + let ancount = u16::from_be_bytes([data[6], data[7]]); + println!( + " #{} from {} — {} bytes, {}, questions={}, answers={}", + count, + addr.as_socket().map(|s| s.to_string()).unwrap_or_default(), + n, + if is_response { "RESPONSE" } else { "QUERY" }, + qdcount, + ancount, + ); + if count >= 20 { + println!("\n (capped at 20, stopping)"); + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + println!("\n Timeout — received {} packets total", count); + break; + } + Err(e) => { + println!("[FAIL] recv error: {}", e); + break; + } + } + } + + // Step 6: Send a _numa._tcp.local announcement + let announcement = build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380); + match socket.send_to(&announcement, &dest.into()) { + Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n), + Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e), + } + + // Verify we can see our own announcement + socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; + let mut buf2 = [MaybeUninit::::zeroed(); 4096]; + println!("Listening for our announcement echo (2s)...\n"); + loop { + match socket.recv_from(&mut buf2) { + Ok((n, addr)) => { + let data: &[u8] = unsafe { &*(&buf2[..n] as *const [MaybeUninit] as *const [u8]) }; + let flags = u16::from_be_bytes([data[2], data[3]]); + let is_response = flags & 0x8000 != 0; + if is_response { + println!( + " Received response from {} ({} bytes) — multicast RX confirmed", + addr.as_socket().map(|s| s.to_string()).unwrap_or_default(), + n + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + println!(" Timeout"); + break; + } + Err(_) => break, + } + } + + // Verdict + println!("\n=== Verdict ==="); + if count > 0 { + println!("[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder", count); + println!(" Safe to proceed with mDNS-based LAN discovery"); + } else { + println!("[WARN] No mDNS packets received — may need further investigation"); + println!(" Possible causes: firewall, mDNSResponder not sharing port"); + } + + Ok(()) +} + +/// Build a minimal mDNS PTR query packet +fn build_mdns_query(name: &str) -> Vec { + let mut pkt = Vec::new(); + + // Header: ID=0, flags=0 (query), QDCOUNT=1 + pkt.extend_from_slice(&[0, 0]); // ID + pkt.extend_from_slice(&[0, 0]); // Flags (standard query) + pkt.extend_from_slice(&[0, 1]); // QDCOUNT = 1 + pkt.extend_from_slice(&[0, 0]); // ANCOUNT + pkt.extend_from_slice(&[0, 0]); // NSCOUNT + pkt.extend_from_slice(&[0, 0]); // ARCOUNT + + // Question: encode name as labels + for label in name.split('.') { + pkt.push(label.len() as u8); + pkt.extend_from_slice(label.as_bytes()); + } + pkt.push(0); // root label + + pkt.extend_from_slice(&[0, 12]); // QTYPE = PTR (12) + pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1) + + pkt +} + +/// Build a minimal mDNS announcement (response with PTR + SRV + TXT) +fn build_mdns_announcement(service_type: &str, instance_name: &str, port: u16) -> Vec { + let mut pkt = Vec::new(); + + // Header: ID=0, flags=0x8400 (response, authoritative), ANCOUNT=1 + pkt.extend_from_slice(&[0, 0]); // ID + pkt.extend_from_slice(&[0x84, 0x00]); // Flags: QR=1, AA=1 + pkt.extend_from_slice(&[0, 0]); // QDCOUNT + pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now) + pkt.extend_from_slice(&[0, 0]); // NSCOUNT + pkt.extend_from_slice(&[0, 0]); // ARCOUNT + + // PTR record: _numa._tcp.local → test-numa._numa._tcp.local + encode_name(&mut pkt, service_type); + pkt.extend_from_slice(&[0, 12]); // TYPE = PTR + pkt.extend_from_slice(&[0, 1]); // CLASS = IN + pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s + + // RDATA: the instance name + let mut rdata = Vec::new(); + encode_name(&mut rdata, instance_name); + pkt.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); // RDLENGTH + pkt.extend_from_slice(&rdata); + + let _ = port; // SRV record would use this — omitted for spike simplicity + + pkt +} + +fn encode_name(buf: &mut Vec, name: &str) { + for label in name.split('.') { + buf.push(label.len() as u8); + buf.extend_from_slice(label.as_bytes()); + } + buf.push(0); +} diff --git a/numa.toml b/numa.toml index faa455d..f0b61e4 100644 --- a/numa.toml +++ b/numa.toml @@ -1,6 +1,7 @@ [server] bind_addr = "0.0.0.0:53" api_port = 5380 +# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access # [upstream] # address = "" # auto-detect from system resolver (default) @@ -18,6 +19,7 @@ enabled = true port = 80 tls_port = 443 tld = "numa" +# bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled # Pre-configured services (numa.numa is always added automatically) # [[services]] @@ -40,3 +42,9 @@ tld = "numa" # record_type = "A" # value = "127.0.0.1" # ttl = 60 + +# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled) +# [lan] +# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) +# broadcast_interval_secs = 30 +# peer_timeout_secs = 90 diff --git a/site/dashboard.html b/site/dashboard.html index a0434a1..a7734c9 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1098,12 +1098,20 @@ function renderServices(entries) { ? 'LAN' : 'local only') : ''; + const routeLines = (e.routes || []).map(r => + `
` + + `${r.path} ` + + `→ :${r.port}` + + (r.strip ? ` (strip)` : '') + + `
` + ).join(''); return `
${e.name}.numa${lanBadge}
localhost:${e.target_port} → proxied
+ ${routeLines}
${e.name === 'numa' ? '' : ``}
diff --git a/src/api.rs b/src/api.rs index b3ae490..5167d3e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -46,6 +46,9 @@ pub fn router(ctx: Arc) -> Router { .route("/services", get(list_services)) .route("/services", post(create_service)) .route("/services/{name}", delete(remove_service)) + .route("/services/{name}/routes", get(list_routes)) + .route("/services/{name}/routes", post(add_route)) + .route("/services/{name}/routes", delete(remove_route)) .with_state(ctx) } @@ -596,6 +599,8 @@ struct ServiceResponse { url: String, healthy: bool, lan_accessible: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + routes: Vec, } #[derive(Deserialize)] @@ -610,7 +615,7 @@ async fn list_services(State(ctx): State>) -> Json>) -> Json = entries .iter() - .map(|(_, 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)); @@ -639,12 +644,13 @@ async fn list_services(State(ctx): State>) -> Json>, Path(name): Path>, + Path(name): Path, +) -> Result>, StatusCode> { + let store = ctx.services.lock().unwrap(); + match store.lookup(&name) { + Some(entry) => Ok(Json(entry.routes.clone())), + None => Err(StatusCode::NOT_FOUND), + } +} + +async fn add_route( + State(ctx): State>, + Path(name): Path, + Json(req): Json, +) -> Result { + if req.path.is_empty() || !req.path.starts_with('/') { + return Err((StatusCode::BAD_REQUEST, "path must start with /".into())); + } + if req.path.contains("/../") || req.path.ends_with("/..") { + return Err((StatusCode::BAD_REQUEST, "path must not contain '..'".into())); + } + if req.port == 0 { + return Err((StatusCode::BAD_REQUEST, "port must be > 0".into())); + } + let mut store = ctx.services.lock().unwrap(); + if store.add_route(&name, req.path, req.port, req.strip) { + Ok(StatusCode::CREATED) + } else { + Err((StatusCode::NOT_FOUND, format!("service '{}' not found", name))) + } +} + +async fn remove_route( + State(ctx): State>, + Path(name): Path, + Json(req): Json, +) -> StatusCode { + let mut store = ctx.services.lock().unwrap(); + if store.remove_route(&name, &req.path) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + async fn check_tcp(addr: std::net::SocketAddr) -> bool { tokio::time::timeout( std::time::Duration::from_millis(100), diff --git a/src/config.rs b/src/config.rs index d7a9a19..44e9fd7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,8 @@ pub struct ServerConfig { pub bind_addr: String, #[serde(default = "default_api_port")] pub api_port: u16, + #[serde(default = "default_api_bind_addr")] + pub api_bind_addr: String, } impl Default for ServerConfig { @@ -42,10 +44,15 @@ impl Default for ServerConfig { ServerConfig { bind_addr: default_bind_addr(), api_port: default_api_port(), + api_bind_addr: default_api_bind_addr(), } } } +fn default_api_bind_addr() -> String { + "127.0.0.1".to_string() +} + fn default_bind_addr() -> String { "0.0.0.0:53".to_string() } @@ -172,6 +179,8 @@ pub struct ProxyConfig { pub tls_port: u16, #[serde(default = "default_proxy_tld")] pub tld: String, + #[serde(default = "default_proxy_bind_addr")] + pub bind_addr: String, } impl Default for ProxyConfig { @@ -181,10 +190,15 @@ impl Default for ProxyConfig { port: default_proxy_port(), tls_port: default_proxy_tls_port(), tld: default_proxy_tld(), + bind_addr: default_proxy_bind_addr(), } } } +fn default_proxy_bind_addr() -> String { + "127.0.0.1".to_string() +} + fn default_proxy_enabled() -> bool { true } @@ -202,16 +216,14 @@ fn default_proxy_tld() -> String { pub struct ServiceConfig { pub name: String, pub target_port: u16, + #[serde(default)] + pub routes: Vec, } #[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")] @@ -222,8 +234,6 @@ 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(), } @@ -231,13 +241,7 @@ impl Default for LanConfig { } fn default_lan_enabled() -> bool { - true -} -fn default_lan_multicast_group() -> String { - "239.255.70.78".to_string() -} -fn default_lan_port() -> u16 { - 5390 + false } fn default_lan_broadcast_interval() -> u64 { 30 diff --git a/src/lan.rs b/src/lan.rs index 360ed5d..ea2e6b7 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -1,13 +1,22 @@ use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; use std::time::{Duration, Instant}; use log::{debug, info, warn}; -use serde::{Deserialize, Serialize}; +use crate::buffer::BytePacketBuffer; use crate::config::LanConfig; use crate::ctx::ServerCtx; +use crate::header::DnsHeader; +use crate::question::{DnsQuestion, QueryType}; + +// --- Constants --- + +const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_PORT: u16 = 5353; +const SERVICE_TYPE: &str = "_numa._tcp.local"; +const MDNS_TTL: u32 = 120; // --- Peer Store --- @@ -63,20 +72,7 @@ impl PeerStore { } } -// --- Multicast --- - -#[derive(Serialize, Deserialize)] -struct Announcement { - instance_id: u64, - host: String, - services: Vec, -} - -#[derive(Serialize, Deserialize)] -struct AnnouncedService { - name: String, - port: u16, -} +// --- mDNS Discovery --- pub fn detect_lan_ip() -> Option { let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; @@ -87,46 +83,40 @@ 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) 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 {}: {}", - config.multicast_group, e - ); - return; - } - }; - let port = config.port; - let interval = Duration::from_secs(config.broadcast_interval_secs); +fn get_hostname() -> String { + std::process::Command::new("hostname") + .arg("-s") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|h| h.trim().to_string()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "numa".to_string()) +} - 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 - }; +/// Generate a per-process instance ID for self-filtering on multi-instance hosts +fn instance_id() -> String { + format!("{}:{}", std::process::id(), std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() % 1_000_000) +} + +pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { + let interval = Duration::from_secs(config.broadcast_interval_secs); let local_ip = *ctx.lan_ip.lock().unwrap(); + let hostname = get_hostname(); + let our_instance_id = instance_id(); + info!( - "LAN discovery on {}:{}, local IP {}, instance {:016x}", - multicast_group, port, local_ip, instance_id + "LAN discovery via mDNS on {}:{}, local IP {}, instance {}._numa._tcp.local", + MDNS_ADDR, MDNS_PORT, local_ip, hostname ); - // Create socket with SO_REUSEADDR for multicast - let std_socket = match create_multicast_socket(multicast_group, port) { + let std_socket = match create_mdns_socket() { Ok(s) => s, Err(e) => { - warn!( - "LAN: could not bind multicast socket: {} — LAN discovery disabled", - e - ); + warn!("LAN: could not bind mDNS socket: {} — LAN discovery disabled", e); return; } }; @@ -138,81 +128,264 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { } }; let socket = Arc::new(socket); + let dest = SocketAddr::new(IpAddr::V4(MDNS_ADDR), MDNS_PORT); - // Spawn sender + // Spawn sender: announce our services periodically let sender_ctx = Arc::clone(&ctx); let sender_socket = Arc::clone(&socket); - let dest = SocketAddr::new(IpAddr::V4(multicast_group), port); + let sender_hostname = hostname.clone(); + let sender_instance_id = our_instance_id.clone(); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); loop { ticker.tick().await; - let services: Vec = { + let services: Vec<(String, u16)> = { let store = sender_ctx.services.lock().unwrap(); - store - .list() - .iter() - .map(|e| AnnouncedService { - name: e.name.clone(), - port: e.target_port, - }) - .collect() + store.list().iter().map(|e| (e.name.clone(), e.target_port)).collect() }; if services.is_empty() { continue; } - let current_ip = sender_ctx.lan_ip.lock().unwrap().to_string(); - let announcement = Announcement { - instance_id, - host: current_ip, - services, - }; - if let Ok(json) = serde_json::to_vec(&announcement) { - let _ = sender_socket.send_to(&json, dest).await; + let current_ip = *sender_ctx.lan_ip.lock().unwrap(); + if let Ok(pkt) = build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) { + let _ = sender_socket.send_to(pkt.filled(), dest).await; } } }); - // Receiver loop + // Send initial browse query + if let Ok(pkt) = build_browse_query() { + let _ = socket.send_to(pkt.filled(), dest).await; + } + + // Receiver loop: parse mDNS responses for _numa._tcp let mut buf = vec![0u8; 4096]; loop { - let (len, src) = match socket.recv_from(&mut buf).await { + let (len, _src) = match socket.recv_from(&mut buf).await { Ok(r) => r, Err(e) => { - debug!("LAN recv error: {}", e); + debug!("mDNS recv error: {}", e); continue; } }; - let announcement: Announcement = match serde_json::from_slice(&buf[..len]) { - Ok(a) => a, - Err(_) => continue, - }; - // Skip self-announcements - if announcement.instance_id == instance_id { - continue; + + let data = &buf[..len]; + if let Some((services, peer_ip, peer_id)) = parse_mdns_response(data) { + // Skip our own announcements via instance ID (works on multi-instance same-host) + if peer_id.as_deref() == Some(our_instance_id.as_str()) { + continue; + } + if !services.is_empty() { + ctx.lan_peers.lock().unwrap().update(peer_ip, &services); + debug!("LAN: {} services from {} (mDNS)", services.len(), peer_ip); + } } - 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; +// --- mDNS Packet Building --- - let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port); +fn build_browse_query() -> crate::Result { + let mut buf = BytePacketBuffer::new(); + + let mut header = DnsHeader::new(); + header.questions = 1; + header.write(&mut buf)?; + + DnsQuestion::new(SERVICE_TYPE.to_string(), QueryType::PTR).write(&mut buf)?; + + Ok(buf) +} + +fn build_announcement( + hostname: &str, + ip: Ipv4Addr, + services: &[(String, u16)], + inst_id: &str, +) -> crate::Result { + let mut buf = BytePacketBuffer::new(); + let instance_name = format!("{}._numa._tcp.local", hostname); + let host_local = format!("{}.local", hostname); + + let mut header = DnsHeader::new(); + header.response = true; + header.authoritative_answer = true; + header.answers = 4; // PTR + SRV + TXT + A + header.write(&mut buf)?; + + // PTR: _numa._tcp.local → ._numa._tcp.local + write_record_header(&mut buf, SERVICE_TYPE, QueryType::PTR.to_num(), 1, MDNS_TTL)?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + buf.write_qname(&instance_name)?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // SRV: ._numa._tcp.local → .local + // Port in SRV is informational; actual service ports are in TXT + write_record_header(&mut buf, &instance_name, QueryType::SRV.to_num(), 0x8001, MDNS_TTL)?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + buf.write_u16(0)?; // priority + buf.write_u16(0)?; // weight + buf.write_u16(0)?; // port (services have individual ports in TXT) + buf.write_qname(&host_local)?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // TXT: services + instance ID for self-filtering + write_record_header(&mut buf, &instance_name, QueryType::TXT.to_num(), 0x8001, MDNS_TTL)?; + let rdlen_pos = buf.pos(); + buf.write_u16(0)?; + let rdata_start = buf.pos(); + let svc_str = services + .iter() + .map(|(name, port)| format!("{}:{}", name, port)) + .collect::>() + .join(","); + write_txt_string(&mut buf, &format!("services={}", svc_str))?; + write_txt_string(&mut buf, &format!("id={}", inst_id))?; + patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; + + // A: .local → IP + write_record_header(&mut buf, &host_local, QueryType::A.to_num(), 0x8001, MDNS_TTL)?; + buf.write_u16(4)?; + for &b in &ip.octets() { + buf.write_u8(b)?; + } + + Ok(buf) +} + +fn write_record_header( + buf: &mut BytePacketBuffer, + name: &str, + rtype: u16, + class: u16, + ttl: u32, +) -> crate::Result<()> { + buf.write_qname(name)?; + buf.write_u16(rtype)?; + buf.write_u16(class)?; + buf.write_u32(ttl)?; + Ok(()) +} + +fn patch_rdlen(buf: &mut BytePacketBuffer, rdlen_pos: usize, rdata_start: usize) -> crate::Result<()> { + let rdlen = (buf.pos() - rdata_start) as u16; + buf.set_u16(rdlen_pos, rdlen) +} + +fn write_txt_string(buf: &mut BytePacketBuffer, s: &str) -> crate::Result<()> { + let bytes = s.as_bytes(); + for chunk in bytes.chunks(255) { + buf.write_u8(chunk.len() as u8)?; + for &b in chunk { + buf.write_u8(b)?; + } + } + Ok(()) +} + +// --- mDNS Packet Parsing --- + +/// Returns (services, peer_ip, instance_id) if this is a Numa mDNS announcement +fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Option)> { + if data.len() < 12 { + return None; + } + + let mut buf = BytePacketBuffer::new(); + buf.buf[..data.len()].copy_from_slice(data); + + let mut header = DnsHeader::new(); + header.read(&mut buf).ok()?; + + if !header.response || header.answers == 0 { + return None; + } + + // Skip questions + for _ in 0..header.questions { + let mut q = DnsQuestion::new(String::new(), QueryType::UNKNOWN(0)); + q.read(&mut buf).ok()?; + } + + let total = header.answers + header.authoritative_entries + header.resource_entries; + let mut txt_services: Option> = None; + let mut peer_instance_id: Option = None; + let mut a_ip: Option = None; + let mut name = String::with_capacity(64); + + for _ in 0..total { + if buf.pos() >= data.len() { + break; + } + + name.clear(); + if buf.read_qname(&mut name).is_err() { + break; + } + + let rtype = buf.read_u16().unwrap_or(0); + let _rclass = buf.read_u16().unwrap_or(0); + let _ttl = buf.read_u32().unwrap_or(0); + let rdlength = buf.read_u16().unwrap_or(0) as usize; + let rdata_start = buf.pos(); + + match rtype { + t if t == QueryType::TXT.to_num() && name.contains("_numa._tcp") => { + let mut pos = rdata_start; + while pos < rdata_start + rdlength && pos < data.len() { + let txt_len = data[pos] as usize; + pos += 1; + if pos + txt_len > data.len() { + break; + } + if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { + if let Some(val) = txt.strip_prefix("services=") { + let svcs: Vec<(String, u16)> = val + .split(',') + .filter_map(|s| { + let mut parts = s.splitn(2, ':'); + let svc_name = parts.next()?.to_string(); + let port = parts.next()?.parse().ok()?; + Some((svc_name, port)) + }) + .collect(); + if !svcs.is_empty() { + txt_services = Some(svcs); + } + } else if let Some(id) = txt.strip_prefix("id=") { + peer_instance_id = Some(id.to_string()); + } + } + pos += txt_len; + } + } + t if t == QueryType::A.to_num() && rdlength == 4 && rdata_start + 4 <= data.len() => { + a_ip = Some(IpAddr::V4(Ipv4Addr::new( + data[rdata_start], + data[rdata_start + 1], + data[rdata_start + 2], + data[rdata_start + 3], + ))); + } + _ => {} + } + + buf.seek(rdata_start + rdlength).ok(); + } + + let services = txt_services?; + // Trust the A record IP if present, otherwise this isn't a complete announcement + let peer_ip = a_ip?; + + Some((services, peer_ip, peer_instance_id)) +} + +fn create_mdns_socket() -> std::io::Result { + let addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT); let socket = socket2::Socket::new( socket2::Domain::IPV4, socket2::Type::DGRAM, @@ -223,6 +396,6 @@ fn create_multicast_socket(group: Ipv4Addr, port: u16) -> std::io::Result numa::Result<()> { // Build service store: config services + persisted user services let mut service_store = ServiceStore::new(); - service_store.insert_from_config("numa", config.server.api_port); + service_store.insert_from_config("numa", config.server.api_port, Vec::new()); for svc in &config.services { - service_store.insert_from_config(&svc.name, svc.target_port); + service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone()); } service_store.load_persisted(); @@ -170,7 +170,7 @@ async fn main() -> numa::Result<()> { } 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)); + "mDNS (_numa._tcp.local)"); } 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", @@ -205,7 +205,7 @@ async fn main() -> numa::Result<()> { // Spawn HTTP API server let api_ctx = Arc::clone(&ctx); - let api_addr: SocketAddr = format!("0.0.0.0:{}", api_port).parse()?; + let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?; tokio::spawn(async move { let app = numa::api::router(api_ctx); let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap(); @@ -213,12 +213,19 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value + let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled { + std::net::Ipv4Addr::UNSPECIFIED + } else { + config.proxy.bind_addr.parse().unwrap_or(std::net::Ipv4Addr::LOCALHOST) + }; + // Spawn HTTP reverse proxy for .numa domains if config.proxy.enabled { let proxy_ctx = Arc::clone(&ctx); let proxy_port = config.proxy.port; tokio::spawn(async move { - numa::proxy::start_proxy(proxy_ctx, proxy_port).await; + numa::proxy::start_proxy(proxy_ctx, proxy_port, proxy_bind).await; }); } @@ -237,7 +244,7 @@ async fn main() -> numa::Result<()> { let proxy_ctx = Arc::clone(&ctx); let tls_port = config.proxy.tls_port; tokio::spawn(async move { - numa::proxy::start_proxy_tls(proxy_ctx, tls_port, tls_config).await; + numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind, tls_config).await; }); } Err(e) => { diff --git a/src/proxy.rs b/src/proxy.rs index 414a53e..087017b 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use axum::body::Body; @@ -25,8 +25,8 @@ struct ProxyState { client: HttpClient, } -pub async fn start_proxy(ctx: Arc, port: u16) { - let addr: SocketAddr = ([0, 0, 0, 0], port).into(); +pub async fn start_proxy(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { + let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -50,8 +50,8 @@ pub async fn start_proxy(ctx: Arc, port: u16) { axum::serve(listener, app).await.unwrap(); } -pub async fn start_proxy_tls(ctx: Arc, port: u16, tls_config: Arc) { - let addr: SocketAddr = ([0, 0, 0, 0], port).into(); +pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr, tls_config: Arc) { + let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, Err(e) => { @@ -135,14 +135,17 @@ async fn proxy_handler(State(state): State, req: Request) -> axum::r } }; - let (target_host, target_port) = { + let request_path = req.uri().path().to_string(); + + let (target_host, target_port, rewritten_path) = { let store = state.ctx.services.lock().unwrap(); if let Some(entry) = store.lookup(&service_name) { - ("localhost".to_string(), entry.target_port) + let (port, path) = entry.resolve_route(&request_path); + ("localhost".to_string(), port, path) } else { let mut peers = state.ctx.lan_peers.lock().unwrap(); match peers.lookup(&service_name) { - Some((ip, port)) => (ip.to_string(), port), + Some((ip, port)) => (ip.to_string(), port, request_path.clone()), None => { return ( StatusCode::NOT_FOUND, @@ -268,13 +271,9 @@ pre .str {{ color: #d48a5a }} } }; - let path_and_query = req - .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); + let query_string = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default(); let target_uri: hyper::Uri = - format!("http://{}:{}{}", target_host, target_port, path_and_query) + format!("http://{}:{}{}{}", target_host, target_port, rewritten_path, query_string) .parse() .unwrap(); diff --git a/src/service_store.rs b/src/service_store.rs index 26b2daf..393d99e 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -8,6 +8,47 @@ use serde::{Deserialize, Serialize}; pub struct ServiceEntry { pub name: String, pub target_port: u16, + #[serde(default)] + pub routes: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RouteEntry { + pub path: String, + pub port: u16, + #[serde(default)] + pub strip: bool, +} + +impl ServiceEntry { + /// Resolve backend port and (possibly rewritten) path for a request + pub fn resolve_route(&self, request_path: &str) -> (u16, String) { + // Longest prefix match + let matched = self.routes.iter() + .filter(|r| { + request_path == r.path + || request_path.starts_with(&r.path) + && (r.path.ends_with('/') || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) + }) + .max_by_key(|r| r.path.len()); + + match matched { + Some(route) => { + let path = if route.strip { + let stripped = &request_path[route.path.len()..]; + if stripped.is_empty() || !stripped.starts_with('/') { + format!("/{}", stripped.trim_start_matches('/')) + } else { + stripped.to_string() + } + } else { + request_path.to_string() + }; + (route.port, path) + } + None => (self.target_port, request_path.to_string()), + } + } } pub struct ServiceStore { @@ -34,7 +75,7 @@ impl ServiceStore { } /// Insert a service from numa.toml config (not persisted) - pub fn insert_from_config(&mut self, name: &str, target_port: u16) { + pub fn insert_from_config(&mut self, name: &str, target_port: u16, routes: Vec) { let key = name.to_lowercase(); self.config_services.insert(key.clone()); self.entries.insert( @@ -42,6 +83,7 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes, }, ); } @@ -54,11 +96,37 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes: Vec::new(), }, ); self.save(); } + pub fn add_route(&mut self, service: &str, path: String, port: u16, strip: bool) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + entry.routes.retain(|r| r.path != path); + entry.routes.push(RouteEntry { path, port, strip }); + self.save(); + true + } else { + false + } + } + + pub fn remove_route(&mut self, service: &str, path: &str) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + let before = entry.routes.len(); + entry.routes.retain(|r| r.path != path); + if entry.routes.len() < before { + self.save(); + return true; + } + } + false + } + pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> { self.entries.get(&name.to_lowercase()) } From c836903db5b3a4507ef1d6582ba96881cc9614d9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 06:57:57 +0200 Subject: [PATCH 02/13] simplify: unify route structs, fix prefix collision, lint fixes - Unify RouteConfig/RouteEntry/RouteResponse into single RouteEntry - Fix prefix collision: /api no longer matches /apiary (segment boundary check) - Add path traversal rejection in route API - Extract MdnsAnnouncement struct (clippy type_complexity) - cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/mdns_coexist.rs | 33 ++++++++------ src/api.rs | 5 ++- src/lan.rs | 96 +++++++++++++++++++++++++++++++--------- src/main.rs | 6 ++- src/service_store.rs | 7 ++- 5 files changed, 107 insertions(+), 40 deletions(-) diff --git a/examples/mdns_coexist.rs b/examples/mdns_coexist.rs index 6386cae..2772d72 100644 --- a/examples/mdns_coexist.rs +++ b/examples/mdns_coexist.rs @@ -1,3 +1,4 @@ +use socket2::{Domain, Protocol, Socket, Type}; /// Spike: can we bind to mDNS multicast (224.0.0.251:5353) alongside macOS mDNSResponder? /// /// Tests: @@ -8,10 +9,8 @@ /// 5. Send a _numa._tcp.local announcement — does it conflict? /// /// Run: cargo run --example mdns_coexist - use std::mem::MaybeUninit; use std::net::{Ipv4Addr, SocketAddrV4}; -use socket2::{Domain, Protocol, Socket, Type}; const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); const MDNS_PORT: u16 = 5353; @@ -66,7 +65,8 @@ fn main() -> std::io::Result<()> { loop { match socket.recv_from(&mut buf) { Ok((n, addr)) => { - let data: &[u8] = unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; + let data: &[u8] = + unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; count += 1; let flags = u16::from_be_bytes([data[2], data[3]]); let is_response = flags & 0x8000 != 0; @@ -98,7 +98,8 @@ fn main() -> std::io::Result<()> { } // Step 6: Send a _numa._tcp.local announcement - let announcement = build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380); + let announcement = + build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380); match socket.send_to(&announcement, &dest.into()) { Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n), Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e), @@ -111,7 +112,8 @@ fn main() -> std::io::Result<()> { loop { match socket.recv_from(&mut buf2) { Ok((n, addr)) => { - let data: &[u8] = unsafe { &*(&buf2[..n] as *const [MaybeUninit] as *const [u8]) }; + let data: &[u8] = + unsafe { &*(&buf2[..n] as *const [MaybeUninit] as *const [u8]) }; let flags = u16::from_be_bytes([data[2], data[3]]); let is_response = flags & 0x8000 != 0; if is_response { @@ -133,7 +135,10 @@ fn main() -> std::io::Result<()> { // Verdict println!("\n=== Verdict ==="); if count > 0 { - println!("[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder", count); + println!( + "[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder", + count + ); println!(" Safe to proceed with mDNS-based LAN discovery"); } else { println!("[WARN] No mDNS packets received — may need further investigation"); @@ -163,7 +168,7 @@ fn build_mdns_query(name: &str) -> Vec { pkt.push(0); // root label pkt.extend_from_slice(&[0, 12]); // QTYPE = PTR (12) - pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1) + pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1) pkt } @@ -173,17 +178,17 @@ fn build_mdns_announcement(service_type: &str, instance_name: &str, port: u16) - let mut pkt = Vec::new(); // Header: ID=0, flags=0x8400 (response, authoritative), ANCOUNT=1 - pkt.extend_from_slice(&[0, 0]); // ID + pkt.extend_from_slice(&[0, 0]); // ID pkt.extend_from_slice(&[0x84, 0x00]); // Flags: QR=1, AA=1 - pkt.extend_from_slice(&[0, 0]); // QDCOUNT - pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now) - pkt.extend_from_slice(&[0, 0]); // NSCOUNT - pkt.extend_from_slice(&[0, 0]); // ARCOUNT + pkt.extend_from_slice(&[0, 0]); // QDCOUNT + pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now) + pkt.extend_from_slice(&[0, 0]); // NSCOUNT + pkt.extend_from_slice(&[0, 0]); // ARCOUNT // PTR record: _numa._tcp.local → test-numa._numa._tcp.local encode_name(&mut pkt, service_type); - pkt.extend_from_slice(&[0, 12]); // TYPE = PTR - pkt.extend_from_slice(&[0, 1]); // CLASS = IN + pkt.extend_from_slice(&[0, 12]); // TYPE = PTR + pkt.extend_from_slice(&[0, 1]); // CLASS = IN pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s // RDATA: the instance name diff --git a/src/api.rs b/src/api.rs index 5167d3e..069c156 100644 --- a/src/api.rs +++ b/src/api.rs @@ -761,7 +761,10 @@ async fn add_route( if store.add_route(&name, req.path, req.port, req.strip) { Ok(StatusCode::CREATED) } else { - Err((StatusCode::NOT_FOUND, format!("service '{}' not found", name))) + Err(( + StatusCode::NOT_FOUND, + format!("service '{}' not found", name), + )) } } diff --git a/src/lan.rs b/src/lan.rs index ea2e6b7..d82b977 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -96,10 +96,15 @@ fn get_hostname() -> String { /// Generate a per-process instance ID for self-filtering on multi-instance hosts fn instance_id() -> String { - format!("{}:{}", std::process::id(), std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() % 1_000_000) + format!( + "{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + % 1_000_000 + ) } pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { @@ -116,7 +121,10 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { let std_socket = match create_mdns_socket() { Ok(s) => s, Err(e) => { - warn!("LAN: could not bind mDNS socket: {} — LAN discovery disabled", e); + warn!( + "LAN: could not bind mDNS socket: {} — LAN discovery disabled", + e + ); return; } }; @@ -141,13 +149,19 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { ticker.tick().await; let services: Vec<(String, u16)> = { let store = sender_ctx.services.lock().unwrap(); - store.list().iter().map(|e| (e.name.clone(), e.target_port)).collect() + store + .list() + .iter() + .map(|e| (e.name.clone(), e.target_port)) + .collect() }; if services.is_empty() { continue; } let current_ip = *sender_ctx.lan_ip.lock().unwrap(); - if let Ok(pkt) = build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) { + if let Ok(pkt) = + build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id) + { let _ = sender_socket.send_to(pkt.filled(), dest).await; } } @@ -170,14 +184,21 @@ pub async fn start_lan_discovery(ctx: Arc, config: &LanConfig) { }; let data = &buf[..len]; - if let Some((services, peer_ip, peer_id)) = parse_mdns_response(data) { + if let Some(ann) = parse_mdns_response(data) { // Skip our own announcements via instance ID (works on multi-instance same-host) - if peer_id.as_deref() == Some(our_instance_id.as_str()) { + if ann.instance_id.as_deref() == Some(our_instance_id.as_str()) { continue; } - if !services.is_empty() { - ctx.lan_peers.lock().unwrap().update(peer_ip, &services); - debug!("LAN: {} services from {} (mDNS)", services.len(), peer_ip); + if !ann.services.is_empty() { + ctx.lan_peers + .lock() + .unwrap() + .update(ann.peer_ip, &ann.services); + debug!( + "LAN: {} services from {} (mDNS)", + ann.services.len(), + ann.peer_ip + ); } } } @@ -223,18 +244,30 @@ fn build_announcement( // SRV: ._numa._tcp.local → .local // Port in SRV is informational; actual service ports are in TXT - write_record_header(&mut buf, &instance_name, QueryType::SRV.to_num(), 0x8001, MDNS_TTL)?; + write_record_header( + &mut buf, + &instance_name, + QueryType::SRV.to_num(), + 0x8001, + MDNS_TTL, + )?; let rdlen_pos = buf.pos(); buf.write_u16(0)?; let rdata_start = buf.pos(); - buf.write_u16(0)?; // priority - buf.write_u16(0)?; // weight - buf.write_u16(0)?; // port (services have individual ports in TXT) + buf.write_u16(0)?; // priority + buf.write_u16(0)?; // weight + buf.write_u16(0)?; // port (services have individual ports in TXT) buf.write_qname(&host_local)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // TXT: services + instance ID for self-filtering - write_record_header(&mut buf, &instance_name, QueryType::TXT.to_num(), 0x8001, MDNS_TTL)?; + write_record_header( + &mut buf, + &instance_name, + QueryType::TXT.to_num(), + 0x8001, + MDNS_TTL, + )?; let rdlen_pos = buf.pos(); buf.write_u16(0)?; let rdata_start = buf.pos(); @@ -248,7 +281,13 @@ fn build_announcement( patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; // A: .local → IP - write_record_header(&mut buf, &host_local, QueryType::A.to_num(), 0x8001, MDNS_TTL)?; + write_record_header( + &mut buf, + &host_local, + QueryType::A.to_num(), + 0x8001, + MDNS_TTL, + )?; buf.write_u16(4)?; for &b in &ip.octets() { buf.write_u8(b)?; @@ -271,7 +310,11 @@ fn write_record_header( Ok(()) } -fn patch_rdlen(buf: &mut BytePacketBuffer, rdlen_pos: usize, rdata_start: usize) -> crate::Result<()> { +fn patch_rdlen( + buf: &mut BytePacketBuffer, + rdlen_pos: usize, + rdata_start: usize, +) -> crate::Result<()> { let rdlen = (buf.pos() - rdata_start) as u16; buf.set_u16(rdlen_pos, rdlen) } @@ -289,8 +332,13 @@ fn write_txt_string(buf: &mut BytePacketBuffer, s: &str) -> crate::Result<()> { // --- mDNS Packet Parsing --- -/// Returns (services, peer_ip, instance_id) if this is a Numa mDNS announcement -fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Option)> { +struct MdnsAnnouncement { + services: Vec<(String, u16)>, + peer_ip: IpAddr, + instance_id: Option, +} + +fn parse_mdns_response(data: &[u8]) -> Option { if data.len() < 12 { return None; } @@ -381,7 +429,11 @@ fn parse_mdns_response(data: &[u8]) -> Option<(Vec<(String, u16)>, IpAddr, Optio // Trust the A record IP if present, otherwise this isn't a complete announcement let peer_ip = a_ip?; - Some((services, peer_ip, peer_instance_id)) + Some(MdnsAnnouncement { + services, + peer_ip, + instance_id: peer_instance_id, + }) } fn create_mdns_socket() -> std::io::Result { diff --git a/src/main.rs b/src/main.rs index b070b70..ed661a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -217,7 +217,11 @@ async fn main() -> numa::Result<()> { let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled { std::net::Ipv4Addr::UNSPECIFIED } else { - config.proxy.bind_addr.parse().unwrap_or(std::net::Ipv4Addr::LOCALHOST) + config + .proxy + .bind_addr + .parse() + .unwrap_or(std::net::Ipv4Addr::LOCALHOST) }; // Spawn HTTP reverse proxy for .numa domains diff --git a/src/service_store.rs b/src/service_store.rs index 393d99e..e8a4ebc 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -24,11 +24,14 @@ impl ServiceEntry { /// Resolve backend port and (possibly rewritten) path for a request pub fn resolve_route(&self, request_path: &str) -> (u16, String) { // Longest prefix match - let matched = self.routes.iter() + let matched = self + .routes + .iter() .filter(|r| { request_path == r.path || request_path.starts_with(&r.path) - && (r.path.ends_with('/') || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) + && (r.path.ends_with('/') + || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) }) .max_by_key(|r| r.path.len()); From 9c290b6ef4aba91c2849e7112eee1c758a586aef Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 07:13:58 +0200 Subject: [PATCH 03/13] fmt: fix proxy.rs formatting for CI rustfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- src/proxy.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/proxy.rs b/src/proxy.rs index 087017b..c4c2ca6 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -50,7 +50,12 @@ pub async fn start_proxy(ctx: Arc, port: u16, bind_addr: Ipv4Addr) { axum::serve(listener, app).await.unwrap(); } -pub async fn start_proxy_tls(ctx: Arc, port: u16, bind_addr: Ipv4Addr, tls_config: Arc) { +pub async fn start_proxy_tls( + ctx: Arc, + port: u16, + bind_addr: Ipv4Addr, + tls_config: Arc, +) { let addr: SocketAddr = (bind_addr, port).into(); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, @@ -271,11 +276,17 @@ pre .str {{ color: #d48a5a }} } }; - let query_string = req.uri().query().map(|q| format!("?{}", q)).unwrap_or_default(); - let target_uri: hyper::Uri = - format!("http://{}:{}{}{}", target_host, target_port, rewritten_path, query_string) - .parse() - .unwrap(); + let query_string = req + .uri() + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let target_uri: hyper::Uri = format!( + "http://{}:{}{}{}", + target_host, target_port, rewritten_path, query_string + ) + .parse() + .unwrap(); // Check for upgrade request (WebSocket, etc.) let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); From 64c4d146eca9864dc6f4b2505b2dad2e3db2385d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 07:49:06 +0200 Subject: [PATCH 04/13] add unit tests for route matching, config defaults, and service store Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 66 ++++++++++++++++++ src/service_store.rs | 160 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 223 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 44e9fd7..e7eb607 100644 --- a/src/config.rs +++ b/src/config.rs @@ -250,6 +250,72 @@ fn default_lan_peer_timeout() -> u64 { 90 } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lan_disabled_by_default() { + assert!(!LanConfig::default().enabled); + } + + #[test] + fn api_binds_localhost_by_default() { + assert_eq!(ServerConfig::default().api_bind_addr, "127.0.0.1"); + } + + #[test] + fn proxy_binds_localhost_by_default() { + assert_eq!(ProxyConfig::default().bind_addr, "127.0.0.1"); + } + + #[test] + fn empty_toml_gives_defaults() { + let config: Config = toml::from_str("").unwrap(); + assert!(!config.lan.enabled); + assert_eq!(config.server.api_bind_addr, "127.0.0.1"); + assert_eq!(config.proxy.bind_addr, "127.0.0.1"); + assert_eq!(config.server.api_port, ServerConfig::default().api_port); + } + + #[test] + fn lan_enabled_parses() { + let config: Config = toml::from_str("[lan]\nenabled = true").unwrap(); + assert!(config.lan.enabled); + } + + #[test] + fn custom_bind_addrs_parse() { + let toml = r#" + [server] + api_bind_addr = "0.0.0.0" + [proxy] + bind_addr = "0.0.0.0" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.server.api_bind_addr, "0.0.0.0"); + assert_eq!(config.proxy.bind_addr, "0.0.0.0"); + } + + #[test] + fn service_routes_parse_from_toml() { + let toml = r#" + [[services]] + name = "app" + target_port = 3000 + routes = [ + { path = "/api", port = 4000, strip = true }, + { path = "/static", port = 5000 }, + ] + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.services.len(), 1); + assert_eq!(config.services[0].routes.len(), 2); + assert!(config.services[0].routes[0].strip); + assert!(!config.services[0].routes[1].strip); // default false + } +} + pub fn load_config(path: &str) -> Result { if !Path::new(path).exists() { return Ok(Config::default()); diff --git a/src/service_store.rs b/src/service_store.rs index e8a4ebc..e7c6d87 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use log::{info, warn}; @@ -57,7 +57,7 @@ impl ServiceEntry { pub struct ServiceStore { entries: HashMap, /// Services defined in numa.toml (not persisted to user file) - config_services: std::collections::HashSet, + config_services: HashSet, persist_path: PathBuf, } @@ -72,7 +72,7 @@ impl ServiceStore { let persist_path = dirs_path(); ServiceStore { entries: HashMap::new(), - config_services: std::collections::HashSet::new(), + config_services: HashSet::new(), persist_path, } } @@ -204,3 +204,157 @@ impl ServiceStore { fn dirs_path() -> PathBuf { crate::config_dir().join("services.json") } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn entry(port: u16, routes: Vec) -> ServiceEntry { + ServiceEntry { + name: "app".into(), + target_port: port, + routes, + } + } + + fn route(path: &str, port: u16, strip: bool) -> RouteEntry { + RouteEntry { + path: path.into(), + port, + strip, + } + } + + fn test_store() -> ServiceStore { + ServiceStore { + entries: HashMap::new(), + config_services: HashSet::new(), + persist_path: PathBuf::from("/dev/null"), + } + } + + // --- resolve_route --- + + #[test] + fn no_routes_returns_default_port() { + let e = entry(3000, vec![]); + assert_eq!(e.resolve_route("/anything"), (3000, "/anything".into())); + } + + #[test] + fn exact_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api"), (4000, "/api".into())); + } + + #[test] + fn prefix_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/api/users".into())); + } + + #[test] + fn segment_boundary_rejects_partial() { + let e = entry(3000, vec![route("/api", 4000, false)]); + // /apiary must NOT match /api — different segment + assert_eq!(e.resolve_route("/apiary"), (3000, "/apiary".into())); + } + + #[test] + fn segment_boundary_rejects_apikey() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/apikey"), (3000, "/apikey".into())); + } + + #[test] + fn longest_prefix_wins() { + let e = entry( + 3000, + vec![route("/api", 4000, false), route("/api/v2", 5000, false)], + ); + assert_eq!( + e.resolve_route("/api/v2/users"), + (5000, "/api/v2/users".into()) + ); + // shorter prefix still works for non-v2 paths + assert_eq!( + e.resolve_route("/api/v1/users"), + (4000, "/api/v1/users".into()) + ); + } + + #[test] + fn strip_removes_prefix() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/users".into())); + } + + #[test] + fn strip_exact_path_gives_root() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api"), (4000, "/".into())); + } + + #[test] + fn trailing_slash_route_matches() { + let e = entry(3000, vec![route("/app/", 4000, false)]); + assert_eq!( + e.resolve_route("/app/dashboard"), + (4000, "/app/dashboard".into()) + ); + } + + // --- ServiceStore: add_route / remove_route --- + + #[test] + fn add_route_to_existing_service() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(store.add_route("app", "/api".into(), 4000, false)); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].path, "/api"); + } + + #[test] + fn add_route_to_missing_service_returns_false() { + let mut store = test_store(); + assert!(!store.add_route("ghost", "/api".into(), 4000, false)); + } + + #[test] + fn add_route_deduplicates_by_path() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + store.add_route("app", "/api".into(), 4000, false); + store.add_route("app", "/api".into(), 5000, true); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].port, 5000); + assert!(entry.routes[0].strip); + } + + #[test] + fn remove_route_returns_true_when_found() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![route("/api", 4000, false)]); + assert!(store.remove_route("app", "/api")); + assert!(store.lookup("app").unwrap().routes.is_empty()); + } + + #[test] + fn remove_route_returns_false_when_missing() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(!store.remove_route("app", "/nope")); + } + + #[test] + fn lookup_is_case_insensitive() { + let mut store = test_store(); + store.insert_from_config("MyApp", 3000, vec![]); + assert!(store.lookup("myapp").is_some()); + assert!(store.lookup("MYAPP").is_some()); + } +} From fb89b7822661052d1152636ecba6226eb2b0b7de Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 08:58:14 +0200 Subject: [PATCH 05/13] dashboard: route CRUD, source-aware service controls, XSS fix - Add inline route management (+ route / x) per service in dashboard - Expose service source (config vs api) in API response - Only show service delete button for API-created services - Pre-fill route port with service target_port - Fix XSS in route path onclick handlers - Skip renderServices refresh while route form is open (editingRoute guard) Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 50 ++++++++++++++++++++++++++++++++++++++++++-- src/api.rs | 21 ++++++++++++++++--- src/service_store.rs | 5 +++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index a7734c9..04ed80d 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -1086,7 +1086,10 @@ async function removeAllowlistDomain(domain) { } catch (err) {} } +let editingRoute = false; + function renderServices(entries) { + if (editingRoute) return; const el = document.getElementById('servicesList'); if (!entries.length) { el.innerHTML = '
No services configured
'; @@ -1098,13 +1101,16 @@ function renderServices(entries) { ? 'LAN' : 'local only') : ''; + const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '"'); const routeLines = (e.routes || []).map(r => - `
` + + `
` + `${r.path} ` + `→ :${r.port}` + (r.strip ? ` (strip)` : '') + + (e.name === 'numa' ? '' : ` `) + `
` ).join(''); + const deletable = e.source !== 'config' && e.name !== 'numa'; return `
@@ -1112,12 +1118,52 @@ function renderServices(entries) {
${e.name}.numa${lanBadge}
localhost:${e.target_port} → proxied
${routeLines} + ${e.name === 'numa' ? '' : `
`}
- ${e.name === 'numa' ? '' : ``} + ${deletable ? `` : ''}
`}).join(''); } +function toggleRouteForm(name) { + const el = document.getElementById('routeForm-' + name); + const opening = el.style.display === 'none'; + el.style.display = opening ? 'block' : 'none'; + editingRoute = opening; +} + +async function addRoute(name) { + const errEl = document.getElementById('routeError-' + name); + errEl.style.display = 'none'; + try { + const path = document.getElementById('routePath-' + name).value.trim(); + const port = parseInt(document.getElementById('routePort-' + name).value) || 0; + const strip = document.getElementById('routeStrip-' + name).checked; + const res = await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, port, strip }), + }); + if (!res.ok) throw new Error(await res.text()); + editingRoute = false; + refresh(); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = 'block'; + } +} + +async function deleteRoute(name, path) { + try { + await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + refresh(); + } catch (err) { /* next refresh will update */ } +} + async function addService(event) { event.preventDefault(); const errEl = document.getElementById('serviceError'); diff --git a/src/api.rs b/src/api.rs index 069c156..5696d70 100644 --- a/src/api.rs +++ b/src/api.rs @@ -601,6 +601,7 @@ struct ServiceResponse { lan_accessible: bool, #[serde(skip_serializing_if = "Vec::is_empty")] routes: Vec, + source: String, } #[derive(Deserialize)] @@ -615,7 +616,19 @@ async fn list_services(State(ctx): State>) -> Json>) -> Json = entries .iter() - .map(|(_, 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)); @@ -644,13 +657,14 @@ async fn list_services(State(ctx): State>) -> Json bool { + self.config_services.contains(name) + } + pub fn list(&self) -> Vec<&ServiceEntry> { let mut entries: Vec<_> = self.entries.values().collect(); entries.sort_by(|a, b| a.name.cmp(&b.name)); From 51dc06690ec51b7d870905203dd7142090388340 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 09:14:18 +0200 Subject: [PATCH 06/13] update README: mDNS, path routing, security defaults, opt-in LAN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LAN discovery section: multicast → mDNS, add opt-in config example - Add path-based routing to Why Numa, Local Service Proxy, comparison table, roadmap - Update developer overrides: 25+ endpoints, mention /diagnose - Comparison table: add path-based routing row - Diagram: multicast → mDNS label Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index de38ad5..979ec2b 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ 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. +- **Path-based routing** — `app.numa/api → :5001`, `app.numa/auth → :5002`. Route URL paths to different backends with optional prefix stripping. Like nginx location blocks, zero config files. +- **LAN service discovery** — Numa instances on the same network find each other automatically via mDNS. Access a teammate's `api.numa` from your machine. Opt-in via `[lan] enabled = true`. +- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 25+ endpoints. Built-in diagnostics: `curl localhost:5380/diagnose/example.com` tells you exactly how any domain resolves. - **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. - **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. @@ -61,6 +62,17 @@ open http://frontend.numa # → proxied to localhost:5173 - **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. +- **Path-based routing** — route URL paths to different backends: + ```toml + [[services]] + name = "app" + target_port = 3000 + routes = [ + { path = "/api", port = 5001 }, + { path = "/auth", port = 5002, strip = true }, + ] + ``` + `app.numa/api/users → :5001/api/users`, `app.numa/auth/login → :5002/login` (stripped) - **Persistent** — services survive restarts - Or configure in `numa.toml`: @@ -77,7 +89,7 @@ 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 │ +│ Numa │ mDNS │ Numa │ │ services: │◄───────────►│ services: │ │ - api (port 8000) │ discovery │ - grafana (3000) │ │ - frontend (5173) │ │ │ @@ -90,7 +102,12 @@ 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`. +Enable LAN discovery in `numa.toml`: +```toml +[lan] +enabled = true +``` +Uses standard mDNS (`_numa._tcp.local` on port 5353) — compatible with Bonjour/Avahi, silently dropped by corporate firewalls instead of triggering IPS alerts. **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: @@ -111,7 +128,8 @@ bind_addr = "0.0.0.0:53" | 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 | +| Path-based routing | No | No | No | No | Prefix match + strip | +| LAN service discovery | No | No | No | No | mDNS, opt-in | | 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 | @@ -133,7 +151,8 @@ 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 +- [x] Path-based routing — URL prefix routing with optional strip, REST API +- [x] LAN service discovery — mDNS auto-discovery (opt-in), 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 From 763ba1de91727cd8132deb8c2f8e60709ba9ec9a Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 10:30:22 +0200 Subject: [PATCH 07/13] add numa lan on/off CLI command, update README - numa lan on/off toggles LAN discovery in numa.toml - Writes [lan] section if missing, updates enabled if present - Colored output with restart hint - README: add lan on/off to help text Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/main.rs b/src/main.rs index ed661a9..678a8bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,17 @@ async fn main() -> numa::Result<()> { } }; } + "lan" => { + let sub = std::env::args().nth(2).unwrap_or_default(); + return match sub.as_str() { + "on" => set_lan_enabled(true), + "off" => set_lan_enabled(false), + _ => { + eprintln!("Usage: numa lan "); + Ok(()) + } + }; + } "version" | "--version" | "-V" => { eprintln!("numa {}", env!("CARGO_PKG_VERSION")); return Ok(()); @@ -65,6 +76,8 @@ async fn main() -> numa::Result<()> { eprintln!(" service stop Uninstall the system service"); eprintln!(" service restart Restart the service with updated binary"); eprintln!(" service status Check if the service is running"); + eprintln!(" lan on Enable LAN service discovery (mDNS)"); + eprintln!(" lan off Disable LAN service discovery"); eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); @@ -338,6 +351,64 @@ async fn network_watch_loop(ctx: Arc) { } } +fn set_lan_enabled(enabled: bool) -> numa::Result<()> { + let path = "numa.toml"; + + if std::path::Path::new(path).exists() { + let contents = std::fs::read_to_string(path)?; + + // Track current TOML section while scanning lines + let mut in_lan = false; + let mut found = false; + let mut lines: Vec = contents + .lines() + .map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lan = trimmed == "[lan]"; + } + if in_lan && !found && trimmed.starts_with("enabled") && trimmed.contains('=') { + found = true; + return format!("enabled = {}", enabled); + } + line.to_string() + }) + .collect(); + + let has_lan_section = lines.iter().any(|l| l.trim() == "[lan]"); + if !found && has_lan_section { + // [lan] exists but no enabled line — insert after it + if let Some(i) = lines.iter().position(|l| l.trim() == "[lan]") { + lines.insert(i + 1, format!("enabled = {}", enabled)); + } + } else if !found { + // No [lan] section — append + lines.push(String::new()); + lines.push("[lan]".to_string()); + lines.push(format!("enabled = {}", enabled)); + } + + let mut result = lines.join("\n"); + if contents.ends_with('\n') && !result.ends_with('\n') { + result.push('\n'); + } + std::fs::write(path, result)?; + } else { + std::fs::write(path, format!("[lan]\nenabled = {}\n", enabled))?; + } + + let label = if enabled { "enabled" } else { "disabled" }; + let color = if enabled { "32" } else { "33" }; + eprintln!( + "\x1b[1;38;2;192;98;58mNuma\x1b[0m — LAN discovery \x1b[{}m{}\x1b[0m", + color, label + ); + if enabled { + eprintln!(" Restart Numa to start mDNS discovery"); + } + Ok(()) +} + async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { let downloaded = download_blocklists(lists).await; From 4020776b8e30ec628ed8725de6ad727eaa432efc Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 10:59:35 +0200 Subject: [PATCH 08/13] simplify set_lan_enabled: fix config path, TOCTOU, double iteration - Accept config path parameter (consistent with main's resolution) - Read first, match on NotFound (eliminates TOCTOU race) - Single position() call replaces any() + position() - Precise key matching via split_once('=') - Preserve original indentation on replacement - Extract print_lan_status helper Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 94 +++++++++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/src/main.rs b/src/main.rs index 678a8bf..7f60419 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,11 +52,14 @@ async fn main() -> numa::Result<()> { } "lan" => { let sub = std::env::args().nth(2).unwrap_or_default(); + let config_path = std::env::args() + .nth(3) + .unwrap_or_else(|| "numa.toml".to_string()); return match sub.as_str() { - "on" => set_lan_enabled(true), - "off" => set_lan_enabled(false), + "on" => set_lan_enabled(true, &config_path), + "off" => set_lan_enabled(false, &config_path), _ => { - eprintln!("Usage: numa lan "); + eprintln!("Usage: numa lan [config-path]"); Ok(()) } }; @@ -351,52 +354,60 @@ async fn network_watch_loop(ctx: Arc) { } } -fn set_lan_enabled(enabled: bool) -> numa::Result<()> { - let path = "numa.toml"; +fn set_lan_enabled(enabled: bool, path: &str) -> numa::Result<()> { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + std::fs::write(path, format!("[lan]\nenabled = {}\n", enabled))?; + print_lan_status(enabled); + return Ok(()); + } + Err(e) => return Err(e.into()), + }; - if std::path::Path::new(path).exists() { - let contents = std::fs::read_to_string(path)?; - - // Track current TOML section while scanning lines - let mut in_lan = false; - let mut found = false; - let mut lines: Vec = contents - .lines() - .map(|line| { - let trimmed = line.trim(); - if trimmed.starts_with('[') { - in_lan = trimmed == "[lan]"; - } - if in_lan && !found && trimmed.starts_with("enabled") && trimmed.contains('=') { - found = true; - return format!("enabled = {}", enabled); - } - line.to_string() - }) - .collect(); - - let has_lan_section = lines.iter().any(|l| l.trim() == "[lan]"); - if !found && has_lan_section { - // [lan] exists but no enabled line — insert after it - if let Some(i) = lines.iter().position(|l| l.trim() == "[lan]") { - lines.insert(i + 1, format!("enabled = {}", enabled)); + // Track current TOML section while scanning lines + let mut in_lan = false; + let mut found = false; + let mut lines: Vec = contents + .lines() + .map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lan = trimmed == "[lan]"; } - } else if !found { - // No [lan] section — append + if in_lan && !found { + if let Some((key, _)) = trimmed.split_once('=') { + if key.trim() == "enabled" { + found = true; + let indent = &line[..line.len() - trimmed.len()]; + return format!("{}enabled = {}", indent, enabled); + } + } + } + line.to_string() + }) + .collect(); + + if !found { + if let Some(i) = lines.iter().position(|l| l.trim() == "[lan]") { + lines.insert(i + 1, format!("enabled = {}", enabled)); + } else { lines.push(String::new()); lines.push("[lan]".to_string()); lines.push(format!("enabled = {}", enabled)); } - - let mut result = lines.join("\n"); - if contents.ends_with('\n') && !result.ends_with('\n') { - result.push('\n'); - } - std::fs::write(path, result)?; - } else { - std::fs::write(path, format!("[lan]\nenabled = {}\n", enabled))?; } + let mut result = lines.join("\n"); + if !result.ends_with('\n') { + result.push('\n'); + } + std::fs::write(path, result)?; + print_lan_status(enabled); + Ok(()) +} + +fn print_lan_status(enabled: bool) { let label = if enabled { "enabled" } else { "disabled" }; let color = if enabled { "32" } else { "33" }; eprintln!( @@ -406,7 +417,6 @@ fn set_lan_enabled(enabled: bool) -> numa::Result<()> { if enabled { eprintln!(" Restart Numa to start mDNS discovery"); } - Ok(()) } async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { From c4e733c8ef61674d68a4fd499b2928d50f33fec5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 11:12:53 +0200 Subject: [PATCH 09/13] README: add numa lan on command to LAN discovery section Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 979ec2b..9b7ad2e 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,11 @@ dig @127.0.0.1 api.numa # → 192.168.1.5 curl http://api.numa # → proxied to Machine A's port 8000 ``` -Enable LAN discovery in `numa.toml`: +Enable LAN discovery: +```bash +numa lan on +``` +Or in `numa.toml`: ```toml [lan] enabled = true From 41a97bb93092a17b38fa10af7f1e72d138b1398b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 11:16:52 +0200 Subject: [PATCH 10/13] dashboard: show LAN status in Local Services panel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add lan_enabled to ServerCtx - Add lan field to /stats API (enabled, peer count) - Dashboard shows "LAN off" (dim) or "LAN on · N peers" (green) - Tooltip shows enable command or mDNS service type Co-Authored-By: Claude Opus 4.6 (1M context) --- site/dashboard.html | 19 ++++++++++++++++++- src/api.rs | 11 +++++++++++ src/ctx.rs | 1 + src/main.rs | 1 + 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/site/dashboard.html b/site/dashboard.html index 04ed80d..76c2a80 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -580,10 +580,11 @@ body {
-
+
Local Services
Give localhost apps clean .numa URLs. Persistent, with HTTP proxy.
+
@@ -874,6 +875,22 @@ async function refresh() { document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); document.getElementById('footerUpstream').textContent = stats.upstream || ''; + + // LAN status indicator + const lanEl = document.getElementById('lanToggle'); + if (stats.lan) { + if (!stats.lan.enabled) { + lanEl.style.color = 'var(--text-dim)'; + lanEl.textContent = 'LAN off'; + lanEl.title = 'Enable with: numa lan on'; + } else { + const pc = stats.lan.peers || 0; + lanEl.style.color = pc > 0 ? 'var(--emerald)' : 'var(--teal)'; + lanEl.textContent = `LAN on · ${pc} peer${pc !== 1 ? 's' : ''}`; + lanEl.title = 'mDNS discovery active (_numa._tcp.local)'; + } + } + document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('blockedCount').textContent = formatNumber(q.blocked); const bl = stats.blocking; diff --git a/src/api.rs b/src/api.rs index 5696d70..4962bef 100644 --- a/src/api.rs +++ b/src/api.rs @@ -134,6 +134,13 @@ struct StatsResponse { cache: CacheStats, overrides: OverrideStats, blocking: BlockingStatsResponse, + lan: LanStatsResponse, +} + +#[derive(Serialize)] +struct LanStatsResponse { + enabled: bool, + peers: usize, } #[derive(Serialize)] @@ -466,6 +473,10 @@ async fn stats(State(ctx): State>) -> Json { domains_loaded: bl_stats.domains_loaded, allowlist_size: bl_stats.allowlist_size, }, + lan: LanStatsResponse { + enabled: ctx.lan_enabled, + peers: ctx.lan_peers.lock().unwrap().list().len(), + }, }) } diff --git a/src/ctx.rs b/src/ctx.rs index 6892a56..b5d896a 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -39,6 +39,7 @@ pub struct ServerCtx { pub timeout: Duration, pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation + pub lan_enabled: bool, } pub async fn handle_query( diff --git a/src/main.rs b/src/main.rs index 7f60419..6e09442 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,6 +159,7 @@ async fn main() -> numa::Result<()> { format!(".{}", config.proxy.tld) }, proxy_tld: config.proxy.tld.clone(), + lan_enabled: config.lan.enabled, }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); From 10f1602803c97c90552b3c0f6be247d35cff4c0b Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 11:21:09 +0200 Subject: [PATCH 11/13] address PR review: SRV port, drop spike, percent-encoded paths - SRV record uses first service's port (was 0, confused dns-sd -L) - Remove examples/mdns_coexist.rs (served its purpose as spike) - Reject percent-encoding in route paths (defense-in-depth) Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/mdns_coexist.rs | 211 --------------------------------------- src/api.rs | 7 +- src/lan.rs | 2 +- 3 files changed, 6 insertions(+), 214 deletions(-) delete mode 100644 examples/mdns_coexist.rs diff --git a/examples/mdns_coexist.rs b/examples/mdns_coexist.rs deleted file mode 100644 index 2772d72..0000000 --- a/examples/mdns_coexist.rs +++ /dev/null @@ -1,211 +0,0 @@ -use socket2::{Domain, Protocol, Socket, Type}; -/// Spike: can we bind to mDNS multicast (224.0.0.251:5353) alongside macOS mDNSResponder? -/// -/// Tests: -/// 1. Bind UDP socket to 0.0.0.0:5353 with SO_REUSEPORT + SO_REUSEADDR -/// 2. Join multicast group 224.0.0.251 -/// 3. Send a PTR query for _services._dns-sd._udp.local (standard browse) -/// 4. Listen for mDNS responses — do we see them alongside mDNSResponder? -/// 5. Send a _numa._tcp.local announcement — does it conflict? -/// -/// Run: cargo run --example mdns_coexist -use std::mem::MaybeUninit; -use std::net::{Ipv4Addr, SocketAddrV4}; - -const MDNS_ADDR: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); -const MDNS_PORT: u16 = 5353; - -fn main() -> std::io::Result<()> { - println!("=== mDNS coexistence spike ===\n"); - - // Step 1: Create UDP socket with SO_REUSEPORT + SO_REUSEADDR - let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; - socket.set_reuse_address(true)?; - #[cfg(unix)] - socket.set_reuse_port(true)?; - println!("[OK] Socket created with SO_REUSEADDR + SO_REUSEPORT"); - - // Step 2: Bind to 0.0.0.0:5353 - let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, MDNS_PORT); - match socket.bind(&bind_addr.into()) { - Ok(()) => println!("[OK] Bound to 0.0.0.0:{}", MDNS_PORT), - Err(e) => { - println!("[FAIL] Cannot bind to port {}: {}", MDNS_PORT, e); - println!(" mDNSResponder may not allow port sharing"); - return Ok(()); - } - } - - // Step 3: Join multicast group - match socket.join_multicast_v4(&MDNS_ADDR, &Ipv4Addr::UNSPECIFIED) { - Ok(()) => println!("[OK] Joined multicast group {}", MDNS_ADDR), - Err(e) => { - println!("[FAIL] Cannot join multicast {}: {}", MDNS_ADDR, e); - return Ok(()); - } - } - - // Step 4: Send a PTR query for _services._dns-sd._udp.local - let query = build_mdns_query("_services._dns-sd._udp.local"); - let dest = SocketAddrV4::new(MDNS_ADDR, MDNS_PORT); - match socket.send_to(&query, &dest.into()) { - Ok(n) => println!("[OK] Sent mDNS browse query ({} bytes)", n), - Err(e) => { - println!("[FAIL] Cannot send to multicast: {}", e); - return Ok(()); - } - } - - // Step 5: Listen for responses (3 second timeout) - socket.set_read_timeout(Some(std::time::Duration::from_secs(3)))?; - let mut buf = [MaybeUninit::::zeroed(); 4096]; - let mut count = 0; - - println!("\nListening for mDNS responses (3s timeout)...\n"); - loop { - match socket.recv_from(&mut buf) { - Ok((n, addr)) => { - let data: &[u8] = - unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; - count += 1; - let flags = u16::from_be_bytes([data[2], data[3]]); - let is_response = flags & 0x8000 != 0; - let qdcount = u16::from_be_bytes([data[4], data[5]]); - let ancount = u16::from_be_bytes([data[6], data[7]]); - println!( - " #{} from {} — {} bytes, {}, questions={}, answers={}", - count, - addr.as_socket().map(|s| s.to_string()).unwrap_or_default(), - n, - if is_response { "RESPONSE" } else { "QUERY" }, - qdcount, - ancount, - ); - if count >= 20 { - println!("\n (capped at 20, stopping)"); - break; - } - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - println!("\n Timeout — received {} packets total", count); - break; - } - Err(e) => { - println!("[FAIL] recv error: {}", e); - break; - } - } - } - - // Step 6: Send a _numa._tcp.local announcement - let announcement = - build_mdns_announcement("_numa._tcp.local", "test-numa._numa._tcp.local", 5380); - match socket.send_to(&announcement, &dest.into()) { - Ok(n) => println!("\n[OK] Sent _numa._tcp.local announcement ({} bytes)", n), - Err(e) => println!("\n[FAIL] Cannot send announcement: {}", e), - } - - // Verify we can see our own announcement - socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?; - let mut buf2 = [MaybeUninit::::zeroed(); 4096]; - println!("Listening for our announcement echo (2s)...\n"); - loop { - match socket.recv_from(&mut buf2) { - Ok((n, addr)) => { - let data: &[u8] = - unsafe { &*(&buf2[..n] as *const [MaybeUninit] as *const [u8]) }; - let flags = u16::from_be_bytes([data[2], data[3]]); - let is_response = flags & 0x8000 != 0; - if is_response { - println!( - " Received response from {} ({} bytes) — multicast RX confirmed", - addr.as_socket().map(|s| s.to_string()).unwrap_or_default(), - n - ); - } - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - println!(" Timeout"); - break; - } - Err(_) => break, - } - } - - // Verdict - println!("\n=== Verdict ==="); - if count > 0 { - println!( - "[PASS] mDNS coexistence works — received {} packets alongside mDNSResponder", - count - ); - println!(" Safe to proceed with mDNS-based LAN discovery"); - } else { - println!("[WARN] No mDNS packets received — may need further investigation"); - println!(" Possible causes: firewall, mDNSResponder not sharing port"); - } - - Ok(()) -} - -/// Build a minimal mDNS PTR query packet -fn build_mdns_query(name: &str) -> Vec { - let mut pkt = Vec::new(); - - // Header: ID=0, flags=0 (query), QDCOUNT=1 - pkt.extend_from_slice(&[0, 0]); // ID - pkt.extend_from_slice(&[0, 0]); // Flags (standard query) - pkt.extend_from_slice(&[0, 1]); // QDCOUNT = 1 - pkt.extend_from_slice(&[0, 0]); // ANCOUNT - pkt.extend_from_slice(&[0, 0]); // NSCOUNT - pkt.extend_from_slice(&[0, 0]); // ARCOUNT - - // Question: encode name as labels - for label in name.split('.') { - pkt.push(label.len() as u8); - pkt.extend_from_slice(label.as_bytes()); - } - pkt.push(0); // root label - - pkt.extend_from_slice(&[0, 12]); // QTYPE = PTR (12) - pkt.extend_from_slice(&[0, 1]); // QCLASS = IN (1) - - pkt -} - -/// Build a minimal mDNS announcement (response with PTR + SRV + TXT) -fn build_mdns_announcement(service_type: &str, instance_name: &str, port: u16) -> Vec { - let mut pkt = Vec::new(); - - // Header: ID=0, flags=0x8400 (response, authoritative), ANCOUNT=1 - pkt.extend_from_slice(&[0, 0]); // ID - pkt.extend_from_slice(&[0x84, 0x00]); // Flags: QR=1, AA=1 - pkt.extend_from_slice(&[0, 0]); // QDCOUNT - pkt.extend_from_slice(&[0, 1]); // ANCOUNT = 1 (just PTR for now) - pkt.extend_from_slice(&[0, 0]); // NSCOUNT - pkt.extend_from_slice(&[0, 0]); // ARCOUNT - - // PTR record: _numa._tcp.local → test-numa._numa._tcp.local - encode_name(&mut pkt, service_type); - pkt.extend_from_slice(&[0, 12]); // TYPE = PTR - pkt.extend_from_slice(&[0, 1]); // CLASS = IN - pkt.extend_from_slice(&[0, 0, 0, 120]); // TTL = 120s - - // RDATA: the instance name - let mut rdata = Vec::new(); - encode_name(&mut rdata, instance_name); - pkt.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); // RDLENGTH - pkt.extend_from_slice(&rdata); - - let _ = port; // SRV record would use this — omitted for spike simplicity - - pkt -} - -fn encode_name(buf: &mut Vec, name: &str) { - for label in name.split('.') { - buf.push(label.len() as u8); - buf.extend_from_slice(label.as_bytes()); - } - buf.push(0); -} diff --git a/src/api.rs b/src/api.rs index 4962bef..be67e29 100644 --- a/src/api.rs +++ b/src/api.rs @@ -777,8 +777,11 @@ async fn add_route( if req.path.is_empty() || !req.path.starts_with('/') { return Err((StatusCode::BAD_REQUEST, "path must start with /".into())); } - if req.path.contains("/../") || req.path.ends_with("/..") { - return Err((StatusCode::BAD_REQUEST, "path must not contain '..'".into())); + if req.path.contains("/../") || req.path.ends_with("/..") || req.path.contains("%") { + return Err(( + StatusCode::BAD_REQUEST, + "path must not contain '..' or percent-encoding".into(), + )); } if req.port == 0 { return Err((StatusCode::BAD_REQUEST, "port must be > 0".into())); diff --git a/src/lan.rs b/src/lan.rs index d82b977..e76fdd0 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -256,7 +256,7 @@ fn build_announcement( let rdata_start = buf.pos(); buf.write_u16(0)?; // priority buf.write_u16(0)?; // weight - buf.write_u16(0)?; // port (services have individual ports in TXT) + buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display buf.write_qname(&host_local)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; From c6b35045d80c68f1a53f1607d19a11a56bfac7b2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 12:24:21 +0200 Subject: [PATCH 12/13] config visibility, PR review fixes, XSS hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config visibility: - startup banner shows config path, data dir, services path - config search: ./numa.toml → ~/.config/numa/ → /usr/local/var/numa/ - /stats API exposes config_path and data_dir, dashboard footer renders them - GET /ca.pem endpoint serves CA cert for cross-device TLS trust - load_config returns ConfigLoad with found flag, warns on not-found - ServerCtx stores PathBuf for config_dir/data_dir, string conversion at boundaries PR review fixes: - add explicit parens in resolve_route operator precedence (service_store.rs) - hostname portability: drop -s flag, trim domain with split('.') (lan.rs) - serve_ca uses spawn_blocking instead of sync fs::read in async handler - load_config: remove TOCTOU exists() check, read directly and handle NotFound XSS hardening: - HTML-escape all user-controlled interpolations in dashboard (service names, route paths, ports, URLs, block check domain/reason) Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 36 +++++++++++++++----------- src/api.rs | 23 +++++++++++++++++ src/config.rs | 61 +++++++++++++++++++++++++++++++++++++++----- src/ctx.rs | 5 ++++ src/lan.rs | 3 +-- src/main.rs | 21 +++++++++++++-- src/service_store.rs | 4 +-- 7 files changed, 126 insertions(+), 27 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index 76c2a80..ef07202 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -875,6 +875,8 @@ async function refresh() { document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs); document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs); document.getElementById('footerUpstream').textContent = stats.upstream || ''; + document.getElementById('footerConfig').textContent = stats.config_path || ''; + document.getElementById('footerData').textContent = stats.data_dir || ''; // LAN status indicator const lanEl = document.getElementById('lanToggle'); @@ -1006,14 +1008,16 @@ async function checkDomain(event) { if (result.blocked) { el.style.background = 'rgba(181, 68, 58, 0.1)'; el.style.color = 'var(--rose)'; - el.innerHTML = `Blocked — ${result.reason}` + - (result.matched_rule ? `
Rule: ${result.matched_rule}` : '') + - ` `; + const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); + el.innerHTML = `Blocked — ${hd(result.reason)}` + + (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : '') + + ` `; } else { + const hd = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); el.style.background = 'rgba(82, 122, 82, 0.1)'; el.style.color = 'var(--emerald)'; - el.innerHTML = `Allowed — ${result.reason}` + - (result.matched_rule ? `
Rule: ${result.matched_rule}` : ''); + el.innerHTML = `Allowed — ${hd(result.reason)}` + + (result.matched_rule ? `
Rule: ${hd(result.matched_rule)}` : ''); } } catch (err) { el.style.display = 'block'; @@ -1118,26 +1122,27 @@ function renderServices(entries) { ? 'LAN' : 'local only') : ''; - const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '"'); + const h = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); const routeLines = (e.routes || []).map(r => `
` + - `${r.path} ` + - `→ :${r.port}` + + `${h(r.path)} ` + + `→ :${parseInt(r.port)||0}` + (r.strip ? ` (strip)` : '') + - (e.name === 'numa' ? '' : ` `) + + (e.name === 'numa' ? '' : ` `) + `
` ).join(''); const deletable = e.source !== 'config' && e.name !== 'numa'; + const name = h(e.name); return `
-
${e.name}.numa${lanBadge}
-
localhost:${e.target_port} → proxied
+
${name}.numa${lanBadge}
+
localhost:${parseInt(e.target_port)||0} → proxied
${routeLines} - ${e.name === 'numa' ? '' : `
`} + ${e.name === 'numa' ? '' : `
`}
- ${deletable ? `` : ''} + ${deletable ? `` : ''}
`}).join(''); } @@ -1222,8 +1227,9 @@ setInterval(refresh, 2000);
- Upstream: - · Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f + Config: + · Data: + · Upstream: · GitHub
diff --git a/src/api.rs b/src/api.rs index be67e29..4166fb3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -49,6 +49,7 @@ pub fn router(ctx: Arc) -> Router { .route("/services/{name}/routes", get(list_routes)) .route("/services/{name}/routes", post(add_route)) .route("/services/{name}/routes", delete(remove_route)) + .route("/ca.pem", get(serve_ca)) .with_state(ctx) } @@ -130,6 +131,8 @@ struct QueryLogResponse { struct StatsResponse { uptime_secs: u64, upstream: String, + config_path: String, + data_dir: String, queries: QueriesStats, cache: CacheStats, overrides: OverrideStats, @@ -451,6 +454,8 @@ async fn stats(State(ctx): State>) -> Json { Json(StatsResponse { uptime_secs: snap.uptime_secs, upstream, + config_path: ctx.config_path.clone(), + data_dir: ctx.data_dir.to_string_lossy().to_string(), queries: QueriesStats { total: snap.total, forwarded: snap.forwarded, @@ -810,6 +815,24 @@ async fn remove_route( } } +async fn serve_ca(State(ctx): State>) -> Result { + let ca_path = ctx.data_dir.join("ca.pem"); + let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path)) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .map_err(|_| StatusCode::NOT_FOUND)?; + Ok(( + [ + (header::CONTENT_TYPE, "application/x-pem-file"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"numa-ca.pem\"", + ), + ], + bytes, + )) +} + async fn check_tcp(addr: std::net::SocketAddr) -> bool { tokio::time::timeout( std::time::Duration::from_millis(100), diff --git a/src/config.rs b/src/config.rs index e7eb607..f0cd811 100644 --- a/src/config.rs +++ b/src/config.rs @@ -316,13 +316,62 @@ mod tests { } } -pub fn load_config(path: &str) -> Result { - if !Path::new(path).exists() { - return Ok(Config::default()); +pub struct ConfigLoad { + pub config: Config, + pub path: String, + pub found: bool, +} + +fn resolve_path(path: &str) -> String { + // canonicalize gives the real absolute path for existing files; + // for non-existent files, build an absolute path manually + std::fs::canonicalize(path) + .or_else(|_| std::env::current_dir().map(|cwd| cwd.join(path))) + .unwrap_or_else(|_| Path::new(path).to_path_buf()) + .to_string_lossy() + .to_string() +} + +pub fn load_config(path: &str) -> Result { + // Try the given path first, then well-known locations (for service mode where cwd is /) + let candidates: Vec = { + let p = Path::new(path); + let mut v = vec![p.to_path_buf()]; + if p.is_relative() { + let filename = p.file_name().unwrap_or(p.as_os_str()); + v.push(crate::config_dir().join(filename)); + v.push(crate::data_dir().join(filename)); + } + v + }; + + for candidate in &candidates { + match std::fs::read_to_string(candidate) { + Ok(contents) => { + let resolved = resolve_path(&candidate.to_string_lossy()); + let config: Config = toml::from_str(&contents)?; + return Ok(ConfigLoad { + config, + path: resolved, + found: true, + }); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e.into()), + } } - let contents = std::fs::read_to_string(path)?; - let config: Config = toml::from_str(&contents)?; - Ok(config) + + // Show config_dir candidate as the "expected" path — it's actionable + let display_path = candidates + .get(1) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| resolve_path(path)); + log::info!("config not found, using defaults (create {})", display_path); + Ok(ConfigLoad { + config: Config::default(), + path: display_path, + found: false, + }) } pub type ZoneMap = HashMap>>; diff --git a/src/ctx.rs b/src/ctx.rs index b5d896a..167caba 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Mutex; use std::time::{Duration, Instant, SystemTime}; @@ -40,6 +41,10 @@ pub struct ServerCtx { pub proxy_tld: String, pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation pub lan_enabled: bool, + pub config_path: String, + pub config_found: bool, + pub config_dir: PathBuf, + pub data_dir: PathBuf, } pub async fn handle_query( diff --git a/src/lan.rs b/src/lan.rs index e76fdd0..609a351 100644 --- a/src/lan.rs +++ b/src/lan.rs @@ -85,11 +85,10 @@ pub fn detect_lan_ip() -> Option { fn get_hostname() -> String { std::process::Command::new("hostname") - .arg("-s") .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|h| h.trim().to_string()) + .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string()) .filter(|h| !h.is_empty()) .unwrap_or_else(|| "numa".to_string()) } diff --git a/src/main.rs b/src/main.rs index 6e09442..35029b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use tokio::net::UdpSocket; use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; use numa::buffer::BytePacketBuffer; use numa::cache::DnsCache; -use numa::config::{build_zone_map, load_config}; +use numa::config::{build_zone_map, load_config, ConfigLoad}; use numa::ctx::{handle_query, ServerCtx}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; @@ -96,7 +96,11 @@ async fn main() -> numa::Result<()> { } else { arg1 // treat as config path for backwards compatibility }; - let config = load_config(&config_path)?; + let ConfigLoad { + config, + path: resolved_config_path, + found: config_found, + } = load_config(&config_path)?; // Discover system DNS in a single pass (upstream + forwarding rules) let system_dns = discover_system_dns(); @@ -160,6 +164,10 @@ async fn main() -> numa::Result<()> { }, proxy_tld: config.proxy.tld.clone(), lan_enabled: config.lan.enabled, + config_path: resolved_config_path, + config_found, + config_dir: numa::config_dir(), + data_dir: numa::data_dir(), }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); @@ -193,6 +201,15 @@ async fn main() -> numa::Result<()> { 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())); } + eprintln!("\x1b[38;2;192;98;58m ╠──────────────────────────────────────────╣\x1b[0m"); + let config_label = if ctx.config_found { + ctx.config_path.clone() + } else { + format!("{} (defaults)", ctx.config_path) + }; + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mConfig\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config_label); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mData\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.data_dir.display()); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;163;152;136mServices\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", ctx.config_dir.join("services.json").display()); eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n"); info!( diff --git a/src/service_store.rs b/src/service_store.rs index 3e0bf9c..7db3ffd 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -29,9 +29,9 @@ impl ServiceEntry { .iter() .filter(|r| { request_path == r.path - || request_path.starts_with(&r.path) + || (request_path.starts_with(&r.path) && (r.path.ends_with('/') - || request_path.as_bytes().get(r.path.len()) == Some(&b'/')) + || request_path.as_bytes().get(r.path.len()) == Some(&b'/'))) }) .max_by_key(|r| r.path.len()); From e7e5c173f272ae6009f3c49a9a2614afec55dba2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Mon, 23 Mar 2026 12:29:18 +0200 Subject: [PATCH 13/13] dynamic banner width, hoist HTML escaper, cache CA, restore log path - banner box width adapts to longest value (fixes overflow with long paths) - hoist h() HTML escape function to script top, remove 3 local copies - serve_ca: add Cache-Control: public, max-age=86400 - restore log path in dashboard footer alongside new config/data fields Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 15 +++-- src/api.rs | 1 + src/main.rs | 132 +++++++++++++++++++++++++++++++++----------- 3 files changed, 108 insertions(+), 40 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index ef07202..348960c 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -661,6 +661,7 @@ body {