From 280e45c6be98d1c459ee936e9a508de0dca97066 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 11:32:42 +0300 Subject: [PATCH 01/16] feat: Windows DNS configuration via netsh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numa install/uninstall now set/restore system DNS on Windows via netsh. Parses ipconfig /all per-interface (adapter name, DHCP status, DNS servers), saves backup to %APPDATA%\numa\original-dns.json, and restores on uninstall (DHCP or static with secondary servers). Handles localization (German adapter/DHCP/DNS labels), disconnected adapters, multiple interfaces, and missing admin privileges. Adds IP validation to discover_windows() for consistency. No Windows Service or CA trust yet — user runs numa in a terminal. Co-Authored-By: Claude Opus 4.6 --- scripts/test-ubuntu.sh | 132 +++++++++++++++++++ src/system_dns.rs | 291 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 scripts/test-ubuntu.sh diff --git a/scripts/test-ubuntu.sh b/scripts/test-ubuntu.sh new file mode 100644 index 0000000..1e6df73 --- /dev/null +++ b/scripts/test-ubuntu.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# Ubuntu integration test for Numa PR #27 +# Usage: scp target/release/numa scripts/test-ubuntu.sh EC2:~ && ssh EC2 'sudo bash test-ubuntu.sh' +set -euo pipefail + +BIN="./numa" +PASS=0 +FAIL=0 + +check() { + local desc="$1"; shift + if "$@" > /dev/null 2>&1; then + echo " ✓ $desc" + PASS=$((PASS + 1)) + else + echo " ✗ $desc" + FAIL=$((FAIL + 1)) + fi +} + +cleanup() { + $BIN uninstall 2>/dev/null || true + killall numa 2>/dev/null || true + sleep 1 +} + +echo "=== Numa Ubuntu Integration Tests ===" +echo "" +chmod +x "$BIN" + +# --- Test 1: Forward mode (default, no config) --- +echo "--- Test 1: Forward mode (default) ---" +cleanup +$BIN 2>&1 & +NUMA_PID=$! +sleep 3 + +check "API responds" curl -sf http://127.0.0.1:5380/health +check "mode is forward" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"forward\""' +check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"' +check "dashboard returns 200" bash -c 'curl -sf -o /dev/null -w "%{http_code}" http://127.0.0.1:5380/ | grep -q 200' +kill $NUMA_PID 2>/dev/null; sleep 1 +echo "" + +# --- Test 2: Recursive mode (explicit opt-in) --- +echo "--- Test 2: Recursive mode ---" +cleanup +mkdir -p /tmp/numa-test +cat > /tmp/numa-test/numa.toml << 'TOML' +[upstream] +mode = "recursive" +[dnssec] +enabled = true +TOML +$BIN /tmp/numa-test/numa.toml 2>&1 & +NUMA_PID=$! +sleep 5 + +check "API responds" curl -sf http://127.0.0.1:5380/health +check "mode is recursive" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"recursive\""' +check "dnssec enabled" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"dnssec\":true"' +check "DNS resolves recursively" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"' +check "AD flag set (DNSSEC)" bash -c 'dig @127.0.0.1 example.com A +dnssec +time=10 | grep "flags:" | grep -q "ad"' +kill $NUMA_PID 2>/dev/null; sleep 1 +echo "" + +# --- Test 3: Auto mode --- +echo "--- Test 3: Auto mode ---" +cleanup +cat > /tmp/numa-test/numa.toml << 'TOML' +[upstream] +mode = "auto" +TOML +$BIN /tmp/numa-test/numa.toml 2>&1 & +NUMA_PID=$! +sleep 10 + +check "API responds" curl -sf http://127.0.0.1:5380/health +MODE=$(curl -sf http://127.0.0.1:5380/stats | python3 -c "import sys,json; print(json.load(sys.stdin)['mode'])" 2>/dev/null || echo "unknown") +echo " → auto resolved to: $MODE" +check "mode is recursive or forward" bash -c "echo '$MODE' | grep -qE '^(recursive|forward)$'" +check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"' +kill $NUMA_PID 2>/dev/null; sleep 1 +echo "" + +# --- Test 4: Install / Uninstall --- +echo "--- Test 4: Install / Uninstall ---" +cleanup +cp "$BIN" /usr/local/bin/numa + +echo " Installing..." +INSTALL_OUTPUT=$($BIN install 2>&1) || true +echo "$INSTALL_OUTPUT" +check "post-install mentions recursive" bash -c "echo '$INSTALL_OUTPUT' | grep -q 'recursive'" +sleep 3 + +check "service is running" systemctl is-active numa +check "API responds after install" curl -sf http://127.0.0.1:5380/health +check "DNS resolves after install" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"' + +echo "" +echo " Uninstalling..." +$BIN uninstall 2>&1 || true +sleep 2 + +check "service stopped" bash -c '! systemctl is-active numa' +echo "" + +# --- Test 5: Port 53 conflict --- +echo "--- Test 5: Port 53 conflict ---" +cleanup +# Start a dummy listener on port 53 +python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(('0.0.0.0',53)); input()" & +BLOCKER_PID=$! +sleep 1 + +$BIN 2>&1 & +NUMA_PID=$! +sleep 3 +# numa should fail to bind +check "numa fails when port 53 taken" bash -c '! kill -0 $NUMA_PID 2>/dev/null' +kill $BLOCKER_PID 2>/dev/null +kill $NUMA_PID 2>/dev/null +echo "" + +# --- Cleanup --- +cleanup +rm -rf /tmp/numa-test + +echo "=== Results: $PASS passed, $FAIL failed ===" +[ $FAIL -eq 0 ] && echo "All tests passed!" || echo "Some tests failed." +exit $FAIL diff --git a/src/system_dns.rs b/src/system_dns.rs index 9dda4af..3d67742 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo { if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { if let Some(ip) = trimmed.split(':').next_back() { let ip = ip.trim(); - if !is_loopback_or_stub(ip) { + if ip.parse::().is_ok() && !is_loopback_or_stub(ip) { upstream = Some(ip.to_string()); break; } @@ -358,6 +358,231 @@ fn discover_windows() -> SystemDnsInfo { } } +#[cfg(any(windows, test))] +#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] +struct WindowsInterfaceDns { + dhcp: bool, + servers: Vec, +} + +#[cfg(any(windows, test))] +fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap { + let mut interfaces = std::collections::HashMap::new(); + let mut current_adapter: Option = None; + let mut current_dhcp = false; + let mut current_dns: Vec = Vec::new(); + let mut in_dns_block = false; + let mut disconnected = false; + + for line in text.lines() { + let trimmed = line.trim(); + + // Adapter section headers start at column 0 + if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') { + if let Some(name) = current_adapter.take() { + if !disconnected { + interfaces.insert( + name, + WindowsInterfaceDns { + dhcp: current_dhcp, + servers: std::mem::take(&mut current_dns), + }, + ); + } + current_dns.clear(); + } + in_dns_block = false; + current_dhcp = false; + disconnected = false; + + // "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German) + let lower = trimmed.to_lowercase(); + if let Some(pos) = lower.find(" adapter ") { + let after = &trimmed[pos + " adapter ".len()..]; + let name = after.trim_end_matches(':').trim(); + if !name.is_empty() { + current_adapter = Some(name.to_string()); + } + } + } else if current_adapter.is_some() { + if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") { + disconnected = true; + } else if trimmed.contains("DHCP") && trimmed.contains(". .") { + current_dhcp = trimmed + .split(':') + .next_back() + .map(|v| { + let v = v.trim().to_lowercase(); + v == "yes" || v == "ja" + }) + .unwrap_or(false); + in_dns_block = false; + } else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { + in_dns_block = true; + if let Some(ip) = trimmed.split(':').next_back() { + let ip = ip.trim(); + if ip.parse::().is_ok() { + current_dns.push(ip.to_string()); + } + } + } else if in_dns_block { + if trimmed.parse::().is_ok() { + current_dns.push(trimmed.to_string()); + } else { + in_dns_block = false; + } + } + } + } + + if let Some(name) = current_adapter { + if !disconnected { + interfaces.insert( + name, + WindowsInterfaceDns { + dhcp: current_dhcp, + servers: current_dns, + }, + ); + } + } + + interfaces +} + +#[cfg(windows)] +fn get_windows_interfaces() -> Result, String> +{ + let output = std::process::Command::new("ipconfig") + .arg("/all") + .output() + .map_err(|e| format!("failed to run ipconfig /all: {}", e))?; + let text = String::from_utf8_lossy(&output.stdout); + Ok(parse_ipconfig_interfaces(&text)) +} + +#[cfg(windows)] +fn windows_backup_path() -> std::path::PathBuf { + crate::config_dir().join("original-dns.json") +} + +#[cfg(windows)] +fn install_windows() -> Result<(), String> { + let interfaces = get_windows_interfaces()?; + if interfaces.is_empty() { + return Err("no active network interfaces found".to_string()); + } + + let path = windows_backup_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + let json = serde_json::to_string_pretty(&interfaces) + .map_err(|e| format!("failed to serialize backup: {}", e))?; + std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?; + + for name in interfaces.keys() { + let status = std::process::Command::new("netsh") + .args([ + "interface", + "ipv4", + "set", + "dnsservers", + name, + "static", + "127.0.0.1", + "primary", + ]) + .status() + .map_err(|e| format!("failed to set DNS for {}: {}", name, e))?; + + if status.success() { + eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name); + } else { + eprintln!( + " warning: failed to set DNS for \"{}\" (run as Administrator?)", + name + ); + } + } + + eprintln!("\n Original DNS saved to {}", path.display()); + eprintln!(" Run 'numa uninstall' to restore."); + eprintln!(" Note: run Numa manually with 'numa' in a terminal.\n"); + eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); + eprintln!(" [upstream]"); + eprintln!(" mode = \"recursive\"\n"); + Ok(()) +} + +#[cfg(windows)] +fn uninstall_windows() -> Result<(), String> { + let path = windows_backup_path(); + let json = std::fs::read_to_string(&path) + .map_err(|e| format!("no backup found at {}: {}", path.display(), e))?; + let original: std::collections::HashMap = + serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?; + + for (name, dns_info) in &original { + if dns_info.dhcp || dns_info.servers.is_empty() { + let status = std::process::Command::new("netsh") + .args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"]) + .status() + .map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?; + + if status.success() { + eprintln!(" restored DNS for \"{}\" -> DHCP", name); + } else { + eprintln!(" warning: failed to restore DNS for \"{}\"", name); + } + } else { + let status = std::process::Command::new("netsh") + .args([ + "interface", + "ipv4", + "set", + "dnsservers", + name, + "static", + &dns_info.servers[0], + "primary", + ]) + .status() + .map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?; + + if !status.success() { + eprintln!(" warning: failed to restore primary DNS for \"{}\"", name); + continue; + } + + for (i, server) in dns_info.servers.iter().skip(1).enumerate() { + let _ = std::process::Command::new("netsh") + .args([ + "interface", + "ipv4", + "add", + "dnsservers", + name, + server, + &format!("index={}", i + 2), + ]) + .status(); + } + + eprintln!( + " restored DNS for \"{}\" -> {}", + name, + dns_info.servers.join(", ") + ); + } + } + + std::fs::remove_file(&path).ok(); + eprintln!("\n System DNS restored. Backup removed.\n"); + Ok(()) +} + /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. @@ -522,7 +747,9 @@ pub fn install_service() -> Result<(), String> { let result = install_service_macos(); #[cfg(target_os = "linux")] let result = install_service_linux(); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + let result = install_windows(); + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] let result = Err::<(), String>("service installation not supported on this OS".to_string()); if result.is_ok() { @@ -546,7 +773,11 @@ pub fn uninstall_service() -> Result<(), String> { { uninstall_service_linux() } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(windows)] + { + uninstall_windows() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] { Err("service uninstallation not supported on this OS".to_string()) } @@ -1027,3 +1258,57 @@ fn untrust_ca() -> Result<(), String> { let _ = ca_path; // suppress unused warning on other platforms Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ipconfig_dhcp_and_static() { + let sample = "\ +Ethernet adapter Ethernet: + + DHCP Enabled. . . . . . . . . . . : Yes + DNS Servers . . . . . . . . . . . : 8.8.8.8 + 8.8.4.4 + +Wireless LAN adapter Wi-Fi: + + DHCP Enabled. . . . . . . . . . . : No + DNS Servers . . . . . . . . . . . : 1.1.1.1 +"; + let result = parse_ipconfig_interfaces(sample); + assert_eq!(result.len(), 2); + assert_eq!( + result["Ethernet"], + WindowsInterfaceDns { + dhcp: true, + servers: vec!["8.8.8.8".into(), "8.8.4.4".into()], + } + ); + assert_eq!( + result["Wi-Fi"], + WindowsInterfaceDns { + dhcp: false, + servers: vec!["1.1.1.1".into()], + } + ); + } + + #[test] + fn parse_ipconfig_skips_disconnected() { + let sample = "\ +Ethernet adapter Ethernet 2: + + Media State . . . . . . . . . . . : Media disconnected + +Wireless LAN adapter Wi-Fi: + + DHCP Enabled. . . . . . . . . . . : Yes + DNS Servers . . . . . . . . . . . : 192.168.1.1 +"; + let result = parse_ipconfig_interfaces(sample); + assert_eq!(result.len(), 1); + assert!(result.contains_key("Wi-Fi")); + } +} -- 2.34.1 From 51c0d066d287c887b14836be80fa8aed34e7e2da Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 11:39:30 +0300 Subject: [PATCH 02/16] ci: add cargo test to Windows CI job Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f306f15..bf6fa77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,5 @@ jobs: run: cargo build - name: clippy run: cargo clippy -- -D warnings + - name: test + run: cargo test -- 2.34.1 From 7ed5415be6add24818e6e68571b1ebe1f80c84f1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 11:58:06 +0300 Subject: [PATCH 03/16] ci: upload Windows binary as artifact for testing Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf6fa77..f59e274 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,8 @@ jobs: run: cargo clippy -- -D warnings - name: test run: cargo test + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: numa-windows-x86_64 + path: target/debug/numa.exe -- 2.34.1 From ccf9b13e04537faff1a4431945ae62bd0ddd7894 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 12:07:19 +0300 Subject: [PATCH 04/16] fix: SRTT decay tests panic on Windows due to Instant underflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, Instant starts near boot time — subtracting large durations panics. Use checked_sub with a process-start fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/srtt.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/srtt.rs b/src/srtt.rs index bfad115..a832f99 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -218,8 +218,14 @@ mod tests { assert_eq!(addrs, original); } + // On Windows, Instant starts near boot time — large subtractions overflow. + // Fall back to a fixed reference point created at process start. + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + fn age(secs: u64) -> Instant { - Instant::now() - std::time::Duration::from_secs(secs) + Instant::now() + .checked_sub(std::time::Duration::from_secs(secs)) + .unwrap_or(*EPOCH.get_or_init(Instant::now)) } /// Cache with ip(1) saturated at FAILURE_PENALTY_MS -- 2.34.1 From e59e25e1a1d7c53a2a868308e68413719eb8a8fe Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 12:12:57 +0300 Subject: [PATCH 05/16] fix: SRTT decay tests use binary search for max Instant age Replace age() helper with set_age_secs() on SrttCache that binary-searches for the maximum subtractable duration. Prevents panic on Windows (Instant starts at boot) while still producing the oldest representable instant for correct decay calculations. Also removes ephemeral test-ubuntu.sh from git. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/test-ubuntu.sh | 132 ----------------------------------------- src/srtt.rs | 52 +++++++++------- 2 files changed, 32 insertions(+), 152 deletions(-) delete mode 100644 scripts/test-ubuntu.sh diff --git a/scripts/test-ubuntu.sh b/scripts/test-ubuntu.sh deleted file mode 100644 index 1e6df73..0000000 --- a/scripts/test-ubuntu.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/bin/bash -# Ubuntu integration test for Numa PR #27 -# Usage: scp target/release/numa scripts/test-ubuntu.sh EC2:~ && ssh EC2 'sudo bash test-ubuntu.sh' -set -euo pipefail - -BIN="./numa" -PASS=0 -FAIL=0 - -check() { - local desc="$1"; shift - if "$@" > /dev/null 2>&1; then - echo " ✓ $desc" - PASS=$((PASS + 1)) - else - echo " ✗ $desc" - FAIL=$((FAIL + 1)) - fi -} - -cleanup() { - $BIN uninstall 2>/dev/null || true - killall numa 2>/dev/null || true - sleep 1 -} - -echo "=== Numa Ubuntu Integration Tests ===" -echo "" -chmod +x "$BIN" - -# --- Test 1: Forward mode (default, no config) --- -echo "--- Test 1: Forward mode (default) ---" -cleanup -$BIN 2>&1 & -NUMA_PID=$! -sleep 3 - -check "API responds" curl -sf http://127.0.0.1:5380/health -check "mode is forward" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"forward\""' -check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"' -check "dashboard returns 200" bash -c 'curl -sf -o /dev/null -w "%{http_code}" http://127.0.0.1:5380/ | grep -q 200' -kill $NUMA_PID 2>/dev/null; sleep 1 -echo "" - -# --- Test 2: Recursive mode (explicit opt-in) --- -echo "--- Test 2: Recursive mode ---" -cleanup -mkdir -p /tmp/numa-test -cat > /tmp/numa-test/numa.toml << 'TOML' -[upstream] -mode = "recursive" -[dnssec] -enabled = true -TOML -$BIN /tmp/numa-test/numa.toml 2>&1 & -NUMA_PID=$! -sleep 5 - -check "API responds" curl -sf http://127.0.0.1:5380/health -check "mode is recursive" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"mode\":\"recursive\""' -check "dnssec enabled" bash -c 'curl -sf http://127.0.0.1:5380/stats | grep -q "\"dnssec\":true"' -check "DNS resolves recursively" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"' -check "AD flag set (DNSSEC)" bash -c 'dig @127.0.0.1 example.com A +dnssec +time=10 | grep "flags:" | grep -q "ad"' -kill $NUMA_PID 2>/dev/null; sleep 1 -echo "" - -# --- Test 3: Auto mode --- -echo "--- Test 3: Auto mode ---" -cleanup -cat > /tmp/numa-test/numa.toml << 'TOML' -[upstream] -mode = "auto" -TOML -$BIN /tmp/numa-test/numa.toml 2>&1 & -NUMA_PID=$! -sleep 10 - -check "API responds" curl -sf http://127.0.0.1:5380/health -MODE=$(curl -sf http://127.0.0.1:5380/stats | python3 -c "import sys,json; print(json.load(sys.stdin)['mode'])" 2>/dev/null || echo "unknown") -echo " → auto resolved to: $MODE" -check "mode is recursive or forward" bash -c "echo '$MODE' | grep -qE '^(recursive|forward)$'" -check "DNS resolves" bash -c 'dig @127.0.0.1 example.com A +short +time=10 | grep -q "[0-9]"' -kill $NUMA_PID 2>/dev/null; sleep 1 -echo "" - -# --- Test 4: Install / Uninstall --- -echo "--- Test 4: Install / Uninstall ---" -cleanup -cp "$BIN" /usr/local/bin/numa - -echo " Installing..." -INSTALL_OUTPUT=$($BIN install 2>&1) || true -echo "$INSTALL_OUTPUT" -check "post-install mentions recursive" bash -c "echo '$INSTALL_OUTPUT' | grep -q 'recursive'" -sleep 3 - -check "service is running" systemctl is-active numa -check "API responds after install" curl -sf http://127.0.0.1:5380/health -check "DNS resolves after install" bash -c 'dig @127.0.0.1 example.com A +short +time=5 | grep -q "[0-9]"' - -echo "" -echo " Uninstalling..." -$BIN uninstall 2>&1 || true -sleep 2 - -check "service stopped" bash -c '! systemctl is-active numa' -echo "" - -# --- Test 5: Port 53 conflict --- -echo "--- Test 5: Port 53 conflict ---" -cleanup -# Start a dummy listener on port 53 -python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.bind(('0.0.0.0',53)); input()" & -BLOCKER_PID=$! -sleep 1 - -$BIN 2>&1 & -NUMA_PID=$! -sleep 3 -# numa should fail to bind -check "numa fails when port 53 taken" bash -c '! kill -0 $NUMA_PID 2>/dev/null' -kill $BLOCKER_PID 2>/dev/null -kill $NUMA_PID 2>/dev/null -echo "" - -# --- Cleanup --- -cleanup -rm -rf /tmp/numa-test - -echo "=== Results: $PASS passed, $FAIL failed ===" -[ $FAIL -eq 0 ] && echo "All tests passed!" || echo "Some tests failed." -exit $FAIL diff --git a/src/srtt.rs b/src/srtt.rs index a832f99..710efc0 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -117,9 +117,31 @@ impl SrttCache { } #[cfg(test)] - fn set_updated_at(&mut self, ip: IpAddr, at: Instant) { + fn set_age_secs(&mut self, ip: IpAddr, age_secs: u64) { if let Some(entry) = self.entries.get_mut(&ip) { - entry.updated_at = at; + // On Windows, Instant can't go before boot time. + // Clamp to the maximum representable past. + entry.updated_at = Instant::now() + .checked_sub(std::time::Duration::from_secs(age_secs)) + .unwrap_or_else(|| { + // Subtract 1ms at a time to find the floor — but that's slow. + // Instead, binary search for the max subtractable duration. + let mut lo = 0u64; + let mut hi = age_secs; + let now = Instant::now(); + while lo < hi { + let mid = lo + (hi - lo + 1) / 2; + if now + .checked_sub(std::time::Duration::from_secs(mid)) + .is_some() + { + lo = mid; + } else { + hi = mid - 1; + } + } + now - std::time::Duration::from_secs(lo) + }); } } @@ -218,16 +240,6 @@ mod tests { assert_eq!(addrs, original); } - // On Windows, Instant starts near boot time — large subtractions overflow. - // Fall back to a fixed reference point created at process start. - static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); - - fn age(secs: u64) -> Instant { - Instant::now() - .checked_sub(std::time::Duration::from_secs(secs)) - .unwrap_or(*EPOCH.get_or_init(Instant::now)) - } - /// Cache with ip(1) saturated at FAILURE_PENALTY_MS fn saturated_penalty_cache() -> SrttCache { let mut cache = SrttCache::new(true); @@ -241,7 +253,7 @@ mod tests { fn no_decay_within_threshold() { let mut cache = SrttCache::new(true); cache.record_rtt(ip(1), 5000, false); - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS); assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms); } @@ -249,7 +261,7 @@ mod tests { fn one_decay_period() { let mut cache = saturated_penalty_cache(); let raw = cache.entries[&ip(1)].srtt_ms; - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS + 1); let expected = (raw + INITIAL_SRTT_MS) / 2; assert_eq!(cache.get(ip(1)), expected); } @@ -258,7 +270,7 @@ mod tests { fn multiple_decay_periods() { let mut cache = saturated_penalty_cache(); let raw = cache.entries[&ip(1)].srtt_ms; - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 4 + 1); let mut expected = raw; for _ in 0..4 { expected = (expected + INITIAL_SRTT_MS) / 2; @@ -271,15 +283,15 @@ mod tests { // 9 periods and 100 periods should produce the same result (capped at 8) let mut cache_a = saturated_penalty_cache(); let mut cache_b = saturated_penalty_cache(); - cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1)); - cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + cache_a.set_age_secs(ip(1), DECAY_AFTER_SECS * 9 + 1); + cache_b.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1))); } #[test] fn decay_converges_toward_initial() { let mut cache = saturated_penalty_cache(); - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); let decayed = cache.get(ip(1)); let diff = decayed.abs_diff(INITIAL_SRTT_MS); assert!( @@ -293,7 +305,7 @@ mod tests { #[test] fn record_rtt_applies_decay_before_ewma() { let mut cache = saturated_penalty_cache(); - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 8); cache.record_rtt(ip(1), 50, false); let srtt = cache.get(ip(1)); // Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381 @@ -311,7 +323,7 @@ mod tests { assert_eq!(addrs, vec![sock(2), sock(1)]); // Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms - cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); let mut addrs = vec![sock(1), sock(2)]; cache.sort_by_rtt(&mut addrs); assert_eq!(addrs, vec![sock(1), sock(2)]); -- 2.34.1 From 19b59d8c4ceae39eb5dac6b1155c02f2803f5568 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 12:39:38 +0300 Subject: [PATCH 06/16] fix: use ProgramData for Windows DNS backup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APPDATA differs between user and admin contexts — install runs as admin but uninstall might resolve a different APPDATA. Use ProgramData which is consistent across elevation contexts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 3d67742..7840c7f 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -463,7 +463,13 @@ fn get_windows_interfaces() -> Result std::path::PathBuf { - crate::config_dir().join("original-dns.json") + // Use ProgramData (not APPDATA) since install requires admin elevation + // and APPDATA differs between user and admin contexts. + std::path::PathBuf::from( + std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()), + ) + .join("numa") + .join("original-dns.json") } #[cfg(windows)] -- 2.34.1 From 4f9946dcd6322f91b979c0da15b0bb0480c33248 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 13:50:57 +0300 Subject: [PATCH 07/16] feat: disable Dnscache on Windows install, re-enable on uninstall Windows DNS Client (Dnscache) holds port 53 at kernel level and can't be stopped via sc/net stop. Disable via registry during install (requires reboot), re-enable on uninstall. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 7840c7f..382ce0a 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -472,6 +472,60 @@ fn windows_backup_path() -> std::path::PathBuf { .join("original-dns.json") } +#[cfg(windows)] +fn disable_dnscache() -> Result { + // Check if Dnscache is running (it holds port 53 at kernel level) + let output = std::process::Command::new("sc") + .args(["query", "Dnscache"]) + .output() + .map_err(|e| format!("failed to query Dnscache: {}", e))?; + let text = String::from_utf8_lossy(&output.stdout); + if !text.contains("RUNNING") { + return Ok(false); + } + + eprintln!(" Disabling DNS Client (Dnscache) to free port 53..."); + // Dnscache can't be stopped via sc/net stop — must disable via registry + let status = std::process::Command::new("reg") + .args([ + "add", + "HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache", + "/v", + "Start", + "/t", + "REG_DWORD", + "/d", + "4", + "/f", + ]) + .status() + .map_err(|e| format!("failed to disable Dnscache: {}", e))?; + + if !status.success() { + return Err("failed to disable Dnscache via registry (run as Administrator?)".into()); + } + + eprintln!(" Dnscache disabled. A reboot is required to free port 53."); + Ok(true) +} + +#[cfg(windows)] +fn enable_dnscache() { + let _ = std::process::Command::new("reg") + .args([ + "add", + "HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache", + "/v", + "Start", + "/t", + "REG_DWORD", + "/d", + "2", + "/f", + ]) + .status(); +} + #[cfg(windows)] fn install_windows() -> Result<(), String> { let interfaces = get_windows_interfaces()?; @@ -513,9 +567,15 @@ fn install_windows() -> Result<(), String> { } } + let needs_reboot = disable_dnscache()?; + eprintln!("\n Original DNS saved to {}", path.display()); - eprintln!(" Run 'numa uninstall' to restore."); - eprintln!(" Note: run Numa manually with 'numa' in a terminal.\n"); + eprintln!(" Run 'numa uninstall' to restore.\n"); + if needs_reboot { + eprintln!(" *** Reboot required, then run 'numa' in an Administrator terminal. ***\n"); + } else { + eprintln!(" Run 'numa' in an Administrator terminal to start.\n"); + } eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); eprintln!(" [upstream]"); eprintln!(" mode = \"recursive\"\n"); @@ -585,7 +645,11 @@ fn uninstall_windows() -> Result<(), String> { } std::fs::remove_file(&path).ok(); - eprintln!("\n System DNS restored. Backup removed.\n"); + + // Re-enable Dnscache + enable_dnscache(); + eprintln!("\n System DNS restored. DNS Client re-enabled."); + eprintln!(" Reboot to fully restore the DNS Client service.\n"); Ok(()) } -- 2.34.1 From 232e6fa4125e3daa240fad3abd04f25efe630c85 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 14:01:16 +0300 Subject: [PATCH 08/16] fix: rewrite SRTT decay tests as pure functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decay tests manipulated Instant timestamps which panics on Windows (Instant can't go before boot time). Rewrite to test decay_for_age() directly — a pure function taking srtt_ms and age_secs, no platform dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/srtt.rs | 109 +++++++++++++++++++--------------------------------- 1 file changed, 39 insertions(+), 70 deletions(-) diff --git a/src/srtt.rs b/src/srtt.rs index 710efc0..fb0d36f 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -47,16 +47,19 @@ impl SrttCache { /// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL. fn decayed_srtt(entry: &SrttEntry) -> u64 { - let age_secs = entry.updated_at.elapsed().as_secs(); + Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs()) + } + + fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 { if age_secs > DECAY_AFTER_SECS { let periods = (age_secs / DECAY_AFTER_SECS).min(8); - let mut srtt = entry.srtt_ms; + let mut srtt = srtt_ms; for _ in 0..periods { srtt = (srtt + INITIAL_SRTT_MS) / 2; } srtt } else { - entry.srtt_ms + srtt_ms } } @@ -117,32 +120,8 @@ impl SrttCache { } #[cfg(test)] - fn set_age_secs(&mut self, ip: IpAddr, age_secs: u64) { - if let Some(entry) = self.entries.get_mut(&ip) { - // On Windows, Instant can't go before boot time. - // Clamp to the maximum representable past. - entry.updated_at = Instant::now() - .checked_sub(std::time::Duration::from_secs(age_secs)) - .unwrap_or_else(|| { - // Subtract 1ms at a time to find the floor — but that's slow. - // Instead, binary search for the max subtractable duration. - let mut lo = 0u64; - let mut hi = age_secs; - let now = Instant::now(); - while lo < hi { - let mid = lo + (hi - lo + 1) / 2; - if now - .checked_sub(std::time::Duration::from_secs(mid)) - .is_some() - { - lo = mid; - } else { - hi = mid - 1; - } - } - now - std::time::Duration::from_secs(lo) - }); - } + fn get_srtt_ms(&self, ip: IpAddr) -> u64 { + self.entries.get(&ip).map(|e| e.srtt_ms).unwrap_or(0) } fn maybe_evict(&mut self) { @@ -251,48 +230,39 @@ mod tests { #[test] fn no_decay_within_threshold() { - let mut cache = SrttCache::new(true); - cache.record_rtt(ip(1), 5000, false); - cache.set_age_secs(ip(1), DECAY_AFTER_SECS); - assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms); + // At exactly DECAY_AFTER_SECS, no decay applied + let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS); + assert_eq!(result, FAILURE_PENALTY_MS); } #[test] fn one_decay_period() { - let mut cache = saturated_penalty_cache(); - let raw = cache.entries[&ip(1)].srtt_ms; - cache.set_age_secs(ip(1), DECAY_AFTER_SECS + 1); - let expected = (raw + INITIAL_SRTT_MS) / 2; - assert_eq!(cache.get(ip(1)), expected); + let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1); + let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2; + assert_eq!(result, expected); } #[test] fn multiple_decay_periods() { - let mut cache = saturated_penalty_cache(); - let raw = cache.entries[&ip(1)].srtt_ms; - cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 4 + 1); - let mut expected = raw; + let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1); + let mut expected = FAILURE_PENALTY_MS; for _ in 0..4 { expected = (expected + INITIAL_SRTT_MS) / 2; } - assert_eq!(cache.get(ip(1)), expected); + assert_eq!(result, expected); } #[test] fn decay_caps_at_8_periods() { // 9 periods and 100 periods should produce the same result (capped at 8) - let mut cache_a = saturated_penalty_cache(); - let mut cache_b = saturated_penalty_cache(); - cache_a.set_age_secs(ip(1), DECAY_AFTER_SECS * 9 + 1); - cache_b.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); - assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1))); + let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1); + let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100); + assert_eq!(a, b); } #[test] fn decay_converges_toward_initial() { - let mut cache = saturated_penalty_cache(); - cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); - let decayed = cache.get(ip(1)); + let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100); let diff = decayed.abs_diff(INITIAL_SRTT_MS); assert!( diff < 25, @@ -304,29 +274,28 @@ mod tests { #[test] fn record_rtt_applies_decay_before_ewma() { - let mut cache = saturated_penalty_cache(); - cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 8); - cache.record_rtt(ip(1), 50, false); - let srtt = cache.get(ip(1)); - // Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381 - assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt); + // Verify decay is applied before EWMA in record_rtt by checking + // that a saturated penalty + long age + new sample produces a low SRTT + let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8); + // EWMA: (decayed * 7 + 50) / 8 + let after_ewma = (decayed * 7 + 50) / 8; + assert!( + after_ewma < 500, + "expected decay before EWMA, got srtt={}", + after_ewma + ); } #[test] fn decay_reranks_stale_failures() { - let mut cache = saturated_penalty_cache(); - for _ in 0..30 { - cache.record_rtt(ip(2), 300, false); - } - let mut addrs = vec![sock(1), sock(2)]; - cache.sort_by_rtt(&mut addrs); - assert_eq!(addrs, vec![sock(2), sock(1)]); - - // Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms - cache.set_age_secs(ip(1), DECAY_AFTER_SECS * 100); - let mut addrs = vec![sock(1), sock(2)]; - cache.sort_by_rtt(&mut addrs); - assert_eq!(addrs, vec![sock(1), sock(2)]); + // After enough decay, a failed server (5000ms) converges toward + // INITIAL (200ms), which is below a stable server at 300ms + let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100); + assert!( + decayed < 300, + "expected decayed penalty ({}) < 300ms", + decayed + ); } #[test] -- 2.34.1 From a464d60f122fb755d8be3efc3f60b212b84cfcd4 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 14:14:38 +0300 Subject: [PATCH 09/16] fix: use Quad9 IP (9.9.9.9) for DoH fallback, not hostname DoH to dns.quad9.net requires DNS to resolve the hostname, which creates a chicken-and-egg loop when numa IS the system resolver (e.g. after numa install on Windows). Using the IP directly avoids the bootstrap dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2cdf4d9..a74af38 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,7 +126,7 @@ async fn main() -> numa::Result<()> { .use_rustls_tls() .build() .unwrap_or_default(); - let url = "https://dns.quad9.net/dns-query".to_string(); + let url = "https://9.9.9.9/dns-query".to_string(); let label = url.clone(); ( numa::config::UpstreamMode::Forward, @@ -152,7 +152,7 @@ async fn main() -> numa::Result<()> { .or_else(numa::system_dns::detect_dhcp_dns) .unwrap_or_else(|| { info!("could not detect system DNS, falling back to Quad9 DoH"); - "https://dns.quad9.net/dns-query".to_string() + "https://9.9.9.9/dns-query".to_string() }) } else { config.upstream.address.clone() -- 2.34.1 From f4342b984455a3957c86f85609105c8fe246cf5f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 14:19:09 +0300 Subject: [PATCH 10/16] refactor: extract DOH_FALLBACK constant Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index a74af38..beb2537 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,8 @@ use numa::ctx::{handle_query, ServerCtx}; use numa::forward::Upstream; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; + +const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; use numa::service_store::ServiceStore; use numa::stats::ServerStats; use numa::system_dns::{ @@ -126,7 +128,7 @@ async fn main() -> numa::Result<()> { .use_rustls_tls() .build() .unwrap_or_default(); - let url = "https://9.9.9.9/dns-query".to_string(); + let url = DOH_FALLBACK.to_string(); let label = url.clone(); ( numa::config::UpstreamMode::Forward, @@ -152,7 +154,7 @@ async fn main() -> numa::Result<()> { .or_else(numa::system_dns::detect_dhcp_dns) .unwrap_or_else(|| { info!("could not detect system DNS, falling back to Quad9 DoH"); - "https://9.9.9.9/dns-query".to_string() + DOH_FALLBACK.to_string() }) } else { config.upstream.address.clone() -- 2.34.1 From 82c4491690e33fe2d022e771c4c9e760f52ff991 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 14:22:58 +0300 Subject: [PATCH 11/16] refactor: extract QUAD9_IP constant Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index beb2537..34710ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use numa::forward::Upstream; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; +const QUAD9_IP: &str = "9.9.9.9"; const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; use numa::service_store::ServiceStore; use numa::stats::ServerStats; @@ -523,7 +524,7 @@ async fn network_watch_loop(ctx: Arc) { let new_addr = dns_info .default_upstream .or_else(numa::system_dns::detect_dhcp_dns) - .unwrap_or_else(|| "9.9.9.9".to_string()); + .unwrap_or_else(|| QUAD9_IP.to_string()); if let Ok(new_sock) = format!("{}:{}", new_addr, ctx.upstream_port).parse::() { -- 2.34.1 From 4f279b5f295cf8d1abea821a25f6ebd3017c4608 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 14:36:15 +0300 Subject: [PATCH 12/16] refactor: remove dead test helpers, fix constant placement Remove unused get_srtt_ms() and saturated_penalty_cache() left over from SRTT test rewrite. Move QUAD9_IP/DOH_FALLBACK after use block. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 6 +++--- src/srtt.rs | 14 -------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/main.rs b/src/main.rs index 34710ae..1092d4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,15 +14,15 @@ use numa::ctx::{handle_query, ServerCtx}; use numa::forward::Upstream; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; - -const QUAD9_IP: &str = "9.9.9.9"; -const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; use numa::service_store::ServiceStore; use numa::stats::ServerStats; use numa::system_dns::{ discover_system_dns, install_service, restart_service, service_status, uninstall_service, }; +const QUAD9_IP: &str = "9.9.9.9"; +const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query"; + #[tokio::main] async fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) diff --git a/src/srtt.rs b/src/srtt.rs index fb0d36f..f763a37 100644 --- a/src/srtt.rs +++ b/src/srtt.rs @@ -119,11 +119,6 @@ impl SrttCache { self.entries.is_empty() } - #[cfg(test)] - fn get_srtt_ms(&self, ip: IpAddr) -> u64 { - self.entries.get(&ip).map(|e| e.srtt_ms).unwrap_or(0) - } - fn maybe_evict(&mut self) { if self.entries.len() < MAX_ENTRIES { return; @@ -219,15 +214,6 @@ mod tests { assert_eq!(addrs, original); } - /// Cache with ip(1) saturated at FAILURE_PENALTY_MS - fn saturated_penalty_cache() -> SrttCache { - let mut cache = SrttCache::new(true); - for _ in 0..30 { - cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false); - } - cache - } - #[test] fn no_decay_within_threshold() { // At exactly DECAY_AFTER_SECS, no decay applied -- 2.34.1 From e28747a339fe39cc333aee7890683525819e9efb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 17:21:39 +0300 Subject: [PATCH 13/16] fix: ignore ConnectionReset on UDP socket (Windows ICMP error) Windows delivers ICMP port-unreachable as ConnectionReset on the next UDP recv_from, crashing numa. Linux/macOS silently ignore these. Catch and continue the recv loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 1092d4a..68022fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -481,7 +481,14 @@ async fn main() -> numa::Result<()> { #[allow(clippy::infinite_loop)] loop { let mut buffer = BytePacketBuffer::new(); - let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?; + let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { + // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets + continue; + } + Err(e) => return Err(e.into()), + }; let ctx = Arc::clone(&ctx); tokio::spawn(async move { -- 2.34.1 From 045018ad88a1f6c1491f7684bec95bccaf217b61 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 17:51:48 +0300 Subject: [PATCH 14/16] feat: auto-start numa on Windows boot via registry Run key Without a Windows Service, rebooting after numa install leaves DNS broken (pointing at 127.0.0.1 with nothing listening). Register numa in HKLM\...\Run so it starts automatically. Removed on uninstall. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/system_dns.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 382ce0a..11fbd1e 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -568,13 +568,14 @@ fn install_windows() -> Result<(), String> { } let needs_reboot = disable_dnscache()?; + register_autostart(); eprintln!("\n Original DNS saved to {}", path.display()); eprintln!(" Run 'numa uninstall' to restore.\n"); if needs_reboot { - eprintln!(" *** Reboot required, then run 'numa' in an Administrator terminal. ***\n"); + eprintln!(" *** Reboot required. Numa will start automatically. ***\n"); } else { - eprintln!(" Run 'numa' in an Administrator terminal to start.\n"); + eprintln!(" Numa will start automatically on next boot.\n"); } eprintln!(" Want full DNS sovereignty? Add to numa.toml:"); eprintln!(" [upstream]"); @@ -582,8 +583,45 @@ fn install_windows() -> Result<(), String> { Ok(()) } +/// Register numa to auto-start on boot via registry Run key. +#[cfg(windows)] +fn register_autostart() { + let exe = std::env::current_exe() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "numa".into()); + let _ = std::process::Command::new("reg") + .args([ + "add", + "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", + "/v", + "Numa", + "/t", + "REG_SZ", + "/d", + &exe, + "/f", + ]) + .status(); + eprintln!(" Registered auto-start on boot."); +} + +/// Remove numa auto-start registry key. +#[cfg(windows)] +fn remove_autostart() { + let _ = std::process::Command::new("reg") + .args([ + "delete", + "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", + "/v", + "Numa", + "/f", + ]) + .status(); +} + #[cfg(windows)] fn uninstall_windows() -> Result<(), String> { + remove_autostart(); let path = windows_backup_path(); let json = std::fs::read_to_string(&path) .map_err(|e| format!("no backup found at {}: {}", path.display(), e))?; -- 2.34.1 From 787733cbea725f3dc0ae5e87335427b036855351 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 17:57:21 +0300 Subject: [PATCH 15/16] docs: update README, Windows plan, and launch drafts for Windows support - README: platform-specific Quick Start, install/uninstall table - Windows plan: Phase 2 complete, Phase 3 scoped - Launch drafts: updated "Does it support Windows?" response Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 26 ++++++++--- docs/implementation/windows-support-plan.md | 49 +++++++++++++++++++++ docs/marketing/launch-drafts.md | 6 ++- 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 docs/implementation/windows-support-plan.md diff --git a/README.md b/README.md index d80fbcc..e96ecda 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,32 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by ## Quick Start ```bash +# macOS brew install razvandimescu/tap/numa -# or: cargo install numa -# or: curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh -sudo numa # port 53 requires root +# Linux +curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh + +# Windows — download from GitHub Releases +# All platforms +cargo install numa +``` + +```bash +sudo numa # run in foreground (port 53 requires root/admin) ``` Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) -Set as system DNS: `sudo numa install` +Set as system DNS: + +| Platform | Install | Uninstall | +|----------|---------|-----------| +| macOS | `sudo numa install` | `sudo numa uninstall` | +| Linux | `sudo numa install` | `sudo numa uninstall` | +| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot | + +On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry. ## Local Services @@ -80,7 +96,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena | Ad blocking | Yes | Yes | — | 385K+ domains | | Web admin UI | Full | Full | — | Dashboard | | Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | -| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary | +| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows | | Community maturity | 56K stars, 10 years | 33K stars | 20 years | New | ## Performance diff --git a/docs/implementation/windows-support-plan.md b/docs/implementation/windows-support-plan.md new file mode 100644 index 0000000..aa64fdd --- /dev/null +++ b/docs/implementation/windows-support-plan.md @@ -0,0 +1,49 @@ +# Windows Support — Implementation Plan + +*March–April 2026* + +## Phase 1: Run on Windows without system integration — DONE (v0.3.0) + +- [x] Cross-platform `config_dir()` and `data_dir()` +- [x] `src/system_dns.rs` — Windows DNS discovery via `ipconfig /all` +- [x] Stubs for install/uninstall/service on unsupported OS +- [x] Multicast LAN discovery (`SO_REUSEPORT` skipped on Windows) +- [x] All deps compile on windows-msvc +- [x] CI: `check-windows` job (build + clippy) +- [x] Cross-platform LAN discovery tested: macOS ↔ Windows + +## Phase 2: DNS configuration — DONE (PR #28) + +- [x] `numa install` — set DNS to 127.0.0.1 via `netsh` for all active interfaces +- [x] `numa uninstall` — restore DNS from backup (DHCP or static with secondaries) +- [x] `ipconfig /all` parser — per-interface adapter name, DHCP status, DNS servers +- [x] Localization — German adapter/DHCP/DNS labels handled +- [x] Disconnected adapters — skipped +- [x] Backup — `%PROGRAMDATA%\numa\original-dns.json` +- [x] Dnscache — disable via registry on install, re-enable on uninstall (reboot required) +- [x] Auto-start — registry Run key (`HKLM\...\Run\Numa`) on install, removed on uninstall +- [x] UDP ConnectionReset — Windows ICMP error 10054 caught and ignored +- [x] IP validation — added to `discover_windows()` +- [x] CI: `cargo test` + binary artifact upload on Windows +- [ ] `README.md` — add Windows install instructions + +## Phase 3: Full service integration (future) + +### Windows Service + +- Use `windows-service` crate to register Numa as a Windows Service +- `sc.exe create numa binPath=...` as alternative +- Auto-start on boot (SYSTEM context, no login required), auto-restart on crash +- Replace registry Run key with proper SCM integration + +### CA trust + +- `certutil.exe -addstore Root ca.pem` to trust Numa CA system-wide +- Reverse: `certutil.exe -delstore Root "Numa Local CA"` +- Needs admin elevation + +### DHCP DNS detection + +- Current `detect_dhcp_dns()` returns `None` on Windows +- Could parse `ipconfig /all` for "DHCP Server" + "DNS Servers" lines +- Or use WinAPI `GetNetworkParams()` diff --git a/docs/marketing/launch-drafts.md b/docs/marketing/launch-drafts.md index c822350..f37a926 100644 --- a/docs/marketing/launch-drafts.md +++ b/docs/marketing/launch-drafts.md @@ -614,5 +614,7 @@ auto-revert. Different audiences: Technitium targets server admins, Numa targets developers on laptops. **"Does it support Windows?"** -macOS and Linux are the primary targets. Windows has scaffolding in the code -but is not tested. If there's demand, it's on the list. +Yes. `numa install` in an admin terminal sets system DNS and auto-starts +numa on boot. Requires a reboot (Windows DNS Client holds port 53 at kernel +level). `numa uninstall` restores everything. Native Windows Service +integration is next. -- 2.34.1 From 8ae86a3e13a4f52b3042aba8b84ae90506d920dc Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 18:15:40 +0300 Subject: [PATCH 16/16] chore: remove docs from git tracking (already gitignored) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ is in .gitignore but files were force-added. Remove from tracking — files remain on disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/implementation/windows-support-plan.md | 49 -- docs/marketing/launch-drafts.md | 620 -------------------- 2 files changed, 669 deletions(-) delete mode 100644 docs/implementation/windows-support-plan.md delete mode 100644 docs/marketing/launch-drafts.md diff --git a/docs/implementation/windows-support-plan.md b/docs/implementation/windows-support-plan.md deleted file mode 100644 index aa64fdd..0000000 --- a/docs/implementation/windows-support-plan.md +++ /dev/null @@ -1,49 +0,0 @@ -# Windows Support — Implementation Plan - -*March–April 2026* - -## Phase 1: Run on Windows without system integration — DONE (v0.3.0) - -- [x] Cross-platform `config_dir()` and `data_dir()` -- [x] `src/system_dns.rs` — Windows DNS discovery via `ipconfig /all` -- [x] Stubs for install/uninstall/service on unsupported OS -- [x] Multicast LAN discovery (`SO_REUSEPORT` skipped on Windows) -- [x] All deps compile on windows-msvc -- [x] CI: `check-windows` job (build + clippy) -- [x] Cross-platform LAN discovery tested: macOS ↔ Windows - -## Phase 2: DNS configuration — DONE (PR #28) - -- [x] `numa install` — set DNS to 127.0.0.1 via `netsh` for all active interfaces -- [x] `numa uninstall` — restore DNS from backup (DHCP or static with secondaries) -- [x] `ipconfig /all` parser — per-interface adapter name, DHCP status, DNS servers -- [x] Localization — German adapter/DHCP/DNS labels handled -- [x] Disconnected adapters — skipped -- [x] Backup — `%PROGRAMDATA%\numa\original-dns.json` -- [x] Dnscache — disable via registry on install, re-enable on uninstall (reboot required) -- [x] Auto-start — registry Run key (`HKLM\...\Run\Numa`) on install, removed on uninstall -- [x] UDP ConnectionReset — Windows ICMP error 10054 caught and ignored -- [x] IP validation — added to `discover_windows()` -- [x] CI: `cargo test` + binary artifact upload on Windows -- [ ] `README.md` — add Windows install instructions - -## Phase 3: Full service integration (future) - -### Windows Service - -- Use `windows-service` crate to register Numa as a Windows Service -- `sc.exe create numa binPath=...` as alternative -- Auto-start on boot (SYSTEM context, no login required), auto-restart on crash -- Replace registry Run key with proper SCM integration - -### CA trust - -- `certutil.exe -addstore Root ca.pem` to trust Numa CA system-wide -- Reverse: `certutil.exe -delstore Root "Numa Local CA"` -- Needs admin elevation - -### DHCP DNS detection - -- Current `detect_dhcp_dns()` returns `None` on Windows -- Could parse `ipconfig /all` for "DHCP Server" + "DNS Servers" lines -- Or use WinAPI `GetNetworkParams()` diff --git a/docs/marketing/launch-drafts.md b/docs/marketing/launch-drafts.md deleted file mode 100644 index f37a926..0000000 --- a/docs/marketing/launch-drafts.md +++ /dev/null @@ -1,620 +0,0 @@ -# Launch Drafts - -## Lessons Learned - -**r/selfhosted** (0 upvotes, hostile) — "replaces Pi-hole" framing triggered -defensive comparisons. Audience protects their stack. - -**r/programare** (26 upvotes, 22 comments, 12K views, 90.6% ratio) — worked -because it led with technical achievement. But: "what does this offer over -/etc/hosts?" and "mature solutions exist (dnsmasq, nginx)" were the top -objections. Tool-replacement angle falls flat with generalist audiences. - -**r/webdev** — removed by moderators (self-promotion rules). - -Key takeaways: - -- Lead with what's *unique*, not what it *replaces* -- Write like explaining to a colleague, not marketing copy -- Pick ONE hook per community — don't try to be everything -- Triple-check the GitHub link works before posting -- Authentic tone > polished bullets -- Agree with "just use X" — then show what X can't do -- Don't oversell the pkarr/token vision — one sentence max -- Benchmark request from r/programare (Mydocalm) — warm follow-up content - ---- - -## Launch Order - -~~0. **r/programare** — done (2026-03-21). 12K views, 26 upvotes, 22 comments.~~ -~~1. **r/webdev** — removed by moderators.~~ - -~~2. **r/degoogle** — done~~ -~~3. **r/node** — done~~ - -4. **r/coolgithubprojects** — zero friction, just post the repo -~~5. **r/sideproject** — done (2026-03-29)~~ -6. **r/dns** — technical DNS audience, recursive + DNSSEC angle -7. **Show HN** — Tuesday-Thursday, 9-10 AM ET -8. **r/rust** — same day as HN, technical deep-dive -9. **r/commandline** — 24h after HN -10. **r/selfhosted** — only if HN hits front page, lead with recursive + LAN discovery -11. **r/programare follow-up** — benchmark post + recursive/DNSSEC update - ---- - -## Community Drafts - -### Show HN - -**Title (72 chars):** -Show HN: I built a DNS resolver from scratch in Rust – no DNS libraries - -**Body:** - -I wanted to understand how DNS actually works at the wire level, so I built -a resolver from scratch. No dns libraries — the RFC 1035 protocol (headers, -labels, compression pointers, record types) is all hand-parsed. It started -as a learning project and turned into something I use daily as my system DNS. - -What it does today: - -- **Forward mode by default** — transparent proxy to your existing DNS with - caching and ad blocking. Changes nothing about your network. -- **Full recursive resolver** — set `mode = "recursive"` and it resolves from - root nameservers. No upstream dependency. CNAME chasing, TLD priming, SRTT. -- **DNSSEC validation** — chain-of-trust verification from root KSK. - RSA/SHA-256, ECDSA P-256, Ed25519. Sets the AD bit on verified responses. -- **Ad blocking** — ~385K+ domains via Hagezi Pro, works on any network -- **DNS-over-HTTPS** — encrypted upstream (Quad9, Cloudflare, or any - provider) as an alternative to recursive mode -- **`.numa` local domains** — register `frontend.numa → localhost:5173` and - it creates both the DNS record and an HTTP/HTTPS reverse proxy with - auto-generated TLS certs. WebSocket passthrough works (Vite HMR). -- **LAN service discovery** — run Numa on two machines, they find each other - via UDP multicast. Zero config. -- **Developer overrides** — point any hostname to any IP, auto-reverts - after N minutes. REST API for scripting. - -Single binary, macOS + Linux. `sudo numa install` and it's your system DNS — -forward mode by default, recursive when you're ready. - -The interesting technical bits: the recursive resolver walks root → TLD → -authoritative with iterative queries, caching NS/DS/DNSKEY records at each -hop. DNSSEC validation verifies RRSIG signatures against DNSKEY, walks the -chain via DS records up to the hardcoded root trust anchor. ECDSA P-256 -verification takes 174ns (benchmarked with criterion). Cold-cache validation -for a new domain is ~90ms, with only 1 network fetch needed (TLD chain is -pre-warmed on startup). SRTT-based nameserver selection learns which -servers respond fastest — average recursive query drops from 2.8s to -237ms after warmup (12x). - -It also handles hostile networks: if your ISP blocks UDP port 53, -Numa detects this after 3 failures and switches all -queries to TCP automatically. Resets when you change networks. RFC 7816 -query minimization means root servers only see the TLD, not your full -query. - -The DNS cache adjusts TTLs on read (remaining time, not original). Each -query is an async tokio task. EDNS0 with DO bit and 1232-byte payload -(DNS Flag Day 2020). - -Longer term I want to add pkarr/DHT resolution for self-sovereign DNS, -but that's future work. - -https://github.com/razvandimescu/numa - ---- - -### r/rust - -**Title:** I built a recursive DNS resolver from scratch in Rust — DNSSEC, no DNS libraries - -**Body:** - -I've been building a DNS resolver in Rust as a learning project that became -my daily driver. The entire DNS wire protocol is implemented by hand — -no `trust-dns`, no `hickory-dns`, no `simple-dns`. Headers, label sequences, -compression pointers, EDNS, all of it. - -Some things I found interesting while building this: - -**Recursive resolution** — iterative queries from root hints, walking -root → TLD → authoritative. CNAME chasing, A+AAAA glue extraction from -additional sections, referral depth limits. TLD priming pre-warms NS + DS + -DNSKEY for 34 gTLDs + EU ccTLDs on startup. - -**DNSSEC chain-of-trust** — the most involved part. Verify RRSIG signatures -against DNSKEY, walk DS records up to the hardcoded root KSK (key tag 20326). -Uses `ring` for crypto: RSA/SHA-256, ECDSA P-256 (174ns per verify), Ed25519. -RFC 3110 RSA keys need converting to PKCS#1 DER for ring — wrote an ASN.1 -encoder for that. RRSIG time validity checks per RFC 4035 §5.3.1. - -**NSEC/NSEC3 denial proofs** — proving a name *doesn't* exist is harder than -proving it does. NSEC uses canonical DNS name ordering to prove gap coverage. -NSEC3 uses iterated SHA-1 hashing + base32hex + a 3-part closest encloser -proof (RFC 5155 §8.4). Both require authority-section RRSIG verification. - -**Wire protocol parsing** — DNS uses a binary format with label compression -(pointers back into the packet via 2-byte offsets). Parsing this correctly -is surprisingly tricky because pointers can chain. I use a `BytePacketBuffer` -that tracks position and handles jumps. - -**Performance** — TLD chain pre-warming means cold-cache DNSSEC validation -needs ~1 DNSKEY fetch (down from 5). Referral DS piggybacking caches DS -from authority sections during resolution. ECDSA P-256 verify: 174ns. -RSA/SHA-256: 10.9µs. DS verify: 257ns. - -**LAN service discovery** — Numa instances on the same network find each -other via UDP multicast. The tricky part was self-filtering: I initially -filtered by IP, but two instances on the same host share an IP. Switched to -a per-process instance ID (`pid ^ nanos`). - -**Auto TLS** — generates a local CA + per-service certs using `rcgen`. -`numa install` trusts the CA in the OS keychain. HTTPS proxy via `rustls` + -`tokio-rustls`. - -Single binary, no runtime dependencies. Uses `tokio`, `axum` (REST -API/dashboard), `hyper` (reverse proxy), `ring` (DNSSEC crypto), `reqwest` -(DoH), `socket2` (multicast), `rcgen` + `rustls` (TLS). - -Happy to discuss any of the implementation decisions. - -https://github.com/razvandimescu/numa - ---- - -### r/degoogle - -**Title:** I replaced cloud DNS with a recursive resolver — resolves from root, no upstream, DNSSEC - -**Body:** - -I wanted a DNS setup with zero cloud dependency. No NextDNS account, -no Cloudflare dashboard, no Pi-hole appliance, no upstream resolver seeing -my queries. Just a single binary on my laptop that resolves everything -itself. - -Built one in Rust. What it does: - -- **Forward mode by default** — transparent proxy to your existing DNS with - caching and ad blocking. Changes nothing about your network. -- **Recursive resolution** — set `mode = "recursive"` and it resolves directly - from root nameservers. No Quad9, no Cloudflare, no upstream dependency. - Each authoritative server only sees the query for its zone — no single - entity sees your full browsing pattern. -- **DNSSEC validation** — verifies the chain of trust from root KSK. - Responses are cryptographically verified — no one can tamper with them - in transit. -- **System-level ad blocking** — Hagezi Pro list (~385K+ domains), - works on any network. Coffee shop WiFi, airport, hotel. -- **ISP resistant** — in recursive mode, if UDP is blocked Numa switches - to TCP automatically. Or set `mode = "auto"` to probe on startup and - fall back to encrypted DoH if needed. -- **Query minimization** — root servers only see the TLD (.com), not - your full domain. RFC 7816. -- **Zero telemetry, zero cloud** — all data stays on your machine. No - account, no login, no analytics. Config is a single TOML file. -- **Local service naming** — bonus for developers: `https://app.numa` - instead of `localhost:3000`, with auto-generated TLS certs - -Single binary, macOS + Linux. `sudo numa install` and it's your system -DNS — forward mode by default, recursive when you're ready. No Docker, -no PHP, no external dependencies. - -The DNS wire protocol is parsed from scratch — no DNS libraries. You can -read every line of code. - -``` -brew install razvandimescu/tap/numa -# or -cargo install numa -``` - -MIT license. https://github.com/razvandimescu/numa - ---- - -### r/node - -**Title:** I replaced localhost:5173 with frontend.numa — auto HTTPS, HMR works, no nginx - -**Body:** - -Running a Vite frontend on :5173, Express API on :3000, maybe docs on -:4000 — I could never remember which port was which. And CORS between -`localhost:5173` and `localhost:3000` is its own special hell. - -How do you get named domains with HTTPS locally? - -1. /etc/hosts + mkcert + nginx -2. dnsmasq + mkcert + Caddy -3. `sudo numa` - -What it actually does: - -``` -curl -X POST localhost:5380/services \ - -d '{"name":"frontend","target_port":5173}' -``` - -Now `https://frontend.numa` works in my browser. Green lock, valid cert. - -- **HMR works** — Vite, webpack, socket.io all pass through the proxy. - No special config. -- **CORS solved** — `frontend.numa` and `api.numa` share the `.numa` - cookie domain. Cross-service auth just works. -- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`. - Like nginx location blocks, zero config files. - -No mkcert, no nginx.conf, no Caddyfile, no editing /etc/hosts. -Single binary, one command. - -``` -brew install razvandimescu/tap/numa -# or -cargo install numa -``` - -https://github.com/razvandimescu/numa - ---- - -### r/dns - -**Title:** Numa — recursive DNS resolver from scratch in Rust, DNSSEC, no DNS libraries - -**Body:** - -I built a recursive DNS resolver where the entire wire protocol (RFC 1035 — -headers, label compression, EDNS0) is hand-parsed. No `hickory-dns`, -no `trust-dns`. - -What it does: -- Full recursive resolver from root hints (iterative queries, no upstream needed) -- DNSSEC chain-of-trust validation (RSA/SHA-256, ECDSA P-256, Ed25519) -- EDNS0 with DO bit, 1232-byte payload (DNS Flag Day 2020 compliant) -- DNS-over-HTTPS as an alternative upstream mode -- Ad blocking (~385K+ domains via Hagezi Pro) -- Conditional forwarding (auto-detects Tailscale/VPN split-DNS) -- Local zones, ephemeral overrides with auto-revert via REST API - -DNSSEC implementation: DNSKEY/DS/RRSIG record parsing, canonical wire format -for signed data, key tag computation (RFC 4034), DS digest verification. -Chain walks from zone → TLD → root trust anchor. ECDSA P-256 signature -verification in 174ns. TLD chain pre-warmed on startup. Referral DS records -piggybacked from authority sections during resolution. - -NSEC/NSEC3 authenticated denial of existence: NXDOMAIN gap proofs, NSEC3 -closest encloser proofs (3-part per RFC 5155), NODATA type absence proofs, -authority-section RRSIG verification. Iteration cap at 500 for NSEC3 DoS -prevention. - -What it doesn't do (yet): no authoritative zone serving (AXFR/NOTIFY). - -Single binary, macOS + Linux. MIT license. - -https://github.com/razvandimescu/numa - ---- - -### Lobsters (invite-only) - -**Title:** Numa — DNS resolver from scratch in Rust, no DNS libraries - -**Body:** - -I built a DNS resolver in Rust — RFC 1035 wire protocol parsed by hand, -no `trust-dns` or `hickory-dns`. Started as a learning project, became -my daily system DNS. - -Beyond resolving, it does local `.numa` domains with auto HTTPS reverse -proxy (register `frontend.numa → localhost:5173`, get a green lock and -WebSocket passthrough), and LAN service discovery via UDP multicast — -two machines running Numa find each other's services automatically. - -Implementation bits I found interesting: DNS label compression (chained -2-byte pointers back into the packet), browsers rejecting wildcard certs -under single-label TLDs (`*.numa` fails — need per-service SANs), and -`SO_REUSEPORT` on macOS for multiple processes binding the same multicast -port. - -Set `mode = "recursive"` for DNSSEC-validated resolution from root -nameservers — no upstream, no middleman. - -Single binary, macOS + Linux. - -https://github.com/razvandimescu/numa - ---- - -### r/coolgithubprojects - -**Post type:** Image post with `hero-demo.gif`, GitHub link in first comment. - -**Title:** Numa — portable DNS resolver built from scratch in Rust. Ad blocking, local HTTPS domains, LAN discovery, recursive resolution with DNSSEC. Single binary. - -**First comment (post immediately):** - -https://github.com/razvandimescu/numa - -``` -brew install razvandimescu/tap/numa && sudo numa -``` - -No DNS libraries — RFC 1035 wire protocol parsed by hand. -Recursive resolution from root nameservers with full DNSSEC -chain-of-trust validation. 385K+ blocked ad domains. -.numa local domains with auto TLS and WebSocket proxy. - ---- - -### r/sideproject - -**Title:** I built a DNS resolver from scratch in Rust — it's now my daily system DNS - -**Body:** - -Last year I wanted to understand how DNS actually works at the wire -level, so I started parsing RFC 1035 packets by hand. No DNS libraries, -no trust-dns, no hickory-dns — just bytes and the spec. - -It turned into something I use every day. What it does now: - -- **Ad blocking** on any network (coffee shops, airports) — 385K+ - domains blocked, travels with my laptop -- **Local service naming** — `https://frontend.numa` instead of - `localhost:5173`, with auto-generated TLS certs and WebSocket - passthrough for HMR -- **Recursive resolution** from root nameservers with DNSSEC - chain-of-trust validation — set `mode = "recursive"` for full - privacy, no upstream dependency, no single entity sees my query - pattern -- **LAN discovery** — two machines running Numa find each other's - services automatically via mDNS - -Single Rust binary, ~8MB, MIT license. `sudo numa install` and it's your -system DNS — caching, ad blocking, .numa domains, zero config changes. - -I wrote about the technical journey here: -- [I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html) -- [Implementing DNSSEC from Scratch](https://numa.rs/blog/posts/dnssec-from-scratch.html) - -https://github.com/razvandimescu/numa - ---- - -### r/webdev (Showoff Saturday — posted 2026-03-28) - -**Title:** I replaced localhost:5173 with frontend.numa — shared cookie domain, auto HTTPS, no nginx - -**Body:** - -The port numbers weren't the real problem. It was CORS between -`localhost:5173` and `localhost:3000`, Secure cookies not setting over -HTTP, and service workers requiring a secure context. - -I built a DNS resolver that gives local services named domains under a -shared TLD: - -``` -curl -X POST localhost:5380/services \ - -d '{"name":"frontend","target_port":5173}' -``` - -Now `https://frontend.numa` and `https://api.numa` share the `.numa` -cookie domain. Cross-service auth just works. Secure cookies set. -Service workers run. - -What's under the hood: -- **Auto HTTPS** — generates a local CA + per-service TLS certs. Green - lock, no mkcert. -- **WebSocket passthrough** — Vite/webpack HMR goes through the proxy. - No special config. -- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`. - Like nginx location blocks. -- **Also a full DNS resolver** — forward mode with caching and ad - blocking by default. Set `mode = "recursive"` for full DNSSEC-validated - resolution from root nameservers. - -Single Rust binary. `sudo numa install` and it's your system DNS — caching, -ad blocking, .numa domains. No nginx, no Caddy, no /etc/hosts. - -``` -brew install razvandimescu/tap/numa -# or -cargo install numa -``` - -https://github.com/razvandimescu/numa - -**Lessons from r/node (2026-03-24):** "Can't remember 3 ports?" got -pushback — the CORS/cookie angle resonated more. Lead with what you -can't do without it, not what's annoying. - ---- - -### r/commandline - -**Title:** numa — local dev DNS with auto HTTPS and LAN service discovery, single Rust binary - -**Body:** - -I run 5-6 local services and wanted named domains with HTTPS instead of -remembering port numbers. Built a DNS resolver that handles `.numa` -domains: - -``` -curl -X POST localhost:5380/services \ - -d '{"name":"api","target_port":8000}' -``` - -Now `https://api.numa` resolves, proxies to localhost:8000, and has a -valid TLS cert. WebSocket passthrough works — Vite HMR goes through -the proxy fine. - -The part I didn't expect to be useful: LAN service discovery. Two -machines running numa find each other via UDP multicast. I register -`api.numa` on my laptop, my teammate's numa instance picks it up -automatically. Zero config. - -Also blocks ~385K+ ad domains since it's already your DNS resolver. -Portable — works on any network (coffee shops, airports). Set -`mode = "recursive"` for full DNSSEC-validated resolution from root -nameservers — no upstream dependency. - -``` -brew install razvandimescu/tap/numa -sudo numa -``` - -Single binary, DNS wire protocol parsed from scratch (no DNS libraries). - -https://github.com/razvandimescu/numa - ---- - -### r/selfhosted (only if Show HN hits front page) - -**Title:** Numa — recursive resolver + ad blocking + LAN service discovery in one binary - -**Body:** - -I built a DNS resolver in Rust that I've been running as my system DNS. -Two features I'm most proud of: - -**Recursive resolution + DNSSEC** — set `mode = "recursive"` and it resolves -from root nameservers, no upstream dependency. Chain-of-trust verification -(RSA, ECDSA, Ed25519), NSEC/NSEC3 denial proofs. No single entity sees your -full query pattern — each authoritative server only sees its zone's queries. - -**LAN service discovery** — I register `api.numa → localhost:8000` on my -laptop. My colleague's machine, also running Numa, picks it up via UDP -multicast — `api.numa` resolves to my IP on his machine. Zero config. - -The rest of what it does: -- **Ad blocking** — ~385K+ domains (Hagezi Pro), portable. Works on any - network including coffee shops and airports. -- **DNS-over-HTTPS** — encrypted upstream as an alternative to recursive mode. -- **Auto HTTPS for local services** — generates a local CA + per-service - TLS certs. `https://frontend.numa` with a green lock, WebSocket passthrough. -- **Hub mode** — point other devices' DNS to it, they get ad blocking + - `.numa` resolution without installing anything. - -Replaces Pi-hole + Unbound in one binary. No Raspberry Pi, no Docker, no PHP. - -Single binary, macOS + Linux. Config is one optional TOML file. - -**What it doesn't do (yet):** No web-based config editor (TOML + REST API). -DoT listener is in progress. - -`brew install razvandimescu/tap/numa` or `cargo install numa` - -https://github.com/razvandimescu/numa - ---- - -## Preparation Checklist - -- [ ] Verify GitHub repo is PUBLIC before any post -- [ ] Build some comment history on posting account first -- [ ] Post HN Tuesday-Thursday, 9-10 AM Eastern -- [ ] Respond to every comment within 2 hours for the first 6 hours -- [ ] Have fixes ready to ship within 24h for reported issues -- [ ] Don't oversell the pkarr/token vision — one sentence max - -## Rules - -- Verify GitHub repo is PUBLIC before every post -- Use an account with comment history, not a fresh one -- Respond to every comment within 2 hours -- Never be defensive — acknowledge valid criticism, redirect -- If someone says "just use X" — agree it works, explain what's *uniquely different* -- Lead with unique capabilities, not tool replacement - ---- - -## Prepared Responses - -**"What does this offer over /etc/hosts?"** *(actual r/programare objection)* -/etc/hosts is static and per-machine. Numa gives you: auto-revert after N -minutes (great for testing), a REST API so scripts can create/remove entries, -HTTPS reverse proxy with auto TLS, and LAN discovery so you don't have to -edit hosts on every device. Different tools for different problems. - -**"Mature solutions already exist (dnsmasq, nginx, etc.)"** *(actual r/programare objection)* -Absolutely — and they're great. The thing they don't do: register a service -on machine A and have it automatically appear on machine B via multicast. -Numa integrates DNS + reverse proxy + TLS + discovery into one binary so -those pieces work together. If you only need DNS forwarding, dnsmasq is the -right tool. - -**"Why not Pi-hole / AdGuard Home?"** -They're network appliances — need dedicated hardware or Docker. Numa is a -single binary on your laptop. When you move to a coffee shop, your ad -blocking comes with you. Plus the reverse proxy + LAN discovery. - -**"Why from scratch / no DNS libraries?"** -Started as a learning project to understand the wire protocol. Turned out -having full control over the pipeline makes features like conditional -forwarding and override injection trivial — they're just steps in the -resolution chain. - -**"Vibe coded / AI generated?"** -I use AI as a coding partner — same as using Stack Overflow or pair -programming. I make the architecture decisions, direct what gets built, -and review everything. The DNS wire protocol parser was the original -learning project I wrote by hand. Later features were built collaboratively -with AI assistance. You can read every line — nothing is opaque generated -slop. - -**"Why sudo / why port 53?"** -Port 53 requires root on Unix. Numa only needs it for the UDP socket. -You can also bind to a high port for testing: `bind_addr = "127.0.0.1:5353"`. - -**"What about .numa TLD conflicts?"** -The TLD is configurable in `numa.toml`. If `.numa` ever becomes official, -change it to anything else. - -**"Does it support DoH/DoT?"** -DoH is built in — set `address = "https://9.9.9.9/dns-query"` in -`[upstream]` and your queries are encrypted. Or set `mode = "auto"` to -probe root servers and fall back to DoH if blocked. DoT listener support -is in progress (PR #25). - -**"But Quad9/Cloudflare still sees my queries"** -In forward mode (the default), yes — your upstream resolver sees your queries. -Set `mode = "recursive"` and Numa resolves directly from root nameservers — -no single upstream sees your full query pattern. Each authoritative server -only sees the query relevant to its zone. Add `[dnssec] enabled = true` to -cryptographically verify responses. - -**"Show me benchmarks / performance numbers"** *(actual r/programare request)* -Benchmark suite is in `benches/` (criterion). Cached round-trip: 691ns. -Pipeline throughput: ~2.0M qps. DNSSEC: ECDSA P-256 verify 174ns, RSA/SHA-256 -10.9µs, DS verify 257ns. Cold-cache DNSSEC validation ~90ms (1 network fetch, -TLD chain pre-warmed). Full comparison against system resolver, Quad9, -Cloudflare, Google on the site. - -**"Why not just use Unbound?"** -Numa supports recursive resolution with DNSSEC validation, same as Unbound -(`mode = "recursive"`). The difference: -Numa also has built-in ad blocking, a dashboard, `.numa` local domains with -auto HTTPS, LAN service discovery, and developer overrides. Unbound does -one thing well; Numa integrates six features into one binary. - -**"Why not Technitium?"** -Technitium is the closest in features — recursive, DNSSEC, ad blocking, -dashboard. Good tool. Two differences: (1) Numa is a single static binary, -Technitium requires the .NET runtime; (2) Numa has developer tooling that -Technitium doesn't — `.numa` local domains with auto TLS reverse proxy, -path-based routing, LAN service discovery, ephemeral overrides with -auto-revert. Different audiences: Technitium targets server admins, Numa -targets developers on laptops. - -**"Does it support Windows?"** -Yes. `numa install` in an admin terminal sets system DNS and auto-starts -numa on boot. Requires a reboot (Windows DNS Client holds port 53 at kernel -level). `numa uninstall` restores everything. Native Windows Service -integration is next. -- 2.34.1