feat: Windows DNS configuration via netsh (#28)
* feat: Windows DNS configuration via netsh 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 <noreply@anthropic.com> * ci: add cargo test to Windows CI job Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: upload Windows binary as artifact for testing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: SRTT decay tests panic on Windows due to Instant underflow 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: use ProgramData for Windows DNS backup path 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: rewrite SRTT decay tests as pure functions 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * refactor: extract DOH_FALLBACK constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: extract QUAD9_IP constant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * chore: remove docs from git tracking (already gitignored) 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -37,3 +37,10 @@ jobs:
|
|||||||
run: cargo build
|
run: cargo build
|
||||||
- name: clippy
|
- name: clippy
|
||||||
run: cargo clippy -- -D warnings
|
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
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -15,16 +15,32 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# macOS
|
||||||
brew install razvandimescu/tap/numa
|
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`)
|
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
|
## 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 |
|
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||||
| Web admin UI | Full | Full | — | Dashboard |
|
| Web admin UI | Full | Full | — | Dashboard |
|
||||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
| 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 |
|
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -20,6 +20,9 @@ use numa::system_dns::{
|
|||||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
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]
|
#[tokio::main]
|
||||||
async fn main() -> numa::Result<()> {
|
async fn main() -> numa::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||||
@@ -126,7 +129,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
.use_rustls_tls()
|
.use_rustls_tls()
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let url = "https://dns.quad9.net/dns-query".to_string();
|
let url = DOH_FALLBACK.to_string();
|
||||||
let label = url.clone();
|
let label = url.clone();
|
||||||
(
|
(
|
||||||
numa::config::UpstreamMode::Forward,
|
numa::config::UpstreamMode::Forward,
|
||||||
@@ -152,7 +155,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||||
"https://dns.quad9.net/dns-query".to_string()
|
DOH_FALLBACK.to_string()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
config.upstream.address.clone()
|
config.upstream.address.clone()
|
||||||
@@ -478,7 +481,14 @@ async fn main() -> numa::Result<()> {
|
|||||||
#[allow(clippy::infinite_loop)]
|
#[allow(clippy::infinite_loop)]
|
||||||
loop {
|
loop {
|
||||||
let mut buffer = BytePacketBuffer::new();
|
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);
|
let ctx = Arc::clone(&ctx);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -521,7 +531,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
|||||||
let new_addr = dns_info
|
let new_addr = dns_info
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.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) =
|
if let Ok(new_sock) =
|
||||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||||
{
|
{
|
||||||
|
|||||||
101
src/srtt.rs
101
src/srtt.rs
@@ -47,16 +47,19 @@ impl SrttCache {
|
|||||||
|
|
||||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
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 {
|
if age_secs > DECAY_AFTER_SECS {
|
||||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||||
let mut srtt = entry.srtt_ms;
|
let mut srtt = srtt_ms;
|
||||||
for _ in 0..periods {
|
for _ in 0..periods {
|
||||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||||
}
|
}
|
||||||
srtt
|
srtt
|
||||||
} else {
|
} else {
|
||||||
entry.srtt_ms
|
srtt_ms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +119,6 @@ impl SrttCache {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
|
|
||||||
if let Some(entry) = self.entries.get_mut(&ip) {
|
|
||||||
entry.updated_at = at;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn maybe_evict(&mut self) {
|
fn maybe_evict(&mut self) {
|
||||||
if self.entries.len() < MAX_ENTRIES {
|
if self.entries.len() < MAX_ENTRIES {
|
||||||
return;
|
return;
|
||||||
@@ -218,63 +214,41 @@ mod tests {
|
|||||||
assert_eq!(addrs, original);
|
assert_eq!(addrs, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn age(secs: u64) -> Instant {
|
|
||||||
Instant::now() - std::time::Duration::from_secs(secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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]
|
#[test]
|
||||||
fn no_decay_within_threshold() {
|
fn no_decay_within_threshold() {
|
||||||
let mut cache = SrttCache::new(true);
|
// At exactly DECAY_AFTER_SECS, no decay applied
|
||||||
cache.record_rtt(ip(1), 5000, false);
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
||||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
|
assert_eq!(result, FAILURE_PENALTY_MS);
|
||||||
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_decay_period() {
|
fn one_decay_period() {
|
||||||
let mut cache = saturated_penalty_cache();
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
||||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
||||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
|
assert_eq!(result, expected);
|
||||||
let expected = (raw + INITIAL_SRTT_MS) / 2;
|
|
||||||
assert_eq!(cache.get(ip(1)), expected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_decay_periods() {
|
fn multiple_decay_periods() {
|
||||||
let mut cache = saturated_penalty_cache();
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
||||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
let mut expected = FAILURE_PENALTY_MS;
|
||||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
|
|
||||||
let mut expected = raw;
|
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
expected = (expected + INITIAL_SRTT_MS) / 2;
|
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||||
}
|
}
|
||||||
assert_eq!(cache.get(ip(1)), expected);
|
assert_eq!(result, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_caps_at_8_periods() {
|
fn decay_caps_at_8_periods() {
|
||||||
// 9 periods and 100 periods should produce the same result (capped at 8)
|
// 9 periods and 100 periods should produce the same result (capped at 8)
|
||||||
let mut cache_a = saturated_penalty_cache();
|
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
|
||||||
let mut cache_b = saturated_penalty_cache();
|
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
|
assert_eq!(a, b);
|
||||||
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
|
||||||
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_converges_toward_initial() {
|
fn decay_converges_toward_initial() {
|
||||||
let mut cache = saturated_penalty_cache();
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
|
||||||
let decayed = cache.get(ip(1));
|
|
||||||
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||||
assert!(
|
assert!(
|
||||||
diff < 25,
|
diff < 25,
|
||||||
@@ -286,29 +260,28 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn record_rtt_applies_decay_before_ewma() {
|
fn record_rtt_applies_decay_before_ewma() {
|
||||||
let mut cache = saturated_penalty_cache();
|
// Verify decay is applied before EWMA in record_rtt by checking
|
||||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
|
// that a saturated penalty + long age + new sample produces a low SRTT
|
||||||
cache.record_rtt(ip(1), 50, false);
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
||||||
let srtt = cache.get(ip(1));
|
// EWMA: (decayed * 7 + 50) / 8
|
||||||
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
|
let after_ewma = (decayed * 7 + 50) / 8;
|
||||||
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
|
assert!(
|
||||||
|
after_ewma < 500,
|
||||||
|
"expected decay before EWMA, got srtt={}",
|
||||||
|
after_ewma
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_reranks_stale_failures() {
|
fn decay_reranks_stale_failures() {
|
||||||
let mut cache = saturated_penalty_cache();
|
// After enough decay, a failed server (5000ms) converges toward
|
||||||
for _ in 0..30 {
|
// INITIAL (200ms), which is below a stable server at 300ms
|
||||||
cache.record_rtt(ip(2), 300, false);
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
}
|
assert!(
|
||||||
let mut addrs = vec![sock(1), sock(2)];
|
decayed < 300,
|
||||||
cache.sort_by_rtt(&mut addrs);
|
"expected decayed penalty ({}) < 300ms",
|
||||||
assert_eq!(addrs, vec![sock(2), sock(1)]);
|
decayed
|
||||||
|
);
|
||||||
// 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));
|
|
||||||
let mut addrs = vec![sock(1), sock(2)];
|
|
||||||
cache.sort_by_rtt(&mut addrs);
|
|
||||||
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||||
if let Some(ip) = trimmed.split(':').next_back() {
|
if let Some(ip) = trimmed.split(':').next_back() {
|
||||||
let ip = ip.trim();
|
let ip = ip.trim();
|
||||||
if !is_loopback_or_stub(ip) {
|
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||||
upstream = Some(ip.to_string());
|
upstream = Some(ip.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -358,6 +358,339 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||||
|
struct WindowsInterfaceDns {
|
||||||
|
dhcp: bool,
|
||||||
|
servers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
||||||
|
let mut interfaces = std::collections::HashMap::new();
|
||||||
|
let mut current_adapter: Option<String> = None;
|
||||||
|
let mut current_dhcp = false;
|
||||||
|
let mut current_dns: Vec<String> = 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::<std::net::IpAddr>().is_ok() {
|
||||||
|
current_dns.push(ip.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if in_dns_block {
|
||||||
|
if trimmed.parse::<std::net::IpAddr>().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<std::collections::HashMap<String, WindowsInterfaceDns>, 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 {
|
||||||
|
// 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)]
|
||||||
|
fn disable_dnscache() -> Result<bool, String> {
|
||||||
|
// 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()?;
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Numa will start automatically. ***\n");
|
||||||
|
} else {
|
||||||
|
eprintln!(" Numa will start automatically on next boot.\n");
|
||||||
|
}
|
||||||
|
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||||
|
eprintln!(" [upstream]");
|
||||||
|
eprintln!(" mode = \"recursive\"\n");
|
||||||
|
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))?;
|
||||||
|
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// Find the upstream for a domain by checking forwarding rules.
|
||||||
/// Returns None if no rule matches (use default upstream).
|
/// Returns None if no rule matches (use default upstream).
|
||||||
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
||||||
@@ -522,7 +855,9 @@ pub fn install_service() -> Result<(), String> {
|
|||||||
let result = install_service_macos();
|
let result = install_service_macos();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let result = install_service_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());
|
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||||
|
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
@@ -546,7 +881,11 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
uninstall_service_linux()
|
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())
|
Err("service uninstallation not supported on this OS".to_string())
|
||||||
}
|
}
|
||||||
@@ -1027,3 +1366,57 @@ fn untrust_ca() -> Result<(), String> {
|
|||||||
let _ = ca_path; // suppress unused warning on other platforms
|
let _ = ca_path; // suppress unused warning on other platforms
|
||||||
Ok(())
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user