feat: forward-by-default, auto recursive mode, Linux install fixes (#27)

* feat: auto recursive mode, fix Linux install

Auto mode (new default): probes a root server on startup; uses
recursive resolution if outbound DNS works, falls back to Quad9 DoH
if blocked. Dashboard shows mode indicator (green/yellow).

Linux install fixes:
- Add DNSStubListener=no to resolved drop-in (frees port 53)
- Configure DNS before starting service (correct ordering)
- Skip 127.0.0.53 in upstream detection
- `numa install` now does everything (service + DNS + CA)
- `numa uninstall` mirrors install (stop service + restore DNS)
- Extract is_loopback_or_stub() for consistent filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enable DNSSEC validation by default

With recursive as the default mode, DNSSEC validation completes the
trustless resolution chain. Strict mode remains off by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: forward search domains to VPC resolver on Linux

Parse search/domain lines from resolv.conf and create conditional
forwarding rules to the original nameserver or AWS VPC resolver
(169.254.169.253). Fixes internal hostname resolution on cloud VMs
where recursive mode can't resolve private DNS zones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: single-pass resolv.conf parsing, eliminate redundancies

Parse resolv.conf once for both upstream and search domains instead
of 2-3 reads. Extract CLOUD_VPC_RESOLVER constant. Use &'static str
for mode in StatsResponse. Remove dead read_upstream_from_file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: macOS install health check, harden recursive probe

Verify numa is listening (API port) before redirecting system DNS on
macOS — if the service fails to start (e.g. port 53 in use), unload
the service and abort instead of breaking DNS. Probe up to 3 root
hints before declaring recursive mode unavailable. Validate IPs from
resolvectl to avoid IPv6 fragment extraction. Extract DEFAULT_API_PORT
constant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: widen make_rule cfg gate to include Linux

make_rule was gated to macOS-only but discover_linux() calls it for
search domain forwarding rules. CI failed on Linux with E0425.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: forward mode as default, recursive opt-in

Forward mode (transparent proxy to system DNS) is now the default.
Recursive and auto modes are explicit opt-in via config. This avoids
bypassing corporate DNS policies, captive portals, VPC private zones,
and parental controls on first install.

- Move #[default] from Auto to Forward on UpstreamMode
- DNSSEC defaults to off (no-op in forward mode)
- 3-way match in main: Forward/Recursive/Auto with clean separation
- Post-install message suggests mode = "recursive" for sovereignty
- Update README, site, and launch drafts messaging

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:
Razvan Dimescu
2026-04-01 08:49:16 +03:00
committed by GitHub
parent 4e5b88496c
commit 98da440c84
9 changed files with 282 additions and 144 deletions

View File

@@ -8,7 +8,7 @@
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded. Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
![Numa dashboard](assets/hero-demo.gif) ![Numa dashboard](assets/hero-demo.gif)
@@ -24,7 +24,7 @@ sudo numa # port 53 requires root
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 && sudo numa service start` Set as system DNS: `sudo numa install`
## Local Services ## Local Services
@@ -43,7 +43,7 @@ Add path-based routing (`app.numa/api → :5001`), share services across machine
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop. 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
Two resolution modes: **forward** (relay to Quad9/Cloudflare via encrypted DoH) or **recursive** (resolve from root nameservers — no upstream dependency, no single entity sees your full query pattern). DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html) By default, Numa forwards to your existing system DNS — everything works as before, just with caching and ad blocking on top. For full privacy, set `mode = "recursive"` — Numa resolves directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
## LAN Discovery ## LAN Discovery

View File

@@ -70,8 +70,10 @@ echo ""
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)" echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
echo "" echo ""
echo " Get started:" echo " Get started:"
echo " sudo numa # start the DNS server" echo " sudo numa install # install service + set as system DNS"
echo " sudo numa install # set as system DNS"
echo " sudo numa service start # run as persistent service"
echo " open http://localhost:5380 # dashboard" echo " open http://localhost:5380 # dashboard"
echo "" echo ""
echo " Other commands:"
echo " sudo numa # run in foreground (no service)"
echo " sudo numa uninstall # restore original DNS"
echo ""

View File

@@ -882,6 +882,9 @@ async function refresh() {
document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || ''; document.getElementById('footerData').textContent = stats.data_dir || '';
const modeEl = document.getElementById('footerMode');
modeEl.textContent = stats.mode || '—';
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
@@ -1236,6 +1239,7 @@ setInterval(refresh, 2000);
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span> Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span> · Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span> · Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Mode: <span id="footerMode" style="color:var(--text-dim);"></span>
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span> · DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span>
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span> · SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span> · Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — DNS you own. Everywhere you go.</title> <title>Numa — DNS you own. Everywhere you go.</title>
<meta name="description" content="DNS you own. Recursive resolver with full DNSSEC validation, ad blocking, .numa local domains, developer overrides. A single portable binary built from scratch in Rust."> <meta name="description" content="DNS you own. Portable DNS resolver with caching, ad blocking, .numa local domains, developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
<link rel="canonical" href="https://numa.rs"> <link rel="canonical" href="https://numa.rs">
<meta property="og:title" content="Numa — DNS you own. Everywhere you go."> <meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
<meta property="og:description" content="Recursive DNS resolver with full DNSSEC validation, ad blocking, .numa local domains, and developer overrides. Built from scratch in Rust."> <meta property="og:description" content="Portable DNS resolver with caching, ad blocking, .numa local domains, and developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://numa.rs"> <meta property="og:url" content="https://numa.rs">
<link rel="stylesheet" href="/fonts/fonts.css"> <link rel="stylesheet" href="/fonts/fonts.css">
@@ -1232,17 +1232,17 @@ footer .closing {
<div class="reveal"> <div class="reveal">
<div class="section-label">How It Works</div> <div class="section-label">How It Works</div>
<h2>What it does today</h2> <h2>What it does today</h2>
<p class="lead">A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.</p> <p class="lead">A DNS resolver with caching, ad blocking, local service domains, and a REST API. Optional recursive resolution with DNSSEC. Everything runs in a single binary.</p>
</div> </div>
<div class="layers-grid"> <div class="layers-grid">
<div class="layer-card reveal reveal-delay-1"> <div class="layer-card reveal reveal-delay-1">
<div class="layer-badge">Layer 1</div> <div class="layer-badge">Layer 1</div>
<h3>Resolve &amp; Protect</h3> <h3>Resolve &amp; Protect</h3>
<ul> <ul>
<li>Recursive resolution &mdash; resolve from root nameservers, no upstream needed</li> <li>Forward mode by default &mdash; transparent proxy to your existing DNS, with caching</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li> <li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>DNS-over-HTTPS &mdash; encrypted upstream as alternative to recursive mode</li> <li>Recursive resolution &mdash; opt-in, resolve from root nameservers, no upstream needed</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>TTL-aware caching (sub-ms lookups)</li> <li>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; macOS, Linux, and Windows</li> <li>Single binary, portable &mdash; macOS, Linux, and Windows</li>
</ul> </ul>

View File

@@ -160,6 +160,7 @@ struct QueryLogResponse {
struct StatsResponse { struct StatsResponse {
uptime_secs: u64, uptime_secs: u64,
upstream: String, upstream: String,
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
config_path: String, config_path: String,
data_dir: String, data_dir: String,
dnssec: bool, dnssec: bool,
@@ -486,6 +487,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
Json(StatsResponse { Json(StatsResponse {
uptime_secs: snap.uptime_secs, uptime_secs: snap.uptime_secs,
upstream, upstream,
mode: ctx.upstream_mode.as_str(),
config_path: ctx.config_path.clone(), config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(), data_dir: ctx.data_dir.to_string_lossy().to_string(),
dnssec: ctx.dnssec_enabled, dnssec: ctx.dnssec_enabled,

View File

@@ -59,18 +59,31 @@ fn default_bind_addr() -> String {
"0.0.0.0:53".to_string() "0.0.0.0:53".to_string()
} }
pub const DEFAULT_API_PORT: u16 = 5380;
fn default_api_port() -> u16 { fn default_api_port() -> u16 {
5380 DEFAULT_API_PORT
} }
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)] #[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum UpstreamMode { pub enum UpstreamMode {
Auto,
#[default] #[default]
Forward, Forward,
Recursive, Recursive,
} }
impl UpstreamMode {
pub fn as_str(&self) -> &'static str {
match self {
UpstreamMode::Auto => "auto",
UpstreamMode::Forward => "forward",
UpstreamMode::Recursive => "recursive",
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpstreamConfig { pub struct UpstreamConfig {
#[serde(default)] #[serde(default)]
@@ -103,10 +116,14 @@ impl Default for UpstreamConfig {
} }
} }
fn default_srtt() -> bool { fn default_true() -> bool {
true true
} }
fn default_srtt() -> bool {
default_true()
}
fn default_prime_tlds() -> Vec<String> { fn default_prime_tlds() -> Vec<String> {
vec![ vec![
// gTLDs // gTLDs

View File

@@ -17,8 +17,7 @@ use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; use numa::service_store::ServiceStore;
use numa::stats::ServerStats; use numa::stats::ServerStats;
use numa::system_dns::{ use numa::system_dns::{
discover_system_dns, install_service, install_system_dns, restart_service, service_status, discover_system_dns, install_service, restart_service, service_status, uninstall_service,
uninstall_service, uninstall_system_dns,
}; };
#[tokio::main] #[tokio::main]
@@ -31,12 +30,12 @@ async fn main() -> numa::Result<()> {
let arg1 = std::env::args().nth(1).unwrap_or_default(); let arg1 = std::env::args().nth(1).unwrap_or_default();
match arg1.as_str() { match arg1.as_str() {
"install" => { "install" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
return install_system_dns().map_err(|e| e.into()); return install_service().map_err(|e| e.into());
} }
"uninstall" => { "uninstall" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
return uninstall_system_dns().map_err(|e| e.into()); return uninstall_service().map_err(|e| e.into());
} }
"service" => { "service" => {
let sub = std::env::args().nth(2).unwrap_or_default(); let sub = std::env::args().nth(2).unwrap_or_default();
@@ -107,6 +106,46 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules) // Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns(); let system_dns = discover_system_dns();
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
numa::config::UpstreamMode::Auto => {
info!("auto mode: probing recursive resolution...");
if numa::recursive::probe_recursive(&root_hints).await {
info!("recursive probe succeeded — self-sovereign mode");
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
} else {
log::warn!("recursive probe failed — falling back to Quad9 DoH");
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
let url = "https://dns.quad9.net/dns-query".to_string();
let label = url.clone();
(
numa::config::UpstreamMode::Forward,
false,
Upstream::Doh { url, client },
label,
)
}
}
numa::config::UpstreamMode::Recursive => {
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
}
numa::config::UpstreamMode::Forward => {
let upstream_addr = if config.upstream.address.is_empty() { let upstream_addr = if config.upstream.address.is_empty() {
system_dns system_dns
.default_upstream .default_upstream
@@ -129,10 +168,19 @@ async fn main() -> numa::Result<()> {
client, client,
} }
} else { } else {
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?; let addr: SocketAddr =
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr) Upstream::Udp(addr)
}; };
let upstream_label = upstream.to_string(); let label = upstream.to_string();
(
numa::config::UpstreamMode::Forward,
config.upstream.address.is_empty(),
upstream,
label,
)
}
};
let api_port = config.server.api_port; let api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new(); let mut blocklist = BlocklistStore::new();
@@ -183,7 +231,7 @@ async fn main() -> numa::Result<()> {
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
forwarding_rules, forwarding_rules,
upstream: Mutex::new(upstream), upstream: Mutex::new(upstream),
upstream_auto: config.upstream.address.is_empty(), upstream_auto,
upstream_port: config.upstream.port, upstream_port: config.upstream.port,
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
timeout: Duration::from_millis(config.upstream.timeout_ms), timeout: Duration::from_millis(config.upstream.timeout_ms),
@@ -199,8 +247,8 @@ async fn main() -> numa::Result<()> {
config_dir: numa::config_dir(), config_dir: numa::config_dir(),
data_dir: numa::data_dir(), data_dir: numa::data_dir(),
tls_config: initial_tls, tls_config: initial_tls,
upstream_mode: config.upstream.mode, upstream_mode: resolved_mode,
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints), root_hints,
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)), srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()), inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled, dnssec_enabled: config.dnssec.enabled,

View File

@@ -65,6 +65,21 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
} }
} }
/// Probe whether recursive resolution works by querying root servers.
/// Tries up to 3 hints before declaring failure.
pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool {
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
probe.header.recursion_desired = false;
for hint in root_hints.iter().take(3) {
if let Ok(resp) = forward_udp(&probe, *hint, Duration::from_secs(3)).await {
if !resp.answers.is_empty() || !resp.authorities.is_empty() {
return true;
}
}
}
false
}
pub async fn prime_tld_cache( pub async fn prime_tld_cache(
cache: &RwLock<DnsCache>, cache: &RwLock<DnsCache>,
root_hints: &[SocketAddr], root_hints: &[SocketAddr],

View File

@@ -2,6 +2,10 @@ use std::net::SocketAddr;
use log::info; use log::info;
fn is_loopback_or_stub(addr: &str) -> bool {
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
}
/// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`. /// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ForwardingRule { pub struct ForwardingRule {
@@ -26,10 +30,7 @@ pub fn discover_system_dns() -> SystemDnsInfo {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
SystemDnsInfo { discover_linux()
default_upstream: detect_upstream_linux_or_backup(),
forwarding_rules: Vec::new(),
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -102,11 +103,7 @@ fn discover_macos() -> SystemDnsInfo {
if ns.parse::<std::net::Ipv4Addr>().is_ok() { if ns.parse::<std::net::Ipv4Addr>().is_ok() {
current_nameserver = Some(ns.clone()); current_nameserver = Some(ns.clone());
// Capture first non-supplemental, non-loopback nameserver as default upstream // Capture first non-supplemental, non-loopback nameserver as default upstream
if !is_supplemental if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&ns) {
&& default_upstream.is_none()
&& ns != "127.0.0.1"
&& ns != "0.0.0.0"
{
default_upstream = Some(ns); default_upstream = Some(ns);
} }
} }
@@ -156,7 +153,7 @@ fn discover_macos() -> SystemDnsInfo {
} }
} }
#[cfg(target_os = "macos")] #[cfg(any(target_os = "macos", target_os = "linux"))]
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> { fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
Some(ForwardingRule { Some(ForwardingRule {
@@ -166,38 +163,100 @@ fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
}) })
} }
/// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf
/// only has loopback (meaning numa install already ran).
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn detect_upstream_linux_or_backup() -> Option<String> { const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
// Try /etc/resolv.conf first
if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") { #[cfg(target_os = "linux")]
fn discover_linux() -> SystemDnsInfo {
// Parse resolv.conf once for both upstream and search domains
let (upstream, search_domains) = parse_resolv_conf("/etc/resolv.conf");
let default_upstream = if let Some(ns) = upstream {
info!("detected system upstream: {}", ns); info!("detected system upstream: {}", ns);
return Some(ns); Some(ns)
} } else {
// If resolv.conf only has loopback, check the backup from `numa install` // Fallback to backup from a previous `numa install`
let backup = { let backup = {
let home = std::env::var("HOME") let home = std::env::var("HOME")
.map(std::path::PathBuf::from) .map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/root")); .unwrap_or_else(|_| std::path::PathBuf::from("/root"));
home.join(".numa").join("original-resolv.conf") home.join(".numa").join("original-resolv.conf")
}; };
if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) { let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
if let Some(ref ns) = ns {
info!("detected original upstream from backup: {}", ns); info!("detected original upstream from backup: {}", ns);
return Some(ns);
} }
None ns
};
// On cloud VMs (AWS/GCP), internal domains need to reach the VPC resolver
let forwarding_rules = if search_domains.is_empty() {
Vec::new()
} else {
let forwarder = resolvectl_dns_server().unwrap_or_else(|| CLOUD_VPC_RESOLVER.to_string());
let rules: Vec<_> = search_domains
.iter()
.filter_map(|domain| {
let rule = make_rule(domain, &forwarder)?;
info!("forwarding .{} to {}", domain, forwarder);
Some(rule)
})
.collect();
if !rules.is_empty() {
info!("detected {} search domain forwarding rules", rules.len());
}
rules
};
SystemDnsInfo {
default_upstream,
forwarding_rules,
}
} }
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
/// nameserver and all search domains.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn read_upstream_from_file(path: &str) -> Option<String> { fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
let text = std::fs::read_to_string(path).ok()?; let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return (None, Vec::new()),
};
let mut upstream = None;
let mut search_domains = Vec::new();
for line in text.lines() { for line in text.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("nameserver") { if line.starts_with("nameserver") {
if upstream.is_none() {
if let Some(ns) = line.split_whitespace().nth(1) { if let Some(ns) = line.split_whitespace().nth(1) {
if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" { if !is_loopback_or_stub(ns) {
return Some(ns.to_string()); upstream = Some(ns.to_string());
}
}
}
} else if line.starts_with("search") || line.starts_with("domain") {
for domain in line.split_whitespace().skip(1) {
search_domains.push(domain.to_string());
}
}
}
(upstream, search_domains)
}
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
#[cfg(target_os = "linux")]
fn resolvectl_dns_server() -> Option<String> {
let output = std::process::Command::new("resolvectl")
.args(["status", "--no-pager"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("DNS Servers") || line.contains("Current DNS Server") {
if let Some(ip) = line.split(':').next_back() {
let ip = ip.trim();
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
return Some(ip.to_string());
} }
} }
} }
@@ -236,10 +295,7 @@ fn detect_dhcp_dns_macos() -> Option<String> {
// Take the first non-loopback DNS server // Take the first non-loopback DNS server
for addr in inner.split(',') { for addr in inner.split(',') {
let addr = addr.trim(); let addr = addr.trim();
if !addr.is_empty() if !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().is_ok()
&& addr != "127.0.0.1"
&& addr != "0.0.0.0"
&& addr.parse::<std::net::Ipv4Addr>().is_ok()
{ {
log::info!("detected DHCP DNS: {}", addr); log::info!("detected DHCP DNS: {}", addr);
return Some(addr.to_string()); return Some(addr.to_string());
@@ -278,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 !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" { if !is_loopback_or_stub(ip) {
upstream = Some(ip.to_string()); upstream = Some(ip.to_string());
break; break;
} }
@@ -316,43 +372,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
// --- System DNS configuration (install/uninstall) --- // --- System DNS configuration (install/uninstall) ---
/// Set the system DNS to 127.0.0.1 so all queries go through Numa.
/// Saves the original DNS settings for later restoration.
pub fn install_system_dns() -> Result<(), String> {
#[cfg(target_os = "macos")]
let result = install_macos();
#[cfg(target_os = "linux")]
let result = install_linux();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let result = Err("system DNS configuration not supported on this OS".to_string());
if result.is_ok() {
if let Err(e) = trust_ca() {
eprintln!(" warning: could not trust CA: {}", e);
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
}
}
result
}
/// Restore the original system DNS settings saved during install.
pub fn uninstall_system_dns() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")]
{
uninstall_macos()
}
#[cfg(target_os = "linux")]
{
uninstall_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("system DNS configuration not supported on this OS".to_string())
}
}
// --- macOS implementation --- // --- macOS implementation ---
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -500,21 +519,25 @@ const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service";
/// Install Numa as a system service that starts on boot and auto-restarts. /// Install Numa as a system service that starts on boot and auto-restarts.
pub fn install_service() -> Result<(), String> { pub fn install_service() -> Result<(), String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ let result = install_service_macos();
install_service_macos()
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ let result = install_service_linux();
install_service_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "macos", target_os = "linux")))]
{ let result = Err::<(), String>("service installation not supported on this OS".to_string());
Err("service installation not supported on this OS".to_string())
if result.is_ok() {
if let Err(e) = trust_ca() {
eprintln!(" warning: could not trust CA: {}", e);
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
} }
}
result
} }
/// Uninstall the Numa system service. /// Uninstall the Numa system service.
pub fn uninstall_service() -> Result<(), String> { pub fn uninstall_service() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
uninstall_service_macos() uninstall_service_macos()
@@ -609,7 +632,7 @@ fn install_service_macos() -> Result<(), String> {
std::fs::write(PLIST_DEST, plist) std::fs::write(PLIST_DEST, plist)
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
// Load the service // Load the service first so numa is listening before DNS redirect
let status = std::process::Command::new("launchctl") let status = std::process::Command::new("launchctl")
.args(["load", "-w", PLIST_DEST]) .args(["load", "-w", PLIST_DEST])
.status() .status()
@@ -619,14 +642,34 @@ fn install_service_macos() -> Result<(), String> {
return Err("launchctl load failed".to_string()); return Err("launchctl load failed".to_string());
} }
// Set system DNS to 127.0.0.1 now that the service is running // Wait for numa to be ready before redirecting DNS
eprintln!(" Service installed and started."); let api_up = (0..10).any(|i| {
if i > 0 {
std::thread::sleep(std::time::Duration::from_millis(500));
}
std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok()
});
if !api_up {
// Service failed to start — don't redirect DNS to a dead endpoint
let _ = std::process::Command::new("launchctl")
.args(["unload", PLIST_DEST])
.status();
return Err(
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
);
}
if let Err(e) = install_macos() { if let Err(e) = install_macos() {
eprintln!(" warning: failed to configure system DNS: {}", e); eprintln!(" warning: failed to configure system DNS: {}", e);
} }
eprintln!(" Service installed and started.");
eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: /usr/local/var/log/numa.log"); eprintln!(" Logs: /usr/local/var/log/numa.log");
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n"); eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
Ok(()) Ok(())
} }
@@ -708,7 +751,10 @@ fn install_linux() -> Result<(), String> {
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?; .map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
let drop_in = resolved_dir.join("numa.conf"); let drop_in = resolved_dir.join("numa.conf");
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n") std::fs::write(
&drop_in,
"[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n",
)
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?; .map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
let _ = run_systemctl(&["restart", "systemd-resolved"]); let _ = run_systemctl(&["restart", "systemd-resolved"]);
@@ -802,17 +848,21 @@ fn install_service_linux() -> Result<(), String> {
run_systemctl(&["daemon-reload"])?; run_systemctl(&["daemon-reload"])?;
run_systemctl(&["enable", "numa"])?; run_systemctl(&["enable", "numa"])?;
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started."); // Configure system DNS before starting numa so resolved releases port 53 first
// Set system DNS now that the service is running
if let Err(e) = install_linux() { if let Err(e) = install_linux() {
eprintln!(" warning: failed to configure system DNS: {}", e); eprintln!(" warning: failed to configure system DNS: {}", e);
} }
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started.");
eprintln!(" Numa will auto-start on boot and restart if killed."); eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: journalctl -u numa -f"); eprintln!(" Logs: journalctl -u numa -f");
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n"); eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
Ok(()) Ok(())
} }