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 e90fbea..372470d 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/src/api.rs b/src/api.rs
index 04a81bf..61935f2 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -160,6 +160,7 @@ struct QueryLogResponse {
struct StatsResponse {
uptime_secs: u64,
upstream: String,
+ mode: String,
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().to_string(),
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..d38f275 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -67,10 +67,21 @@ fn default_api_port() -> u16 {
#[serde(rename_all = "lowercase")]
pub enum UpstreamMode {
#[default]
+ Auto,
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)]
diff --git a/src/main.rs b/src/main.rs
index 5505392..3a5b004 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,63 @@ 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,
+ // Resolve upstream mode + address in one block
+ let resolved_mode;
+ let upstream_auto;
+ let (upstream, upstream_label) = if 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");
+ resolved_mode = numa::config::UpstreamMode::Recursive;
+ upstream_auto = false;
+ let dummy_upstream = Upstream::Udp("0.0.0.0:0".parse().unwrap());
+ (dummy_upstream, "recursive (root hints)".to_string())
+ } else {
+ log::warn!("recursive probe failed — falling back to Quad9 DoH");
+ resolved_mode = numa::config::UpstreamMode::Forward;
+ upstream_auto = false;
+ 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();
+ (Upstream::Doh { url, client }, label)
}
} else {
- let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
- Upstream::Udp(addr)
+ resolved_mode = config.upstream.mode;
+ upstream_auto = config.upstream.address.is_empty();
+
+ 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();
+ (upstream, label)
};
- let upstream_label = upstream.to_string();
let api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new();
@@ -183,7 +213,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 +229,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..aed93e7 100644
--- a/src/recursive.rs
+++ b/src/recursive.rs
@@ -65,6 +65,20 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
}
}
+/// Probe whether recursive resolution works by querying a root server.
+pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool {
+ let hint = match root_hints.first() {
+ Some(h) => *h,
+ None => return false,
+ };
+ let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
+ probe.header.recursion_desired = false;
+ match forward_udp(&probe, hint, Duration::from_secs(3)).await {
+ Ok(resp) => !resp.answers.is_empty() || !resp.authorities.is_empty(),
+ Err(_) => 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..65e5adf 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 {
@@ -102,11 +106,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);
}
}
@@ -196,7 +196,7 @@ fn read_upstream_from_file(path: &str) -> Option {
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" {
+ if !is_loopback_or_stub(ns) {
return Some(ns.to_string());
}
}
@@ -236,10 +236,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 +275,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 +313,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 +460,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,6 +573,11 @@ fn install_service_macos() -> Result<(), String> {
std::fs::write(PLIST_DEST, plist)
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
+ // Configure system DNS before starting service
+ if let Err(e) = install_macos() {
+ eprintln!(" warning: failed to configure system DNS: {}", e);
+ }
+
// Load the service
let status = std::process::Command::new("launchctl")
.args(["load", "-w", PLIST_DEST])
@@ -619,11 +588,7 @@ 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.");
- if let Err(e) = install_macos() {
- eprintln!(" warning: failed to configure system DNS: {}", e);
- }
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");
@@ -708,8 +673,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,14 +770,15 @@ 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");