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

Merged
razvandimescu merged 7 commits from feat/auto-recursive-install-fixes into main 2026-04-01 13:49:16 +08:00
7 changed files with 133 additions and 101 deletions
Showing only changes of commit e608e12000 - Show all commits

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

@@ -160,6 +160,7 @@ struct QueryLogResponse {
struct StatsResponse { struct StatsResponse {
uptime_secs: u64, uptime_secs: u64,
upstream: String, upstream: String,
mode: String,
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().to_string(),
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

@@ -67,10 +67,21 @@ fn default_api_port() -> u16 {
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum UpstreamMode { pub enum UpstreamMode {
#[default] #[default]
Auto,
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)]

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,35 @@ 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);
// 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 {
resolved_mode = config.upstream.mode;
upstream_auto = config.upstream.address.is_empty();
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
@@ -132,7 +160,9 @@ async fn main() -> numa::Result<()> {
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();
(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 +213,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 +229,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,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( 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 {
@@ -102,11 +106,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);
} }
} }
@@ -196,7 +196,7 @@ fn read_upstream_from_file(path: &str) -> Option<String> {
let line = line.trim(); let line = line.trim();
if line.starts_with("nameserver") { if line.starts_with("nameserver") {
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()); return Some(ns.to_string());
} }
} }
@@ -236,10 +236,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 +275,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 +313,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 +460,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,6 +573,11 @@ 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))?;
// Configure system DNS before starting service
if let Err(e) = install_macos() {
eprintln!(" warning: failed to configure system DNS: {}", e);
}
// Load the service // Load the service
let status = std::process::Command::new("launchctl") let status = std::process::Command::new("launchctl")
.args(["load", "-w", PLIST_DEST]) .args(["load", "-w", PLIST_DEST])
@@ -619,11 +588,7 @@ 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
eprintln!(" Service installed and started."); 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!(" 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 service stop' to fully uninstall.\n");
@@ -708,7 +673,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,14 +770,15 @@ 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 service stop' to fully uninstall.\n");