Compare commits
90 Commits
v0.7.2
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f80d1ab7f | ||
|
|
766935ec97 | ||
|
|
efe3669540 | ||
|
|
ad34fe2d9e | ||
|
|
80fcfd10ae | ||
|
|
e4a8893214 | ||
|
|
d979cd9505 | ||
|
|
8c421b9fa3 | ||
|
|
ad7884f2f6 | ||
|
|
6a70ab0f1b | ||
|
|
0b883d1c0d | ||
|
|
7f46f6271e | ||
|
|
f3ca83246c | ||
|
|
da93a3cde3 | ||
|
|
98da440c84 | ||
|
|
4e5b88496c | ||
|
|
d5f7ce9e2d | ||
|
|
cc704be590 | ||
|
|
ff1200eb10 | ||
|
|
49535568d9 | ||
|
|
cd1beedf38 | ||
|
|
be52e5c305 | ||
|
|
669498e85f | ||
|
|
d325b92e44 | ||
|
|
261fd2e148 | ||
|
|
30e46e549c | ||
|
|
ac49658c2b | ||
|
|
5265f571d0 | ||
|
|
0ebd924825 | ||
|
|
06d4e91cd2 | ||
|
|
71dbb138bc | ||
|
|
fbf3ca6d11 | ||
|
|
a84f2e7f1d | ||
|
|
7aee90c99b | ||
|
|
1304b1c02c | ||
|
|
59397ecce4 | ||
|
|
f849a4d65f | ||
|
|
962b400f4c | ||
|
|
1f4063d5db | ||
|
|
c6bc307f0a | ||
|
|
c5208e934d | ||
|
|
d69b79451e | ||
|
|
0b194256a9 | ||
|
|
e0c1997056 | ||
|
|
9e07064c94 | ||
|
|
43cedf11f7 | ||
|
|
cd6a54c652 | ||
|
|
9f89627c5a | ||
|
|
e7e5c173f2 | ||
|
|
c6b35045d8 | ||
|
|
10f1602803 | ||
|
|
41a97bb930 | ||
|
|
c4e733c8ef | ||
|
|
4020776b8e | ||
|
|
763ba1de91 | ||
|
|
51dc06690e | ||
|
|
fb89b78226 | ||
|
|
64c4d146ec | ||
|
|
9c290b6ef4 | ||
|
|
c836903db5 | ||
|
|
5e5a6544bc | ||
|
|
227af04564 | ||
|
|
4c58ff49b0 | ||
|
|
d261e8bc86 | ||
|
|
2de337ac36 | ||
|
|
5810ee5aac | ||
|
|
06850de728 | ||
|
|
995916d01b | ||
|
|
7aca3b1991 | ||
|
|
b7d64a9707 | ||
|
|
c333705a0e | ||
|
|
50d17ae118 | ||
|
|
5495107c9e | ||
|
|
02e83ccd72 | ||
|
|
ccbf893b92 | ||
|
|
cd90b50d68 | ||
|
|
5866ff1ba1 | ||
|
|
9a3de2f231 | ||
|
|
6fdadd637c | ||
|
|
9041ccc2e1 | ||
|
|
c9f1d98f45 | ||
|
|
6a8e47bbb5 | ||
|
|
de50720834 | ||
|
|
216ec76640 | ||
|
|
08aaebec7e | ||
|
|
3e40f795da | ||
|
|
8dcebaaca6 | ||
|
|
a48809fc25 | ||
|
|
e94e75101f | ||
|
|
32f50cd254 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
@@ -37,3 +48,10 @@ jobs:
|
||||
run: cargo build
|
||||
- name: clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: test
|
||||
run: cargo test
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: numa-windows-x86_64
|
||||
path: target/debug/numa.exe
|
||||
|
||||
90
.github/workflows/release.yml
vendored
90
.github/workflows/release.yml
vendored
@@ -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
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.7.2"
|
||||
version = "0.9.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.7.2"
|
||||
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"
|
||||
|
||||
37
README.md
37
README.md
@@ -8,20 +8,39 @@
|
||||
|
||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
||||
|
||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
|
||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
brew install razvandimescu/tap/numa # or: cargo install numa
|
||||
sudo numa # port 53 requires root
|
||||
# macOS
|
||||
brew install razvandimescu/tap/numa
|
||||
|
||||
# Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||
|
||||
# Windows — download from GitHub Releases
|
||||
# All platforms
|
||||
cargo install numa
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo numa # run in foreground (port 53 requires root/admin)
|
||||
```
|
||||
|
||||
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
||||
|
||||
Set as system DNS: `sudo numa install && sudo numa service start`
|
||||
Set as system DNS:
|
||||
|
||||
| Platform | Install | Uninstall |
|
||||
|----------|---------|-----------|
|
||||
| macOS | `sudo numa install` | `sudo numa uninstall` |
|
||||
| Linux | `sudo numa install` | `sudo numa uninstall` |
|
||||
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
|
||||
|
||||
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
|
||||
|
||||
## Local Services
|
||||
|
||||
@@ -40,7 +59,13 @@ Add path-based routing (`app.numa/api → :5001`), share services across machine
|
||||
|
||||
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
|
||||
|
||||
Two resolution modes: **forward** (relay to Quad9/Cloudflare via encrypted DoH) or **recursive** (resolve from root nameservers — no upstream dependency, no single entity sees your full query pattern). DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||
Three resolution modes:
|
||||
|
||||
- **`forward`** (default) — transparent proxy to your existing system DNS. Everything works as before, just with caching and ad blocking on top. Captive portals, VPNs, corporate DNS — all respected.
|
||||
- **`recursive`** — resolve directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. Add `[dnssec] enabled = true` for full chain-of-trust validation.
|
||||
- **`auto`** — probe root servers on startup, recursive if reachable, encrypted DoH fallback if blocked.
|
||||
|
||||
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||
|
||||
## LAN Discovery
|
||||
|
||||
@@ -71,7 +96,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||
| Web admin UI | Full | Full | — | Dashboard |
|
||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -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/>
|
||||
|
||||
10
install.sh
10
install.sh
@@ -70,8 +70,10 @@ 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 " sudo numa install # install service + set as system DNS"
|
||||
echo " open http://localhost:5380 # dashboard"
|
||||
echo ""
|
||||
echo " Other commands:"
|
||||
echo " sudo numa # run in foreground (no service)"
|
||||
echo " sudo numa uninstall # restore original DNS"
|
||||
echo ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,7 +54,7 @@ enabled = true
|
||||
port = 80
|
||||
tls_port = 443
|
||||
tld = "numa"
|
||||
# bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled
|
||||
# bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN access to .numa services
|
||||
|
||||
# Pre-configured services (numa.numa is always added automatically)
|
||||
# [[services]]
|
||||
|
||||
@@ -101,7 +101,7 @@ body {
|
||||
/* Stat cards row */
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
@@ -125,6 +125,8 @@ body {
|
||||
.stat-card.blocked::before { background: var(--rose); }
|
||||
.stat-card.overrides::before { background: var(--violet); }
|
||||
.stat-card.uptime::before { background: var(--cyan); }
|
||||
.stat-card.memory::before { background: var(--text-dim); }
|
||||
.stat-card.memory .stat-value { color: var(--text-secondary); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
@@ -468,10 +470,74 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Memory sidebar panel */
|
||||
.memory-bar {
|
||||
display: flex;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.memory-bar-seg {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
.memory-bar-seg.cache { background: var(--teal); }
|
||||
.memory-bar-seg.blocklist { background: var(--rose); }
|
||||
.memory-bar-seg.querylog { background: var(--amber); }
|
||||
.memory-bar-seg.srtt { background: var(--cyan); }
|
||||
.memory-bar-seg.overrides { background: var(--violet); }
|
||||
.memory-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.memory-row:last-child { border-bottom: none; }
|
||||
.memory-row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.memory-row-label {
|
||||
flex: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.memory-row-size {
|
||||
width: 65px;
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.memory-row-entries {
|
||||
width: 90px;
|
||||
text-align: right;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.memory-rss {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1100px) {
|
||||
.main-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.stats-row { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.dashboard { padding: 1rem; }
|
||||
@@ -524,6 +590,11 @@ body {
|
||||
<div class="stat-value" id="uptime">—</div>
|
||||
<div class="stat-sub" id="uptimeSub"> </div>
|
||||
</div>
|
||||
<div class="stat-card memory">
|
||||
<div class="stat-label">Memory</div>
|
||||
<div class="stat-value" id="memoryRss">—</div>
|
||||
<div class="stat-sub" id="memorySub"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution paths -->
|
||||
@@ -648,6 +719,17 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory breakdown -->
|
||||
<div class="panel" id="memoryPanel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Memory</span>
|
||||
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
|
||||
</div>
|
||||
<div class="panel-body" id="memoryBody">
|
||||
<div class="empty-state">No memory data</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache entries -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -712,6 +794,69 @@ function formatRemaining(secs) {
|
||||
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
||||
}
|
||||
|
||||
const MEMORY_COMPONENTS = [
|
||||
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
|
||||
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
|
||||
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
|
||||
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
|
||||
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
|
||||
];
|
||||
|
||||
function renderMemory(mem, stats) {
|
||||
if (!mem) return;
|
||||
|
||||
// Stat card
|
||||
document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes);
|
||||
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
|
||||
|
||||
const entryCounts = {
|
||||
cache: stats.cache.entries,
|
||||
blocklist: stats.blocking.domains_loaded,
|
||||
query_log: mem.query_log_entries,
|
||||
srtt: mem.srtt_entries,
|
||||
overrides: stats.overrides.active,
|
||||
};
|
||||
|
||||
// Sidebar panel
|
||||
const total = mem.total_estimated_bytes || 1;
|
||||
document.getElementById('memoryTotal').textContent = formatBytes(total);
|
||||
|
||||
const barSegments = MEMORY_COMPONENTS.map(c => {
|
||||
const bytes = mem[c.key + '_bytes'] || 0;
|
||||
const pct = ((bytes / total) * 100).toFixed(1);
|
||||
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
|
||||
}).join('');
|
||||
|
||||
const rows = MEMORY_COMPONENTS.map(c => {
|
||||
const bytes = mem[c.key + '_bytes'] || 0;
|
||||
const entries = entryCounts[c.key] || 0;
|
||||
return `
|
||||
<div class="memory-row">
|
||||
<div class="memory-row-dot" style="background:${c.color}"></div>
|
||||
<span class="memory-row-label">${c.label}</span>
|
||||
<span class="memory-row-size">${formatBytes(bytes)}</span>
|
||||
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('memoryBody').innerHTML = `
|
||||
<div class="memory-bar">${barSegments}</div>
|
||||
${rows}
|
||||
<div class="memory-rss">
|
||||
<span>Process Footprint</span>
|
||||
<span>${formatBytes(mem.process_memory_bytes)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const PATH_DEFS = [
|
||||
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
||||
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
||||
@@ -882,6 +1027,9 @@ async function refresh() {
|
||||
document.getElementById('footerUpstream').textContent = stats.upstream || '';
|
||||
document.getElementById('footerConfig').textContent = stats.config_path || '';
|
||||
document.getElementById('footerData').textContent = stats.data_dir || '';
|
||||
const modeEl = document.getElementById('footerMode');
|
||||
modeEl.textContent = stats.mode || '—';
|
||||
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
|
||||
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
|
||||
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
|
||||
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
|
||||
@@ -945,7 +1093,7 @@ async function refresh() {
|
||||
prevTime = now;
|
||||
|
||||
// Cache hit rate
|
||||
const answered = q.cached + q.forwarded + q.local + q.overridden;
|
||||
const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden;
|
||||
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
|
||||
document.getElementById('cacheRate').textContent = hitRate + '%';
|
||||
|
||||
@@ -957,6 +1105,7 @@ async function refresh() {
|
||||
renderServices(services);
|
||||
renderBlockingInfo(blockingInfo);
|
||||
renderAllowlist(allowlist);
|
||||
renderMemory(stats.memory, stats);
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('statusDot').className = 'status-dot error';
|
||||
@@ -1236,6 +1385,7 @@ setInterval(refresh, 2000);
|
||||
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
|
||||
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
|
||||
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
|
||||
· Mode: <span id="footerMode" style="color:var(--text-dim);">—</span>
|
||||
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);">—</span>
|
||||
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);">—</span>
|
||||
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Numa — DNS you own. Everywhere you go.</title>
|
||||
<meta name="description" content="DNS you own. Recursive resolver with full DNSSEC validation, ad blocking, .numa local domains, developer overrides. A single portable binary built from scratch in Rust.">
|
||||
<meta name="description" content="DNS you own. Portable DNS resolver with caching, ad blocking, .numa local domains, developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
|
||||
<link rel="canonical" href="https://numa.rs">
|
||||
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
|
||||
<meta property="og:description" content="Recursive DNS resolver with full DNSSEC validation, ad blocking, .numa local domains, and developer overrides. Built from scratch in Rust.">
|
||||
<meta property="og:description" content="Portable DNS resolver with caching, ad blocking, .numa local domains, and developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://numa.rs">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
@@ -1232,17 +1232,17 @@ footer .closing {
|
||||
<div class="reveal">
|
||||
<div class="section-label">How It Works</div>
|
||||
<h2>What it does today</h2>
|
||||
<p class="lead">A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.</p>
|
||||
<p class="lead">A DNS resolver with caching, ad blocking, local service domains, and a REST API. Optional recursive resolution with DNSSEC. Everything runs in a single binary.</p>
|
||||
</div>
|
||||
<div class="layers-grid">
|
||||
<div class="layer-card reveal reveal-delay-1">
|
||||
<div class="layer-badge">Layer 1</div>
|
||||
<h3>Resolve & Protect</h3>
|
||||
<ul>
|
||||
<li>Recursive resolution — resolve from root nameservers, no upstream needed</li>
|
||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||
<li>Forward mode by default — transparent proxy to your existing DNS, with caching</li>
|
||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||
<li>DNS-over-HTTPS — encrypted upstream as alternative to recursive mode</li>
|
||||
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</li>
|
||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||
</ul>
|
||||
|
||||
62
src/api.rs
62
src/api.rs
@@ -160,6 +160,7 @@ struct QueryLogResponse {
|
||||
struct StatsResponse {
|
||||
uptime_secs: u64,
|
||||
upstream: String,
|
||||
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
|
||||
config_path: String,
|
||||
data_dir: String,
|
||||
dnssec: bool,
|
||||
@@ -169,6 +170,7 @@ struct StatsResponse {
|
||||
overrides: OverrideStats,
|
||||
blocking: BlockingStatsResponse,
|
||||
lan: LanStatsResponse,
|
||||
memory: MemoryStats,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -209,6 +211,19 @@ struct BlockingStatsResponse {
|
||||
allowlist_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MemoryStats {
|
||||
cache_bytes: usize,
|
||||
blocklist_bytes: usize,
|
||||
query_log_bytes: usize,
|
||||
query_log_entries: usize,
|
||||
srtt_bytes: usize,
|
||||
srtt_entries: usize,
|
||||
overrides_bytes: usize,
|
||||
total_estimated_bytes: usize,
|
||||
process_memory_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DiagnoseResponse {
|
||||
domain: String,
|
||||
@@ -410,14 +425,8 @@ async fn forward_query_for_diagnose(
|
||||
timeout: std::time::Duration,
|
||||
) -> (bool, String) {
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::DnsQuestion;
|
||||
|
||||
let mut query = DnsPacket::new();
|
||||
query.header.id = 0xBEEF;
|
||||
query.header.recursion_desired = true;
|
||||
query
|
||||
.questions
|
||||
.push(DnsQuestion::new(domain.to_string(), QueryType::A));
|
||||
let query = DnsPacket::query(0xBEEF, domain, QueryType::A);
|
||||
|
||||
match forward_query(&query, upstream, timeout).await {
|
||||
Ok(resp) => (
|
||||
@@ -476,12 +485,29 @@ async fn query_log(
|
||||
|
||||
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
let snap = ctx.stats.lock().unwrap().snapshot();
|
||||
let (cache_len, cache_max) = {
|
||||
let (cache_len, cache_max, cache_bytes) = {
|
||||
let cache = ctx.cache.read().unwrap();
|
||||
(cache.len(), cache.max_entries())
|
||||
(cache.len(), cache.max_entries(), cache.heap_bytes())
|
||||
};
|
||||
let override_count = ctx.overrides.read().unwrap().active_count();
|
||||
let bl_stats = ctx.blocklist.read().unwrap().stats();
|
||||
let (override_count, overrides_bytes) = {
|
||||
let ov = ctx.overrides.read().unwrap();
|
||||
(ov.active_count(), ov.heap_bytes())
|
||||
};
|
||||
let (bl_stats, blocklist_bytes) = {
|
||||
let bl = ctx.blocklist.read().unwrap();
|
||||
(bl.stats(), bl.heap_bytes())
|
||||
};
|
||||
let (query_log_bytes, query_log_entries) = {
|
||||
let log = ctx.query_log.lock().unwrap();
|
||||
(log.heap_bytes(), log.len())
|
||||
};
|
||||
let (srtt_bytes, srtt_entries, srtt_enabled) = {
|
||||
let s = ctx.srtt.read().unwrap();
|
||||
(s.heap_bytes(), s.len(), s.is_enabled())
|
||||
};
|
||||
|
||||
let total_estimated =
|
||||
cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes;
|
||||
|
||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)".to_string()
|
||||
@@ -492,10 +518,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
Json(StatsResponse {
|
||||
uptime_secs: snap.uptime_secs,
|
||||
upstream,
|
||||
mode: ctx.upstream_mode.as_str(),
|
||||
config_path: ctx.config_path.clone(),
|
||||
data_dir: ctx.data_dir.to_string_lossy().to_string(),
|
||||
dnssec: ctx.dnssec_enabled,
|
||||
srtt: ctx.srtt.read().unwrap().is_enabled(),
|
||||
srtt: srtt_enabled,
|
||||
queries: QueriesStats {
|
||||
total: snap.total,
|
||||
forwarded: snap.forwarded,
|
||||
@@ -524,6 +551,17 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
enabled: ctx.lan_enabled,
|
||||
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
||||
},
|
||||
memory: MemoryStats {
|
||||
cache_bytes,
|
||||
blocklist_bytes,
|
||||
query_log_bytes,
|
||||
query_log_entries,
|
||||
srtt_bytes,
|
||||
srtt_entries,
|
||||
overrides_bytes,
|
||||
total_estimated_bytes: total_estimated,
|
||||
process_memory_bytes: crate::stats::process_memory_bytes(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,15 @@ impl BlocklistStore {
|
||||
self.allowlist.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let per_slot_overhead = std::mem::size_of::<u64>() + std::mem::size_of::<String>() + 1;
|
||||
let domains_table = self.domains.capacity() * per_slot_overhead;
|
||||
let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum();
|
||||
let allow_table = self.allowlist.capacity() * per_slot_overhead;
|
||||
let allow_heap: usize = self.allowlist.iter().map(|d| d.capacity()).sum();
|
||||
domains_table + domains_heap + allow_table + allow_heap
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> BlocklistStats {
|
||||
BlocklistStats {
|
||||
enabled: self.is_enabled(),
|
||||
@@ -234,6 +243,23 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
||||
domains
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_domains() {
|
||||
let mut store = BlocklistStore::new();
|
||||
let empty = store.heap_bytes();
|
||||
let domains: HashSet<String> = ["example.com", "example.org", "test.net"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
store.swap_domains(domains, vec![]);
|
||||
assert!(store.heap_bytes() > empty);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
|
||||
40
src/cache.rs
40
src/cache.rs
@@ -142,6 +142,26 @@ impl DnsCache {
|
||||
self.entry_count = 0;
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let outer_slot = std::mem::size_of::<u64>()
|
||||
+ std::mem::size_of::<String>()
|
||||
+ std::mem::size_of::<HashMap<QueryType, CacheEntry>>()
|
||||
+ 1;
|
||||
let mut total = self.entries.capacity() * outer_slot;
|
||||
for (domain, type_map) in &self.entries {
|
||||
total += domain.capacity();
|
||||
let inner_slot = std::mem::size_of::<u64>()
|
||||
+ std::mem::size_of::<QueryType>()
|
||||
+ std::mem::size_of::<CacheEntry>()
|
||||
+ 1;
|
||||
total += type_map.capacity() * inner_slot;
|
||||
for entry in type_map.values() {
|
||||
total += entry.packet.heap_bytes();
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, domain: &str) {
|
||||
let domain_lower = domain.to_lowercase();
|
||||
if let Some(type_map) = self.entries.remove(&domain_lower) {
|
||||
@@ -194,3 +214,23 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
|
||||
record.set_ttl(new_ttl);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::packet::DnsPacket;
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_entries() {
|
||||
let mut cache = DnsCache::new(100, 1, 3600);
|
||||
let empty = cache.heap_bytes();
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
cache.insert("example.com", QueryType::A, &pkt);
|
||||
assert!(cache.heap_bytes() > empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,18 +59,31 @@ fn default_bind_addr() -> String {
|
||||
"0.0.0.0:53".to_string()
|
||||
}
|
||||
|
||||
pub const DEFAULT_API_PORT: u16 = 5380;
|
||||
|
||||
fn default_api_port() -> u16 {
|
||||
5380
|
||||
DEFAULT_API_PORT
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpstreamMode {
|
||||
Auto,
|
||||
#[default]
|
||||
Forward,
|
||||
Recursive,
|
||||
}
|
||||
|
||||
impl UpstreamMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
UpstreamMode::Auto => "auto",
|
||||
UpstreamMode::Forward => "forward",
|
||||
UpstreamMode::Recursive => "recursive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpstreamConfig {
|
||||
#[serde(default)]
|
||||
@@ -103,10 +116,14 @@ impl Default for UpstreamConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_srtt() -> bool {
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_srtt() -> bool {
|
||||
default_true()
|
||||
}
|
||||
|
||||
fn default_prime_tlds() -> Vec<String> {
|
||||
vec![
|
||||
// gTLDs
|
||||
|
||||
137
src/ctx.rs
137
src/ctx.rs
@@ -93,18 +93,13 @@ pub async fn handle_query(
|
||||
} else if qname == "localhost" || qname.ends_with(".localhost") {
|
||||
// RFC 6761: .localhost always resolves to loopback
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv6Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
_ => resp.answers.push(DnsRecord::A {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv4Addr::LOCALHOST,
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
resp.answers.push(sinkhole_record(
|
||||
&qname,
|
||||
qtype,
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
std::net::Ipv6Addr::LOCALHOST,
|
||||
300,
|
||||
));
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
@@ -113,12 +108,17 @@ pub async fn handle_query(
|
||||
} else if !ctx.proxy_tld_suffix.is_empty()
|
||||
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
|
||||
{
|
||||
// Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP
|
||||
// Resolve .numa: remote clients get LAN IP (can't reach 127.0.0.1), local get loopback
|
||||
let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname);
|
||||
let is_remote = !src_addr.ip().is_loopback();
|
||||
let resolve_ip = {
|
||||
let local = ctx.services.lock().unwrap();
|
||||
if local.lookup(service_name).is_some() {
|
||||
std::net::Ipv4Addr::LOCALHOST
|
||||
if is_remote {
|
||||
*ctx.lan_ip.lock().unwrap()
|
||||
} else {
|
||||
std::net::Ipv4Addr::LOCALHOST
|
||||
}
|
||||
} else {
|
||||
let mut peers = ctx.lan_peers.lock().unwrap();
|
||||
peers
|
||||
@@ -130,38 +130,24 @@ pub async fn handle_query(
|
||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
};
|
||||
let v6 = if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
||||
std::net::Ipv6Addr::LOCALHOST
|
||||
} else {
|
||||
resolve_ip.to_ipv6_mapped()
|
||||
};
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||
domain: qname.clone(),
|
||||
addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
||||
std::net::Ipv6Addr::LOCALHOST
|
||||
} else {
|
||||
resolve_ip.to_ipv6_mapped()
|
||||
},
|
||||
ttl: 300,
|
||||
}),
|
||||
_ => resp.answers.push(DnsRecord::A {
|
||||
domain: qname.clone(),
|
||||
addr: resolve_ip,
|
||||
ttl: 300,
|
||||
}),
|
||||
}
|
||||
resp.answers
|
||||
.push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300));
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
match qtype {
|
||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv6Addr::UNSPECIFIED,
|
||||
ttl: 60,
|
||||
}),
|
||||
_ => resp.answers.push(DnsRecord::A {
|
||||
domain: qname.clone(),
|
||||
addr: std::net::Ipv4Addr::UNSPECIFIED,
|
||||
ttl: 60,
|
||||
}),
|
||||
}
|
||||
resp.answers.push(sinkhole_record(
|
||||
&qname,
|
||||
qtype,
|
||||
std::net::Ipv4Addr::UNSPECIFIED,
|
||||
std::net::Ipv6Addr::UNSPECIFIED,
|
||||
60,
|
||||
));
|
||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
@@ -176,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, || {
|
||||
@@ -383,6 +392,27 @@ fn is_special_use_domain(qname: &str) -> bool {
|
||||
qname == "local" || qname.ends_with(".local")
|
||||
}
|
||||
|
||||
fn sinkhole_record(
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
v4: std::net::Ipv4Addr,
|
||||
v6: std::net::Ipv6Addr,
|
||||
ttl: u32,
|
||||
) -> DnsRecord {
|
||||
match qtype {
|
||||
QueryType::AAAA => DnsRecord::AAAA {
|
||||
domain: domain.to_string(),
|
||||
addr: v6,
|
||||
ttl,
|
||||
},
|
||||
_ => DnsRecord::A {
|
||||
domain: domain.to_string(),
|
||||
addr: v4,
|
||||
ttl,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
enum Disposition {
|
||||
Leader(broadcast::Sender<Option<DnsPacket>>),
|
||||
Follower(broadcast::Receiver<Option<DnsPacket>>),
|
||||
@@ -675,15 +705,6 @@ mod tests {
|
||||
|
||||
// ---- Integration: resolve_coalesced with mock futures ----
|
||||
|
||||
fn mock_query(id: u16, domain: &str, qtype: QueryType) -> DnsPacket {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = id;
|
||||
pkt.header.recursion_desired = true;
|
||||
pkt.questions
|
||||
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
|
||||
pkt
|
||||
}
|
||||
|
||||
fn mock_response(domain: &str) -> DnsPacket {
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.response = true;
|
||||
@@ -706,7 +727,7 @@ mod tests {
|
||||
let count = resolve_count.clone();
|
||||
let inf = inflight.clone();
|
||||
let key = ("coalesce.test".to_string(), QueryType::A);
|
||||
let query = mock_query(100 + i, "coalesce.test", QueryType::A);
|
||||
let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
|
||||
handles.push(tokio::spawn(async move {
|
||||
resolve_coalesced(&inf, key, &query, || async {
|
||||
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -744,8 +765,8 @@ mod tests {
|
||||
let count1 = resolve_count.clone();
|
||||
let count2 = resolve_count.clone();
|
||||
|
||||
let query_a = mock_query(200, "same.domain", QueryType::A);
|
||||
let query_aaaa = mock_query(201, "same.domain", QueryType::AAAA);
|
||||
let query_a = DnsPacket::query(200, "same.domain", QueryType::A);
|
||||
let query_aaaa = DnsPacket::query(201, "same.domain", QueryType::AAAA);
|
||||
|
||||
let h1 = tokio::spawn(async move {
|
||||
resolve_coalesced(
|
||||
@@ -788,7 +809,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn inflight_map_cleaned_after_error() {
|
||||
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||
let query = mock_query(300, "will-fail.test", QueryType::A);
|
||||
let query = DnsPacket::query(300, "will-fail.test", QueryType::A);
|
||||
|
||||
let (_, path, _) = resolve_coalesced(
|
||||
&inflight,
|
||||
@@ -809,7 +830,7 @@ mod tests {
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..3u16 {
|
||||
let inf = inflight.clone();
|
||||
let query = mock_query(400 + i, "fail.test", QueryType::A);
|
||||
let query = DnsPacket::query(400 + i, "fail.test", QueryType::A);
|
||||
handles.push(tokio::spawn(async move {
|
||||
resolve_coalesced(
|
||||
&inf,
|
||||
@@ -849,7 +870,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn servfail_leader_includes_question_section() {
|
||||
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||
let query = mock_query(500, "question.test", QueryType::A);
|
||||
let query = DnsPacket::query(500, "question.test", QueryType::A);
|
||||
|
||||
let (resp, _, _) = resolve_coalesced(
|
||||
&inflight,
|
||||
@@ -873,7 +894,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn leader_error_preserves_message() {
|
||||
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||
let query = mock_query(700, "err-msg.test", QueryType::A);
|
||||
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
|
||||
|
||||
let (_, path, err) = resolve_coalesced(
|
||||
&inflight,
|
||||
|
||||
@@ -141,7 +141,7 @@ mod tests {
|
||||
use std::future::IntoFuture;
|
||||
|
||||
use crate::header::ResultCode;
|
||||
use crate::question::{DnsQuestion, QueryType};
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[test]
|
||||
@@ -160,12 +160,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn make_query() -> DnsPacket {
|
||||
let mut q = DnsPacket::new();
|
||||
q.header.id = 0xABCD;
|
||||
q.header.recursion_desired = true;
|
||||
q.questions
|
||||
.push(DnsQuestion::new("example.com".to_string(), QueryType::A));
|
||||
q
|
||||
DnsPacket::query(0xABCD, "example.com", QueryType::A)
|
||||
}
|
||||
|
||||
fn make_response(query: &DnsPacket) -> DnsPacket {
|
||||
|
||||
153
src/main.rs
153
src/main.rs
@@ -17,10 +17,12 @@ use numa::query_log::QueryLog;
|
||||
use numa::service_store::ServiceStore;
|
||||
use numa::stats::ServerStats;
|
||||
use numa::system_dns::{
|
||||
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
|
||||
uninstall_service, uninstall_system_dns,
|
||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
||||
};
|
||||
|
||||
const QUAD9_IP: &str = "9.9.9.9";
|
||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> numa::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
@@ -31,12 +33,12 @@ async fn main() -> numa::Result<()> {
|
||||
let arg1 = std::env::args().nth(1).unwrap_or_default();
|
||||
match arg1.as_str() {
|
||||
"install" => {
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n");
|
||||
return install_system_dns().map_err(|e| e.into());
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
|
||||
return install_service().map_err(|e| e.into());
|
||||
}
|
||||
"uninstall" => {
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
|
||||
return uninstall_system_dns().map_err(|e| e.into());
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
|
||||
return uninstall_service().map_err(|e| e.into());
|
||||
}
|
||||
"service" => {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
@@ -107,32 +109,81 @@ async fn main() -> numa::Result<()> {
|
||||
// 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
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| {
|
||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||
"https://dns.quad9.net/dns-query".to_string()
|
||||
})
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||
|
||||
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Upstream::Doh {
|
||||
url: upstream_addr,
|
||||
client,
|
||||
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
||||
numa::config::UpstreamMode::Auto => {
|
||||
info!("auto mode: probing recursive resolution...");
|
||||
if numa::recursive::probe_recursive(&root_hints).await {
|
||||
info!("recursive probe succeeded — self-sovereign mode");
|
||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
} else {
|
||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let url = DOH_FALLBACK.to_string();
|
||||
let label = url.clone();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
false,
|
||||
Upstream::Doh { url, client },
|
||||
label,
|
||||
)
|
||||
}
|
||||
}
|
||||
numa::config::UpstreamMode::Recursive => {
|
||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
}
|
||||
numa::config::UpstreamMode::Forward => {
|
||||
let upstream_addr = if config.upstream.address.is_empty() {
|
||||
system_dns
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| {
|
||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||
DOH_FALLBACK.to_string()
|
||||
})
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
|
||||
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Upstream::Doh {
|
||||
url: upstream_addr,
|
||||
client,
|
||||
}
|
||||
} else {
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||
Upstream::Udp(addr)
|
||||
};
|
||||
let label = upstream.to_string();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
config.upstream.address.is_empty(),
|
||||
upstream,
|
||||
label,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||
Upstream::Udp(addr)
|
||||
};
|
||||
let upstream_label = upstream.to_string();
|
||||
let api_port = config.server.api_port;
|
||||
|
||||
let mut blocklist = BlocklistStore::new();
|
||||
@@ -183,7 +234,7 @@ async fn main() -> numa::Result<()> {
|
||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||
forwarding_rules,
|
||||
upstream: Mutex::new(upstream),
|
||||
upstream_auto: config.upstream.address.is_empty(),
|
||||
upstream_auto,
|
||||
upstream_port: config.upstream.port,
|
||||
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
||||
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||
@@ -199,8 +250,8 @@ async fn main() -> numa::Result<()> {
|
||||
config_dir: numa::config_dir(),
|
||||
data_dir: numa::data_dir(),
|
||||
tls_config: initial_tls,
|
||||
upstream_mode: config.upstream.mode,
|
||||
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
|
||||
upstream_mode: resolved_mode,
|
||||
root_hints,
|
||||
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
|
||||
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
dnssec_enabled: config.dnssec.enabled,
|
||||
@@ -208,7 +259,6 @@ async fn main() -> numa::Result<()> {
|
||||
});
|
||||
|
||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||
|
||||
// Build banner rows, then size the box to fit the longest value
|
||||
let api_url = format!("http://localhost:{}", api_port);
|
||||
let proxy_label = if config.proxy.enabled {
|
||||
@@ -308,6 +358,17 @@ async fn main() -> numa::Result<()> {
|
||||
);
|
||||
if let Some(ref label) = proxy_label {
|
||||
row("Proxy", g, label);
|
||||
if config.proxy.bind_addr == "127.0.0.1" {
|
||||
let y = "\x1b[38;2;204;176;59m"; // yellow
|
||||
row(
|
||||
"",
|
||||
y,
|
||||
&format!(
|
||||
"⚠ proxy on 127.0.0.1 — .{} not LAN reachable",
|
||||
config.proxy.tld
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
@@ -375,16 +436,11 @@ async fn main() -> numa::Result<()> {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value
|
||||
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
|
||||
std::net::Ipv4Addr::UNSPECIFIED
|
||||
} else {
|
||||
config
|
||||
.proxy
|
||||
.bind_addr
|
||||
.parse()
|
||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
||||
};
|
||||
let proxy_bind: std::net::Ipv4Addr = config
|
||||
.proxy
|
||||
.bind_addr
|
||||
.parse()
|
||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST);
|
||||
|
||||
// Spawn HTTP reverse proxy for .numa domains
|
||||
if config.proxy.enabled {
|
||||
@@ -425,7 +481,14 @@ async fn main() -> numa::Result<()> {
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
|
||||
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
|
||||
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
@@ -468,7 +531,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
let new_addr = dns_info
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| "9.9.9.9".to_string());
|
||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||
if let Ok(new_sock) =
|
||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||
{
|
||||
|
||||
@@ -117,6 +117,22 @@ impl OverrideStore {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let per_slot = std::mem::size_of::<u64>()
|
||||
+ std::mem::size_of::<String>()
|
||||
+ std::mem::size_of::<OverrideEntry>()
|
||||
+ 1;
|
||||
let table = self.entries.capacity() * per_slot;
|
||||
let heap: usize = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes()
|
||||
})
|
||||
.sum();
|
||||
table + heap
|
||||
}
|
||||
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.entries.values().filter(|e| !e.is_expired()).count()
|
||||
}
|
||||
@@ -154,3 +170,16 @@ fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsR
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_entries() {
|
||||
let mut store = OverrideStore::new();
|
||||
let empty = store.heap_bytes();
|
||||
store.insert("example.com", "1.2.3.4", 300, None).unwrap();
|
||||
assert!(store.heap_bytes() > empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,34 @@ impl DnsPacket {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(id: u16, domain: &str, qtype: crate::question::QueryType) -> DnsPacket {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = id;
|
||||
pkt.header.recursion_desired = true;
|
||||
pkt.questions
|
||||
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
|
||||
pkt
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
fn records_heap(records: &[DnsRecord]) -> usize {
|
||||
records
|
||||
.iter()
|
||||
.map(|r| std::mem::size_of::<DnsRecord>() + r.heap_bytes())
|
||||
.sum::<usize>()
|
||||
}
|
||||
let questions: usize = self
|
||||
.questions
|
||||
.iter()
|
||||
.map(|q| std::mem::size_of::<DnsQuestion>() + q.name.capacity())
|
||||
.sum();
|
||||
questions
|
||||
+ records_heap(&self.answers)
|
||||
+ records_heap(&self.authorities)
|
||||
+ records_heap(&self.resources)
|
||||
+ self.edns.as_ref().map_or(0, |e| e.options.capacity())
|
||||
}
|
||||
|
||||
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query.header.id;
|
||||
@@ -582,4 +610,16 @@ mod tests {
|
||||
panic!("expected DNSKEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_accounts_for_records() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
let empty = pkt.heap_bytes();
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
assert!(pkt.heap_bytes() > empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,21 @@ impl QueryLog {
|
||||
self.entries.push_back(entry);
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|e| std::mem::size_of::<QueryLogEntry>() + e.domain.capacity())
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
@@ -77,3 +92,25 @@ pub struct QueryLogFilter {
|
||||
pub since: Option<SystemTime>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_entries() {
|
||||
let mut log = QueryLog::new(100);
|
||||
let empty = log.heap_bytes();
|
||||
log.push(QueryLogEntry {
|
||||
timestamp: SystemTime::now(),
|
||||
src_addr: "127.0.0.1:1234".parse().unwrap(),
|
||||
domain: "example.com".into(),
|
||||
query_type: QueryType::A,
|
||||
path: QueryPath::Forwarded,
|
||||
rescode: ResultCode::NOERROR,
|
||||
latency_us: 500,
|
||||
dnssec: DnssecStatus::Indeterminate,
|
||||
});
|
||||
assert!(log.heap_bytes() > empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,46 @@ impl DnsRecord {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
match self {
|
||||
DnsRecord::A { domain, .. } => domain.capacity(),
|
||||
DnsRecord::NS { domain, host, .. } | DnsRecord::CNAME { domain, host, .. } => {
|
||||
domain.capacity() + host.capacity()
|
||||
}
|
||||
DnsRecord::MX { domain, host, .. } => domain.capacity() + host.capacity(),
|
||||
DnsRecord::AAAA { domain, .. } => domain.capacity(),
|
||||
DnsRecord::DNSKEY {
|
||||
domain, public_key, ..
|
||||
} => domain.capacity() + public_key.capacity(),
|
||||
DnsRecord::DS { domain, digest, .. } => domain.capacity() + digest.capacity(),
|
||||
DnsRecord::RRSIG {
|
||||
domain,
|
||||
signer_name,
|
||||
signature,
|
||||
..
|
||||
} => domain.capacity() + signer_name.capacity() + signature.capacity(),
|
||||
DnsRecord::NSEC {
|
||||
domain,
|
||||
next_domain,
|
||||
type_bitmap,
|
||||
..
|
||||
} => domain.capacity() + next_domain.capacity() + type_bitmap.capacity(),
|
||||
DnsRecord::NSEC3 {
|
||||
domain,
|
||||
salt,
|
||||
next_hashed_owner,
|
||||
type_bitmap,
|
||||
..
|
||||
} => {
|
||||
domain.capacity()
|
||||
+ salt.capacity()
|
||||
+ next_hashed_owner.capacity()
|
||||
+ type_bitmap.capacity()
|
||||
}
|
||||
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ttl(&mut self, new_ttl: u32) {
|
||||
match self {
|
||||
DnsRecord::A { ttl, .. }
|
||||
@@ -650,4 +690,14 @@ mod tests {
|
||||
let parsed = round_trip(&rec);
|
||||
assert_eq!(rec, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_reflects_string_capacity() {
|
||||
let rec = DnsRecord::CNAME {
|
||||
domain: "a]".repeat(100),
|
||||
host: "b".repeat(200),
|
||||
ttl: 60,
|
||||
};
|
||||
assert!(rec.heap_bytes() >= 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::cache::DnsCache;
|
||||
use crate::forward::forward_udp;
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::{DnsQuestion, QueryType};
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
use crate::srtt::SrttCache;
|
||||
|
||||
@@ -32,6 +32,14 @@ fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
|
||||
SocketAddr::new(ip.into(), 53)
|
||||
}
|
||||
|
||||
fn record_to_addr(rec: &DnsRecord) -> Option<SocketAddr> {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => Some(dns_addr(*addr)),
|
||||
DnsRecord::AAAA { addr, .. } => Some(dns_addr(*addr)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_udp_state() {
|
||||
UDP_DISABLED.store(false, Ordering::Release);
|
||||
UDP_FAILURES.store(0, Ordering::Release);
|
||||
@@ -46,11 +54,8 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
|
||||
Some(h) => *h,
|
||||
None => return,
|
||||
};
|
||||
let mut probe = DnsPacket::new();
|
||||
probe.header.id = next_id();
|
||||
probe
|
||||
.questions
|
||||
.push(DnsQuestion::new(".".to_string(), QueryType::NS));
|
||||
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
|
||||
probe.header.recursion_desired = false;
|
||||
if forward_udp(&probe, hint, Duration::from_millis(1500))
|
||||
.await
|
||||
.is_ok()
|
||||
@@ -60,6 +65,21 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether recursive resolution works by querying root servers.
|
||||
/// Tries up to 3 hints before declaring failure.
|
||||
pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool {
|
||||
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
|
||||
probe.header.recursion_desired = false;
|
||||
for hint in root_hints.iter().take(3) {
|
||||
if let Ok(resp) = forward_udp(&probe, *hint, Duration::from_secs(3)).await {
|
||||
if !resp.answers.is_empty() || !resp.authorities.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn prime_tld_cache(
|
||||
cache: &RwLock<DnsCache>,
|
||||
root_hints: &[SocketAddr],
|
||||
@@ -296,17 +316,8 @@ pub(crate) fn resolve_iterative<'a>(
|
||||
)
|
||||
.await
|
||||
{
|
||||
for rec in &ns_resp.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => {
|
||||
new_ns_addrs.push(dns_addr(*addr));
|
||||
}
|
||||
DnsRecord::AAAA { addr, .. } => {
|
||||
new_ns_addrs.push(dns_addr(*addr));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
new_ns_addrs
|
||||
.extend(ns_resp.answers.iter().filter_map(record_to_addr));
|
||||
}
|
||||
if !new_ns_addrs.is_empty() {
|
||||
break;
|
||||
@@ -360,13 +371,7 @@ fn find_closest_ns(
|
||||
if let DnsRecord::NS { host, .. } = ns_rec {
|
||||
for qt in [QueryType::A, QueryType::AAAA] {
|
||||
if let Some(resp) = guard.lookup(host, qt) {
|
||||
for rec in &resp.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
addrs.extend(resp.answers.iter().filter_map(record_to_addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,13 +457,7 @@ fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
|
||||
let mut addrs = Vec::new();
|
||||
for qt in [QueryType::A, QueryType::AAAA] {
|
||||
if let Some(pkt) = guard.lookup(name, qt) {
|
||||
for rec in &pkt.answers {
|
||||
match rec {
|
||||
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
addrs.extend(pkt.answers.iter().filter_map(record_to_addr));
|
||||
}
|
||||
}
|
||||
addrs
|
||||
@@ -468,15 +467,13 @@ fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
|
||||
response
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
Some(dns_addr(*addr))
|
||||
.filter(|r| match r {
|
||||
DnsRecord::A { domain, .. } | DnsRecord::AAAA { domain, .. } => {
|
||||
domain.eq_ignore_ascii_case(ns_name)
|
||||
}
|
||||
DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||
Some(dns_addr(*addr))
|
||||
}
|
||||
_ => None,
|
||||
_ => false,
|
||||
})
|
||||
.filter_map(record_to_addr)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -596,12 +593,8 @@ async fn send_query(
|
||||
server: SocketAddr,
|
||||
srtt: &RwLock<SrttCache>,
|
||||
) -> crate::Result<DnsPacket> {
|
||||
let mut query = DnsPacket::new();
|
||||
query.header.id = next_id();
|
||||
let mut query = DnsPacket::query(next_id(), qname, qtype);
|
||||
query.header.recursion_desired = false;
|
||||
query
|
||||
.questions
|
||||
.push(DnsQuestion::new(qname.to_string(), qtype));
|
||||
query.edns = Some(crate::packet::EdnsOpt {
|
||||
do_bit: true,
|
||||
..Default::default()
|
||||
@@ -1056,11 +1049,7 @@ mod tests {
|
||||
})
|
||||
.await;
|
||||
|
||||
let mut query = DnsPacket::new();
|
||||
query.header.id = 0xBEEF;
|
||||
query
|
||||
.questions
|
||||
.push(DnsQuestion::new("test.com".to_string(), QueryType::A));
|
||||
let query = DnsPacket::query(0xBEEF, "test.com", QueryType::A);
|
||||
|
||||
let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2))
|
||||
.await
|
||||
@@ -1120,11 +1109,7 @@ mod tests {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let mut query = DnsPacket::new();
|
||||
query.header.id = 0xCAFE;
|
||||
query
|
||||
.questions
|
||||
.push(DnsQuestion::new("strict.test".to_string(), QueryType::A));
|
||||
let query = DnsPacket::query(0xCAFE, "strict.test", QueryType::A);
|
||||
|
||||
let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2))
|
||||
.await
|
||||
|
||||
117
src/srtt.rs
117
src/srtt.rs
@@ -47,16 +47,19 @@ impl SrttCache {
|
||||
|
||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||
let age_secs = entry.updated_at.elapsed().as_secs();
|
||||
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
|
||||
if age_secs > DECAY_AFTER_SECS {
|
||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||
let mut srtt = entry.srtt_ms;
|
||||
let mut srtt = srtt_ms;
|
||||
for _ in 0..periods {
|
||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
srtt
|
||||
} else {
|
||||
entry.srtt_ms
|
||||
srtt_ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +103,14 @@ impl SrttCache {
|
||||
addrs.sort_by_key(|a| self.get(a.ip()));
|
||||
}
|
||||
|
||||
pub fn heap_bytes(&self) -> usize {
|
||||
let per_slot = std::mem::size_of::<u64>()
|
||||
+ std::mem::size_of::<IpAddr>()
|
||||
+ std::mem::size_of::<SrttEntry>()
|
||||
+ 1;
|
||||
self.entries.capacity() * per_slot
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
@@ -108,13 +119,6 @@ impl SrttCache {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
|
||||
if let Some(entry) = self.entries.get_mut(&ip) {
|
||||
entry.updated_at = at;
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_evict(&mut self) {
|
||||
if self.entries.len() < MAX_ENTRIES {
|
||||
return;
|
||||
@@ -210,63 +214,41 @@ mod tests {
|
||||
assert_eq!(addrs, original);
|
||||
}
|
||||
|
||||
fn age(secs: u64) -> Instant {
|
||||
Instant::now() - std::time::Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Cache with ip(1) saturated at FAILURE_PENALTY_MS
|
||||
fn saturated_penalty_cache() -> SrttCache {
|
||||
let mut cache = SrttCache::new(true);
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false);
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decay_within_threshold() {
|
||||
let mut cache = SrttCache::new(true);
|
||||
cache.record_rtt(ip(1), 5000, false);
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
|
||||
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms);
|
||||
// At exactly DECAY_AFTER_SECS, no decay applied
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
||||
assert_eq!(result, FAILURE_PENALTY_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_decay_period() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
|
||||
let expected = (raw + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
||||
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_decay_periods() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
|
||||
let mut expected = raw;
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
||||
let mut expected = FAILURE_PENALTY_MS;
|
||||
for _ in 0..4 {
|
||||
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_caps_at_8_periods() {
|
||||
// 9 periods and 100 periods should produce the same result (capped at 8)
|
||||
let mut cache_a = saturated_penalty_cache();
|
||||
let mut cache_b = saturated_penalty_cache();
|
||||
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
|
||||
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
|
||||
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
|
||||
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_converges_toward_initial() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
let decayed = cache.get(ip(1));
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||
assert!(
|
||||
diff < 25,
|
||||
@@ -278,29 +260,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_rtt_applies_decay_before_ewma() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
|
||||
cache.record_rtt(ip(1), 50, false);
|
||||
let srtt = cache.get(ip(1));
|
||||
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
|
||||
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
|
||||
// Verify decay is applied before EWMA in record_rtt by checking
|
||||
// that a saturated penalty + long age + new sample produces a low SRTT
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
||||
// EWMA: (decayed * 7 + 50) / 8
|
||||
let after_ewma = (decayed * 7 + 50) / 8;
|
||||
assert!(
|
||||
after_ewma < 500,
|
||||
"expected decay before EWMA, got srtt={}",
|
||||
after_ewma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_reranks_stale_failures() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(2), 300, false);
|
||||
}
|
||||
let mut addrs = vec![sock(1), sock(2)];
|
||||
cache.sort_by_rtt(&mut addrs);
|
||||
assert_eq!(addrs, vec![sock(2), sock(1)]);
|
||||
// After enough decay, a failed server (5000ms) converges toward
|
||||
// INITIAL (200ms), which is below a stable server at 300ms
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert!(
|
||||
decayed < 300,
|
||||
"expected decayed penalty ({}) < 300ms",
|
||||
decayed
|
||||
);
|
||||
}
|
||||
|
||||
// Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
let mut addrs = vec![sock(1), sock(2)];
|
||||
cache.sort_by_rtt(&mut addrs);
|
||||
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_entries() {
|
||||
let mut cache = SrttCache::new(true);
|
||||
let empty = cache.heap_bytes();
|
||||
for i in 1..=10u8 {
|
||||
cache.record_rtt(ip(i), 100, false);
|
||||
}
|
||||
assert!(cache.heap_bytes() > empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
87
src/stats.rs
87
src/stats.rs
@@ -1,5 +1,92 @@
|
||||
use std::time::Instant;
|
||||
|
||||
/// Returns the process memory footprint in bytes, or 0 if unavailable.
|
||||
/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm.
|
||||
pub fn process_memory_bytes() -> usize {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos_rss()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_rss()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_rss() -> usize {
|
||||
use std::mem;
|
||||
extern "C" {
|
||||
fn mach_task_self() -> u32;
|
||||
fn task_info(
|
||||
target_task: u32,
|
||||
flavor: u32,
|
||||
task_info_out: *mut TaskVmInfo,
|
||||
task_info_count: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
// Partial task_vm_info_data_t — only fields up to phys_footprint.
|
||||
#[repr(C)]
|
||||
struct TaskVmInfo {
|
||||
virtual_size: u64,
|
||||
region_count: i32,
|
||||
page_size: i32,
|
||||
resident_size: u64,
|
||||
resident_size_peak: u64,
|
||||
device: u64,
|
||||
device_peak: u64,
|
||||
internal: u64,
|
||||
internal_peak: u64,
|
||||
external: u64,
|
||||
external_peak: u64,
|
||||
reusable: u64,
|
||||
reusable_peak: u64,
|
||||
purgeable_volatile_pmap: u64,
|
||||
purgeable_volatile_resident: u64,
|
||||
purgeable_volatile_virtual: u64,
|
||||
compressed: u64,
|
||||
compressed_peak: u64,
|
||||
compressed_lifetime: u64,
|
||||
phys_footprint: u64,
|
||||
}
|
||||
const TASK_VM_INFO: u32 = 22;
|
||||
let mut info: TaskVmInfo = unsafe { mem::zeroed() };
|
||||
let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<u32>()) as u32;
|
||||
let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) };
|
||||
if kr == 0 {
|
||||
info.phys_footprint as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_rss() -> usize {
|
||||
extern "C" {
|
||||
fn sysconf(name: i32) -> i64;
|
||||
}
|
||||
const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29)
|
||||
let page_size = unsafe { sysconf(SC_PAGESIZE) };
|
||||
let page_size = if page_size > 0 {
|
||||
page_size as usize
|
||||
} else {
|
||||
4096
|
||||
};
|
||||
|
||||
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
|
||||
if let Some(rss_pages) = statm.split_whitespace().nth(1) {
|
||||
if let Ok(pages) = rss_pages.parse::<usize>() {
|
||||
return pages * page_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
pub struct ServerStats {
|
||||
queries_total: u64,
|
||||
queries_forwarded: u64,
|
||||
|
||||
@@ -2,6 +2,10 @@ use std::net::SocketAddr;
|
||||
|
||||
use log::info;
|
||||
|
||||
fn is_loopback_or_stub(addr: &str) -> bool {
|
||||
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
|
||||
}
|
||||
|
||||
/// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForwardingRule {
|
||||
@@ -26,10 +30,7 @@ pub fn discover_system_dns() -> SystemDnsInfo {
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
SystemDnsInfo {
|
||||
default_upstream: detect_upstream_linux_or_backup(),
|
||||
forwarding_rules: Vec::new(),
|
||||
}
|
||||
discover_linux()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@@ -102,11 +103,7 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
if ns.parse::<std::net::Ipv4Addr>().is_ok() {
|
||||
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"
|
||||
{
|
||||
if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&ns) {
|
||||
default_upstream = Some(ns);
|
||||
}
|
||||
}
|
||||
@@ -156,7 +153,7 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
|
||||
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
|
||||
Some(ForwardingRule {
|
||||
@@ -166,38 +163,100 @@ 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(target_os = "linux")]
|
||||
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
|
||||
}
|
||||
const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_upstream_from_file(path: &str) -> Option<String> {
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
fn discover_linux() -> SystemDnsInfo {
|
||||
// Parse resolv.conf once for both upstream and search domains
|
||||
let (upstream, search_domains) = parse_resolv_conf("/etc/resolv.conf");
|
||||
|
||||
let default_upstream = if let Some(ns) = upstream {
|
||||
info!("detected system upstream: {}", ns);
|
||||
Some(ns)
|
||||
} else {
|
||||
// Fallback to backup from a previous `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")
|
||||
};
|
||||
let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
|
||||
if let Some(ref ns) = ns {
|
||||
info!("detected original upstream from backup: {}", ns);
|
||||
}
|
||||
ns
|
||||
};
|
||||
|
||||
// On cloud VMs (AWS/GCP), internal domains need to reach the VPC resolver
|
||||
let forwarding_rules = if search_domains.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let forwarder = resolvectl_dns_server().unwrap_or_else(|| CLOUD_VPC_RESOLVER.to_string());
|
||||
let rules: Vec<_> = search_domains
|
||||
.iter()
|
||||
.filter_map(|domain| {
|
||||
let rule = make_rule(domain, &forwarder)?;
|
||||
info!("forwarding .{} to {}", domain, forwarder);
|
||||
Some(rule)
|
||||
})
|
||||
.collect();
|
||||
if !rules.is_empty() {
|
||||
info!("detected {} search domain forwarding rules", rules.len());
|
||||
}
|
||||
rules
|
||||
};
|
||||
|
||||
SystemDnsInfo {
|
||||
default_upstream,
|
||||
forwarding_rules,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
|
||||
/// nameserver and all search domains.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return (None, Vec::new()),
|
||||
};
|
||||
let mut upstream = None;
|
||||
let mut search_domains = Vec::new();
|
||||
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());
|
||||
if upstream.is_none() {
|
||||
if let Some(ns) = line.split_whitespace().nth(1) {
|
||||
if !is_loopback_or_stub(ns) {
|
||||
upstream = Some(ns.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if line.starts_with("search") || line.starts_with("domain") {
|
||||
for domain in line.split_whitespace().skip(1) {
|
||||
search_domains.push(domain.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
(upstream, search_domains)
|
||||
}
|
||||
|
||||
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn resolvectl_dns_server() -> Option<String> {
|
||||
let output = std::process::Command::new("resolvectl")
|
||||
.args(["status", "--no-pager"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains("DNS Servers") || line.contains("Current DNS Server") {
|
||||
if let Some(ip) = line.split(':').next_back() {
|
||||
let ip = ip.trim();
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||
return Some(ip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,10 +295,7 @@ fn detect_dhcp_dns_macos() -> Option<String> {
|
||||
// Take the first non-loopback DNS server
|
||||
for addr in inner.split(',') {
|
||||
let addr = addr.trim();
|
||||
if !addr.is_empty()
|
||||
&& addr != "127.0.0.1"
|
||||
&& addr != "0.0.0.0"
|
||||
&& addr.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
if !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
{
|
||||
log::info!("detected DHCP DNS: {}", addr);
|
||||
return Some(addr.to_string());
|
||||
@@ -278,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
|
||||
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||
if let Some(ip) = trimmed.split(':').next_back() {
|
||||
let ip = ip.trim();
|
||||
if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" {
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||
upstream = Some(ip.to_string());
|
||||
break;
|
||||
}
|
||||
@@ -302,6 +358,339 @@ fn discover_windows() -> SystemDnsInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||
struct WindowsInterfaceDns {
|
||||
dhcp: bool,
|
||||
servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
||||
let mut interfaces = std::collections::HashMap::new();
|
||||
let mut current_adapter: Option<String> = None;
|
||||
let mut current_dhcp = false;
|
||||
let mut current_dns: Vec<String> = Vec::new();
|
||||
let mut in_dns_block = false;
|
||||
let mut disconnected = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Adapter section headers start at column 0
|
||||
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
||||
if let Some(name) = current_adapter.take() {
|
||||
if !disconnected {
|
||||
interfaces.insert(
|
||||
name,
|
||||
WindowsInterfaceDns {
|
||||
dhcp: current_dhcp,
|
||||
servers: std::mem::take(&mut current_dns),
|
||||
},
|
||||
);
|
||||
}
|
||||
current_dns.clear();
|
||||
}
|
||||
in_dns_block = false;
|
||||
current_dhcp = false;
|
||||
disconnected = false;
|
||||
|
||||
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
||||
let lower = trimmed.to_lowercase();
|
||||
if let Some(pos) = lower.find(" adapter ") {
|
||||
let after = &trimmed[pos + " adapter ".len()..];
|
||||
let name = after.trim_end_matches(':').trim();
|
||||
if !name.is_empty() {
|
||||
current_adapter = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
} else if current_adapter.is_some() {
|
||||
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
||||
disconnected = true;
|
||||
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
||||
current_dhcp = trimmed
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|v| {
|
||||
let v = v.trim().to_lowercase();
|
||||
v == "yes" || v == "ja"
|
||||
})
|
||||
.unwrap_or(false);
|
||||
in_dns_block = false;
|
||||
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||
in_dns_block = true;
|
||||
if let Some(ip) = trimmed.split(':').next_back() {
|
||||
let ip = ip.trim();
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||
current_dns.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
} else if in_dns_block {
|
||||
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
||||
current_dns.push(trimmed.to_string());
|
||||
} else {
|
||||
in_dns_block = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = current_adapter {
|
||||
if !disconnected {
|
||||
interfaces.insert(
|
||||
name,
|
||||
WindowsInterfaceDns {
|
||||
dhcp: current_dhcp,
|
||||
servers: current_dns,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interfaces
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
||||
{
|
||||
let output = std::process::Command::new("ipconfig")
|
||||
.arg("/all")
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_ipconfig_interfaces(&text))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_backup_path() -> std::path::PathBuf {
|
||||
// Use ProgramData (not APPDATA) since install requires admin elevation
|
||||
// and APPDATA differs between user and admin contexts.
|
||||
std::path::PathBuf::from(
|
||||
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
||||
)
|
||||
.join("numa")
|
||||
.join("original-dns.json")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn disable_dnscache() -> Result<bool, String> {
|
||||
// Check if Dnscache is running (it holds port 53 at kernel level)
|
||||
let output = std::process::Command::new("sc")
|
||||
.args(["query", "Dnscache"])
|
||||
.output()
|
||||
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if !text.contains("RUNNING") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
|
||||
// Dnscache can't be stopped via sc/net stop — must disable via registry
|
||||
let status = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||
"/v",
|
||||
"Start",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"4",
|
||||
"/f",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
|
||||
}
|
||||
|
||||
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn enable_dnscache() {
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||
"/v",
|
||||
"Start",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"2",
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn install_windows() -> Result<(), String> {
|
||||
let interfaces = get_windows_interfaces()?;
|
||||
if interfaces.is_empty() {
|
||||
return Err("no active network interfaces found".to_string());
|
||||
}
|
||||
|
||||
let path = windows_backup_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&interfaces)
|
||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||
|
||||
for name in interfaces.keys() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"set",
|
||||
"dnsservers",
|
||||
name,
|
||||
"static",
|
||||
"127.0.0.1",
|
||||
"primary",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
||||
|
||||
if status.success() {
|
||||
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
||||
} else {
|
||||
eprintln!(
|
||||
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
register_autostart();
|
||||
|
||||
eprintln!("\n Original DNS saved to {}", path.display());
|
||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||
if needs_reboot {
|
||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||
} else {
|
||||
eprintln!(" Numa will start automatically on next boot.\n");
|
||||
}
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register numa to auto-start on boot via registry Run key.
|
||||
#[cfg(windows)]
|
||||
fn register_autostart() {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "numa".into());
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||
"/v",
|
||||
"Numa",
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
&exe,
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
eprintln!(" Registered auto-start on boot.");
|
||||
}
|
||||
|
||||
/// Remove numa auto-start registry key.
|
||||
#[cfg(windows)]
|
||||
fn remove_autostart() {
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"delete",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||
"/v",
|
||||
"Numa",
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn uninstall_windows() -> Result<(), String> {
|
||||
remove_autostart();
|
||||
let path = windows_backup_path();
|
||||
let json = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
||||
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
||||
|
||||
for (name, dns_info) in &original {
|
||||
if dns_info.dhcp || dns_info.servers.is_empty() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||
|
||||
if status.success() {
|
||||
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
||||
} else {
|
||||
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
||||
}
|
||||
} else {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"set",
|
||||
"dnsservers",
|
||||
name,
|
||||
"static",
|
||||
&dns_info.servers[0],
|
||||
"primary",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||
|
||||
if !status.success() {
|
||||
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
||||
let _ = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"add",
|
||||
"dnsservers",
|
||||
name,
|
||||
server,
|
||||
&format!("index={}", i + 2),
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
" restored DNS for \"{}\" -> {}",
|
||||
name,
|
||||
dns_info.servers.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
// Re-enable Dnscache
|
||||
enable_dnscache();
|
||||
eprintln!("\n System DNS restored. DNS Client re-enabled.");
|
||||
eprintln!(" Reboot to fully restore the DNS Client service.\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -316,43 +705,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
|
||||
|
||||
// --- System DNS configuration (install/uninstall) ---
|
||||
|
||||
/// Set the system DNS to 127.0.0.1 so all queries go through Numa.
|
||||
/// Saves the original DNS settings for later restoration.
|
||||
pub fn install_system_dns() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let result = install_macos();
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = install_linux();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
let result = Err("system DNS configuration not supported on this OS".to_string());
|
||||
|
||||
if result.is_ok() {
|
||||
if let Err(e) = trust_ca() {
|
||||
eprintln!(" warning: could not trust CA: {}", e);
|
||||
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Restore the original system DNS settings saved during install.
|
||||
pub fn uninstall_system_dns() -> Result<(), String> {
|
||||
let _ = untrust_ca();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
uninstall_macos()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
uninstall_linux()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Err("system DNS configuration not supported on this OS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// --- macOS implementation ---
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -424,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"])
|
||||
@@ -436,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());
|
||||
@@ -480,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();
|
||||
@@ -500,21 +862,27 @@ const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service";
|
||||
/// Install Numa as a system service that starts on boot and auto-restarts.
|
||||
pub fn install_service() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
install_service_macos()
|
||||
}
|
||||
let result = install_service_macos();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
install_service_linux()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
Err("service installation not supported on this OS".to_string())
|
||||
let result = install_service_linux();
|
||||
#[cfg(windows)]
|
||||
let result = install_windows();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||
|
||||
if result.is_ok() {
|
||||
if let Err(e) = trust_ca() {
|
||||
eprintln!(" warning: could not trust CA: {}", e);
|
||||
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Uninstall the Numa system service.
|
||||
pub fn uninstall_service() -> Result<(), String> {
|
||||
let _ = untrust_ca();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
uninstall_service_macos()
|
||||
@@ -523,7 +891,11 @@ pub fn uninstall_service() -> Result<(), String> {
|
||||
{
|
||||
uninstall_service_linux()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
uninstall_windows()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service uninstallation not supported on this OS".to_string())
|
||||
}
|
||||
@@ -531,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()
|
||||
{
|
||||
@@ -544,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();
|
||||
@@ -554,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(())
|
||||
}
|
||||
@@ -593,23 +970,27 @@ 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))?;
|
||||
|
||||
// Load the service
|
||||
// Load the service first so numa is listening before DNS redirect
|
||||
let status = std::process::Command::new("launchctl")
|
||||
.args(["load", "-w", PLIST_DEST])
|
||||
.status()
|
||||
@@ -619,14 +1000,34 @@ fn install_service_macos() -> Result<(), String> {
|
||||
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.");
|
||||
// Wait for numa to be ready before redirecting DNS
|
||||
let api_up = (0..10).any(|i| {
|
||||
if i > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok()
|
||||
});
|
||||
if !api_up {
|
||||
// Service failed to start — don't redirect DNS to a dead endpoint
|
||||
let _ = std::process::Command::new("launchctl")
|
||||
.args(["unload", PLIST_DEST])
|
||||
.status();
|
||||
return Err(
|
||||
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = install_macos() {
|
||||
eprintln!(" warning: failed to configure system DNS: {}", e);
|
||||
}
|
||||
|
||||
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 fully uninstall.\n");
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -708,8 +1109,11 @@ fn install_linux() -> Result<(), String> {
|
||||
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
|
||||
|
||||
let drop_in = resolved_dir.join("numa.conf");
|
||||
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n")
|
||||
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||
std::fs::write(
|
||||
&drop_in,
|
||||
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
|
||||
)
|
||||
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||
|
||||
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
||||
eprintln!(" systemd-resolved detected.");
|
||||
@@ -745,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))?;
|
||||
|
||||
@@ -784,35 +1188,30 @@ 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))?;
|
||||
|
||||
run_systemctl(&["daemon-reload"])?;
|
||||
run_systemctl(&["enable", "numa"])?;
|
||||
run_systemctl(&["start", "numa"])?;
|
||||
|
||||
eprintln!(" Service installed and started.");
|
||||
|
||||
// Set system DNS now that the service is running
|
||||
// Configure system DNS before starting numa so resolved releases port 53 first
|
||||
if let Err(e) = install_linux() {
|
||||
eprintln!(" warning: failed to configure system DNS: {}", e);
|
||||
}
|
||||
|
||||
run_systemctl(&["start", "numa"])?;
|
||||
|
||||
eprintln!(" Service installed and started.");
|
||||
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||
eprintln!(" Logs: journalctl -u numa -f");
|
||||
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
|
||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -977,3 +1376,76 @@ fn untrust_ca() -> Result<(), String> {
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ipconfig_dhcp_and_static() {
|
||||
let sample = "\
|
||||
Ethernet adapter Ethernet:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : Yes
|
||||
DNS Servers . . . . . . . . . . . : 8.8.8.8
|
||||
8.8.4.4
|
||||
|
||||
Wireless LAN adapter Wi-Fi:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : No
|
||||
DNS Servers . . . . . . . . . . . : 1.1.1.1
|
||||
";
|
||||
let result = parse_ipconfig_interfaces(sample);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(
|
||||
result["Ethernet"],
|
||||
WindowsInterfaceDns {
|
||||
dhcp: true,
|
||||
servers: vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
result["Wi-Fi"],
|
||||
WindowsInterfaceDns {
|
||||
dhcp: false,
|
||||
servers: vec!["1.1.1.1".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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 = "\
|
||||
Ethernet adapter Ethernet 2:
|
||||
|
||||
Media State . . . . . . . . . . . : Media disconnected
|
||||
|
||||
Wireless LAN adapter Wi-Fi:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : Yes
|
||||
DNS Servers . . . . . . . . . . . : 192.168.1.1
|
||||
";
|
||||
let result = parse_ipconfig_interfaces(sample);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result.contains_key("Wi-Fi"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user