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 ""
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 " 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 ""

View File

@@ -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: <span id="footerConfig" 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>
· Mode: <span id="footerMode" 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>
· 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 {
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<Arc<ServerCtx>>) -> Json<StatsResponse> {
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,

View File

@@ -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)]

View File

@@ -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,6 +106,35 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules)
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() {
system_dns
.default_upstream
@@ -132,7 +160,9 @@ async fn main() -> numa::Result<()> {
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr)
};
let upstream_label = upstream.to_string();
let label = upstream.to_string();
(upstream, label)
};
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,

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(
cache: &RwLock<DnsCache>,
root_hints: &[SocketAddr],

View File

@@ -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::<std::net::Ipv4Addr>().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<String> {
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<String> {
// 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::<std::net::Ipv4Addr>().is_ok()
if !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().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<S
// --- 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 ---
#[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,7 +673,10 @@ 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")
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"]);
@@ -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");