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