diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1971c6b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,78 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ strategy:
+ matrix:
+ include:
+ - target: x86_64-apple-darwin
+ os: macos-latest
+ name: numa-macos-x86_64
+ - target: aarch64-apple-darwin
+ os: macos-latest
+ name: numa-macos-aarch64
+ - target: x86_64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: numa-linux-x86_64
+ - target: aarch64-unknown-linux-gnu
+ os: ubuntu-latest
+ name: numa-linux-aarch64
+
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Install cross-compilation tools
+ if: matrix.target == 'aarch64-unknown-linux-gnu'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y gcc-aarch64-linux-gnu
+
+ - name: Build
+ run: cargo build --release --target ${{ matrix.target }}
+ env:
+ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
+
+ - name: Package
+ run: |
+ cd target/${{ matrix.target }}/release
+ tar czf ../../../${{ matrix.name }}.tar.gz numa
+ cd ../../..
+ sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.name }}
+ path: |
+ ${{ matrix.name }}.tar.gz
+ ${{ matrix.name }}.tar.gz.sha256
+
+ release:
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ merge-multiple: true
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ generate_release_notes: true
+ files: |
+ *.tar.gz
+ *.sha256
diff --git a/com.numa.dns.plist b/com.numa.dns.plist
index 4ef561c..67c90fa 100644
--- a/com.numa.dns.plist
+++ b/com.numa.dns.plist
@@ -16,5 +16,10 @@
/usr/local/var/log/numa.log
StandardErrorPath
/usr/local/var/log/numa.log
+ EnvironmentVariables
+
+ RUST_LOG
+ info
+
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..3771907
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+# Numa installer — detects OS/arch and downloads the latest release
+# Usage: curl -sSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
+set -e
+
+REPO="razvandimescu/numa"
+INSTALL_DIR="/usr/local/bin"
+
+# Detect OS
+OS="$(uname -s)"
+case "$OS" in
+ Darwin) OS_NAME="macos" ;;
+ Linux) OS_NAME="linux" ;;
+ *) echo "Unsupported OS: $OS"; exit 1 ;;
+esac
+
+# Detect architecture
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64|amd64) ARCH_NAME="x86_64" ;;
+ arm64|aarch64) ARCH_NAME="aarch64" ;;
+ *) echo "Unsupported architecture: $ARCH"; exit 1 ;;
+esac
+
+ASSET="numa-${OS_NAME}-${ARCH_NAME}.tar.gz"
+
+echo ""
+echo " \033[1;38;2;192;98;58mNuma\033[0m installer"
+echo ""
+echo " OS: $OS_NAME"
+echo " Arch: $ARCH_NAME"
+echo ""
+
+# Get latest release tag
+echo " Fetching latest release..."
+TAG=$(curl -sSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')
+
+if [ -z "$TAG" ]; then
+ echo " Error: could not find latest release."
+ echo " Check https://github.com/${REPO}/releases"
+ exit 1
+fi
+
+URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}"
+echo " Downloading ${TAG}..."
+
+# Download and extract
+TMP=$(mktemp -d)
+curl -sSL "$URL" -o "$TMP/$ASSET"
+tar xzf "$TMP/$ASSET" -C "$TMP"
+
+# Install
+if [ -w "$INSTALL_DIR" ]; then
+ mv "$TMP/numa" "$INSTALL_DIR/numa"
+else
+ echo " Installing to $INSTALL_DIR (requires sudo)..."
+ sudo mv "$TMP/numa" "$INSTALL_DIR/numa"
+fi
+
+chmod +x "$INSTALL_DIR/numa"
+rm -rf "$TMP"
+
+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 ""
diff --git a/numa.toml b/numa.toml
index 27dc1f6..b2aee0e 100644
--- a/numa.toml
+++ b/numa.toml
@@ -2,10 +2,11 @@
bind_addr = "0.0.0.0:53"
api_port = 5380
-[upstream]
-address = "8.8.8.8"
-port = 53
-timeout_ms = 3000
+# [upstream]
+# address = "" # auto-detect from system resolver (default)
+# address = "9.9.9.9" # or set explicitly
+# port = 53
+# timeout_ms = 3000
[cache]
max_entries = 10000
diff --git a/src/config.rs b/src/config.rs
index 56beaec..799bb41 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -69,7 +69,7 @@ impl Default for UpstreamConfig {
}
fn default_upstream_addr() -> String {
- "8.8.8.8".to_string()
+ String::new() // empty = auto-detect from system resolver
}
fn default_upstream_port() -> u16 {
53
diff --git a/src/main.rs b/src/main.rs
index 46d0b4d..5188071 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,8 +14,8 @@ use numa::override_store::OverrideStore;
use numa::query_log::QueryLog;
use numa::stats::ServerStats;
use numa::system_dns::{
- discover_forwarding_rules, install_service, install_system_dns, service_status,
- uninstall_service, uninstall_system_dns,
+ discover_system_dns, install_service, install_system_dns, service_status, uninstall_service,
+ uninstall_system_dns,
};
#[tokio::main]
@@ -75,8 +75,18 @@ async fn main() -> numa::Result<()> {
};
let config = load_config(&config_path)?;
- let upstream: SocketAddr =
- format!("{}:{}", config.upstream.address, config.upstream.port).parse()?;
+ // 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.unwrap_or_else(|| {
+ info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)");
+ "9.9.9.9".to_string()
+ })
+ } else {
+ config.upstream.address.clone()
+ };
+ let upstream: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
let api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new();
@@ -87,8 +97,7 @@ async fn main() -> numa::Result<()> {
blocklist.set_enabled(false);
}
- // Auto-discover conditional forwarding rules from OS (Tailscale, VPN, etc.)
- let forwarding_rules = discover_forwarding_rules();
+ let forwarding_rules = system_dns.forwarding_rules;
let ctx = Arc::new(ServerCtx {
socket: UdpSocket::bind(&config.server.bind_addr).await?,
diff --git a/src/packet.rs b/src/packet.rs
index 9158a77..bca60c2 100644
--- a/src/packet.rs
+++ b/src/packet.rs
@@ -70,7 +70,11 @@ impl DnsPacket {
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
// Filter out UNKNOWN records (e.g. EDNS OPT) that we can't re-serialize
let answers: Vec<_> = self.answers.iter().filter(|r| !r.is_unknown()).collect();
- let authorities: Vec<_> = self.authorities.iter().filter(|r| !r.is_unknown()).collect();
+ let authorities: Vec<_> = self
+ .authorities
+ .iter()
+ .filter(|r| !r.is_unknown())
+ .collect();
let resources: Vec<_> = self.resources.iter().filter(|r| !r.is_unknown()).collect();
let mut header = self.header.clone();
diff --git a/src/system_dns.rs b/src/system_dns.rs
index da04cc7..1ca7ffd 100644
--- a/src/system_dns.rs
+++ b/src/system_dns.rs
@@ -10,38 +10,48 @@ pub struct ForwardingRule {
pub upstream: SocketAddr,
}
-/// Discover system DNS forwarding rules from the OS.
-/// On macOS, parses `scutil --dns`. Returns rules sorted longest-suffix-first
-/// so more specific matches take priority.
-pub fn discover_forwarding_rules() -> Vec {
+/// Result of system DNS discovery — default upstream + conditional forwarding rules.
+pub struct SystemDnsInfo {
+ pub default_upstream: Option,
+ pub forwarding_rules: Vec,
+}
+
+/// Discover system DNS configuration in a single pass.
+/// On macOS: parses `scutil --dns` once for both the default upstream and forwarding rules.
+/// On Linux: reads `/etc/resolv.conf` for upstream, no forwarding rules yet.
+pub fn discover_system_dns() -> SystemDnsInfo {
#[cfg(target_os = "macos")]
{
discover_macos()
}
#[cfg(not(target_os = "macos"))]
{
- info!("system DNS auto-discovery not implemented for this OS");
- Vec::new()
+ SystemDnsInfo {
+ default_upstream: detect_upstream_linux_or_backup(),
+ forwarding_rules: Vec::new(),
+ }
}
}
#[cfg(target_os = "macos")]
-fn discover_macos() -> Vec {
+fn discover_macos() -> SystemDnsInfo {
use log::{debug, warn};
let output = match std::process::Command::new("scutil").arg("--dns").output() {
Ok(o) => o,
Err(e) => {
warn!("failed to run scutil --dns: {}", e);
- return Vec::new();
+ return SystemDnsInfo {
+ default_upstream: None,
+ forwarding_rules: Vec::new(),
+ };
}
};
let text = String::from_utf8_lossy(&output.stdout);
let mut rules = Vec::new();
+ let mut default_upstream: Option = None;
- // Parse resolver blocks: look for blocks with both `domain` and `nameserver[0]`
- // that have the `Supplemental` flag (conditional forwarding, not default)
let mut current_domain: Option = None;
let mut current_nameserver: Option = None;
let mut is_supplemental = false;
@@ -50,7 +60,7 @@ fn discover_macos() -> Vec {
let line = line.trim();
if line.starts_with("resolver #") {
- // Emit previous block if valid
+ // Emit previous supplemental block as forwarding rule
if let (Some(domain), Some(ns), true) = (
current_domain.take(),
current_nameserver.take(),
@@ -64,7 +74,6 @@ fn discover_macos() -> Vec {
current_nameserver = None;
is_supplemental = false;
} else if line.starts_with("domain") && line.contains(':') {
- // "domain : tailcee7cc.ts.net."
if let Some(val) = line.split(':').nth(1) {
let domain = val.trim().trim_end_matches('.').to_lowercase();
if !domain.is_empty()
@@ -78,15 +87,21 @@ fn discover_macos() -> Vec {
} else if line.starts_with("nameserver[0]") && line.contains(':') {
if let Some(val) = line.split(':').nth(1) {
let ns = val.trim().to_string();
- // Only use IPv4 nameservers for now
if ns.parse::().is_ok() {
- current_nameserver = Some(ns);
+ 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"
+ {
+ default_upstream = Some(ns);
+ }
}
}
} else if line.starts_with("flags") && line.contains("Supplemental") {
is_supplemental = true;
} else if line.starts_with("DNS configuration (for scoped") {
- // Stop at scoped section — those are interface-specific, not conditional
if let (Some(domain), Some(ns), true) = (
current_domain.take(),
current_nameserver.take(),
@@ -116,12 +131,17 @@ fn discover_macos() -> Vec {
rule.suffix, rule.upstream
);
}
-
if rules.is_empty() {
- debug!("no conditional forwarding rules discovered from scutil --dns");
+ debug!("no conditional forwarding rules discovered");
+ }
+ if let Some(ref ns) = default_upstream {
+ info!("detected system upstream: {}", ns);
}
- rules
+ SystemDnsInfo {
+ default_upstream,
+ forwarding_rules: rules,
+ }
}
#[cfg(target_os = "macos")]
@@ -134,6 +154,45 @@ 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(not(target_os = "macos"))]
+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
+}
+
+#[cfg(not(target_os = "macos"))]
+fn read_upstream_from_file(path: &str) -> Option {
+ let text = std::fs::read_to_string(path).ok()?;
+ 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());
+ }
+ }
+ }
+ }
+ None
+}
+
/// Find the upstream for a domain by checking forwarding rules.
/// Returns None if no rule matches (use default upstream).
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
@@ -395,19 +454,28 @@ fn install_service_macos() -> Result<(), String> {
.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())
+ if !status.success() {
+ 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");
+ Ok(())
}
#[cfg(target_os = "macos")]
fn uninstall_service_macos() -> Result<(), String> {
+ // Restore DNS first, while numa is still running to handle any final queries
+ if let Err(e) = uninstall_macos() {
+ eprintln!(" warning: failed to restore system DNS: {}", e);
+ }
+
// 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 {
@@ -576,14 +644,24 @@ fn install_service_linux() -> Result<(), String> {
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started.");
+
+ // Set system DNS now that the service is running
+ if let Err(e) = install_linux() {
+ eprintln!(" warning: failed to configure system DNS: {}", e);
+ }
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");
+ eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
Ok(())
}
#[cfg(target_os = "linux")]
fn uninstall_service_linux() -> Result<(), String> {
+ // Restore DNS first, while numa is still running
+ if let Err(e) = uninstall_linux() {
+ eprintln!(" warning: failed to restore system DNS: {}", e);
+ }
+
if let Err(e) = run_systemctl(&["stop", "numa"]) {
eprintln!(" warning: {}", e);
}