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); }