diff --git a/README.md b/README.md
index c58b413..07e3624 100644
--- a/README.md
+++ b/README.md
@@ -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.
-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.

@@ -24,7 +24,7 @@ sudo numa # port 53 requires root
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
@@ -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.
-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
diff --git a/install.sh b/install.sh
index a388659..cfac11a 100755
--- a/install.sh
+++ b/install.sh
@@ -70,8 +70,10 @@ echo ""
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
echo ""
echo " Get started:"
-echo " sudo numa # start the DNS server"
-echo " sudo numa install # set as system DNS"
-echo " sudo numa service start # run as persistent service"
-echo " open http://localhost:5380 # dashboard"
+echo " sudo numa install # install service + set as system DNS"
+echo " open http://localhost:5380 # dashboard"
+echo ""
+echo " Other commands:"
+echo " sudo numa # run in foreground (no service)"
+echo " sudo numa uninstall # restore original DNS"
echo ""
diff --git a/site/dashboard.html b/site/dashboard.html
index 9f86ffe..c54a331 100644
--- a/site/dashboard.html
+++ b/site/dashboard.html
@@ -882,6 +882,9 @@ async function refresh() {
document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || '';
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').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
@@ -1236,6 +1239,7 @@ setInterval(refresh, 2000);
Config:
· Data:
· Upstream:
+ · Mode:
· DNSSEC:
· SRTT:
· Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f
diff --git a/site/index.html b/site/index.html
index f027bd8..7f22686 100644
--- a/site/index.html
+++ b/site/index.html
@@ -4,10 +4,10 @@
Numa — DNS you own. Everywhere you go.
-
+
-
+
@@ -1232,17 +1232,17 @@ footer .closing {
How It Works
What it does today
-
A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.
+
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.
Layer 1
Resolve & Protect
- Recursive resolution — resolve from root nameservers, no upstream needed
- DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)
+ Forward mode by default — transparent proxy to your existing DNS, with caching
Ad & tracker blocking — 385K+ domains, zero config
- DNS-over-HTTPS — encrypted upstream as alternative to recursive mode
+ Recursive resolution — opt-in, resolve from root nameservers, no upstream needed
+ DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)
TTL-aware caching (sub-ms lookups)
Single binary, portable — macOS, Linux, and Windows
diff --git a/src/api.rs b/src/api.rs
index 04a81bf..9bf9bae 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -160,6 +160,7 @@ struct QueryLogResponse {
struct StatsResponse {
uptime_secs: u64,
upstream: String,
+ mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
config_path: String,
data_dir: String,
dnssec: bool,
@@ -486,6 +487,7 @@ async fn stats(State(ctx): State
>) -> Json {
Json(StatsResponse {
uptime_secs: snap.uptime_secs,
upstream,
+ mode: ctx.upstream_mode.as_str(),
config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(),
dnssec: ctx.dnssec_enabled,
diff --git a/src/config.rs b/src/config.rs
index b022fd5..0cf5cb0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -59,18 +59,31 @@ fn default_bind_addr() -> String {
"0.0.0.0:53".to_string()
}
+pub const DEFAULT_API_PORT: u16 = 5380;
+
fn default_api_port() -> u16 {
- 5380
+ DEFAULT_API_PORT
}
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamMode {
+ Auto,
#[default]
Forward,
Recursive,
}
+impl UpstreamMode {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ UpstreamMode::Auto => "auto",
+ UpstreamMode::Forward => "forward",
+ UpstreamMode::Recursive => "recursive",
+ }
+ }
+}
+
#[derive(Deserialize)]
pub struct UpstreamConfig {
#[serde(default)]
@@ -103,10 +116,14 @@ impl Default for UpstreamConfig {
}
}
-fn default_srtt() -> bool {
+fn default_true() -> bool {
true
}
+fn default_srtt() -> bool {
+ default_true()
+}
+
fn default_prime_tlds() -> Vec {
vec![
// gTLDs
diff --git a/src/main.rs b/src/main.rs
index 5505392..2cdf4d9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17,8 +17,7 @@ use numa::query_log::QueryLog;
use numa::service_store::ServiceStore;
use numa::stats::ServerStats;
use numa::system_dns::{
- discover_system_dns, install_service, install_system_dns, restart_service, service_status,
- uninstall_service, uninstall_system_dns,
+ discover_system_dns, install_service, restart_service, service_status, uninstall_service,
};
#[tokio::main]
@@ -31,12 +30,12 @@ async fn main() -> numa::Result<()> {
let arg1 = std::env::args().nth(1).unwrap_or_default();
match arg1.as_str() {
"install" => {
- eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n");
- return install_system_dns().map_err(|e| e.into());
+ eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
+ return install_service().map_err(|e| e.into());
}
"uninstall" => {
- eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
- return uninstall_system_dns().map_err(|e| e.into());
+ eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
+ return uninstall_service().map_err(|e| e.into());
}
"service" => {
let sub = std::env::args().nth(2).unwrap_or_default();
@@ -107,32 +106,81 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns();
- let upstream_addr = if config.upstream.address.is_empty() {
- system_dns
- .default_upstream
- .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()
- })
- } else {
- config.upstream.address.clone()
- };
+ let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
- let upstream: Upstream = if upstream_addr.starts_with("https://") {
- let client = reqwest::Client::builder()
- .use_rustls_tls()
- .build()
- .unwrap_or_default();
- Upstream::Doh {
- url: upstream_addr,
- client,
+ 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() {
+ system_dns
+ .default_upstream
+ .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()
+ })
+ } else {
+ config.upstream.address.clone()
+ };
+
+ let upstream: Upstream = if upstream_addr.starts_with("https://") {
+ let client = reqwest::Client::builder()
+ .use_rustls_tls()
+ .build()
+ .unwrap_or_default();
+ Upstream::Doh {
+ url: upstream_addr,
+ client,
+ }
+ } else {
+ let addr: SocketAddr =
+ format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
+ Upstream::Udp(addr)
+ };
+ let label = upstream.to_string();
+ (
+ numa::config::UpstreamMode::Forward,
+ config.upstream.address.is_empty(),
+ upstream,
+ label,
+ )
}
- } else {
- let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
- Upstream::Udp(addr)
};
- let upstream_label = upstream.to_string();
let api_port = config.server.api_port;
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)),
forwarding_rules,
upstream: Mutex::new(upstream),
- upstream_auto: config.upstream.address.is_empty(),
+ upstream_auto,
upstream_port: config.upstream.port,
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
timeout: Duration::from_millis(config.upstream.timeout_ms),
@@ -199,8 +247,8 @@ async fn main() -> numa::Result<()> {
config_dir: numa::config_dir(),
data_dir: numa::data_dir(),
tls_config: initial_tls,
- upstream_mode: config.upstream.mode,
- root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
+ upstream_mode: resolved_mode,
+ root_hints,
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled,
diff --git a/src/recursive.rs b/src/recursive.rs
index 82f9879..7801bec 100644
--- a/src/recursive.rs
+++ b/src/recursive.rs
@@ -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(
cache: &RwLock,
root_hints: &[SocketAddr],
diff --git a/src/system_dns.rs b/src/system_dns.rs
index 57559b5..9dda4af 100644
--- a/src/system_dns.rs
+++ b/src/system_dns.rs
@@ -2,6 +2,10 @@ use std::net::SocketAddr;
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`.
#[derive(Debug, Clone)]
pub struct ForwardingRule {
@@ -26,10 +30,7 @@ pub fn discover_system_dns() -> SystemDnsInfo {
}
#[cfg(target_os = "linux")]
{
- SystemDnsInfo {
- default_upstream: detect_upstream_linux_or_backup(),
- forwarding_rules: Vec::new(),
- }
+ discover_linux()
}
#[cfg(windows)]
{
@@ -102,11 +103,7 @@ fn discover_macos() -> SystemDnsInfo {
if ns.parse::().is_ok() {
current_nameserver = Some(ns.clone());
// Capture first non-supplemental, non-loopback nameserver as default upstream
- if !is_supplemental
- && default_upstream.is_none()
- && ns != "127.0.0.1"
- && ns != "0.0.0.0"
- {
+ if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&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 {
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
Some(ForwardingRule {
@@ -166,38 +163,100 @@ fn make_rule(domain: &str, nameserver: &str) -> Option {
})
}
-/// 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")]
-fn detect_upstream_linux_or_backup() -> Option {
- // Try /etc/resolv.conf first
- if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") {
- info!("detected system upstream: {}", ns);
- return Some(ns);
- }
- // If resolv.conf only has loopback, check the backup from `numa install`
- let backup = {
- let home = std::env::var("HOME")
- .map(std::path::PathBuf::from)
- .unwrap_or_else(|_| std::path::PathBuf::from("/root"));
- home.join(".numa").join("original-resolv.conf")
- };
- if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) {
- info!("detected original upstream from backup: {}", ns);
- return Some(ns);
- }
- None
-}
+const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
#[cfg(target_os = "linux")]
-fn read_upstream_from_file(path: &str) -> Option {
- let text = std::fs::read_to_string(path).ok()?;
+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);
+ Some(ns)
+ } else {
+ // Fallback to backup from a previous `numa install`
+ let backup = {
+ let home = std::env::var("HOME")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|_| std::path::PathBuf::from("/root"));
+ home.join(".numa").join("original-resolv.conf")
+ };
+ let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
+ if let Some(ref ns) = ns {
+ info!("detected original upstream from backup: {}", ns);
+ }
+ 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")]
+fn parse_resolv_conf(path: &str) -> (Option, Vec) {
+ 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() {
let line = line.trim();
if line.starts_with("nameserver") {
- if let Some(ns) = line.split_whitespace().nth(1) {
- if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" {
- return Some(ns.to_string());
+ if upstream.is_none() {
+ if let Some(ns) = line.split_whitespace().nth(1) {
+ if !is_loopback_or_stub(ns) {
+ 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 {
+ 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::().is_ok() && !is_loopback_or_stub(ip) {
+ return Some(ip.to_string());
}
}
}
@@ -236,10 +295,7 @@ fn detect_dhcp_dns_macos() -> Option {
// Take the first non-loopback DNS server
for addr in inner.split(',') {
let addr = addr.trim();
- if !addr.is_empty()
- && addr != "127.0.0.1"
- && addr != "0.0.0.0"
- && addr.parse::().is_ok()
+ if !is_loopback_or_stub(addr) && addr.parse::().is_ok()
{
log::info!("detected DHCP DNS: {}", addr);
return Some(addr.to_string());
@@ -278,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 !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" {
+ if !is_loopback_or_stub(ip) {
upstream = Some(ip.to_string());
break;
}
@@ -316,43 +372,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option 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 ---
#[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.
pub fn install_service() -> Result<(), String> {
#[cfg(target_os = "macos")]
- {
- install_service_macos()
- }
+ let result = install_service_macos();
#[cfg(target_os = "linux")]
- {
- install_service_linux()
- }
+ let result = install_service_linux();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
- {
- Err("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 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.
pub fn uninstall_service() -> Result<(), String> {
+ let _ = untrust_ca();
+
#[cfg(target_os = "macos")]
{
uninstall_service_macos()
@@ -609,7 +632,7 @@ fn install_service_macos() -> Result<(), String> {
std::fs::write(PLIST_DEST, plist)
.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")
.args(["load", "-w", PLIST_DEST])
.status()
@@ -619,14 +642,34 @@ fn install_service_macos() -> Result<(), String> {
return Err("launchctl load failed".to_string());
}
- // Set system DNS to 127.0.0.1 now that the service is running
- eprintln!(" Service installed and started.");
+ // Wait for numa to be ready before redirecting DNS
+ 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() {
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!(" 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(())
}
@@ -708,8 +751,11 @@ fn install_linux() -> Result<(), String> {
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
let drop_in = resolved_dir.join("numa.conf");
- std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n")
- .map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
+ 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))?;
let _ = run_systemctl(&["restart", "systemd-resolved"]);
eprintln!(" systemd-resolved detected.");
@@ -802,17 +848,21 @@ fn install_service_linux() -> Result<(), String> {
run_systemctl(&["daemon-reload"])?;
run_systemctl(&["enable", "numa"])?;
- run_systemctl(&["start", "numa"])?;
- eprintln!(" Service installed and started.");
-
- // Set system DNS now that the service is running
+ // Configure system DNS before starting numa so resolved releases port 53 first
if let Err(e) = install_linux() {
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!(" 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(())
}