From 4645df50e0c4cb201576390a2b979137b4932f11 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 20 Mar 2026 12:24:03 +0200 Subject: [PATCH 01/15] add Linux systemd service and DNS configuration Linux: - numa install: backs up /etc/resolv.conf, sets nameserver to 127.0.0.1 - numa uninstall: restores original /etc/resolv.conf from backup - numa service start: installs systemd unit, enables + starts - numa service stop: stops, disables, removes unit file - numa service status: shows systemctl status macOS: launchd plist (already working) Both platforms: Restart=always / KeepAlive=true for crash recovery. Co-Authored-By: Claude Opus 4.6 --- com.numa.dns.plist | 20 ++++ numa.service | 16 +++ src/system_dns.rs | 248 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 com.numa.dns.plist create mode 100644 numa.service diff --git a/com.numa.dns.plist b/com.numa.dns.plist new file mode 100644 index 0000000..4ef561c --- /dev/null +++ b/com.numa.dns.plist @@ -0,0 +1,20 @@ + + + + + Label + com.numa.dns + ProgramArguments + + /usr/local/bin/numa + + RunAtLoad + + KeepAlive + + StandardOutPath + /usr/local/var/log/numa.log + StandardErrorPath + /usr/local/var/log/numa.log + + diff --git a/numa.service b/numa.service new file mode 100644 index 0000000..50b0909 --- /dev/null +++ b/numa.service @@ -0,0 +1,16 @@ +[Unit] +Description=Numa DNS — DNS you own, everywhere you go +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/numa +Restart=always +RestartSec=2 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=numa + +[Install] +WantedBy=multi-user.target diff --git a/src/system_dns.rs b/src/system_dns.rs index 3e9d4e6..564dca4 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -315,17 +315,253 @@ fn uninstall_macos() -> Result<(), String> { Ok(()) } -// --- Linux stubs --- +// --- Service management --- + +#[cfg(target_os = "macos")] +const PLIST_LABEL: &str = "com.numa.dns"; +#[cfg(target_os = "macos")] +const PLIST_DEST: &str = "/Library/LaunchDaemons/com.numa.dns.plist"; +#[cfg(target_os = "linux")] +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() + } + #[cfg(target_os = "linux")] + { + install_service_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service installation not supported on this OS".to_string()) + } +} + +/// Uninstall the Numa system service. +pub fn uninstall_service() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + uninstall_service_macos() + } + #[cfg(target_os = "linux")] + { + uninstall_service_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service uninstallation not supported on this OS".to_string()) + } +} + +/// Show the service status. +pub fn service_status() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + service_status_macos() + } + #[cfg(target_os = "linux")] + { + service_status_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("service status not supported on this OS".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn install_service_macos() -> Result<(), String> { + // Check binary exists + if !std::path::Path::new("/usr/local/bin/numa").exists() { + return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); + } + + // Create log directory + std::fs::create_dir_all("/usr/local/var/log") + .map_err(|e| format!("failed to create log dir: {}", e))?; + + // Write plist + let plist = include_str!("../com.numa.dns.plist"); + std::fs::write(PLIST_DEST, plist) + .map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; + + // Load the service + let status = std::process::Command::new("launchctl") + .args(["load", "-w", PLIST_DEST]) + .status() + .map_err(|e| format!("failed to run launchctl: {}", e))?; + + if status.success() { + 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 uninstall.\n"); + Ok(()) + } else { + Err("launchctl load failed".to_string()) + } +} + +#[cfg(target_os = "macos")] +fn uninstall_service_macos() -> Result<(), String> { + // Unload the service + let _ = std::process::Command::new("launchctl") + .args(["unload", "-w", PLIST_DEST]) + .status(); + + // Remove plist + if std::path::Path::new(PLIST_DEST).exists() { + std::fs::remove_file(PLIST_DEST) + .map_err(|e| format!("failed to remove {}: {}", PLIST_DEST, e))?; + } + + eprintln!(" Service uninstalled. Numa will no longer auto-start.\n"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn service_status_macos() -> Result<(), String> { + let output = std::process::Command::new("launchctl") + .args(["list", PLIST_LABEL]) + .output() + .map_err(|e| format!("failed to run launchctl: {}", e))?; + + if output.status.success() { + let text = String::from_utf8_lossy(&output.stdout); + eprintln!(" Numa service is loaded.\n"); + for line in text.lines() { + eprintln!(" {}", line); + } + eprintln!(); + } else { + eprintln!(" Numa service is not installed.\n"); + } + Ok(()) +} + +// --- Linux implementation --- + +#[cfg(target_os = "linux")] +fn backup_path_linux() -> std::path::PathBuf { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/root")); + let dir = home.join(".numa"); + let _ = std::fs::create_dir_all(&dir); + dir.join("original-resolv.conf") +} #[cfg(target_os = "linux")] fn install_linux() -> Result<(), String> { - Err( - "Linux auto-configuration not yet implemented. Manually set your DNS to 127.0.0.1" - .to_string(), - ) + let backup = backup_path_linux(); + let resolv = std::path::Path::new("/etc/resolv.conf"); + + // Save current resolv.conf + if resolv.exists() { + std::fs::copy(resolv, &backup) + .map_err(|e| format!("failed to backup /etc/resolv.conf: {}", e))?; + eprintln!(" Saved /etc/resolv.conf to {}", backup.display()); + } + + // Write new resolv.conf pointing to Numa + let content = + "# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n"; + std::fs::write(resolv, content) + .map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?; + + eprintln!(" Set /etc/resolv.conf -> nameserver 127.0.0.1"); + eprintln!(" Run 'sudo numa uninstall' to restore.\n"); + Ok(()) } #[cfg(target_os = "linux")] fn uninstall_linux() -> Result<(), String> { - Err("Linux auto-configuration not yet implemented.".to_string()) + let backup = backup_path_linux(); + let resolv = std::path::Path::new("/etc/resolv.conf"); + + if backup.exists() { + std::fs::copy(&backup, resolv) + .map_err(|e| format!("failed to restore /etc/resolv.conf: {}", e))?; + std::fs::remove_file(&backup).ok(); + eprintln!(" Restored /etc/resolv.conf from backup. Backup removed.\n"); + } else { + eprintln!(" No backup found at {}.", backup.display()); + eprintln!(" Manually edit /etc/resolv.conf to restore your DNS.\n"); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn install_service_linux() -> Result<(), String> { + if !std::path::Path::new("/usr/local/bin/numa").exists() { + return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); + } + + let unit = include_str!("../numa.service"); + std::fs::write(SYSTEMD_UNIT, unit) + .map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?; + + run_systemctl(&["daemon-reload"])?; + run_systemctl(&["enable", "numa"])?; + 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 uninstall.\n"); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn uninstall_service_linux() -> Result<(), String> { + let _ = run_systemctl(&["stop", "numa"]); + let _ = run_systemctl(&["disable", "numa"]); + + if std::path::Path::new(SYSTEMD_UNIT).exists() { + std::fs::remove_file(SYSTEMD_UNIT) + .map_err(|e| format!("failed to remove {}: {}", SYSTEMD_UNIT, e))?; + } + let _ = run_systemctl(&["daemon-reload"]); + + eprintln!(" Service uninstalled. Numa will no longer auto-start.\n"); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn service_status_linux() -> Result<(), String> { + let output = std::process::Command::new("systemctl") + .args(["status", "numa"]) + .output() + .map_err(|e| format!("failed to run systemctl: {}", e))?; + + let text = String::from_utf8_lossy(&output.stdout); + if text.is_empty() { + eprintln!(" Numa service is not installed.\n"); + } else { + for line in text.lines() { + eprintln!(" {}", line); + } + eprintln!(); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn run_systemctl(args: &[&str]) -> Result<(), String> { + let status = std::process::Command::new("systemctl") + .args(args) + .status() + .map_err(|e| format!("systemctl {} failed: {}", args.join(" "), e))?; + if status.success() { + Ok(()) + } else { + Err(format!( + "systemctl {} exited with {}", + args.join(" "), + status + )) + } } From 57c4742f09e5062b4e4bde062268eb65d193bae1 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 20 Mar 2026 12:32:20 +0200 Subject: [PATCH 02/15] harden Linux DNS config and fix review findings - Detect systemd-resolved: use drop-in config instead of overwriting /etc/resolv.conf (which gets regenerated) - Warn if /etc/resolv.conf is a symlink (NetworkManager, etc.) - Fix TOCTOU: attempt copy/remove directly, handle NotFound - Remove side-effect from backup_path_linux (no eager mkdir) - Fix macOS $HOME fallback: /var/root instead of /tmp - Log warnings on launchctl/systemctl failures instead of silencing - Delete plist before unloading (prevents zombie restarts) - Extract ensure_binary_installed helper on Linux Co-Authored-By: Claude Opus 4.6 --- src/system_dns.rs | 140 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 106 insertions(+), 34 deletions(-) diff --git a/src/system_dns.rs b/src/system_dns.rs index 564dca4..da04cc7 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -186,8 +186,9 @@ pub fn uninstall_system_dns() -> Result<(), String> { #[cfg(target_os = "macos")] fn numa_data_dir() -> std::path::PathBuf { let home = std::env::var("HOME") + .or_else(|_| std::env::var("SUDO_USER").map(|u| format!("/Users/{}", u))) .map(std::path::PathBuf::from) - .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); + .unwrap_or_else(|_| std::path::PathBuf::from("/var/root")); home.join(".numa") } @@ -407,15 +408,23 @@ fn install_service_macos() -> Result<(), String> { #[cfg(target_os = "macos")] fn uninstall_service_macos() -> Result<(), String> { + // Remove plist first so service won't restart on boot even if unload fails + if let Err(e) = std::fs::remove_file(PLIST_DEST) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("failed to remove {}: {}", PLIST_DEST, e)); + } + } + // Unload the service - let _ = std::process::Command::new("launchctl") + let status = std::process::Command::new("launchctl") .args(["unload", "-w", PLIST_DEST]) .status(); - - // Remove plist - if std::path::Path::new(PLIST_DEST).exists() { - std::fs::remove_file(PLIST_DEST) - .map_err(|e| format!("failed to remove {}: {}", PLIST_DEST, e))?; + if let Ok(s) = status { + if !s.success() { + eprintln!( + " warning: launchctl unload returned non-zero (service may still be running)" + ); + } } eprintln!(" Service uninstalled. Numa will no longer auto-start.\n"); @@ -449,24 +458,63 @@ fn backup_path_linux() -> std::path::PathBuf { let home = std::env::var("HOME") .map(std::path::PathBuf::from) .unwrap_or_else(|_| std::path::PathBuf::from("/root")); - let dir = home.join(".numa"); - let _ = std::fs::create_dir_all(&dir); - dir.join("original-resolv.conf") + home.join(".numa").join("original-resolv.conf") +} + +#[cfg(target_os = "linux")] +fn is_systemd_resolved_active() -> bool { + std::process::Command::new("systemctl") + .args(["is-active", "--quiet", "systemd-resolved"]) + .status() + .map(|s| s.success()) + .unwrap_or(false) } #[cfg(target_os = "linux")] fn install_linux() -> Result<(), String> { - let backup = backup_path_linux(); - let resolv = std::path::Path::new("/etc/resolv.conf"); + // Detect systemd-resolved — direct resolv.conf manipulation won't persist + if is_systemd_resolved_active() { + let resolved_dir = std::path::Path::new("/etc/systemd/resolved.conf.d"); + std::fs::create_dir_all(resolved_dir) + .map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?; - // Save current resolv.conf - if resolv.exists() { - std::fs::copy(resolv, &backup) - .map_err(|e| format!("failed to backup /etc/resolv.conf: {}", e))?; - eprintln!(" Saved /etc/resolv.conf to {}", backup.display()); + 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))?; + + let _ = run_systemctl(&["restart", "systemd-resolved"]); + eprintln!(" systemd-resolved detected."); + eprintln!(" Installed drop-in: {}", drop_in.display()); + eprintln!(" Run 'sudo numa uninstall' to remove.\n"); + return Ok(()); + } + + // Fallback: direct resolv.conf manipulation + let resolv = std::path::Path::new("/etc/resolv.conf"); + let backup = backup_path_linux(); + + // Ensure backup directory exists + if let Some(parent) = backup.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {}", parent.display(), e))?; + } + + // Back up current resolv.conf (ignore NotFound) + match std::fs::copy(resolv, &backup) { + Ok(_) => eprintln!(" Saved /etc/resolv.conf to {}", backup.display()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("failed to backup /etc/resolv.conf: {}", e)), + } + + if resolv + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + eprintln!(" warning: /etc/resolv.conf is a symlink — changes may not persist."); + eprintln!(" Consider using systemd-resolved or NetworkManager instead.\n"); } - // Write new resolv.conf pointing to Numa let content = "# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n"; std::fs::write(resolv, content) @@ -479,26 +527,45 @@ fn install_linux() -> Result<(), String> { #[cfg(target_os = "linux")] fn uninstall_linux() -> Result<(), String> { + // Check for systemd-resolved drop-in first + let drop_in = std::path::Path::new("/etc/systemd/resolved.conf.d/numa.conf"); + if drop_in.exists() { + std::fs::remove_file(drop_in) + .map_err(|e| format!("failed to remove {}: {}", drop_in.display(), e))?; + let _ = run_systemctl(&["restart", "systemd-resolved"]); + eprintln!(" Removed systemd-resolved drop-in. DNS restored.\n"); + return Ok(()); + } + + // Fallback: restore resolv.conf from backup let backup = backup_path_linux(); let resolv = std::path::Path::new("/etc/resolv.conf"); - if backup.exists() { - std::fs::copy(&backup, resolv) - .map_err(|e| format!("failed to restore /etc/resolv.conf: {}", e))?; - std::fs::remove_file(&backup).ok(); - eprintln!(" Restored /etc/resolv.conf from backup. Backup removed.\n"); - } else { - eprintln!(" No backup found at {}.", backup.display()); - eprintln!(" Manually edit /etc/resolv.conf to restore your DNS.\n"); + match std::fs::copy(&backup, resolv) { + Ok(_) => { + std::fs::remove_file(&backup).ok(); + eprintln!(" Restored /etc/resolv.conf from backup. Backup removed.\n"); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!(" No backup found at {}.", backup.display()); + eprintln!(" Manually edit /etc/resolv.conf to restore your DNS.\n"); + } + Err(e) => return Err(format!("failed to restore /etc/resolv.conf: {}", e)), + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_binary_installed() -> Result<(), String> { + if !std::path::Path::new("/usr/local/bin/numa").exists() { + return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); } Ok(()) } #[cfg(target_os = "linux")] fn install_service_linux() -> Result<(), String> { - if !std::path::Path::new("/usr/local/bin/numa").exists() { - return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string()); - } + ensure_binary_installed()?; let unit = include_str!("../numa.service"); std::fs::write(SYSTEMD_UNIT, unit) @@ -517,12 +584,17 @@ fn install_service_linux() -> Result<(), String> { #[cfg(target_os = "linux")] fn uninstall_service_linux() -> Result<(), String> { - let _ = run_systemctl(&["stop", "numa"]); - let _ = run_systemctl(&["disable", "numa"]); + if let Err(e) = run_systemctl(&["stop", "numa"]) { + eprintln!(" warning: {}", e); + } + if let Err(e) = run_systemctl(&["disable", "numa"]) { + eprintln!(" warning: {}", e); + } - if std::path::Path::new(SYSTEMD_UNIT).exists() { - std::fs::remove_file(SYSTEMD_UNIT) - .map_err(|e| format!("failed to remove {}: {}", SYSTEMD_UNIT, e))?; + if let Err(e) = std::fs::remove_file(SYSTEMD_UNIT) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(format!("failed to remove {}: {}", SYSTEMD_UNIT, e)); + } } let _ = run_systemctl(&["daemon-reload"]); From 0658ed73101a7aa2fcbde4e4cff541ee50cb5001 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 20 Mar 2026 12:41:16 +0200 Subject: [PATCH 03/15] add service management CLI, log path in dashboard footer - numa service start/stop/status commands (launchd + systemd) - Dashboard footer shows log paths (macOS + Linux) and GitHub link - Help text updated with all service commands Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 6 ++++++ src/main.rs | 29 ++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/site/dashboard.html b/site/dashboard.html index c39b7c3..e49a840 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -838,5 +838,11 @@ async function allowDomain(domain) { refresh(); setInterval(refresh, 2000); + +
+ Logs: macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f + · GitHub +
+ diff --git a/src/main.rs b/src/main.rs index 95d8719..46d0b4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,10 @@ use numa::ctx::{handle_query, ServerCtx}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; use numa::stats::ServerStats; -use numa::system_dns::{discover_forwarding_rules, install_system_dns, uninstall_system_dns}; +use numa::system_dns::{ + discover_forwarding_rules, install_service, install_system_dns, service_status, + uninstall_service, uninstall_system_dns, +}; #[tokio::main] async fn main() -> numa::Result<()> { @@ -32,14 +35,30 @@ async fn main() -> numa::Result<()> { eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); return uninstall_system_dns().map_err(|e| e.into()); } + "service" => { + let sub = std::env::args().nth(2).unwrap_or_default(); + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n"); + return match sub.as_str() { + "start" => install_service().map_err(|e| e.into()), + "stop" => uninstall_service().map_err(|e| e.into()), + "status" => service_status().map_err(|e| e.into()), + _ => { + eprintln!("Usage: numa service "); + Ok(()) + } + }; + } "help" | "--help" | "-h" => { eprintln!("Usage: numa [command] [config-path]"); eprintln!(); eprintln!("Commands:"); - eprintln!(" (none) Start the DNS server (default)"); - eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)"); - eprintln!(" uninstall Restore original system DNS settings"); - eprintln!(" help Show this help"); + eprintln!(" (none) Start the DNS server (default)"); + eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)"); + eprintln!(" uninstall Restore original system DNS settings"); + eprintln!(" service start Install as system service (auto-start on boot)"); + eprintln!(" service stop Uninstall the system service"); + eprintln!(" service status Check if the service is running"); + eprintln!(" help Show this help"); eprintln!(); eprintln!("Config path defaults to numa.toml"); return Ok(()); From 7e29f3cb57ac936951e401f97340d93c46af20e0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 20 Mar 2026 12:58:25 +0200 Subject: [PATCH 04/15] add domain check endpoint and dashboard search box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /blocking/check/{domain} — returns whether a domain is blocked, the reason (exact match, parent domain, allowlist, disabled), and the matching rule. Dashboard sidebar has a "Check Domain" search box with inline results and one-click allow button. Co-Authored-By: Claude Opus 4.6 --- site/dashboard.html | 45 ++++++++++++++++++++++++++++++ src/api.rs | 9 ++++++ src/blocklist.rs | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/site/dashboard.html b/site/dashboard.html index e49a840..d2b25cd 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -519,6 +519,22 @@ body {