Compare commits
9 Commits
v0.9.0
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f80d1ab7f | ||
|
|
766935ec97 | ||
|
|
efe3669540 | ||
|
|
ad34fe2d9e | ||
|
|
80fcfd10ae | ||
|
|
e4a8893214 | ||
|
|
d979cd9505 | ||
|
|
8c421b9fa3 | ||
|
|
ad7884f2f6 |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -27,6 +27,17 @@ jobs:
|
|||||||
- name: audit
|
- name: audit
|
||||||
run: cargo install cargo-audit && cargo 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:
|
check-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
90
.github/workflows/release.yml
vendored
90
.github/workflows/release.yml
vendored
@@ -108,3 +108,93 @@ jobs:
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
*.sha256
|
*.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
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<string>com.numa.dns</string>
|
<string>com.numa.dns</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/usr/local/bin/numa</string>
|
<string>{{exe_path}}</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/local/bin/numa
|
ExecStart={{exe_path}}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
|
|||||||
23
src/ctx.rs
23
src/ctx.rs
@@ -162,6 +162,29 @@ pub async fn handle_query(
|
|||||||
resp.header.authed_data = true;
|
resp.header.authed_data = true;
|
||||||
}
|
}
|
||||||
(resp, QueryPath::Cached, cached_dnssec)
|
(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 {
|
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||||
let key = (qname.clone(), qtype);
|
let key = (qname.clone(), qtype);
|
||||||
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ fn install_macos() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write 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 {
|
for service in &services {
|
||||||
let status = std::process::Command::new("networksetup")
|
let status = std::process::Command::new("networksetup")
|
||||||
.args(["-setdnsservers", service, "127.0.0.1"])
|
.args(["-setdnsservers", service, "127.0.0.1"])
|
||||||
@@ -788,6 +788,11 @@ fn install_macos() -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to set DNS for \"{}\"", service);
|
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());
|
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
||||||
@@ -832,6 +837,11 @@ fn uninstall_macos() -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to restore DNS for \"{}\"", service);
|
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();
|
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).
|
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
||||||
pub fn restart_service() -> Result<(), String> {
|
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"))]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
let version = {
|
let version = {
|
||||||
match std::process::Command::new("/usr/local/bin/numa")
|
match std::process::Command::new(&exe_path)
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
@@ -906,6 +920,7 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
let exe_path = exe_path.to_string_lossy();
|
||||||
let output = std::process::Command::new("launchctl")
|
let output = std::process::Command::new("launchctl")
|
||||||
.args(["list", PLIST_LABEL])
|
.args(["list", PLIST_LABEL])
|
||||||
.output();
|
.output();
|
||||||
@@ -916,11 +931,11 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
// This will kill us too (we ARE /usr/local/bin/numa), so
|
// This will kill us too (we ARE /usr/local/bin/numa), so
|
||||||
// codesign and print output first.
|
// codesign and print output first.
|
||||||
let _ = std::process::Command::new("codesign")
|
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
|
.output(); // use output() to suppress codesign stderr
|
||||||
eprintln!(" Service restarting → {}\n", version);
|
eprintln!(" Service restarting → {}\n", version);
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.args(["-f", "/usr/local/bin/numa"])
|
.args(["-f", &exe_path])
|
||||||
.status();
|
.status();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -955,19 +970,23 @@ pub fn service_status() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
fn install_service_macos() -> Result<(), String> {
|
fn replace_exe_path(service: &str) -> Result<String, String> {
|
||||||
// Check binary exists
|
let exe_path =
|
||||||
if !std::path::Path::new("/usr/local/bin/numa").exists() {
|
std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?;
|
||||||
return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string());
|
Ok(service.replace("{{exe_path}}", &exe_path.to_string_lossy()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn install_service_macos() -> Result<(), String> {
|
||||||
// Create log directory
|
// Create log directory
|
||||||
std::fs::create_dir_all("/usr/local/var/log")
|
std::fs::create_dir_all("/usr/local/var/log")
|
||||||
.map_err(|e| format!("failed to create log dir: {}", e))?;
|
.map_err(|e| format!("failed to create log dir: {}", e))?;
|
||||||
|
|
||||||
// Write plist
|
// Write plist
|
||||||
let plist = include_str!("../com.numa.dns.plist");
|
let plist = include_str!("../com.numa.dns.plist");
|
||||||
|
let plist = replace_exe_path(plist)?;
|
||||||
|
|
||||||
std::fs::write(PLIST_DEST, plist)
|
std::fs::write(PLIST_DEST, plist)
|
||||||
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
|
.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");
|
let drop_in = resolved_dir.join("numa.conf");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&drop_in,
|
&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))?;
|
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||||
|
|
||||||
@@ -1130,7 +1149,7 @@ fn install_linux() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content =
|
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)
|
std::fs::write(resolv, content)
|
||||||
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
|
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
|
||||||
|
|
||||||
@@ -1169,19 +1188,10 @@ fn uninstall_linux() -> Result<(), String> {
|
|||||||
Ok(())
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn install_service_linux() -> Result<(), String> {
|
fn install_service_linux() -> Result<(), String> {
|
||||||
ensure_binary_installed()?;
|
|
||||||
|
|
||||||
let unit = include_str!("../numa.service");
|
let unit = include_str!("../numa.service");
|
||||||
|
let unit = replace_exe_path(unit)?;
|
||||||
std::fs::write(SYSTEMD_UNIT, unit)
|
std::fs::write(SYSTEMD_UNIT, unit)
|
||||||
.map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?;
|
.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]
|
#[test]
|
||||||
fn parse_ipconfig_skips_disconnected() {
|
fn parse_ipconfig_skips_disconnected() {
|
||||||
let sample = "\
|
let sample = "\
|
||||||
|
|||||||
Reference in New Issue
Block a user