add auto-detect upstream, install script, release workflow
- Default upstream auto-detected from system resolver (scutil/resolv.conf) instead of hardcoding Google 8.8.8.8. Falls back to Quad9 (9.9.9.9). - Single scutil --dns pass for both upstream detection and forwarding rules - Linux: reads backup resolv.conf if current only has loopback - Service start/stop now couples DNS config (install on start, uninstall on stop) - Install script for one-line binary install from GitHub Releases - GitHub Actions release workflow: builds for macOS/Linux x86_64/aarch64 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
78
.github/workflows/release.yml
vendored
Normal file
78
.github/workflows/release.yml
vendored
Normal file
@@ -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
|
||||
@@ -16,5 +16,10 @@
|
||||
<string>/usr/local/var/log/numa.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/usr/local/var/log/numa.log</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>RUST_LOG</key>
|
||||
<string>info</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
71
install.sh
Executable file
71
install.sh
Executable file
@@ -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 ""
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
src/main.rs
21
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?,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ForwardingRule> {
|
||||
/// Result of system DNS discovery — default upstream + conditional forwarding rules.
|
||||
pub struct SystemDnsInfo {
|
||||
pub default_upstream: Option<String>,
|
||||
pub forwarding_rules: Vec<ForwardingRule>,
|
||||
}
|
||||
|
||||
/// 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<ForwardingRule> {
|
||||
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<String> = 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<String> = None;
|
||||
let mut current_nameserver: Option<String> = None;
|
||||
let mut is_supplemental = false;
|
||||
@@ -50,7 +60,7 @@ fn discover_macos() -> Vec<ForwardingRule> {
|
||||
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<ForwardingRule> {
|
||||
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<ForwardingRule> {
|
||||
} 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::<std::net::Ipv4Addr>().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<ForwardingRule> {
|
||||
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<ForwardingRule> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
// 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<String> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user