9 Commits

Author SHA1 Message Date
Razvan Dimescu
2f80d1ab7f ci: auto-update Homebrew tap on release
After creating a GitHub release, the new update-homebrew job:
- Extracts SHA256 checksums from build artifacts
- Generates an updated numa.rb formula with correct version and hashes
- Pushes it to razvandimescu/homebrew-tap via the GitHub API

Requires HOMEBREW_TAP_TOKEN secret (PAT with repo scope on homebrew-tap).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:56:34 +03:00
Razvan Dimescu
766935ec97 style: fix rustfmt formatting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:46:54 +03:00
Razvan Dimescu
efe3669540 fix: gate exe_path and replace_exe_path for Windows clippy, add macOS CI
- Gate exe_path in restart_service() and replace_exe_path() behind
  #[cfg(any(target_os = "macos", target_os = "linux"))] to fix
  unused variable and dead code warnings on Windows
- Add macOS CI job (clippy + tests)
- Add test for template substitution in plist and systemd unit files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:46:54 +03:00
Laurin Brandner
ad34fe2d9e Fix unit replacement for linux 2026-04-06 22:28:30 +03:00
Laurin Brandner
80fcfd10ae flexible installation path 2026-04-06 22:28:30 +03:00
Razvan Dimescu
e4a8893214 Merge pull request #30 from razvandimescu/release/v0.9.1
chore: bump version to 0.9.1
2026-04-03 00:39:45 +03:00
Razvan Dimescu
d979cd9505 chore: bump version to 0.9.1
Fix: forwarding rules ignored in recursive mode (Tailscale/VPN).
Fix: browsers treating .numa as search query (add search domain).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:08:36 +03:00
Razvan Dimescu
8c421b9fa3 fix: check forwarding rules before recursive resolution (#29)
Conditional forwarding (Tailscale .ts.net, VPC private zones) was
only checked in the forward mode branch. In recursive mode, queries
for forwarding-rule domains went to root servers instead of the
configured upstream, returning NXDOMAIN for private domains.

Move the forwarding rule check before the recursive/forward branch
so it takes priority regardless of mode.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:07:11 +03:00
Razvan Dimescu
ad7884f2f6 fix: add numa search domain on install for browser compatibility
Chrome treats single-label TLDs (e.g. frontend.numa) as search
queries unless a trailing slash is added. Adding "numa" as a search
domain tells the OS resolver that .numa is valid, so browsers
resolve it directly.

macOS: networksetup -setsearchdomains, cleared on uninstall
Linux (resolved): Domains=~. numa in drop-in
Linux (resolv.conf): search numa

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 17:50:22 +03:00
8 changed files with 178 additions and 25 deletions

View File

@@ -27,6 +27,17 @@ jobs:
- name: audit
run: cargo install cargo-audit && cargo audit
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: clippy
run: cargo clippy -- -D warnings
- name: test
run: cargo test
check-windows:
runs-on: windows-latest
steps:

View File

@@ -108,3 +108,93 @@ jobs:
*.tar.gz
*.zip
*.sha256
update-homebrew:
needs: release
runs-on: ubuntu-latest
steps:
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Download SHA256 files
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Extract checksums
id: sha
run: |
echo "macos_arm=$(awk '{print $1}' numa-macos-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
echo "macos_x86=$(awk '{print $1}' numa-macos-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
echo "linux_arm=$(awk '{print $1}' numa-linux-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
echo "linux_x86=$(awk '{print $1}' numa-linux-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
- name: Update Homebrew formula
uses: actions/github-script@v7
with:
github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
script: |
const version = '${{ steps.version.outputs.version }}';
const base = `https://github.com/razvandimescu/numa/releases/download/v${version}`;
const formula = `class Numa < Formula
desc "Portable DNS resolver with ad blocking, .numa local service proxy, and developer overrides"
homepage "https://github.com/razvandimescu/numa"
license "MIT"
version "${version}"
on_macos do
if Hardware::CPU.arm?
url "${base}/numa-macos-aarch64.tar.gz"
sha256 "${{ steps.sha.outputs.macos_arm }}"
else
url "${base}/numa-macos-x86_64.tar.gz"
sha256 "${{ steps.sha.outputs.macos_x86 }}"
end
end
on_linux do
if Hardware::CPU.arm?
url "${base}/numa-linux-aarch64.tar.gz"
sha256 "${{ steps.sha.outputs.linux_arm }}"
else
url "${base}/numa-linux-x86_64.tar.gz"
sha256 "${{ steps.sha.outputs.linux_x86 }}"
end
end
def install
bin.install "numa"
end
def caveats
<<~EOS
Numa requires root to bind port 53:
sudo numa # start the DNS server
sudo numa install # set as system DNS
sudo numa service start # run as persistent service
Dashboard: http://localhost:5380
EOS
end
test do
assert_match "numa", shell_output("#{bin}/numa --version")
end
end
`.replace(/^ /gm, '');
const { data: existing } = await github.rest.repos.getContent({
owner: 'razvandimescu',
repo: 'homebrew-tap',
path: 'numa.rb',
});
await github.rest.repos.createOrUpdateFileContents({
owner: 'razvandimescu',
repo: 'homebrew-tap',
path: 'numa.rb',
message: `numa ${version}`,
content: Buffer.from(formula).toString('base64'),
sha: existing.sha,
});

2
Cargo.lock generated
View File

@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.9.0"
version = "0.9.1"
dependencies = [
"arc-swap",
"axum",

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.9.0"
version = "0.9.1"
authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021"
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"

View File

@@ -6,7 +6,7 @@
<string>com.numa.dns</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/numa</string>
<string>{{exe_path}}</string>
</array>
<key>RunAtLoad</key>
<true/>

View File

@@ -5,7 +5,7 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/numa
ExecStart={{exe_path}}
Restart=always
RestartSec=2
StandardOutput=journal

View File

@@ -162,6 +162,29 @@ pub async fn handle_query(
resp.header.authed_data = true;
}
(resp, QueryPath::Cached, cached_dnssec)
} else if let Some(fwd_addr) =
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
{
// Conditional forwarding takes priority over recursive mode
// (e.g. Tailscale .ts.net, VPC private zones)
let upstream = Upstream::Udp(fwd_addr);
match forward_query(&query, &upstream, ctx.timeout).await {
Ok(resp) => {
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
}
Err(e) => {
error!(
"{} | {:?} {} | FORWARD ERROR | {}",
src_addr, qtype, qname, e
);
(
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
)
}
}
} else if ctx.upstream_mode == UpstreamMode::Recursive {
let key = (qname.clone(), qtype);
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {

View File

@@ -776,7 +776,7 @@ fn install_macos() -> Result<(), String> {
.map_err(|e| format!("failed to serialize backup: {}", e))?;
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
// Set DNS to 127.0.0.1 for each service
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
for service in &services {
let status = std::process::Command::new("networksetup")
.args(["-setdnsservers", service, "127.0.0.1"])
@@ -788,6 +788,11 @@ fn install_macos() -> Result<(), String> {
} else {
eprintln!(" warning: failed to set DNS for \"{}\"", service);
}
// Add "numa" as search domain so browsers resolve .numa without trailing slash
let _ = std::process::Command::new("networksetup")
.args(["-setsearchdomains", service, "numa"])
.status();
}
eprintln!("\n Original DNS saved to {}", backup_path().display());
@@ -832,6 +837,11 @@ fn uninstall_macos() -> Result<(), String> {
} else {
eprintln!(" warning: failed to restore DNS for \"{}\"", service);
}
// Clear the "numa" search domain
let _ = std::process::Command::new("networksetup")
.args(["-setsearchdomains", service, "Empty"])
.status();
}
std::fs::remove_file(&path).ok();
@@ -893,9 +903,13 @@ pub fn uninstall_service() -> Result<(), String> {
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
pub fn restart_service() -> Result<(), String> {
#[cfg(any(target_os = "macos", target_os = "linux"))]
let exe_path =
std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?;
#[cfg(any(target_os = "macos", target_os = "linux"))]
let version = {
match std::process::Command::new("/usr/local/bin/numa")
match std::process::Command::new(&exe_path)
.arg("--version")
.output()
{
@@ -906,6 +920,7 @@ pub fn restart_service() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let exe_path = exe_path.to_string_lossy();
let output = std::process::Command::new("launchctl")
.args(["list", PLIST_LABEL])
.output();
@@ -916,11 +931,11 @@ pub fn restart_service() -> Result<(), String> {
// This will kill us too (we ARE /usr/local/bin/numa), so
// codesign and print output first.
let _ = std::process::Command::new("codesign")
.args(["-f", "-s", "-", "/usr/local/bin/numa"])
.args(["-f", "-s", "-", &exe_path])
.output(); // use output() to suppress codesign stderr
eprintln!(" Service restarting → {}\n", version);
let _ = std::process::Command::new("pkill")
.args(["-f", "/usr/local/bin/numa"])
.args(["-f", &exe_path])
.status();
Ok(())
}
@@ -955,19 +970,23 @@ pub fn service_status() -> Result<(), String> {
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn replace_exe_path(service: &str) -> Result<String, String> {
let exe_path =
std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?;
Ok(service.replace("{{exe_path}}", &exe_path.to_string_lossy()))
}
#[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");
let plist = replace_exe_path(plist)?;
std::fs::write(PLIST_DEST, plist)
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
@@ -1092,7 +1111,7 @@ fn install_linux() -> Result<(), String> {
let drop_in = resolved_dir.join("numa.conf");
std::fs::write(
&drop_in,
"[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n",
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
)
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
@@ -1130,7 +1149,7 @@ fn install_linux() -> Result<(), String> {
}
let content =
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n";
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
std::fs::write(resolv, content)
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
@@ -1169,19 +1188,10 @@ fn uninstall_linux() -> Result<(), String> {
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> {
ensure_binary_installed()?;
let unit = include_str!("../numa.service");
let unit = replace_exe_path(unit)?;
std::fs::write(SYSTEMD_UNIT, unit)
.map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?;
@@ -1403,6 +1413,25 @@ Wireless LAN adapter Wi-Fi:
);
}
#[test]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn replace_exe_path_substitutes_template() {
let plist = include_str!("../com.numa.dns.plist");
let unit = include_str!("../numa.service");
assert!(plist.contains("{{exe_path}}"), "plist missing placeholder");
assert!(
unit.contains("{{exe_path}}"),
"unit file missing placeholder"
);
let result = replace_exe_path(plist).expect("replace_exe_path failed for plist");
assert!(!result.contains("{{exe_path}}"));
let result = replace_exe_path(unit).expect("replace_exe_path failed for unit");
assert!(!result.contains("{{exe_path}}"));
}
#[test]
fn parse_ipconfig_skips_disconnected() {
let sample = "\