From 280e45c6be98d1c459ee936e9a508de0dca97066 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 1 Apr 2026 11:32:42 +0300 Subject: [PATCH] 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")); + } +}