Compare commits
52 Commits
v0.13.0
...
Edi013/bug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fc59a7bd5 | ||
|
|
f9ce82f4b0 | ||
|
|
1d9495c013 | ||
|
|
34b75833b8 | ||
|
|
99af97a67b | ||
|
|
9e56054f37 | ||
|
|
fe9f31616e | ||
|
|
9f08d8b489 | ||
|
|
9bea038cb6 | ||
|
|
f0a1dd7106 | ||
|
|
6789c321bc | ||
|
|
da40a8dbfc | ||
|
|
65e65028a0 | ||
|
|
d3eab73a31 | ||
|
|
22ec684e48 | ||
|
|
aa040fd8a4 | ||
|
|
b69cc89d38 | ||
|
|
ebb801650e | ||
|
|
30bb7365c9 | ||
|
|
0118ab0f44 | ||
|
|
a87f907d20 | ||
|
|
1c5e703330 | ||
|
|
cc635f2f73 | ||
|
|
7bb484ada3 | ||
|
|
b610160cd1 | ||
|
|
cea4b0ef88 | ||
|
|
43a5ca4bd5 | ||
|
|
b403671e11 | ||
|
|
6f0144b237 | ||
|
|
4bd08e206d | ||
|
|
ebb2a5db39 | ||
|
|
e0e0f50838 | ||
|
|
120ba5200e | ||
|
|
45046bcf6e | ||
|
|
b4b939c78b | ||
|
|
9a85e271ec | ||
|
|
7dc1a0686f | ||
|
|
a02722cdf9 | ||
|
|
3b77dcff61 | ||
|
|
7cc110a0a1 | ||
|
|
75fe625f39 | ||
|
|
908d076d9b | ||
|
|
5381e65be4 | ||
|
|
6b0a30d004 | ||
|
|
169679bfe4 | ||
|
|
d3f046da4c | ||
|
|
0bdde40f40 | ||
|
|
155c1c4da0 | ||
|
|
b40004fe5e | ||
|
|
b8ddc16027 | ||
|
|
48f67be2f1 | ||
|
|
ca00846393 |
75
.github/workflows/ci.yml
vendored
75
.github/workflows/ci.yml
vendored
@@ -3,8 +3,22 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'site/**'
|
||||
- 'blog/**'
|
||||
- 'drafts/**'
|
||||
- '*.md'
|
||||
- 'scripts/serve-site.sh'
|
||||
- 'scripts/generate-blog-index.sh'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'site/**'
|
||||
- 'blog/**'
|
||||
- 'drafts/**'
|
||||
- '*.md'
|
||||
- 'scripts/serve-site.sh'
|
||||
- 'scripts/generate-blog-index.sh'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -42,6 +56,8 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: build
|
||||
@@ -55,3 +71,62 @@ jobs:
|
||||
with:
|
||||
name: numa-windows-x86_64
|
||||
path: target/debug/numa.exe
|
||||
|
||||
integration-linux:
|
||||
needs: [check]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: build
|
||||
run: cargo build --release
|
||||
- name: install / verify / re-install / uninstall
|
||||
run: |
|
||||
sudo ./target/release/numa install
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:5380/health
|
||||
dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.'
|
||||
sudo ./target/release/numa install
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:5380/health
|
||||
sudo ./target/release/numa uninstall
|
||||
sleep 1
|
||||
! curl -sf http://127.0.0.1:5380/health 2>/dev/null
|
||||
- name: cleanup
|
||||
if: always()
|
||||
run: |
|
||||
sudo ./target/release/numa uninstall 2>/dev/null || true
|
||||
# systemd-resolved has a ~40s DNS reconfiguration stall after
|
||||
# restart (systemd issue #22521) that breaks the runner agent's
|
||||
# connection to GitHub. Bridge it by replacing the stub-resolv
|
||||
# symlink with a direct upstream — DNS works instantly and the
|
||||
# runner can phone home for post-job steps.
|
||||
sudo rm -f /etc/resolv.conf
|
||||
echo "nameserver 8.8.8.8" | sudo tee /etc/resolv.conf > /dev/null
|
||||
getent hosts github.com >/dev/null
|
||||
|
||||
integration-macos:
|
||||
needs: [check-macos]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: build
|
||||
run: cargo build --release
|
||||
- name: install / verify / re-install / uninstall
|
||||
run: |
|
||||
sudo ./target/release/numa install
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:5380/health
|
||||
dig @127.0.0.1 example.com +short +timeout=5 | grep -q '.'
|
||||
sudo ./target/release/numa install
|
||||
sleep 2
|
||||
curl -sf http://127.0.0.1:5380/health
|
||||
sudo ./target/release/numa uninstall
|
||||
sleep 1
|
||||
! curl -sf http://127.0.0.1:5380/health 2>/dev/null
|
||||
- name: cleanup
|
||||
if: always()
|
||||
run: sudo ./target/release/numa uninstall 2>/dev/null || true
|
||||
|
||||
45
.github/workflows/docker.yml
vendored
Normal file
45
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
7
.github/workflows/publish-aur.yml
vendored
7
.github/workflows/publish-aur.yml
vendored
@@ -23,6 +23,13 @@ name: Publish - Arch Linux AUR Package
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'site/**'
|
||||
- 'blog/**'
|
||||
- 'drafts/**'
|
||||
- '*.md'
|
||||
- 'scripts/serve-site.sh'
|
||||
- 'scripts/generate-blog-index.sh'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,3 +4,6 @@ CLAUDE.md
|
||||
docs/
|
||||
site/blog/posts/
|
||||
ios/
|
||||
drafts/
|
||||
site/blog/index.html
|
||||
.DS_Store
|
||||
|
||||
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -1330,7 +1330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
@@ -1359,6 +1359,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tower",
|
||||
"webpki-roots 1.0.6",
|
||||
"windows-service",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
@@ -1834,9 +1835,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -2583,6 +2584,17 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-service"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"widestring",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
@@ -33,6 +33,9 @@ rustls-pemfile = "2.2.0"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-service = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
||||
@@ -6,6 +6,7 @@ RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs
|
||||
RUN cargo build --release 2>/dev/null || true
|
||||
RUN rm -rf src
|
||||
COPY src/ src/
|
||||
COPY benches/ benches/
|
||||
COPY site/ site/
|
||||
COPY numa.toml com.numa.dns.plist numa.service ./
|
||||
RUN touch src/main.rs src/lib.rs
|
||||
@@ -13,5 +14,6 @@ RUN cargo build --release
|
||||
|
||||
FROM alpine:3.23
|
||||
COPY --from=builder /app/target/release/numa /usr/local/bin/numa
|
||||
RUN mkdir -p /root/.config/numa && printf '[server]\napi_bind_addr = "0.0.0.0"\n\n[proxy]\nenabled = true\nbind_addr = "0.0.0.0"\n' > /root/.config/numa/numa.toml
|
||||
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
|
||||
ENTRYPOINT ["numa"]
|
||||
|
||||
13
Makefile
13
Makefile
@@ -32,6 +32,19 @@ blog:
|
||||
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
|
||||
echo " $$f → site/blog/posts/$$name.html"; \
|
||||
done
|
||||
@scripts/generate-blog-index.sh
|
||||
|
||||
blog-drafts: blog
|
||||
@if [ -d drafts ] && ls drafts/*.md >/dev/null 2>&1; then \
|
||||
for f in drafts/*.md; do \
|
||||
name=$$(basename "$$f" .md); \
|
||||
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
|
||||
echo " $$f → site/blog/posts/$$name.html (draft)"; \
|
||||
done; \
|
||||
BLOG_INCLUDE_DRAFTS=1 scripts/generate-blog-index.sh; \
|
||||
else \
|
||||
echo " No drafts found"; \
|
||||
fi
|
||||
|
||||
release:
|
||||
ifndef VERSION
|
||||
|
||||
2
PKGBUILD
2
PKGBUILD
@@ -9,7 +9,7 @@ url="https://github.com/razvandimescu/numa"
|
||||
license=('MIT')
|
||||
options=('!lto')
|
||||
depends=('gcc-libs' 'glibc')
|
||||
makedepends=('cargo' 'git')
|
||||
makedepends=('cargo' 'git' 'llvm-libs')
|
||||
provides=("$_pkgname")
|
||||
conflicts=("$_pkgname")
|
||||
backup=('etc/numa.toml')
|
||||
|
||||
23
README.md
23
README.md
@@ -27,6 +27,9 @@ yay -S numa-git
|
||||
# Windows — download from GitHub Releases
|
||||
# All platforms
|
||||
cargo install numa
|
||||
|
||||
# Docker
|
||||
docker run -d --name numa --network host ghcr.io/razvandimescu/numa
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -102,6 +105,26 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
|
||||
**Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Recommended — host networking (Linux)
|
||||
docker run -d --name numa --network host ghcr.io/razvandimescu/numa
|
||||
|
||||
# Port mapping (macOS/Windows Docker Desktop)
|
||||
docker run -d --name numa -p 53:53/udp -p 53:53/tcp -p 5380:5380 ghcr.io/razvandimescu/numa
|
||||
```
|
||||
|
||||
Dashboard at `http://localhost:5380`. The image binds the API and proxy to `0.0.0.0` by default. Override with a custom config:
|
||||
|
||||
```bash
|
||||
docker run -d --name numa --network host \
|
||||
-v /path/to/numa.toml:/root/.config/numa/numa.toml \
|
||||
ghcr.io/razvandimescu/numa
|
||||
```
|
||||
|
||||
Multi-arch: `linux/amd64` and `linux/arm64`.
|
||||
|
||||
## How It Compares
|
||||
|
||||
| | Pi-hole | AdGuard Home | Unbound | Numa |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: I Built a DNS Resolver from Scratch in Rust
|
||||
description: How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries.
|
||||
date: March 2026
|
||||
date: 2026-03-20
|
||||
---
|
||||
|
||||
I wanted to understand how DNS actually works. Not the "it translates domain names to IP addresses" explanation — the actual bytes on the wire. What does a DNS packet look like? How does label compression work? Why is everything crammed into 512 bytes?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Implementing DNSSEC from Scratch in Rust
|
||||
description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries.
|
||||
date: March 2026
|
||||
date: 2026-03-28
|
||||
---
|
||||
|
||||
In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: DNS-over-TLS from Scratch in Rust
|
||||
description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
|
||||
date: April 2026
|
||||
date: 2026-04-06
|
||||
---
|
||||
|
||||
The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it.
|
||||
|
||||
171
blog/fixing-doh-tail-latency.md
Normal file
171
blog/fixing-doh-tail-latency.md
Normal file
@@ -0,0 +1,171 @@
|
||||
---
|
||||
title: Fixing DNS tail latency with a 5-line config and a 50-line function
|
||||
description: Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms.
|
||||
date: 2026-04-12
|
||||
---
|
||||
|
||||
If you're using reqwest for small HTTP/2 payloads, you probably have a tail latency problem you don't know about. Hyper's default flow control windows are 10,000× oversized for anything under 1 KB, and its dispatch channel adds periodic 40-140ms stalls that don't show up in median benchmarks.
|
||||
|
||||
I hit this building Numa's DoH forwarding path. Median was 10ms, mean was 23ms — the tail was dragging everything.
|
||||
|
||||
<div class="hero-metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-vs">DoH forwarding p99</div>
|
||||
<div class="metric-value">113 → 71ms</div>
|
||||
<div class="metric-label">window tuning + request hedging</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-vs">Cold recursive p99</div>
|
||||
<div class="metric-value">2.3s → 538ms</div>
|
||||
<div class="metric-label">NS caching, serve-stale, parallel queries</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-vs">Forwarding σ</div>
|
||||
<div class="metric-value">31 → 13ms</div>
|
||||
<div class="metric-label">random spikes become parallel races</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The fix was a 5-line reqwest config and a 50-line hedging function. This post is also an advertisement for Dean & Barroso's 2013 paper ["The Tail at Scale"](https://research.google/pubs/pub40801/) — a decade-old idea that still demolishes dispatch spikes. The same ideas later took my cold recursive p99 from 2.3 seconds to 538ms.
|
||||
|
||||
---
|
||||
|
||||
## The cause: hyper's dispatch channel
|
||||
|
||||
Reqwest sits on top of hyper, which interposes an mpsc dispatch channel and a separate `ClientTask` between `.send()` and the h2 stream. I instrumented the forwarding path and confirmed: 100% of the spike time lives in the `send()` phase, and a parallel heartbeat task showed zero runtime lag during spikes. The tokio runtime was fine — the stall was internal to hyper's request scheduling.
|
||||
|
||||
Hickory-resolver doesn't have this issue. It holds `h2::SendRequest<Bytes>` directly and calls `ready().await; send_request()` in the caller's task — no channel, no scheduling dependency. I used it as a reference point throughout.
|
||||
|
||||
## Fix #1 — HTTP/2 window sizes
|
||||
|
||||
Reqwest inherits hyper's HTTP/2 defaults: 2 MB stream window, 5 MB connection window. For DNS responses (~200 bytes), that's ~10,000× oversized — unnecessary WINDOW_UPDATE frames, bloated bookkeeping on every poll, and different server-side scheduling behavior.
|
||||
|
||||
Setting both windows to the h2 spec default (64 KB) dropped my median from 13.3ms to 10.1ms:
|
||||
|
||||
```rust
|
||||
reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.http2_initial_stream_window_size(65_535)
|
||||
.http2_initial_connection_window_size(65_535)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.pool_idle_timeout(Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
```
|
||||
|
||||
**Any Rust code using reqwest for tiny-payload HTTP/2 workloads — DoH, API polling, metric scraping — is probably hitting this.**
|
||||
|
||||
## Fix #2 — Request hedging
|
||||
|
||||
["The Tail at Scale"](https://research.google/pubs/pub40801/) (Dean & Barroso, 2013): fire a request, and if it doesn't return within your P50 latency, fire the same request in parallel. First response wins.
|
||||
|
||||
The intuition: if 5% of requests spike due to independent random events, two parallel requests means only 0.25% of pairs spike on *both*. The tail collapses.
|
||||
|
||||
**The surprise: hedging against the same upstream works.** HTTP/2 multiplexes streams — two `send_request()` calls on one connection become independent h2 streams. If one stalls in the dispatch channel, the other keeps making progress.
|
||||
|
||||
```rust
|
||||
pub async fn forward_with_hedging_raw(
|
||||
wire: &[u8],
|
||||
primary: &Upstream,
|
||||
secondary: &Upstream,
|
||||
hedge_delay: Duration,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
let primary_fut = forward_query_raw(wire, primary, timeout_duration);
|
||||
tokio::pin!(primary_fut);
|
||||
let delay = sleep(hedge_delay);
|
||||
tokio::pin!(delay);
|
||||
|
||||
// Phase 1: wait for primary to return OR the hedge delay.
|
||||
tokio::select! {
|
||||
result = &mut primary_fut => return result,
|
||||
_ = &mut delay => {}
|
||||
}
|
||||
|
||||
// Phase 2: hedge delay expired — fire secondary, keep primary alive.
|
||||
let secondary_fut = forward_query_raw(wire, secondary, timeout_duration);
|
||||
tokio::pin!(secondary_fut);
|
||||
|
||||
// First successful response wins.
|
||||
tokio::select! {
|
||||
r = primary_fut => r,
|
||||
r = secondary_fut => r,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The [production version](https://github.com/razvandimescu/numa/blob/main/src/forward.rs#L267) adds error handling — if one leg fails, it waits for the other. In production, Numa passes the same `&Upstream` twice when only one is configured. I extended hedging to all protocols — UDP (rescues packet loss on WiFi), DoT (rescues TLS handshake stalls). Configurable via `hedge_ms`; set to 0 to disable.
|
||||
|
||||
**Caveat: hedging hurts on degraded networks.** When latency is consistently high (no random spikes, just slow), the hedge adds overhead with nothing to rescue. Hedging is a variance reducer, not a latency reducer — it only helps when spikes are *random*.
|
||||
|
||||
---
|
||||
|
||||
## Forwarding results
|
||||
|
||||
5 iterations × 101 domains × 10 rounds, 5,050 samples per method. Hickory-resolver included as a reference (it uses h2 directly, no dispatch channel):
|
||||
|
||||
| | Single | **Hedged** | Hickory (ref) |
|
||||
|---|---|---|---|
|
||||
| mean | 17.4ms | **14.3ms** | 16.8ms |
|
||||
| median | 10.4ms | **10.2ms** | 13.3ms |
|
||||
| p95 | 52.5ms | **28.6ms** | 37.7ms |
|
||||
| p99 | 113.4ms | **71.3ms** | 98.1ms |
|
||||
| σ | 30.6ms | **13.2ms** | 19.1ms |
|
||||
|
||||
The internal improvement: hedging cut p95 by 45%, p99 by 37%, σ by 57%. The exact margin vs hickory varies with network conditions; the σ reduction is consistent across runs.
|
||||
|
||||
## Recursive resolution: from 2.3 seconds to 538ms
|
||||
|
||||
Forwarding is one job. Recursive resolution — walking from root hints through TLD nameservers to the authoritative server — is a different one. I started 15× behind Unbound on cold recursive p99 and traced it to four root causes.
|
||||
|
||||
**1. Missing NS delegation caching.** I cached glue records (ns1's IP) but not the delegation itself. Every `.com` query walked from root. Fix: cache NS records from referral authority sections. (10 lines)
|
||||
|
||||
**2. Expired cache entries caused full cold resolutions.** Fix: serve-stale ([RFC 8767](https://www.rfc-editor.org/rfc/rfc8767)) — return expired entries with TTL=1 while revalidating in the background. (20 lines)
|
||||
|
||||
**3. Wasting 1,900ms per unreachable server.** 800ms UDP timeout + unconditional 1,500ms TCP fallback. Fix: 400ms UDP, TCP only for truncation. (5 lines)
|
||||
|
||||
**4. Sequential NS queries on cold starts.** Fix: fire to the top 2 nameservers simultaneously. First response wins, SRTT recorded for both. Same hedging principle. (50 lines)
|
||||
|
||||
<div class="before-after">
|
||||
<div class="ba-item">
|
||||
<div class="ba-label">p99 before</div>
|
||||
<div class="ba-value ba-before">2,367ms</div>
|
||||
</div>
|
||||
<div class="ba-arrow">→</div>
|
||||
<div class="ba-item">
|
||||
<div class="ba-label">p99 after</div>
|
||||
<div class="ba-value ba-after">538ms</div>
|
||||
</div>
|
||||
<div class="ba-item ba-ref">
|
||||
<div class="ba-label">Unbound (ref)</div>
|
||||
<div class="ba-value">748ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Genuine cold benchmarks — unique subdomains, 1 query per domain, 5 iterations, 505 samples per server:
|
||||
|
||||
| | Baseline | Final | Unbound (ref) |
|
||||
|---|---|---|---|
|
||||
| p99 | 2,367ms | **538ms** | 748ms |
|
||||
| σ | 254ms | **114ms** | 457ms |
|
||||
| median | — | 77.6ms | 74.7ms |
|
||||
|
||||
Unbound wins median by ~4%. Where hedging shines is the tail — domains with slow or unreachable nameservers, where parallel queries turn worst-case sequential timeouts into races. Cache hits are tied at 0.1ms across Numa, Unbound, and AdGuard Home.
|
||||
|
||||
What I'm exploring next: persistent SRTT data across restarts (currently cold-starts lose all server timing), aggressive NSEC caching to shortcut negative lookups, and adaptive hedge delays that tune themselves to observed network conditions instead of a fixed 10ms.
|
||||
|
||||
---
|
||||
|
||||
## Takeaways
|
||||
|
||||
The real hero of this post is Dean & Barroso. Hedging works because **spikes are random, and two random draws rarely both lose**. It's effective for any HTTP/2 client, any language, any forwarder topology. Nobody we know of ships it by default.
|
||||
|
||||
If you're building a Rust service that makes many small HTTP/2 requests to the same backend: check your flow control window sizes first, then implement hedging. Don't rewrite the client.
|
||||
|
||||
Benchmarks are in [`benches/recursive_compare.rs`](https://github.com/razvandimescu/numa/blob/main/benches/recursive_compare.rs) — run them yourself. If you're using reqwest for tiny-payload workloads and try the window size fix, I'd love to hear if you see the same improvement.
|
||||
|
||||
---
|
||||
|
||||
Numa is a DNS resolver that runs on your laptop or phone. DoH, DoT, .numa local domains, ad blocking, developer overrides, a REST API, and all the optimization work in this post. [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa).
|
||||
48
build.rs
Normal file
48
build.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
fn main() {
|
||||
// --long forces "TAG-N-gSHA[-dirty]" format even on exact tag matches,
|
||||
// making parsing unambiguous for pre-release tags like v0.14.0-rc1.
|
||||
let git_version = std::process::Command::new("git")
|
||||
.args(["describe", "--tags", "--always", "--dirty", "--long"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.and_then(|raw| parse_git_describe(raw.trim()));
|
||||
|
||||
if let Some(v) = git_version {
|
||||
println!("cargo:rustc-env=NUMA_BUILD_VERSION={}", v);
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
}
|
||||
|
||||
/// Parse `git describe --long` output into a SemVer-compatible string.
|
||||
/// "v0.13.1-0-ga87f907" → "0.13.1"
|
||||
/// "v0.13.1-9-ga87f907" → "0.13.1+a87f907"
|
||||
/// "v0.14.0-rc1-0-ga87f907" → "0.14.0-rc1"
|
||||
/// "v0.14.0-rc1-3-ga87f907-dirty" → "0.14.0-rc1+a87f907-dirty"
|
||||
/// "a87f907" → "0.0.0+a87f907"
|
||||
fn parse_git_describe(s: &str) -> Option<String> {
|
||||
let s = s.strip_prefix('v').unwrap_or(s);
|
||||
let dirty = s.ends_with("-dirty");
|
||||
let s = s.strip_suffix("-dirty").unwrap_or(s);
|
||||
|
||||
// --long format: TAG-N-gSHA. Split from the right so tags with hyphens work.
|
||||
let gpos = s.rfind("-g")?;
|
||||
let sha = &s[gpos + 2..];
|
||||
let rest = &s[..gpos];
|
||||
let npos = rest.rfind('-')?;
|
||||
let n: u32 = rest[npos + 1..].parse().ok()?;
|
||||
let tag = &rest[..npos];
|
||||
|
||||
if tag.is_empty() {
|
||||
return Some(format!("0.0.0+{}", sha));
|
||||
}
|
||||
|
||||
Some(match (n, dirty) {
|
||||
(0, false) => tag.to_string(),
|
||||
(0, true) => format!("{}+{}-dirty", tag, sha),
|
||||
(_, false) => format!("{}+{}", tag, sha),
|
||||
(_, true) => format!("{}+{}-dirty", tag, sha),
|
||||
})
|
||||
}
|
||||
@@ -58,6 +58,14 @@ api_port = 5380
|
||||
# [[forwarding]]
|
||||
# suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream
|
||||
# upstream = "10.0.0.1" # port 53 default
|
||||
#
|
||||
# [[forwarding]] # DoT upstream: tls://IP[:port]#hostname
|
||||
# suffix = ["google.com", "goog"] # hostname is the TLS SNI / cert name
|
||||
# upstream = "tls://9.9.9.9#dns.quad9.net" # port 853 default
|
||||
#
|
||||
# [[forwarding]] # DoH upstream: full https:// URL
|
||||
# suffix = "example.corp"
|
||||
# upstream = "https://dns.quad9.net/dns-query"
|
||||
|
||||
# [blocking]
|
||||
# enabled = true # set to false to disable ad blocking
|
||||
|
||||
239
scripts/generate-blog-index.sh
Executable file
239
scripts/generate-blog-index.sh
Executable file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generate site/blog/index.html from blog/*.md frontmatter.
|
||||
# Reads title, description, date from YAML frontmatter in each post.
|
||||
# Sorts newest first (by date string — "April 2026" > "March 2026").
|
||||
|
||||
OUT="site/blog/index.html"
|
||||
|
||||
# Extract frontmatter fields from a markdown file
|
||||
extract() {
|
||||
local file="$1" field="$2"
|
||||
sed -n '/^---$/,/^---$/p' "$file" | grep "^${field}:" | sed "s/^${field}: *//"
|
||||
}
|
||||
|
||||
# Collect posts: "date|name|title|description" per line
|
||||
posts=""
|
||||
sources="blog/*.md"
|
||||
if [ "${BLOG_INCLUDE_DRAFTS:-}" = "1" ] && ls drafts/*.md >/dev/null 2>&1; then
|
||||
sources="blog/*.md drafts/*.md"
|
||||
fi
|
||||
for f in $sources; do
|
||||
name=$(basename "$f" .md)
|
||||
title=$(extract "$f" title)
|
||||
desc=$(extract "$f" description)
|
||||
date=$(extract "$f" date)
|
||||
posts+="${date}|${name}|${title}|${desc}"$'\n'
|
||||
done
|
||||
|
||||
# Sort by ISO date (YYYY-MM-DD), newest first
|
||||
posts=$(echo "$posts" | grep -v '^$' | sort -t'|' -k1 -r)
|
||||
|
||||
# Format ISO date (YYYY-MM-DD) to "Month YYYY"
|
||||
format_date() {
|
||||
local months=(January February March April May June July August September October November December)
|
||||
local y="${1%%-*}"
|
||||
local m="${1#*-}"; m="${m%%-*}"; m=$((10#$m))
|
||||
echo "${months[$((m-1))]} $y"
|
||||
}
|
||||
|
||||
# Generate post list items
|
||||
items=""
|
||||
while IFS='|' read -r date name title desc; do
|
||||
display_date=$(format_date "$date")
|
||||
items+=" <li>
|
||||
<a href=\"/blog/posts/${name}.html\">
|
||||
<div class=\"post-title\">${title}</div>
|
||||
<div class=\"post-desc\">${desc}</div>
|
||||
<div class=\"post-date\">${display_date}</div>
|
||||
</a>
|
||||
</li>
|
||||
"
|
||||
done <<< "$posts"
|
||||
|
||||
# Write the full index.html — style matches the existing hand-maintained version
|
||||
cat > "$OUT" << HTMLEOF
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Blog — Numa</title>
|
||||
<meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch.">
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
<style>
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg-deep: #f5f0e8;
|
||||
--bg-surface: #ece5da;
|
||||
--bg-card: #faf7f2;
|
||||
--amber: #c0623a;
|
||||
--amber-dim: #9e4e2d;
|
||||
--teal: #6b7c4e;
|
||||
--text-primary: #2c2418;
|
||||
--text-secondary: #6b5e4f;
|
||||
--text-dim: #a39888;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--font-display: 'Instrument Serif', Georgia, serif;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
line-height: 1.7;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.blog-nav {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.blog-nav a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.blog-nav a:hover { color: var(--amber); }
|
||||
|
||||
.blog-nav .wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||
|
||||
.blog-nav .sep {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.blog-index {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 2rem 6rem;
|
||||
}
|
||||
|
||||
.blog-index h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post-list li {
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-list li:first-child {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.post-list a {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.post-list .post-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
margin-bottom: 0.4rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.post-list a:hover .post-title {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.post-list .post-desc {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.post-list .post-date {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.blog-footer {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.blog-footer a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
.blog-footer a:hover { color: var(--amber); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="blog-nav">
|
||||
<a href="/" class="wordmark">Numa</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="/blog/">Blog</a>
|
||||
</nav>
|
||||
|
||||
<main class="blog-index">
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
${items} </ul>
|
||||
</main>
|
||||
|
||||
<footer class="blog-footer">
|
||||
<a href="https://github.com/razvandimescu/numa">GitHub</a>
|
||||
<a href="/">Home</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
echo " blog/index.html generated ($(echo "$posts" | wc -l | tr -d ' ') posts)"
|
||||
14
scripts/serve-site.sh
Executable file
14
scripts/serve-site.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-9000}"
|
||||
|
||||
if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then
|
||||
PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg
|
||||
make blog-drafts
|
||||
else
|
||||
make blog
|
||||
fi
|
||||
|
||||
echo "Serving site at http://localhost:$PORT"
|
||||
cd site && python3 -m http.server "$PORT"
|
||||
@@ -267,9 +267,105 @@ body::before {
|
||||
.blog-footer a:hover { color: var(--amber); }
|
||||
|
||||
/* --- Responsive --- */
|
||||
/* Hero metrics cards */
|
||||
.hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.metric-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
.metric-vs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.metric-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 400;
|
||||
color: var(--amber);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Before/after progression */
|
||||
.before-after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.ba-item { text-align: center; }
|
||||
.ba-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.ba-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.ba-before {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(192, 98, 58, 0.4);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.ba-after { color: var(--amber); }
|
||||
.ba-arrow { font-size: 1.5rem; color: var(--text-dim); }
|
||||
.ba-ref {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Spike highlight */
|
||||
.spike {
|
||||
background: rgba(192, 98, 58, 0.12);
|
||||
padding: 0.15em 0.5em;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
color: var(--amber-dim);
|
||||
}
|
||||
|
||||
/* Section dividers */
|
||||
.article hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 3rem auto;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.article { padding: 2rem 1.25rem 4rem; }
|
||||
.article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; }
|
||||
.hero-metrics { grid-template-columns: 1fr; }
|
||||
.before-after { flex-direction: column; gap: 0.75rem; }
|
||||
.ba-ref { border-left: none; border-top: 1px solid var(--border); padding-left: 0; padding-top: 0.75rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -168,10 +168,17 @@ body::before {
|
||||
<main class="blog-index">
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
<li>
|
||||
<a href="/blog/posts/fixing-doh-tail-latency.html">
|
||||
<div class="post-title">Fixing DNS tail latency with a 5-line config and a 50-line function</div>
|
||||
<div class="post-desc">Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms.</div>
|
||||
<div class="post-date">April 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/blog/posts/dot-from-scratch.html">
|
||||
<div class="post-title">DNS-over-TLS from Scratch in Rust</div>
|
||||
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.</div>
|
||||
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.</div>
|
||||
<div class="post-date">April 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
@@ -185,7 +192,7 @@ body::before {
|
||||
<li>
|
||||
<a href="/blog/posts/dns-from-scratch.html">
|
||||
<div class="post-title">I Built a DNS Resolver from Scratch in Rust</div>
|
||||
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.</div>
|
||||
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries.</div>
|
||||
<div class="post-date">March 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -217,6 +217,7 @@ body {
|
||||
min-width: 2px;
|
||||
}
|
||||
.path-bar-fill.forward { background: var(--amber); }
|
||||
.path-bar-fill.upstream { background: var(--amber-dim); }
|
||||
.path-bar-fill.recursive { background: var(--cyan); }
|
||||
.path-bar-fill.cached { background: var(--teal); }
|
||||
.path-bar-fill.local { background: var(--violet); }
|
||||
@@ -285,6 +286,7 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
.path-tag.FORWARD { background: rgba(192, 98, 58, 0.12); color: var(--amber-dim); }
|
||||
.path-tag.UPSTREAM { background: rgba(160, 120, 72, 0.12); color: var(--amber-dim); }
|
||||
.path-tag.RECURSIVE { background: rgba(74, 124, 138, 0.12); color: var(--cyan); }
|
||||
.path-tag.CACHED { background: rgba(107, 124, 78, 0.12); color: var(--teal-dim); }
|
||||
.path-tag.LOCAL { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
|
||||
@@ -550,7 +552,11 @@ body {
|
||||
@media (max-width: 700px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.dashboard { padding: 1rem; }
|
||||
.header { padding: 1rem; }
|
||||
.header { padding: 0.8rem 1rem; }
|
||||
.logo { font-size: 1.4rem; }
|
||||
.tagline { display: none; }
|
||||
#headerVersion { display: none; }
|
||||
#phoneSetup { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -559,6 +565,7 @@ body {
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">Numa</div>
|
||||
<span id="headerVersion" style="font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);"></span>
|
||||
<div class="tagline">DNS that governs itself</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:1.2rem;">
|
||||
@@ -655,6 +662,7 @@ body {
|
||||
<option value="RECURSIVE">recursive</option>
|
||||
<option value="COALESCED">coalesced</option>
|
||||
<option value="FORWARD">forward</option>
|
||||
<option value="UPSTREAM">upstream</option>
|
||||
<option value="CACHED">cached</option>
|
||||
<option value="BLOCKED">blocked</option>
|
||||
<option value="OVERRIDE">override</option>
|
||||
@@ -936,10 +944,12 @@ function renderMemory(mem, stats) {
|
||||
|
||||
function renderBarChart(containerId, defs, data, total) {
|
||||
total = total || 1;
|
||||
document.getElementById(containerId).innerHTML = defs.map(d => {
|
||||
const count = data[d.key] || 0;
|
||||
const pct = ((count / total) * 100).toFixed(1);
|
||||
return `
|
||||
document.getElementById(containerId).innerHTML = defs
|
||||
.filter(d => (data[d.key] || 0) > 0)
|
||||
.map(d => {
|
||||
const count = data[d.key] || 0;
|
||||
const pct = ((count / total) * 100).toFixed(1);
|
||||
return `
|
||||
<div class="path-bar-row">
|
||||
<span class="path-label">${d.label}</span>
|
||||
<div class="path-bar-track">
|
||||
@@ -947,7 +957,7 @@ function renderBarChart(containerId, defs, data, total) {
|
||||
</div>
|
||||
<span class="path-pct">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function encryptionPct(transport) {
|
||||
@@ -957,6 +967,7 @@ function encryptionPct(transport) {
|
||||
|
||||
const PATH_DEFS = [
|
||||
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
||||
{ key: 'upstream', label: 'Upstream', cls: 'upstream' },
|
||||
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
||||
{ key: 'cached', label: 'Cached', cls: 'cached' },
|
||||
{ key: 'local', label: 'Local', cls: 'local' },
|
||||
@@ -1130,16 +1141,23 @@ async function refresh() {
|
||||
document.getElementById('totalQueries').textContent = formatNumber(q.total);
|
||||
document.getElementById('uptime').textContent = formatUptime(stats.uptime_secs);
|
||||
document.getElementById('uptimeSub').textContent = formatUptimeSub(stats.uptime_secs);
|
||||
document.getElementById('headerVersion').textContent = stats.version ? 'v' + stats.version : '';
|
||||
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';
|
||||
document.getElementById('footerSrtt').style.color = stats.srtt ? 'var(--emerald)' : 'var(--text-dim)';
|
||||
if (!document.getElementById('footerLogs').textContent) {
|
||||
const isWin = stats.data_dir && stats.data_dir.includes(':\\');
|
||||
const isMac = stats.data_dir && stats.data_dir.includes('/usr/local/');
|
||||
const logsEl = document.getElementById('footerLogs');
|
||||
logsEl.textContent = isWin
|
||||
? stats.data_dir + '\\numa.log'
|
||||
: isMac ? '/usr/local/var/log/numa.log'
|
||||
: 'journalctl -u numa -f';
|
||||
}
|
||||
|
||||
// LAN status indicator
|
||||
const lanEl = document.getElementById('lanToggle');
|
||||
@@ -1209,7 +1227,7 @@ async function refresh() {
|
||||
prevTime = now;
|
||||
|
||||
// Cache hit rate
|
||||
const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden;
|
||||
const answered = q.cached + q.forwarded + q.upstream + 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 + '%';
|
||||
|
||||
@@ -1339,6 +1357,7 @@ function renderBlockingInfo(info) {
|
||||
}
|
||||
|
||||
function renderAllowlist(entries) {
|
||||
if (document.activeElement && document.activeElement.id === 'allowDomainInput') return;
|
||||
const el = document.getElementById('blockingAllowlist');
|
||||
const count = entries.length;
|
||||
el.innerHTML = `
|
||||
@@ -1498,14 +1517,14 @@ refresh();
|
||||
setInterval(refresh, 2000);
|
||||
</script>
|
||||
|
||||
<div style="text-align:center;padding:0.8rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);">
|
||||
<div style="text-align:center;padding:0.8rem 0.8rem 0.4rem;font-family:var(--font-mono);font-size:0.68rem;color:var(--text-dim);line-height:1.8;">
|
||||
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>
|
||||
· Logs: <span id="footerLogs" style="user-select:all;color:var(--emerald);"></span>
|
||||
<br>
|
||||
Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></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>
|
||||
· <a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener" style="color:var(--amber);text-decoration:none;">GitHub</a>
|
||||
</div>
|
||||
|
||||
|
||||
49
src/api.rs
49
src/api.rs
@@ -160,6 +160,7 @@ struct QueryLogResponse {
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StatsResponse {
|
||||
version: &'static str,
|
||||
uptime_secs: u64,
|
||||
upstream: String,
|
||||
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
|
||||
@@ -201,6 +202,7 @@ struct LanStatsResponse {
|
||||
struct QueriesStats {
|
||||
total: u64,
|
||||
forwarded: u64,
|
||||
upstream: u64,
|
||||
recursive: u64,
|
||||
coalesced: u64,
|
||||
cached: u64,
|
||||
@@ -538,6 +540,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
};
|
||||
|
||||
Json(StatsResponse {
|
||||
version: crate::version(),
|
||||
uptime_secs: snap.uptime_secs,
|
||||
upstream,
|
||||
mode: ctx.upstream_mode.as_str(),
|
||||
@@ -548,6 +551,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
queries: QueriesStats {
|
||||
total: snap.total,
|
||||
forwarded: snap.forwarded,
|
||||
upstream: snap.upstream,
|
||||
recursive: snap.recursive,
|
||||
coalesced: snap.coalesced,
|
||||
cached: snap.cached,
|
||||
@@ -1020,53 +1024,10 @@ mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use http::Request;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn test_ctx() -> Arc<ServerCtx> {
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: std::collections::HashMap::new(),
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)],
|
||||
vec![],
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
hedge_delay: std::time::Duration::ZERO,
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: "/tmp/test-numa.toml".to_string(),
|
||||
config_found: false,
|
||||
config_dir: std::path::PathBuf::from("/tmp"),
|
||||
data_dir: std::path::PathBuf::from("/tmp"),
|
||||
tls_config: None,
|
||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
||||
inflight: Mutex::new(std::collections::HashMap::new()),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
})
|
||||
Arc::new(crate::testutil::test_ctx().await)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -46,12 +46,12 @@ pub struct ForwardingRuleConfig {
|
||||
|
||||
impl ForwardingRuleConfig {
|
||||
fn to_runtime_rules(&self) -> Result<Vec<crate::system_dns::ForwardingRule>> {
|
||||
let addr = crate::forward::parse_upstream_addr(&self.upstream, 53)
|
||||
let upstream = crate::forward::parse_upstream(&self.upstream, 53)
|
||||
.map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?;
|
||||
Ok(self
|
||||
.suffix
|
||||
.iter()
|
||||
.map(|s| crate::system_dns::ForwardingRule::new(s.clone(), addr))
|
||||
.map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone()))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -710,6 +710,10 @@ mod tests {
|
||||
};
|
||||
let runtime = rule.to_runtime_rules().unwrap();
|
||||
assert_eq!(runtime.len(), 1);
|
||||
assert!(matches!(
|
||||
runtime[0].upstream,
|
||||
crate::forward::Upstream::Udp(_)
|
||||
));
|
||||
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361");
|
||||
assert_eq!(runtime[0].suffix, "home.local");
|
||||
}
|
||||
@@ -733,6 +737,38 @@ mod tests {
|
||||
assert!(rule.to_runtime_rules().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_upstream_accepts_dot_scheme() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["google.com".to_string()],
|
||||
upstream: "tls://9.9.9.9#dns.quad9.net".to_string(),
|
||||
};
|
||||
let runtime = rule
|
||||
.to_runtime_rules()
|
||||
.expect("tls:// upstream should parse");
|
||||
assert_eq!(runtime.len(), 1);
|
||||
assert_eq!(
|
||||
runtime[0].upstream.to_string(),
|
||||
"tls://9.9.9.9:853#dns.quad9.net"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_upstream_accepts_doh_scheme() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["goog".to_string()],
|
||||
upstream: "https://dns.quad9.net/dns-query".to_string(),
|
||||
};
|
||||
let runtime = rule
|
||||
.to_runtime_rules()
|
||||
.expect("https:// upstream should parse");
|
||||
assert_eq!(runtime.len(), 1);
|
||||
assert_eq!(
|
||||
runtime[0].upstream.to_string(),
|
||||
"https://dns.quad9.net/dns-query"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_config_rules_take_precedence_over_discovered() {
|
||||
let config_rules = vec![ForwardingRuleConfig {
|
||||
@@ -741,7 +777,7 @@ mod tests {
|
||||
}];
|
||||
let discovered = vec![crate::system_dns::ForwardingRule::new(
|
||||
"home.local".to_string(),
|
||||
"192.168.1.1:53".parse().unwrap(),
|
||||
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
|
||||
)];
|
||||
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
|
||||
let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged)
|
||||
@@ -757,7 +793,7 @@ mod tests {
|
||||
}];
|
||||
let discovered = vec![crate::system_dns::ForwardingRule::new(
|
||||
"corp.example".to_string(),
|
||||
"192.168.1.1:53".parse().unwrap(),
|
||||
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
|
||||
)];
|
||||
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
|
||||
assert_eq!(merged.len(), 2);
|
||||
|
||||
262
src/ctx.rs
262
src/ctx.rs
@@ -88,7 +88,7 @@ pub async fn resolve_query(
|
||||
src_addr: SocketAddr,
|
||||
ctx: &Arc<ServerCtx>,
|
||||
transport: Transport,
|
||||
) -> crate::Result<BytePacketBuffer> {
|
||||
) -> crate::Result<(BytePacketBuffer, QueryPath)> {
|
||||
let start = Instant::now();
|
||||
|
||||
let (qname, qtype) = match query.questions.first() {
|
||||
@@ -96,7 +96,8 @@ pub async fn resolve_query(
|
||||
None => return Err("empty question section".into()),
|
||||
};
|
||||
|
||||
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||
// Pipeline: overrides -> .localhost -> local zones -> special-use (unless forwarded)
|
||||
// -> .tld proxy -> blocklist -> cache -> forwarding -> recursive/upstream
|
||||
// Each lock is scoped to avoid holding MutexGuard across await points.
|
||||
let (response, path, dnssec) = {
|
||||
let override_record = ctx.overrides.read().unwrap().lookup(&qname);
|
||||
@@ -119,8 +120,10 @@ pub async fn resolve_query(
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
} else if is_special_use_domain(&qname)
|
||||
&& crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none()
|
||||
{
|
||||
// RFC 6761/8880: answer locally unless a forwarding rule covers this zone.
|
||||
let resp = special_use_response(&query, &qname, qtype);
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if !ctx.proxy_tld_suffix.is_empty()
|
||||
@@ -187,13 +190,12 @@ pub async fn resolve_query(
|
||||
resp.header.authed_data = true;
|
||||
}
|
||||
(resp, QueryPath::Cached, cached_dnssec)
|
||||
} else if let Some(fwd_addr) =
|
||||
} else if let Some(upstream) =
|
||||
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_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await {
|
||||
match forward_and_cache(raw_wire, upstream, ctx, &qname, qtype).await {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!(
|
||||
@@ -244,7 +246,7 @@ pub async fn resolve_query(
|
||||
.await
|
||||
{
|
||||
Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
Ok(resp) => (resp, QueryPath::Upstream, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e);
|
||||
(
|
||||
@@ -373,7 +375,7 @@ pub async fn resolve_query(
|
||||
dnssec,
|
||||
});
|
||||
|
||||
Ok(resp_buffer)
|
||||
Ok((resp_buffer, path))
|
||||
}
|
||||
|
||||
fn cache_and_parse(
|
||||
@@ -457,7 +459,7 @@ pub async fn handle_query(
|
||||
}
|
||||
};
|
||||
match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await {
|
||||
Ok(resp_buffer) => {
|
||||
Ok((resp_buffer, _)) => {
|
||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1036,4 +1038,244 @@ mod tests {
|
||||
"error message must be preserved for logging"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Full-pipeline resolve_query tests ----
|
||||
|
||||
/// Send a query through the full resolve_query pipeline and return
|
||||
/// the parsed response + query path.
|
||||
async fn resolve_in_test(
|
||||
ctx: &Arc<ServerCtx>,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
) -> (DnsPacket, QueryPath) {
|
||||
let query = DnsPacket::query(0xBEEF, domain, qtype);
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
query.write(&mut buf).unwrap();
|
||||
let raw = &buf.buf[..buf.pos];
|
||||
let src: SocketAddr = "127.0.0.1:1234".parse().unwrap();
|
||||
|
||||
let (resp_buf, path) = resolve_query(query, raw, src, ctx, Transport::Udp)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled());
|
||||
let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap();
|
||||
(resp, path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn special_use_private_ptr_returns_nxdomain() {
|
||||
let ctx = Arc::new(crate::testutil::test_ctx().await);
|
||||
let (resp, path) =
|
||||
resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await;
|
||||
assert_eq!(path, QueryPath::Local);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarding_rule_overrides_special_use_domain() {
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::NOERROR;
|
||||
let upstream_addr = crate::testutil::mock_upstream(resp).await;
|
||||
|
||||
let mut ctx = crate::testutil::test_ctx().await;
|
||||
ctx.forwarding_rules = vec![ForwardingRule::new(
|
||||
"168.192.in-addr.arpa".to_string(),
|
||||
Upstream::Udp(upstream_addr),
|
||||
)];
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) =
|
||||
resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await;
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
QueryPath::Forwarded,
|
||||
"forwarding rule must take precedence over special-use NXDOMAIN"
|
||||
);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_override_takes_precedence() {
|
||||
let ctx = crate::testutil::test_ctx().await;
|
||||
ctx.overrides
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert("override.test", "1.2.3.4", 60, None)
|
||||
.unwrap();
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "override.test", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Overridden);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_localhost_resolves_to_loopback() {
|
||||
let ctx = Arc::new(crate::testutil::test_ctx().await);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "localhost", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Local);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_localhost_subdomain_resolves_to_loopback() {
|
||||
let ctx = Arc::new(crate::testutil::test_ctx().await);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Local);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_local_zone_returns_configured_record() {
|
||||
let mut ctx = crate::testutil::test_ctx().await;
|
||||
let mut inner = HashMap::new();
|
||||
inner.insert(
|
||||
QueryType::A,
|
||||
vec![DnsRecord::A {
|
||||
domain: "myapp.test".to_string(),
|
||||
addr: Ipv4Addr::new(10, 0, 0, 42),
|
||||
ttl: 300,
|
||||
}],
|
||||
);
|
||||
ctx.zone_map.insert("myapp.test".to_string(), inner);
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "myapp.test", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Local);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)),
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_tld_proxy_resolves_service() {
|
||||
let ctx = crate::testutil::test_ctx().await;
|
||||
ctx.services.lock().unwrap().insert("grafana", 3000);
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "grafana.numa", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Local);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_blocklist_sinkhole() {
|
||||
let ctx = crate::testutil::test_ctx().await;
|
||||
let mut domains = std::collections::HashSet::new();
|
||||
domains.insert("ads.tracker.test".to_string());
|
||||
ctx.blocklist.write().unwrap().swap_domains(domains, vec![]);
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "ads.tracker.test", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Blocked);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::UNSPECIFIED),
|
||||
other => panic!("expected sinkhole A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_cache_hit() {
|
||||
let ctx = Arc::new(crate::testutil::test_ctx().await);
|
||||
|
||||
// Pre-populate cache with a response
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::NOERROR;
|
||||
pkt.questions.push(crate::question::DnsQuestion {
|
||||
name: "cached.test".to_string(),
|
||||
qtype: QueryType::A,
|
||||
});
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "cached.test".to_string(),
|
||||
addr: Ipv4Addr::new(5, 5, 5, 5),
|
||||
ttl: 3600,
|
||||
});
|
||||
ctx.cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert("cached.test", QueryType::A, &pkt);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "cached.test", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Cached);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_forwarding_returns_upstream_answer() {
|
||||
let mut upstream_resp = DnsPacket::new();
|
||||
upstream_resp.header.response = true;
|
||||
upstream_resp.header.rescode = ResultCode::NOERROR;
|
||||
upstream_resp.answers.push(DnsRecord::A {
|
||||
domain: "internal.corp".to_string(),
|
||||
addr: Ipv4Addr::new(10, 1, 2, 3),
|
||||
ttl: 600,
|
||||
});
|
||||
let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||
|
||||
let mut ctx = crate::testutil::test_ctx().await;
|
||||
ctx.forwarding_rules = vec![ForwardingRule::new(
|
||||
"corp".to_string(),
|
||||
Upstream::Udp(upstream_addr),
|
||||
)];
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Forwarded);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { domain, addr, .. } => {
|
||||
assert_eq!(domain, "internal.corp");
|
||||
assert_eq!(*addr, Ipv4Addr::new(10, 1, 2, 3));
|
||||
}
|
||||
other => panic!("expected A record, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipeline_default_pool_reports_upstream_path() {
|
||||
let mut upstream_resp = DnsPacket::new();
|
||||
upstream_resp.header.response = true;
|
||||
upstream_resp.header.rescode = ResultCode::NOERROR;
|
||||
upstream_resp.answers.push(DnsRecord::A {
|
||||
domain: "example.com".to_string(),
|
||||
addr: Ipv4Addr::new(93, 184, 216, 34),
|
||||
ttl: 300,
|
||||
});
|
||||
let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||
|
||||
let ctx = crate::testutil::test_ctx().await;
|
||||
ctx.upstream_pool
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set_primary(vec![Upstream::Udp(upstream_addr)]);
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let (resp, path) = resolve_in_test(&ctx, "example.com", QueryType::A).await;
|
||||
assert_eq!(path, QueryPath::Upstream);
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
assert_eq!(resp.answers.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ async fn resolve_doh(
|
||||
let questions = query.questions.clone();
|
||||
|
||||
match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await {
|
||||
Ok(resp_buffer) => {
|
||||
Ok((resp_buffer, _)) => {
|
||||
let min_ttl = extract_min_ttl(resp_buffer.filled());
|
||||
dns_response(resp_buffer.filled(), min_ttl)
|
||||
}
|
||||
|
||||
84
src/dot.rs
84
src/dot.rs
@@ -211,7 +211,7 @@ async fn handle_dot_connection<S>(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_buffer) => {
|
||||
Ok((resp_buffer, _)) => {
|
||||
if write_framed(&mut stream, resp_buffer.filled())
|
||||
.await
|
||||
.is_err()
|
||||
@@ -279,7 +279,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rcgen::{CertificateParams, DnType, KeyPair};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
|
||||
@@ -344,63 +344,29 @@ mod tests {
|
||||
async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) {
|
||||
let (server_tls, cert_der) = test_tls_configs();
|
||||
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
// Bind an unresponsive upstream and leak it so it lives for the test duration.
|
||||
let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap()));
|
||||
let upstream_addr = blackhole.local_addr().unwrap();
|
||||
let ctx = Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: {
|
||||
let mut m = HashMap::new();
|
||||
let mut inner = HashMap::new();
|
||||
inner.insert(
|
||||
QueryType::A,
|
||||
vec![DnsRecord::A {
|
||||
domain: "dot-test.example".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
|
||||
ttl: 300,
|
||||
}],
|
||||
);
|
||||
m.insert("dot-test.example".to_string(), inner);
|
||||
m
|
||||
},
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(upstream_addr)],
|
||||
vec![],
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: Duration::from_millis(200),
|
||||
hedge_delay: Duration::ZERO,
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: String::new(),
|
||||
config_found: false,
|
||||
config_dir: std::path::PathBuf::from("/tmp"),
|
||||
data_dir: std::path::PathBuf::from("/tmp"),
|
||||
tls_config: Some(arc_swap::ArcSwap::from(server_tls)),
|
||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
||||
inflight: Mutex::new(HashMap::new()),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
});
|
||||
let upstream_addr = crate::testutil::blackhole_upstream();
|
||||
|
||||
let mut ctx = crate::testutil::test_ctx().await;
|
||||
ctx.zone_map = {
|
||||
let mut m = HashMap::new();
|
||||
let mut inner = HashMap::new();
|
||||
inner.insert(
|
||||
QueryType::A,
|
||||
vec![DnsRecord::A {
|
||||
domain: "dot-test.example".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
|
||||
ttl: 300,
|
||||
}],
|
||||
);
|
||||
m.insert("dot-test.example".to_string(), inner);
|
||||
m
|
||||
};
|
||||
ctx.upstream_pool = Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(upstream_addr)],
|
||||
vec![],
|
||||
));
|
||||
ctx.tls_config = Some(arc_swap::ArcSwap::from(server_tls));
|
||||
let ctx = Arc::new(ctx);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
@@ -36,6 +36,12 @@ impl PartialEq for Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Upstream {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Upstream {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -49,7 +55,10 @@ impl fmt::Display for Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result<SocketAddr, String> {
|
||||
pub(crate) fn parse_upstream_addr(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
) -> std::result::Result<SocketAddr, String> {
|
||||
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
|
||||
if let Ok(addr) = s.parse::<SocketAddr>() {
|
||||
return Ok(addr);
|
||||
|
||||
@@ -43,7 +43,7 @@ impl HealthMeta {
|
||||
#[cfg(test)]
|
||||
pub fn test_fixture() -> Self {
|
||||
HealthMeta {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
version: crate::version(),
|
||||
hostname: "test-host".to_string(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled: false,
|
||||
@@ -99,7 +99,7 @@ impl HealthMeta {
|
||||
}
|
||||
|
||||
HealthMeta {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
version: crate::version(),
|
||||
hostname: crate::hostname(),
|
||||
sni: "numa.numa".to_string(),
|
||||
dot_enabled,
|
||||
|
||||
22
src/lib.rs
22
src/lib.rs
@@ -20,6 +20,7 @@ pub mod query_log;
|
||||
pub mod question;
|
||||
pub mod record;
|
||||
pub mod recursive;
|
||||
pub mod serve;
|
||||
pub mod service_store;
|
||||
pub mod setup_phone;
|
||||
pub mod srtt;
|
||||
@@ -28,9 +29,23 @@ pub mod system_dns;
|
||||
pub mod tls;
|
||||
pub mod wire;
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod windows_service;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Build version string. On tagged releases: `0.13.1`. On commits ahead
|
||||
/// of a tag: `0.13.1+a87f907`. With uncommitted changes: `0.13.1+a87f907-dirty`.
|
||||
/// Falls back to `CARGO_PKG_VERSION` when built outside a git repo (e.g.
|
||||
/// from a source tarball).
|
||||
pub fn version() -> &'static str {
|
||||
option_env!("NUMA_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Detect the machine hostname via the `hostname` command. Returns the
|
||||
/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command
|
||||
/// fails. Call sites that need the short form (e.g., mDNS instance
|
||||
@@ -86,14 +101,11 @@ where
|
||||
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||
/// if a pre-v0.10.1 install already lives there.
|
||||
/// macOS root daemon: /usr/local/var/numa (Homebrew prefix)
|
||||
/// Windows: %APPDATA%\numa
|
||||
/// Windows: %PROGRAMDATA%\numa (same as data_dir — no per-user config on Windows)
|
||||
pub fn config_dir() -> std::path::PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::path::PathBuf::from(
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
||||
)
|
||||
.join("numa")
|
||||
data_dir()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
|
||||
686
src/main.rs
686
src/main.rs
@@ -1,51 +1,49 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use log::{error, info};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
|
||||
use numa::buffer::BytePacketBuffer;
|
||||
use numa::cache::DnsCache;
|
||||
use numa::config::{build_zone_map, load_config, ConfigLoad};
|
||||
use numa::ctx::{handle_query, ServerCtx};
|
||||
use numa::forward::{parse_upstream, Upstream, UpstreamPool};
|
||||
use numa::override_store::OverrideStore;
|
||||
use numa::query_log::QueryLog;
|
||||
use numa::service_store::ServiceStore;
|
||||
use numa::stats::{ServerStats, Transport};
|
||||
use numa::system_dns::{
|
||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
||||
install_service, restart_service, service_status, start_service, stop_service,
|
||||
uninstall_service,
|
||||
};
|
||||
|
||||
const QUAD9_IP: &str = "9.9.9.9";
|
||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
||||
fn main() -> numa::Result<()> {
|
||||
// Handle CLI subcommands
|
||||
let arg1 = std::env::args().nth(1).unwrap_or_default();
|
||||
|
||||
#[cfg(windows)]
|
||||
if arg1 == "--service" {
|
||||
// Running under SCM — stderr goes nowhere. Redirect logs to a file.
|
||||
let log_path = numa::data_dir().join("numa.log");
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.expect("failed to open log file");
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.format_timestamp_millis()
|
||||
.target(env_logger::Target::Pipe(Box::new(log_file)))
|
||||
.init();
|
||||
numa::windows_service::run_as_service()
|
||||
.map_err(|e| format!("windows service dispatcher failed: {}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> numa::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
// Handle CLI subcommands
|
||||
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 — installing\n");
|
||||
eprintln!("\x1b[1;38;5;166mNuma\x1b[0m — installing\n");
|
||||
return install_service().map_err(|e| e.into());
|
||||
}
|
||||
"uninstall" => {
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
|
||||
eprintln!("\x1b[1;38;5;166mNuma\x1b[0m — uninstalling\n");
|
||||
return uninstall_service().map_err(|e| e.into());
|
||||
}
|
||||
"service" => {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — service management\n");
|
||||
eprintln!("\x1b[1;38;5;166mNuma\x1b[0m — service management\n");
|
||||
return match sub.as_str() {
|
||||
"start" => install_service().map_err(|e| e.into()),
|
||||
"stop" => uninstall_service().map_err(|e| e.into()),
|
||||
"start" => start_service().map_err(|e| e.into()),
|
||||
"stop" => stop_service().map_err(|e| e.into()),
|
||||
"restart" => restart_service().map_err(|e| e.into()),
|
||||
"status" => service_status().map_err(|e| e.into()),
|
||||
_ => {
|
||||
@@ -55,7 +53,12 @@ async fn main() -> numa::Result<()> {
|
||||
};
|
||||
}
|
||||
"setup-phone" => {
|
||||
return numa::setup_phone::run().await.map_err(|e| e.into());
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
return runtime
|
||||
.block_on(numa::setup_phone::run())
|
||||
.map_err(|e| e.into());
|
||||
}
|
||||
"lan" => {
|
||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||
@@ -102,7 +105,7 @@ async fn main() -> numa::Result<()> {
|
||||
&& !arg1.ends_with(".toml")
|
||||
{
|
||||
eprintln!(
|
||||
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
|
||||
"\x1b[1;38;5;166mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
|
||||
arg1
|
||||
);
|
||||
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
|
||||
@@ -118,552 +121,11 @@ async fn main() -> numa::Result<()> {
|
||||
} else {
|
||||
arg1 // treat as config path for backwards compatibility
|
||||
};
|
||||
let ConfigLoad {
|
||||
config,
|
||||
path: resolved_config_path,
|
||||
found: config_found,
|
||||
} = load_config(&config_path)?;
|
||||
|
||||
// Discover system DNS in a single pass (upstream + forwarding rules)
|
||||
let system_dns = discover_system_dns();
|
||||
|
||||
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||
|
||||
let recursive_pool = || {
|
||||
let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]);
|
||||
(dummy, "recursive (root hints)".to_string())
|
||||
};
|
||||
|
||||
let (resolved_mode, upstream_auto, pool, 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 (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
} 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();
|
||||
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||
(numa::config::UpstreamMode::Forward, false, pool, label)
|
||||
}
|
||||
}
|
||||
numa::config::UpstreamMode::Recursive => {
|
||||
let (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
}
|
||||
numa::config::UpstreamMode::Forward => {
|
||||
let addrs = if config.upstream.address.is_empty() {
|
||||
let detected = 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()
|
||||
});
|
||||
vec![detected]
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
|
||||
let primary: Vec<Upstream> = addrs
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<numa::Result<Vec<_>>>()?;
|
||||
let fallback: Vec<Upstream> = config
|
||||
.upstream
|
||||
.fallback
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<numa::Result<Vec<_>>>()?;
|
||||
|
||||
let pool = UpstreamPool::new(primary, fallback);
|
||||
let label = pool.label();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
config.upstream.address.is_empty(),
|
||||
pool,
|
||||
label,
|
||||
)
|
||||
}
|
||||
};
|
||||
let api_port = config.server.api_port;
|
||||
|
||||
let mut blocklist = BlocklistStore::new();
|
||||
for domain in &config.blocking.allowlist {
|
||||
blocklist.add_to_allowlist(domain);
|
||||
}
|
||||
if !config.blocking.enabled {
|
||||
blocklist.set_enabled(false);
|
||||
}
|
||||
|
||||
// Build service store: config services + persisted user services
|
||||
let mut service_store = ServiceStore::new();
|
||||
service_store.insert_from_config("numa", config.server.api_port, Vec::new());
|
||||
for svc in &config.services {
|
||||
service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone());
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
for fwd in &config.forwarding {
|
||||
for suffix in &fwd.suffix {
|
||||
info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream);
|
||||
}
|
||||
}
|
||||
let forwarding_rules =
|
||||
numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?;
|
||||
|
||||
// Resolve data_dir from config, falling back to the platform default.
|
||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
||||
let resolved_data_dir = config
|
||||
.server
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(numa::data_dir);
|
||||
|
||||
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
|
||||
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||
let service_names = service_store.names();
|
||||
match numa::tls::build_tls_config(
|
||||
&config.proxy.tld,
|
||||
&service_names,
|
||||
Vec::new(),
|
||||
&resolved_data_dir,
|
||||
) {
|
||||
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
|
||||
Err(e) => {
|
||||
if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) {
|
||||
eprint!("{}", advisory);
|
||||
} else {
|
||||
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let doh_enabled = initial_tls.is_some();
|
||||
let health_meta = numa::health::HealthMeta::build(
|
||||
&resolved_data_dir,
|
||||
config.dot.enabled,
|
||||
config.dot.port,
|
||||
config.mobile.port,
|
||||
config.dnssec.enabled,
|
||||
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||
config.lan.enabled,
|
||||
config.blocking.enabled,
|
||||
doh_enabled,
|
||||
);
|
||||
|
||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||
|
||||
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Some(advisory) =
|
||||
numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e)
|
||||
{
|
||||
eprint!("{}", advisory);
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: build_zone_map(&config.zones)?,
|
||||
cache: RwLock::new(DnsCache::new(
|
||||
config.cache.max_entries,
|
||||
config.cache.min_ttl,
|
||||
config.cache.max_ttl,
|
||||
)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(ServerStats::new()),
|
||||
overrides: RwLock::new(OverrideStore::new()),
|
||||
blocklist: RwLock::new(blocklist),
|
||||
query_log: Mutex::new(QueryLog::new(1000)),
|
||||
services: Mutex::new(service_store),
|
||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||
forwarding_rules,
|
||||
upstream_pool: Mutex::new(pool),
|
||||
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),
|
||||
hedge_delay: Duration::from_millis(config.upstream.hedge_ms),
|
||||
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(".{}", config.proxy.tld)
|
||||
},
|
||||
proxy_tld: config.proxy.tld.clone(),
|
||||
lan_enabled: config.lan.enabled,
|
||||
config_path: resolved_config_path,
|
||||
config_found,
|
||||
config_dir: numa::config_dir(),
|
||||
data_dir: resolved_data_dir,
|
||||
tls_config: initial_tls,
|
||||
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,
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
health_meta,
|
||||
ca_pem,
|
||||
mobile_enabled: config.mobile.enabled,
|
||||
mobile_port: config.mobile.port,
|
||||
});
|
||||
|
||||
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 {
|
||||
if config.proxy.tls_port > 0 {
|
||||
Some(format!(
|
||||
"http://:{} https://:{}",
|
||||
config.proxy.port, config.proxy.tls_port
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
"http://*.{} on :{}",
|
||||
config.proxy.tld, config.proxy.port
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config_label = if ctx.config_found {
|
||||
ctx.config_path.clone()
|
||||
} else {
|
||||
format!("{} (defaults)", ctx.config_path)
|
||||
};
|
||||
let data_label = ctx.data_dir.display().to_string();
|
||||
let services_label = ctx.config_dir.join("services.json").display().to_string();
|
||||
|
||||
// label (10) + value + padding (2) = inner width; minimum 40 for the title row
|
||||
let val_w = [
|
||||
config.server.bind_addr.len(),
|
||||
api_url.len(),
|
||||
upstream_label.len(),
|
||||
config_label.len(),
|
||||
data_label.len(),
|
||||
services_label.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(proxy_label.as_ref().map(|s| s.len()))
|
||||
.max()
|
||||
.unwrap_or(30);
|
||||
let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title
|
||||
|
||||
let o = "\x1b[38;2;192;98;58m"; // orange
|
||||
let g = "\x1b[38;2;107;124;78m"; // green
|
||||
let d = "\x1b[38;2;163;152;136m"; // dim
|
||||
let r = "\x1b[0m"; // reset
|
||||
let b = "\x1b[1;38;2;192;98;58m"; // bold orange
|
||||
let it = "\x1b[3;38;2;163;152;136m"; // italic dim
|
||||
|
||||
let bar_top = "═".repeat(w);
|
||||
let bar_mid = "─".repeat(w);
|
||||
let row = |label: &str, color: &str, value: &str| {
|
||||
eprintln!(
|
||||
"{o} ║{r} {color}{:<9}{r} {:<vw$}{o}║{r}",
|
||||
label,
|
||||
value,
|
||||
vw = w - 12
|
||||
);
|
||||
};
|
||||
|
||||
// Title row: center within the box
|
||||
let title = format!(
|
||||
"{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
|
||||
let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
|
||||
let title_pad = w.saturating_sub(title_visible_len);
|
||||
eprintln!("\n{o} ╔{bar_top}╗{r}");
|
||||
eprint!("{o} ║{r} {title}");
|
||||
eprintln!("{}{o}║{r}", " ".repeat(title_pad));
|
||||
eprintln!("{o} ╠{bar_top}╣{r}");
|
||||
row("DNS", g, &config.server.bind_addr);
|
||||
row("API", g, &api_url);
|
||||
row("Dashboard", g, &api_url);
|
||||
row(
|
||||
"Upstream",
|
||||
g,
|
||||
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)"
|
||||
} else {
|
||||
&upstream_label
|
||||
},
|
||||
);
|
||||
row("Zones", g, &format!("{} records", zone_count));
|
||||
row(
|
||||
"Cache",
|
||||
g,
|
||||
&format!("max {} entries", config.cache.max_entries),
|
||||
);
|
||||
if !config.cache.warm.is_empty() {
|
||||
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
|
||||
}
|
||||
row(
|
||||
"Blocking",
|
||||
g,
|
||||
&if config.blocking.enabled {
|
||||
format!("{} lists", config.blocking.lists.len())
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
},
|
||||
);
|
||||
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.dot.enabled {
|
||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||
}
|
||||
if doh_enabled {
|
||||
row(
|
||||
"DoH",
|
||||
g,
|
||||
&format!("https://:{}/dns-query", config.proxy.tls_port),
|
||||
);
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
}
|
||||
if !ctx.forwarding_rules.is_empty() {
|
||||
row(
|
||||
"Routing",
|
||||
g,
|
||||
&format!("{} conditional rules", ctx.forwarding_rules.len()),
|
||||
);
|
||||
}
|
||||
eprintln!("{o} ╠{bar_mid}╣{r}");
|
||||
row("Config", d, &config_label);
|
||||
row("Data", d, &data_label);
|
||||
row("Services", d, &services_label);
|
||||
eprintln!("{o} ╚{bar_top}╝{r}\n");
|
||||
|
||||
info!(
|
||||
"numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}",
|
||||
config.server.bind_addr, upstream_label, zone_count, config.cache.max_entries, api_port,
|
||||
);
|
||||
|
||||
// Download blocklists on startup
|
||||
let blocklist_lists = config.blocking.lists.clone();
|
||||
let refresh_hours = config.blocking.refresh_hours;
|
||||
if config.blocking.enabled && !blocklist_lists.is_empty() {
|
||||
let bl_ctx = Arc::clone(&ctx);
|
||||
let bl_lists = blocklist_lists.clone();
|
||||
tokio::spawn(async move {
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
|
||||
// Periodic refresh
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(refresh_hours * 3600));
|
||||
interval.tick().await; // skip immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
info!("refreshing blocklists...");
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prime TLD cache (recursive mode only)
|
||||
if ctx.upstream_mode == numa::config::UpstreamMode::Recursive {
|
||||
let prime_ctx = Arc::clone(&ctx);
|
||||
let prime_tlds = config.upstream.prime_tlds;
|
||||
tokio::spawn(async move {
|
||||
numa::recursive::prime_tld_cache(
|
||||
&prime_ctx.cache,
|
||||
&prime_ctx.root_hints,
|
||||
&prime_tlds,
|
||||
&prime_ctx.srtt,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn cache warming for user-configured domains
|
||||
if !config.cache.warm.is_empty() {
|
||||
let warm_ctx = Arc::clone(&ctx);
|
||||
let warm_domains = config.cache.warm.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_warm_loop(warm_ctx, warm_domains).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DoH connection keepalive — prevents idle TLS teardown
|
||||
{
|
||||
let keepalive_ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
doh_keepalive_loop(keepalive_ctx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTP API server
|
||||
let api_ctx = Arc::clone(&ctx);
|
||||
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
||||
tokio::spawn(async move {
|
||||
let app = numa::api::router(api_ctx);
|
||||
let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap();
|
||||
info!("HTTP API listening on {}", api_addr);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Spawn Mobile API listener (read-only subset for iOS/Android companion
|
||||
// apps, LAN-bound by default so phones can reach it). Only idempotent
|
||||
// GETs; no state-mutating routes are exposed here regardless of
|
||||
// the main API's bind address.
|
||||
if config.mobile.enabled {
|
||||
let mobile_ctx = Arc::clone(&ctx);
|
||||
let mobile_bind = config.mobile.bind_addr.clone();
|
||||
let mobile_port = config.mobile.port;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await {
|
||||
log::warn!("Mobile API listener failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let proxy_port = config.proxy.port;
|
||||
tokio::spawn(async move {
|
||||
numa::proxy::start_proxy(proxy_ctx, proxy_port, proxy_bind).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTPS reverse proxy with TLS termination
|
||||
if config.proxy.enabled && config.proxy.tls_port > 0 && ctx.tls_config.is_some() {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let tls_port = config.proxy.tls_port;
|
||||
tokio::spawn(async move {
|
||||
numa::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn network change watcher (upstream re-detection, LAN IP update, peer flush)
|
||||
{
|
||||
let watch_ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
network_watch_loop(watch_ctx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn LAN service discovery
|
||||
if config.lan.enabled {
|
||||
let lan_ctx = Arc::clone(&ctx);
|
||||
let lan_config = config.lan.clone();
|
||||
tokio::spawn(async move {
|
||||
numa::lan::start_lan_discovery(lan_ctx, &lan_config).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DNS-over-TLS listener (RFC 7858)
|
||||
if config.dot.enabled {
|
||||
let dot_ctx = Arc::clone(&ctx);
|
||||
let dot_config = config.dot.clone();
|
||||
tokio::spawn(async move {
|
||||
numa::dot::start_dot(dot_ctx, &dot_config).await;
|
||||
});
|
||||
}
|
||||
|
||||
// UDP DNS listener
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (len, 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 {
|
||||
if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await {
|
||||
error!("{} | HANDLER ERROR | {}", src_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
let mut tick: u64 = 0;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
interval.tick().await; // skip immediate tick
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tick += 1;
|
||||
let mut changed = false;
|
||||
|
||||
// Check LAN IP change (every 5s — cheap, one UDP socket call)
|
||||
if let Some(new_ip) = numa::lan::detect_lan_ip() {
|
||||
let mut current_ip = ctx.lan_ip.lock().unwrap();
|
||||
if new_ip != *current_ip {
|
||||
info!("LAN IP changed: {} → {}", current_ip, new_ip);
|
||||
*current_ip = new_ip;
|
||||
changed = true;
|
||||
numa::recursive::reset_udp_state();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
|
||||
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
|
||||
let dns_info = numa::system_dns::discover_system_dns();
|
||||
let new_addr = dns_info
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||
let mut pool = ctx.upstream_pool.lock().unwrap();
|
||||
if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
|
||||
info!("upstream changed → {}", pool.label());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush stale LAN peers on any network change
|
||||
if changed {
|
||||
ctx.lan_peers.lock().unwrap().clear();
|
||||
info!("flushed LAN peers after network change");
|
||||
}
|
||||
|
||||
// Re-probe UDP every 5 minutes when disabled
|
||||
if tick.is_multiple_of(60) {
|
||||
numa::recursive::probe_udp(&ctx.root_hints).await;
|
||||
}
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(numa::serve::run(config_path))
|
||||
}
|
||||
|
||||
fn set_lan_enabled(enabled: bool, path: &str) -> numa::Result<()> {
|
||||
@@ -723,78 +185,10 @@ fn print_lan_status(enabled: bool) {
|
||||
let label = if enabled { "enabled" } else { "disabled" };
|
||||
let color = if enabled { "32" } else { "33" };
|
||||
eprintln!(
|
||||
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — LAN discovery \x1b[{}m{}\x1b[0m",
|
||||
"\x1b[1;38;5;166mNuma\x1b[0m — LAN discovery \x1b[{}m{}\x1b[0m",
|
||||
color, label
|
||||
);
|
||||
if enabled {
|
||||
eprintln!(" Restart Numa to start mDNS discovery");
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
||||
let downloaded = download_blocklists(lists).await;
|
||||
|
||||
// Parse outside the lock to avoid blocking DNS queries during parse (~100ms)
|
||||
let mut all_domains = std::collections::HashSet::new();
|
||||
let mut sources = Vec::new();
|
||||
for (source, text) in &downloaded {
|
||||
let domains = parse_blocklist(text);
|
||||
info!("blocklist: {} domains from {}", domains.len(), source);
|
||||
all_domains.extend(domains);
|
||||
sources.push(source.clone());
|
||||
}
|
||||
let total = all_domains.len();
|
||||
|
||||
// Swap under lock — sub-microsecond
|
||||
ctx.blocklist
|
||||
.write()
|
||||
.unwrap()
|
||||
.swap_domains(all_domains, sources);
|
||||
info!(
|
||||
"blocking enabled: {} unique domains from {} lists",
|
||||
total,
|
||||
downloaded.len()
|
||||
);
|
||||
}
|
||||
|
||||
async fn warm_domain(ctx: &ServerCtx, domain: &str) {
|
||||
for qtype in [
|
||||
numa::question::QueryType::A,
|
||||
numa::question::QueryType::AAAA,
|
||||
] {
|
||||
numa::ctx::refresh_entry(ctx, domain, qtype).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(25));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
if let Some(upstream) = pool.preferred() {
|
||||
numa::forward::keepalive_doh(upstream).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cache_warm_loop(ctx: Arc<ServerCtx>, domains: Vec<String>) {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
for domain in &domains {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
info!("cache warm: {} domains resolved at startup", domains.len());
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
for domain in &domains {
|
||||
let refresh = ctx.cache.read().unwrap().needs_warm(domain);
|
||||
if refresh {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
646
src/serve.rs
Normal file
646
src/serve.rs
Normal file
@@ -0,0 +1,646 @@
|
||||
//! The main DNS-server runtime.
|
||||
//!
|
||||
//! Extracted from `main.rs` so both the interactive CLI entry and the
|
||||
//! Windows service dispatcher (`windows_service` module) can drive the
|
||||
//! same startup/serve loop.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use log::{error, info};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::{build_zone_map, load_config, ConfigLoad};
|
||||
use crate::ctx::{handle_query, ServerCtx};
|
||||
use crate::forward::{parse_upstream, Upstream, UpstreamPool};
|
||||
use crate::override_store::OverrideStore;
|
||||
use crate::query_log::QueryLog;
|
||||
use crate::service_store::ServiceStore;
|
||||
use crate::stats::{ServerStats, Transport};
|
||||
use crate::system_dns::discover_system_dns;
|
||||
|
||||
const QUAD9_IP: &str = "9.9.9.9";
|
||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
||||
|
||||
/// Boot the DNS server and run until the UDP listener errors out.
|
||||
pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
let ConfigLoad {
|
||||
config,
|
||||
path: resolved_config_path,
|
||||
found: config_found,
|
||||
} = load_config(&config_path)?;
|
||||
|
||||
// Discover system DNS in a single pass (upstream + forwarding rules)
|
||||
let system_dns = discover_system_dns();
|
||||
|
||||
let root_hints = crate::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||
|
||||
let recursive_pool = || {
|
||||
let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]);
|
||||
(dummy, "recursive (root hints)".to_string())
|
||||
};
|
||||
|
||||
let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode {
|
||||
crate::config::UpstreamMode::Auto => {
|
||||
info!("auto mode: probing recursive resolution...");
|
||||
if crate::recursive::probe_recursive(&root_hints).await {
|
||||
info!("recursive probe succeeded — self-sovereign mode");
|
||||
let (pool, label) = recursive_pool();
|
||||
(crate::config::UpstreamMode::Recursive, false, pool, label)
|
||||
} 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();
|
||||
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||
(crate::config::UpstreamMode::Forward, false, pool, label)
|
||||
}
|
||||
}
|
||||
crate::config::UpstreamMode::Recursive => {
|
||||
let (pool, label) = recursive_pool();
|
||||
(crate::config::UpstreamMode::Recursive, false, pool, label)
|
||||
}
|
||||
crate::config::UpstreamMode::Forward => {
|
||||
let addrs = if config.upstream.address.is_empty() {
|
||||
let detected = system_dns
|
||||
.default_upstream
|
||||
.or_else(crate::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| {
|
||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||
DOH_FALLBACK.to_string()
|
||||
});
|
||||
vec![detected]
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
|
||||
let primary: Vec<Upstream> = addrs
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
let fallback: Vec<Upstream> = config
|
||||
.upstream
|
||||
.fallback
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
|
||||
let pool = UpstreamPool::new(primary, fallback);
|
||||
let label = pool.label();
|
||||
(
|
||||
crate::config::UpstreamMode::Forward,
|
||||
config.upstream.address.is_empty(),
|
||||
pool,
|
||||
label,
|
||||
)
|
||||
}
|
||||
};
|
||||
let api_port = config.server.api_port;
|
||||
|
||||
let mut blocklist = BlocklistStore::new();
|
||||
for domain in &config.blocking.allowlist {
|
||||
blocklist.add_to_allowlist(domain);
|
||||
}
|
||||
if !config.blocking.enabled {
|
||||
blocklist.set_enabled(false);
|
||||
}
|
||||
|
||||
// Build service store: config services + persisted user services
|
||||
let mut service_store = ServiceStore::new();
|
||||
service_store.insert_from_config("numa", config.server.api_port, Vec::new());
|
||||
for svc in &config.services {
|
||||
service_store.insert_from_config(&svc.name, svc.target_port, svc.routes.clone());
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
for fwd in &config.forwarding {
|
||||
for suffix in &fwd.suffix {
|
||||
info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream);
|
||||
}
|
||||
}
|
||||
let forwarding_rules =
|
||||
crate::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?;
|
||||
|
||||
// Resolve data_dir from config, falling back to the platform default.
|
||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
||||
let resolved_data_dir = config
|
||||
.server
|
||||
.data_dir
|
||||
.clone()
|
||||
.unwrap_or_else(crate::data_dir);
|
||||
|
||||
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
|
||||
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||
let service_names = service_store.names();
|
||||
match crate::tls::build_tls_config(
|
||||
&config.proxy.tld,
|
||||
&service_names,
|
||||
Vec::new(),
|
||||
&resolved_data_dir,
|
||||
) {
|
||||
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
|
||||
Err(e) => {
|
||||
if let Some(advisory) = crate::tls::try_data_dir_advisory(&e, &resolved_data_dir) {
|
||||
eprint!("{}", advisory);
|
||||
} else {
|
||||
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let doh_enabled = initial_tls.is_some();
|
||||
let health_meta = crate::health::HealthMeta::build(
|
||||
&resolved_data_dir,
|
||||
config.dot.enabled,
|
||||
config.dot.port,
|
||||
config.mobile.port,
|
||||
config.dnssec.enabled,
|
||||
resolved_mode == crate::config::UpstreamMode::Recursive,
|
||||
config.lan.enabled,
|
||||
config.blocking.enabled,
|
||||
doh_enabled,
|
||||
);
|
||||
|
||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||
|
||||
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Some(advisory) =
|
||||
crate::system_dns::try_port53_advisory(&config.server.bind_addr, &e)
|
||||
{
|
||||
eprint!("{}", advisory);
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = Arc::new(ServerCtx {
|
||||
socket,
|
||||
zone_map: build_zone_map(&config.zones)?,
|
||||
cache: RwLock::new(DnsCache::new(
|
||||
config.cache.max_entries,
|
||||
config.cache.min_ttl,
|
||||
config.cache.max_ttl,
|
||||
)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(ServerStats::new()),
|
||||
overrides: RwLock::new(OverrideStore::new()),
|
||||
blocklist: RwLock::new(blocklist),
|
||||
query_log: Mutex::new(QueryLog::new(1000)),
|
||||
services: Mutex::new(service_store),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||
forwarding_rules,
|
||||
upstream_pool: Mutex::new(pool),
|
||||
upstream_auto,
|
||||
upstream_port: config.upstream.port,
|
||||
lan_ip: Mutex::new(crate::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
||||
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||
hedge_delay: Duration::from_millis(config.upstream.hedge_ms),
|
||||
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(".{}", config.proxy.tld)
|
||||
},
|
||||
proxy_tld: config.proxy.tld.clone(),
|
||||
lan_enabled: config.lan.enabled,
|
||||
config_path: resolved_config_path,
|
||||
config_found,
|
||||
config_dir: crate::config_dir(),
|
||||
data_dir: resolved_data_dir,
|
||||
tls_config: initial_tls,
|
||||
upstream_mode: resolved_mode,
|
||||
root_hints,
|
||||
srtt: std::sync::RwLock::new(crate::srtt::SrttCache::new(config.upstream.srtt)),
|
||||
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
dnssec_enabled: config.dnssec.enabled,
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
health_meta,
|
||||
ca_pem,
|
||||
mobile_enabled: config.mobile.enabled,
|
||||
mobile_port: config.mobile.port,
|
||||
});
|
||||
|
||||
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 {
|
||||
if config.proxy.tls_port > 0 {
|
||||
Some(format!(
|
||||
"http://:{} https://:{}",
|
||||
config.proxy.port, config.proxy.tls_port
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
"http://*.{} on :{}",
|
||||
config.proxy.tld, config.proxy.port
|
||||
))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let config_label = if ctx.config_found {
|
||||
ctx.config_path.clone()
|
||||
} else {
|
||||
format!("{} (defaults)", ctx.config_path)
|
||||
};
|
||||
let data_label = ctx.data_dir.display().to_string();
|
||||
let services_label = ctx.config_dir.join("services.json").display().to_string();
|
||||
|
||||
// label (10) + value + padding (2) = inner width; minimum 40 for the title row
|
||||
let val_w = [
|
||||
config.server.bind_addr.len(),
|
||||
api_url.len(),
|
||||
upstream_label.len(),
|
||||
config_label.len(),
|
||||
data_label.len(),
|
||||
services_label.len(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(proxy_label.as_ref().map(|s| s.len()))
|
||||
.max()
|
||||
.unwrap_or(30);
|
||||
let w = (val_w + 12).max(42); // 10 label + 2 padding, min 42 for title
|
||||
|
||||
let o = "\x1b[38;5;166m"; // orange borders (256-color, ~192,98,58)
|
||||
let g = "\x1b[38;5;101m"; // khaki/olive labels (256-color, ~107,124,78)
|
||||
let d = "\x1b[38;5;138m"; // warm grey labels (256-color, ~163,152,136)
|
||||
let r = "\x1b[0m"; // reset
|
||||
let b = "\x1b[1;38;5;166m"; // bold orange title (256-color)
|
||||
let it = "\x1b[3;38;5;138m"; // italic warm grey subtitle
|
||||
|
||||
let bar_top = "═".repeat(w);
|
||||
let bar_mid = "─".repeat(w);
|
||||
let row = |label: &str, color: &str, value: &str| {
|
||||
eprintln!(
|
||||
"{o} ║{r} {color}{:<9}{r} {:<vw$}{o}║{r}",
|
||||
label,
|
||||
value,
|
||||
vw = w - 12
|
||||
);
|
||||
};
|
||||
|
||||
// Title row: center within the box
|
||||
let title = format!(
|
||||
"{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
|
||||
let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
|
||||
let title_pad = w.saturating_sub(title_visible_len);
|
||||
eprintln!("\n{o} ╔{bar_top}╗{r}");
|
||||
eprint!("{o} ║{r} {title}");
|
||||
eprintln!("{}{o}║{r}", " ".repeat(title_pad));
|
||||
eprintln!("{o} ╠{bar_top}╣{r}");
|
||||
row("DNS", g, &config.server.bind_addr);
|
||||
row("API", g, &api_url);
|
||||
row("Dashboard", g, &api_url);
|
||||
row(
|
||||
"Upstream",
|
||||
g,
|
||||
if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)"
|
||||
} else {
|
||||
&upstream_label
|
||||
},
|
||||
);
|
||||
row("Zones", g, &format!("{} records", zone_count));
|
||||
row(
|
||||
"Cache",
|
||||
g,
|
||||
&format!("max {} entries", config.cache.max_entries),
|
||||
);
|
||||
if !config.cache.warm.is_empty() {
|
||||
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
|
||||
}
|
||||
row(
|
||||
"Blocking",
|
||||
g,
|
||||
&if config.blocking.enabled {
|
||||
format!("{} lists", config.blocking.lists.len())
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
},
|
||||
);
|
||||
if let Some(ref label) = proxy_label {
|
||||
row("Proxy", g, label);
|
||||
if config.proxy.bind_addr == "127.0.0.1" {
|
||||
let y = "\x1b[33m"; // yellow
|
||||
row(
|
||||
"",
|
||||
y,
|
||||
&format!(
|
||||
"⚠ proxy on 127.0.0.1 — .{} not LAN reachable",
|
||||
config.proxy.tld
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if config.dot.enabled {
|
||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||
}
|
||||
if doh_enabled {
|
||||
row(
|
||||
"DoH",
|
||||
g,
|
||||
&format!("https://:{}/dns-query", config.proxy.tls_port),
|
||||
);
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
}
|
||||
if !ctx.forwarding_rules.is_empty() {
|
||||
row(
|
||||
"Routing",
|
||||
g,
|
||||
&format!("{} conditional rules", ctx.forwarding_rules.len()),
|
||||
);
|
||||
}
|
||||
eprintln!("{o} ╠{bar_mid}╣{r}");
|
||||
row("Config", d, &config_label);
|
||||
row("Data", d, &data_label);
|
||||
row("Services", d, &services_label);
|
||||
eprintln!("{o} ╚{bar_top}╝{r}\n");
|
||||
|
||||
info!(
|
||||
"numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}",
|
||||
config.server.bind_addr, upstream_label, zone_count, config.cache.max_entries, api_port,
|
||||
);
|
||||
|
||||
// Download blocklists on startup
|
||||
let blocklist_lists = config.blocking.lists.clone();
|
||||
let refresh_hours = config.blocking.refresh_hours;
|
||||
if config.blocking.enabled && !blocklist_lists.is_empty() {
|
||||
let bl_ctx = Arc::clone(&ctx);
|
||||
let bl_lists = blocklist_lists.clone();
|
||||
tokio::spawn(async move {
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
|
||||
// Periodic refresh
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(refresh_hours * 3600));
|
||||
interval.tick().await; // skip immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
info!("refreshing blocklists...");
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prime TLD cache (recursive mode only)
|
||||
if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
let prime_ctx = Arc::clone(&ctx);
|
||||
let prime_tlds = config.upstream.prime_tlds;
|
||||
tokio::spawn(async move {
|
||||
crate::recursive::prime_tld_cache(
|
||||
&prime_ctx.cache,
|
||||
&prime_ctx.root_hints,
|
||||
&prime_tlds,
|
||||
&prime_ctx.srtt,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn cache warming for user-configured domains
|
||||
if !config.cache.warm.is_empty() {
|
||||
let warm_ctx = Arc::clone(&ctx);
|
||||
let warm_domains = config.cache.warm.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_warm_loop(warm_ctx, warm_domains).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DoH connection keepalive — prevents idle TLS teardown
|
||||
{
|
||||
let keepalive_ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
doh_keepalive_loop(keepalive_ctx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTP API server
|
||||
let api_ctx = Arc::clone(&ctx);
|
||||
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
||||
tokio::spawn(async move {
|
||||
let app = crate::api::router(api_ctx);
|
||||
let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap();
|
||||
info!("HTTP API listening on {}", api_addr);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
// Spawn Mobile API listener (read-only subset for iOS/Android companion
|
||||
// apps, LAN-bound by default so phones can reach it). Only idempotent
|
||||
// GETs; no state-mutating routes are exposed here regardless of
|
||||
// the main API's bind address.
|
||||
if config.mobile.enabled {
|
||||
let mobile_ctx = Arc::clone(&ctx);
|
||||
let mobile_bind = config.mobile.bind_addr.clone();
|
||||
let mobile_port = config.mobile.port;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await {
|
||||
log::warn!("Mobile API listener failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let proxy_port = config.proxy.port;
|
||||
tokio::spawn(async move {
|
||||
crate::proxy::start_proxy(proxy_ctx, proxy_port, proxy_bind).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTPS reverse proxy with TLS termination
|
||||
if config.proxy.enabled && config.proxy.tls_port > 0 && ctx.tls_config.is_some() {
|
||||
let proxy_ctx = Arc::clone(&ctx);
|
||||
let tls_port = config.proxy.tls_port;
|
||||
tokio::spawn(async move {
|
||||
crate::proxy::start_proxy_tls(proxy_ctx, tls_port, proxy_bind).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn network change watcher (upstream re-detection, LAN IP update, peer flush)
|
||||
{
|
||||
let watch_ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
network_watch_loop(watch_ctx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn LAN service discovery
|
||||
if config.lan.enabled {
|
||||
let lan_ctx = Arc::clone(&ctx);
|
||||
let lan_config = config.lan.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::lan::start_lan_discovery(lan_ctx, &lan_config).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DNS-over-TLS listener (RFC 7858)
|
||||
if config.dot.enabled {
|
||||
let dot_ctx = Arc::clone(&ctx);
|
||||
let dot_config = config.dot.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::dot::start_dot(dot_ctx, &dot_config).await;
|
||||
});
|
||||
}
|
||||
|
||||
// UDP DNS listener
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (len, 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 {
|
||||
if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await {
|
||||
error!("{} | HANDLER ERROR | {}", src_addr, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn network_watch_loop(ctx: Arc<ServerCtx>) {
|
||||
let mut tick: u64 = 0;
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(5));
|
||||
interval.tick().await; // skip immediate tick
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
tick += 1;
|
||||
let mut changed = false;
|
||||
|
||||
// Check LAN IP change (every 5s — cheap, one UDP socket call)
|
||||
if let Some(new_ip) = crate::lan::detect_lan_ip() {
|
||||
let mut current_ip = ctx.lan_ip.lock().unwrap();
|
||||
if new_ip != *current_ip {
|
||||
info!("LAN IP changed: {} → {}", current_ip, new_ip);
|
||||
*current_ip = new_ip;
|
||||
changed = true;
|
||||
crate::recursive::reset_udp_state();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
|
||||
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
|
||||
let dns_info = crate::system_dns::discover_system_dns();
|
||||
let new_addr = dns_info
|
||||
.default_upstream
|
||||
.or_else(crate::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||
let mut pool = ctx.upstream_pool.lock().unwrap();
|
||||
if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
|
||||
info!("upstream changed → {}", pool.label());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush stale LAN peers on any network change
|
||||
if changed {
|
||||
ctx.lan_peers.lock().unwrap().clear();
|
||||
info!("flushed LAN peers after network change");
|
||||
}
|
||||
|
||||
// Re-probe UDP every 5 minutes when disabled
|
||||
if tick.is_multiple_of(60) {
|
||||
crate::recursive::probe_udp(&ctx.root_hints).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
||||
let downloaded = download_blocklists(lists).await;
|
||||
|
||||
// Parse outside the lock to avoid blocking DNS queries during parse (~100ms)
|
||||
let mut all_domains = std::collections::HashSet::new();
|
||||
let mut sources = Vec::new();
|
||||
for (source, text) in &downloaded {
|
||||
let domains = parse_blocklist(text);
|
||||
info!("blocklist: {} domains from {}", domains.len(), source);
|
||||
all_domains.extend(domains);
|
||||
sources.push(source.clone());
|
||||
}
|
||||
let total = all_domains.len();
|
||||
|
||||
// Swap under lock — sub-microsecond
|
||||
ctx.blocklist
|
||||
.write()
|
||||
.unwrap()
|
||||
.swap_domains(all_domains, sources);
|
||||
info!(
|
||||
"blocking enabled: {} unique domains from {} lists",
|
||||
total,
|
||||
downloaded.len()
|
||||
);
|
||||
}
|
||||
|
||||
async fn warm_domain(ctx: &ServerCtx, domain: &str) {
|
||||
for qtype in [
|
||||
crate::question::QueryType::A,
|
||||
crate::question::QueryType::AAAA,
|
||||
] {
|
||||
crate::ctx::refresh_entry(ctx, domain, qtype).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(25));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
if let Some(upstream) = pool.preferred() {
|
||||
crate::forward::keepalive_doh(upstream).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cache_warm_loop(ctx: Arc<ServerCtx>, domains: Vec<String>) {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
for domain in &domains {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
info!("cache warm: {} domains resolved at startup", domains.len());
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
for domain in &domains {
|
||||
let refresh = ctx.cache.read().unwrap().needs_warm(domain);
|
||||
if refresh {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ pub async fn run() -> Result<(), String> {
|
||||
if !api_reachable {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
|
||||
" \x1b[1;38;5;166mNuma\x1b[0m — mobile API is not reachable on port {}.",
|
||||
SETUP_PORT
|
||||
);
|
||||
eprintln!();
|
||||
@@ -77,7 +77,7 @@ pub async fn run() -> Result<(), String> {
|
||||
let qr = render_qr(&url)?;
|
||||
|
||||
eprintln!();
|
||||
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
|
||||
eprintln!(" \x1b[1;38;5;166mNuma Phone Setup\x1b[0m");
|
||||
eprintln!();
|
||||
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
|
||||
eprintln!();
|
||||
|
||||
14
src/stats.rs
14
src/stats.rs
@@ -90,6 +90,7 @@ fn linux_rss() -> usize {
|
||||
pub struct ServerStats {
|
||||
queries_total: u64,
|
||||
queries_forwarded: u64,
|
||||
queries_upstream: u64,
|
||||
queries_recursive: u64,
|
||||
queries_coalesced: u64,
|
||||
queries_cached: u64,
|
||||
@@ -127,7 +128,10 @@ impl Transport {
|
||||
pub enum QueryPath {
|
||||
Local,
|
||||
Cached,
|
||||
/// Matched a `[[forwarding]]` suffix rule.
|
||||
Forwarded,
|
||||
/// Resolved via the default `[upstream]` pool (no suffix match).
|
||||
Upstream,
|
||||
Recursive,
|
||||
Coalesced,
|
||||
Blocked,
|
||||
@@ -141,6 +145,7 @@ impl QueryPath {
|
||||
QueryPath::Local => "LOCAL",
|
||||
QueryPath::Cached => "CACHED",
|
||||
QueryPath::Forwarded => "FORWARD",
|
||||
QueryPath::Upstream => "UPSTREAM",
|
||||
QueryPath::Recursive => "RECURSIVE",
|
||||
QueryPath::Coalesced => "COALESCED",
|
||||
QueryPath::Blocked => "BLOCKED",
|
||||
@@ -156,6 +161,8 @@ impl QueryPath {
|
||||
Some(QueryPath::Cached)
|
||||
} else if s.eq_ignore_ascii_case("FORWARD") {
|
||||
Some(QueryPath::Forwarded)
|
||||
} else if s.eq_ignore_ascii_case("UPSTREAM") {
|
||||
Some(QueryPath::Upstream)
|
||||
} else if s.eq_ignore_ascii_case("RECURSIVE") {
|
||||
Some(QueryPath::Recursive)
|
||||
} else if s.eq_ignore_ascii_case("COALESCED") {
|
||||
@@ -183,6 +190,7 @@ impl ServerStats {
|
||||
ServerStats {
|
||||
queries_total: 0,
|
||||
queries_forwarded: 0,
|
||||
queries_upstream: 0,
|
||||
queries_recursive: 0,
|
||||
queries_coalesced: 0,
|
||||
queries_cached: 0,
|
||||
@@ -204,6 +212,7 @@ impl ServerStats {
|
||||
QueryPath::Local => self.queries_local += 1,
|
||||
QueryPath::Cached => self.queries_cached += 1,
|
||||
QueryPath::Forwarded => self.queries_forwarded += 1,
|
||||
QueryPath::Upstream => self.queries_upstream += 1,
|
||||
QueryPath::Recursive => self.queries_recursive += 1,
|
||||
QueryPath::Coalesced => self.queries_coalesced += 1,
|
||||
QueryPath::Blocked => self.queries_blocked += 1,
|
||||
@@ -232,6 +241,7 @@ impl ServerStats {
|
||||
uptime_secs: self.uptime_secs(),
|
||||
total: self.queries_total,
|
||||
forwarded: self.queries_forwarded,
|
||||
upstream: self.queries_upstream,
|
||||
recursive: self.queries_recursive,
|
||||
coalesced: self.queries_coalesced,
|
||||
cached: self.queries_cached,
|
||||
@@ -253,10 +263,11 @@ impl ServerStats {
|
||||
let secs = uptime.as_secs() % 60;
|
||||
|
||||
log::info!(
|
||||
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
||||
"STATS | uptime {}h{}m{}s | total {} | fwd {} | upstream {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
||||
hours, mins, secs,
|
||||
self.queries_total,
|
||||
self.queries_forwarded,
|
||||
self.queries_upstream,
|
||||
self.queries_recursive,
|
||||
self.queries_coalesced,
|
||||
self.queries_cached,
|
||||
@@ -272,6 +283,7 @@ pub struct StatsSnapshot {
|
||||
pub uptime_secs: u64,
|
||||
pub total: u64,
|
||||
pub forwarded: u64,
|
||||
pub upstream: u64,
|
||||
pub recursive: u64,
|
||||
pub coalesced: u64,
|
||||
pub cached: u64,
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::net::SocketAddr;
|
||||
|
||||
use log::info;
|
||||
|
||||
use crate::forward::Upstream;
|
||||
|
||||
fn print_recursive_hint() {
|
||||
let is_recursive = crate::config::load_config("numa.toml")
|
||||
.map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive)
|
||||
@@ -22,11 +24,11 @@ fn is_loopback_or_stub(addr: &str) -> bool {
|
||||
pub struct ForwardingRule {
|
||||
pub suffix: String,
|
||||
dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching
|
||||
pub upstream: SocketAddr,
|
||||
pub upstream: Upstream,
|
||||
}
|
||||
|
||||
impl ForwardingRule {
|
||||
pub fn new(suffix: String, upstream: SocketAddr) -> Self {
|
||||
pub fn new(suffix: String, upstream: Upstream) -> Self {
|
||||
let dot_suffix = format!(".{}", suffix);
|
||||
Self {
|
||||
suffix,
|
||||
@@ -87,7 +89,7 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<Stri
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
let o = "\x1b[1;38;2;192;98;58m"; // bold orange
|
||||
let o = "\x1b[1;38;5;166m"; // bold orange
|
||||
let r = "\x1b[0m";
|
||||
Some(format!(
|
||||
"
|
||||
@@ -209,7 +211,7 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
}
|
||||
|
||||
// Sort longest suffix first for most-specific matching
|
||||
rules.sort_by(|a, b| b.suffix.len().cmp(&a.suffix.len()));
|
||||
rules.sort_by_key(|r| std::cmp::Reverse(r.suffix.len()));
|
||||
|
||||
for rule in &rules {
|
||||
info!(
|
||||
@@ -233,7 +235,7 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
|
||||
let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?;
|
||||
Some(ForwardingRule::new(domain.to_string(), addr))
|
||||
Some(ForwardingRule::new(domain.to_string(), Upstream::Udp(addr)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -570,7 +572,7 @@ fn windows_backup_path() -> std::path::PathBuf {
|
||||
|
||||
#[cfg(windows)]
|
||||
fn disable_dnscache() -> Result<bool, String> {
|
||||
// Check if Dnscache is running (it holds port 53 at kernel level)
|
||||
// Check if Dnscache is running (it can hold port 53)
|
||||
let output = std::process::Command::new("sc")
|
||||
.args(["query", "Dnscache"])
|
||||
.output()
|
||||
@@ -601,8 +603,16 @@ fn disable_dnscache() -> Result<bool, String> {
|
||||
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)
|
||||
// Dnscache is disabled for next boot. Check whether port 53 is
|
||||
// actually blocked right now — on many Windows configurations
|
||||
// Dnscache doesn't bind port 53 even while running.
|
||||
let port_blocked = std::net::UdpSocket::bind("127.0.0.1:53").is_err();
|
||||
if port_blocked {
|
||||
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
||||
} else {
|
||||
eprintln!(" Dnscache disabled. Port 53 is free.");
|
||||
}
|
||||
Ok(port_blocked)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -669,6 +679,83 @@ fn install_windows() -> Result<(), String> {
|
||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||
}
|
||||
|
||||
// On re-install, stop the running service first so the binary can be
|
||||
// overwritten and port 53 is released for the Dnscache probe.
|
||||
if is_service_registered() {
|
||||
eprintln!(" Stopping existing service...");
|
||||
stop_service_scm();
|
||||
}
|
||||
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
|
||||
// Copy the binary to a stable path under ProgramData and register it
|
||||
// as a real Windows service (SCM-managed, boot-time, auto-restart).
|
||||
let service_exe = install_service_binary()?;
|
||||
register_service_scm(&service_exe)?;
|
||||
|
||||
if needs_reboot {
|
||||
// Dnscache still holds port 53 until reboot. Do NOT redirect DNS
|
||||
// yet — nothing is listening on 127.0.0.1:53, so redirecting now
|
||||
// would kill DNS. The service will call redirect_dns_to_localhost()
|
||||
// on its first startup after reboot.
|
||||
} else {
|
||||
redirect_dns_with_interfaces(&interfaces)?;
|
||||
|
||||
match start_service_scm() {
|
||||
Ok(_) => eprintln!(" Service started."),
|
||||
Err(e) => eprintln!(
|
||||
" warning: service registered but could not start now: {}",
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" 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 is running.\n");
|
||||
}
|
||||
print_recursive_hint();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stable install location for the service binary. SCM keeps a handle to
|
||||
/// this path; the user's Downloads folder (where `current_exe()` points at
|
||||
/// install time) is not durable.
|
||||
#[cfg(windows)]
|
||||
fn windows_service_exe_path() -> std::path::PathBuf {
|
||||
crate::data_dir().join("bin").join("numa.exe")
|
||||
}
|
||||
|
||||
/// Run `sc.exe` with the given args and return its merged stdout/stderr on
|
||||
/// failure. `sc` emits errors on stdout (not stderr) on Windows, so the
|
||||
/// caller reads stdout to format a useful error.
|
||||
#[cfg(windows)]
|
||||
fn run_sc(args: &[&str]) -> Result<std::process::Output, String> {
|
||||
let out = std::process::Command::new("sc")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run sc {}: {}", args.first().unwrap_or(&""), e))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Point all active network interfaces at 127.0.0.1 so Numa handles DNS.
|
||||
/// Called from the service on first boot after a reboot that freed Dnscache.
|
||||
#[cfg(windows)]
|
||||
pub fn redirect_dns_to_localhost() -> Result<(), String> {
|
||||
let interfaces = get_windows_interfaces()?;
|
||||
redirect_dns_with_interfaces(&interfaces)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn redirect_dns_with_interfaces(
|
||||
interfaces: &std::collections::HashMap<String, WindowsInterfaceDns>,
|
||||
) -> Result<(), String> {
|
||||
for name in interfaces.keys() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args([
|
||||
@@ -693,63 +780,184 @@ fn install_windows() -> Result<(), String> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
register_autostart();
|
||||
|
||||
eprintln!();
|
||||
if !has_useful_existing {
|
||||
eprintln!(" 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");
|
||||
}
|
||||
print_recursive_hint();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register numa to auto-start on boot via registry Run key.
|
||||
/// Copy the currently-running binary to the service install location. SCM
|
||||
/// keeps a handle to this path, so it must be stable across user sessions.
|
||||
#[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.");
|
||||
fn install_service_binary() -> Result<std::path::PathBuf, String> {
|
||||
let src = std::env::current_exe().map_err(|e| format!("current_exe(): {}", e))?;
|
||||
let dst = windows_service_exe_path();
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||
}
|
||||
// Copy only if source and destination differ; running the binary from
|
||||
// its install location is a supported (re-install) case.
|
||||
if src != dst {
|
||||
std::fs::copy(&src, &dst).map_err(|e| {
|
||||
format!(
|
||||
"failed to copy {} -> {}: {}",
|
||||
src.display(),
|
||||
dst.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(dst)
|
||||
}
|
||||
|
||||
/// Remove numa auto-start registry key.
|
||||
/// Remove the service binary on uninstall. Ignore failures — the service
|
||||
/// is already deleted; a leftover file in ProgramData is not a hard error.
|
||||
#[cfg(windows)]
|
||||
fn remove_autostart() {
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"delete",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||
"/v",
|
||||
"Numa",
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
fn remove_service_binary() {
|
||||
let _ = std::fs::remove_file(windows_service_exe_path());
|
||||
}
|
||||
|
||||
/// Register numa with the Service Control Manager, boot-time auto-start,
|
||||
/// LocalSystem context, with a failure policy of restart-after-5s.
|
||||
#[cfg(windows)]
|
||||
fn register_service_scm(exe: &std::path::Path) -> Result<(), String> {
|
||||
let bin_path = format!("\"{}\" --service", exe.display());
|
||||
let name = crate::windows_service::SERVICE_NAME;
|
||||
|
||||
// sc.exe uses a leading space as its `name= value` delimiter; the space
|
||||
// after `=` is mandatory.
|
||||
let create = run_sc(&[
|
||||
"create",
|
||||
name,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"DisplayName=",
|
||||
"Numa DNS",
|
||||
"start=",
|
||||
"auto",
|
||||
"obj=",
|
||||
"LocalSystem",
|
||||
])?;
|
||||
if !create.status.success() {
|
||||
let out = String::from_utf8_lossy(&create.stdout);
|
||||
// "service already exists" is 1073 — treat as idempotent success.
|
||||
if !out.contains("1073") {
|
||||
return Err(format!("sc create failed: {}", out.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
let _ = run_sc(&[
|
||||
"description",
|
||||
name,
|
||||
"Self-sovereign DNS resolver (ad blocking, DoH/DoT, local zones).",
|
||||
]);
|
||||
|
||||
// Restart on crash: 5s, 5s, 10s; reset failure counter after 60s.
|
||||
let _ = run_sc(&[
|
||||
"failure",
|
||||
name,
|
||||
"reset=",
|
||||
"60",
|
||||
"actions=",
|
||||
"restart/5000/restart/5000/restart/10000",
|
||||
]);
|
||||
|
||||
eprintln!(" Registered service '{}' (boot-time).", name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the service. Safe to call on a freshly-registered service — SCM
|
||||
/// will fail with 1056 ("already running") or 1058 ("disabled") and we
|
||||
/// return the underlying error string rather than masking it.
|
||||
#[cfg(windows)]
|
||||
fn start_service_scm() -> Result<(), String> {
|
||||
let out = run_sc(&["start", crate::windows_service::SERVICE_NAME])?;
|
||||
if !out.status.success() {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if text.contains("1056") {
|
||||
return Ok(()); // already running
|
||||
}
|
||||
return Err(format!("sc start failed: {}", text.trim()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the service and wait for it to fully exit. Idempotent —
|
||||
/// already-stopped or missing service is not an error.
|
||||
#[cfg(windows)]
|
||||
fn stop_service_scm() {
|
||||
let name = crate::windows_service::SERVICE_NAME;
|
||||
let _ = run_sc(&["stop", name]);
|
||||
// Wait up to 10s for the service to reach STOPPED state so the
|
||||
// binary file handle is released before we try to overwrite it.
|
||||
for _ in 0..20 {
|
||||
if let Ok(out) = run_sc(&["query", name]) {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
if text.contains("STOPPED") || text.contains("1060") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
eprintln!(" warning: service did not stop within 10s");
|
||||
}
|
||||
|
||||
/// Remove the service from SCM. Idempotent — see `stop_service_scm`.
|
||||
#[cfg(windows)]
|
||||
fn delete_service_scm() {
|
||||
if let Err(e) = run_sc(&["delete", crate::windows_service::SERVICE_NAME]) {
|
||||
log::warn!("sc delete failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the service is registered with SCM (regardless of state).
|
||||
#[cfg(windows)]
|
||||
fn is_service_registered() -> bool {
|
||||
run_sc(&["query", crate::windows_service::SERVICE_NAME])
|
||||
.map(|o| parse_sc_registered(o.status.success(), &String::from_utf8_lossy(&o.stdout)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Parse `sc query` output to determine if a service is registered.
|
||||
/// Extracted for testability — the actual `sc` call is in `is_service_registered`.
|
||||
#[cfg(any(windows, test))]
|
||||
fn parse_sc_registered(exit_success: bool, stdout: &str) -> bool {
|
||||
if exit_success {
|
||||
return true;
|
||||
}
|
||||
// Error 1060 = "The specified service does not exist as an installed service."
|
||||
!stdout.contains("1060")
|
||||
}
|
||||
|
||||
/// Print service state from SCM.
|
||||
#[cfg(windows)]
|
||||
fn service_status_windows() -> Result<(), String> {
|
||||
let out = run_sc(&["query", crate::windows_service::SERVICE_NAME])?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let display = parse_sc_state(&text);
|
||||
eprintln!(" {}\n", display);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse the STATE line from `sc query` output. Returns a human-readable
|
||||
/// string like "STATE : 4 RUNNING" or "Service is not installed."
|
||||
#[cfg(any(windows, test))]
|
||||
fn parse_sc_state(sc_output: &str) -> String {
|
||||
if sc_output.contains("1060") {
|
||||
return "Service is not installed.".to_string();
|
||||
}
|
||||
sc_output
|
||||
.lines()
|
||||
.find(|l| l.contains("STATE"))
|
||||
.map(|l| l.trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn uninstall_windows() -> Result<(), String> {
|
||||
remove_autostart();
|
||||
// Stop + remove the service before touching DNS, so port 53 is released
|
||||
// cleanly and the failure-restart policy doesn't resurrect it.
|
||||
stop_service_scm();
|
||||
delete_service_scm();
|
||||
remove_service_binary();
|
||||
let path = windows_backup_path();
|
||||
let json = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
||||
@@ -822,10 +1030,13 @@ fn uninstall_windows() -> Result<(), String> {
|
||||
/// 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.
|
||||
pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<SocketAddr> {
|
||||
pub fn match_forwarding_rule<'a>(
|
||||
domain: &str,
|
||||
rules: &'a [ForwardingRule],
|
||||
) -> Option<&'a Upstream> {
|
||||
for rule in rules {
|
||||
if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) {
|
||||
return Some(rule.upstream);
|
||||
return Some(&rule.upstream);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -1043,6 +1254,62 @@ pub fn install_service() -> Result<(), String> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Start the service. If already installed, just starts it via the platform
|
||||
/// service manager. If not installed, falls through to a full install.
|
||||
pub fn start_service() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
install_service()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
install_service()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if is_service_registered() {
|
||||
start_service_scm()?;
|
||||
eprintln!(" Service started.\n");
|
||||
Ok(())
|
||||
} else {
|
||||
install_service()
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service start not supported on this OS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the service without uninstalling it.
|
||||
pub fn stop_service() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
uninstall_service()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
uninstall_service()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = run_sc(&["stop", crate::windows_service::SERVICE_NAME])?;
|
||||
if !out.status.success() {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// 1062 = not started, 1060 = does not exist
|
||||
if !text.contains("1062") && !text.contains("1060") {
|
||||
return Err(format!("sc stop failed: {}", text.trim()));
|
||||
}
|
||||
}
|
||||
eprintln!(" Service stopped.\n");
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service stop not supported on this OS".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Uninstall the Numa system service.
|
||||
pub fn uninstall_service() -> Result<(), String> {
|
||||
let _ = untrust_ca();
|
||||
@@ -1112,7 +1379,14 @@ pub fn restart_service() -> Result<(), String> {
|
||||
eprintln!(" Service restarted → {}\n", version);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
stop_service_scm();
|
||||
start_service_scm()?;
|
||||
eprintln!(" Service restarted.\n");
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service restart not supported on this OS".to_string())
|
||||
}
|
||||
@@ -1128,7 +1402,11 @@ pub fn service_status() -> Result<(), String> {
|
||||
{
|
||||
service_status_linux()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
service_status_windows()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service status not supported on this OS".to_string())
|
||||
}
|
||||
@@ -1862,4 +2140,57 @@ Wireless LAN adapter Wi-Fi:
|
||||
let err = std::io::Error::from(std::io::ErrorKind::AddrInUse);
|
||||
assert!(try_port53_advisory("not-an-address", &err).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sc_query_running_service_is_registered() {
|
||||
assert!(parse_sc_registered(true, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sc_query_stopped_service_is_registered() {
|
||||
let output = "SERVICE_NAME: Numa\n TYPE: 10 WIN32_OWN\n STATE: 1 STOPPED\n";
|
||||
assert!(parse_sc_registered(true, output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sc_query_missing_service_not_registered() {
|
||||
let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\nThe specified service does not exist as an installed service.\n";
|
||||
assert!(!parse_sc_registered(false, output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sc_query_other_error_assumes_registered() {
|
||||
// Permission denied or other errors — don't assume unregistered.
|
||||
let output = "[SC] OpenService FAILED 5:\n\nAccess is denied.\n";
|
||||
assert!(parse_sc_registered(false, output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sc_state_running() {
|
||||
let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 4 RUNNING\n WIN32_EXIT_CODE : 0\n";
|
||||
assert!(parse_sc_state(output).contains("RUNNING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sc_state_stopped() {
|
||||
let output = "SERVICE_NAME: Numa\n TYPE : 10 WIN32_OWN_PROCESS\n STATE : 1 STOPPED\n";
|
||||
assert!(parse_sc_state(output).contains("STOPPED"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sc_state_not_installed() {
|
||||
let output = "[SC] EnumQueryServicesStatus:OpenService FAILED 1060:\n\n";
|
||||
assert_eq!(parse_sc_state(output), "Service is not installed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sc_state_empty_output() {
|
||||
assert_eq!(parse_sc_state(""), "unknown");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_config_dir_equals_data_dir() {
|
||||
assert_eq!(crate::config_dir(), crate::data_dir());
|
||||
}
|
||||
}
|
||||
|
||||
95
src/testutil.rs
Normal file
95
src/testutil.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::blocklist::BlocklistStore;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::UpstreamMode;
|
||||
use crate::ctx::ServerCtx;
|
||||
use crate::forward::{Upstream, UpstreamPool};
|
||||
use crate::health::HealthMeta;
|
||||
use crate::lan::PeerStore;
|
||||
use crate::override_store::OverrideStore;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::query_log::QueryLog;
|
||||
use crate::service_store::ServiceStore;
|
||||
use crate::srtt::SrttCache;
|
||||
use crate::stats::ServerStats;
|
||||
/// Minimal `ServerCtx` for tests. Override fields after construction
|
||||
/// (all fields are `pub`), then wrap in `Arc`.
|
||||
pub async fn test_ctx() -> ServerCtx {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
ServerCtx {
|
||||
socket,
|
||||
zone_map: HashMap::new(),
|
||||
cache: RwLock::new(DnsCache::new(100, 60, 86400)),
|
||||
refreshing: Mutex::new(HashSet::new()),
|
||||
stats: Mutex::new(ServerStats::new()),
|
||||
overrides: RwLock::new(OverrideStore::new()),
|
||||
blocklist: RwLock::new(BlocklistStore::new()),
|
||||
query_log: Mutex::new(QueryLog::new(100)),
|
||||
services: Mutex::new(ServiceStore::new()),
|
||||
lan_peers: Mutex::new(PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream_pool: Mutex::new(UpstreamPool::new(
|
||||
vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())],
|
||||
vec![],
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(Ipv4Addr::LOCALHOST),
|
||||
timeout: Duration::from_millis(200),
|
||||
hedge_delay: Duration::ZERO,
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
config_path: "/tmp/test-numa.toml".to_string(),
|
||||
config_found: false,
|
||||
config_dir: PathBuf::from("/tmp"),
|
||||
data_dir: PathBuf::from("/tmp"),
|
||||
tls_config: None,
|
||||
upstream_mode: UpstreamMode::Forward,
|
||||
root_hints: Vec::new(),
|
||||
srtt: RwLock::new(SrttCache::new(true)),
|
||||
inflight: Mutex::new(HashMap::new()),
|
||||
dnssec_enabled: false,
|
||||
dnssec_strict: false,
|
||||
health_meta: HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a UDP socket that replies to the first DNS query with the given
|
||||
/// response packet (patching the query ID to match). Returns the socket address.
|
||||
pub async fn mock_upstream(response: DnsPacket) -> SocketAddr {
|
||||
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = sock.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 512];
|
||||
let (_, src) = sock.recv_from(&mut buf).await.unwrap();
|
||||
let query_id = u16::from_be_bytes([buf[0], buf[1]]);
|
||||
let mut resp = response;
|
||||
resp.header.id = query_id;
|
||||
let mut out = BytePacketBuffer::new();
|
||||
resp.write(&mut out).unwrap();
|
||||
sock.send_to(out.filled(), src).await.unwrap();
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// UDP socket that accepts connections but never replies.
|
||||
/// Useful as an upstream that triggers timeouts.
|
||||
pub fn blackhole_upstream() -> SocketAddr {
|
||||
let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let addr = sock.local_addr().unwrap();
|
||||
// Leak so it stays bound for the duration of the test process.
|
||||
Box::leak(Box::new(sock));
|
||||
addr
|
||||
}
|
||||
@@ -49,7 +49,7 @@ pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option<Stri
|
||||
if io_err.kind() != std::io::ErrorKind::PermissionDenied {
|
||||
return None;
|
||||
}
|
||||
let o = "\x1b[1;38;2;192;98;58m";
|
||||
let o = "\x1b[1;38;5;166m";
|
||||
let r = "\x1b[0m";
|
||||
Some(format!(
|
||||
"
|
||||
|
||||
147
src/windows_service.rs
Normal file
147
src/windows_service.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Windows service wrapper.
|
||||
//!
|
||||
//! Lets the `numa.exe` binary act as a real Windows service registered with
|
||||
//! the Service Control Manager (SCM). Invoked via `numa.exe --service` (the
|
||||
//! form that `sc create … binPath=` uses).
|
||||
//!
|
||||
//! Interactive runs (`numa.exe`, `numa.exe run`, `numa.exe install`) do not
|
||||
//! go through this module — they keep their existing console-attached
|
||||
//! behaviour.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use windows_service::service::{
|
||||
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
|
||||
};
|
||||
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
|
||||
use windows_service::{define_windows_service, service_dispatcher};
|
||||
|
||||
pub const SERVICE_NAME: &str = "Numa";
|
||||
|
||||
define_windows_service!(ffi_service_main, service_main);
|
||||
|
||||
/// Entry point the SCM hands control to after `StartServiceCtrlDispatcherW`.
|
||||
/// Any panic here vanishes silently into the service host — log instead of
|
||||
/// unwrapping.
|
||||
fn service_main(_arguments: Vec<OsString>) {
|
||||
if let Err(e) = run_service() {
|
||||
log::error!("numa service exited with error: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_service() -> windows_service::Result<()> {
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::channel::<()>();
|
||||
|
||||
let event_handler = move |control_event| -> ServiceControlHandlerResult {
|
||||
match control_event {
|
||||
ServiceControl::Stop | ServiceControl::Shutdown => {
|
||||
let _ = shutdown_tx.send(());
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||
_ => ServiceControlHandlerResult::NotImplemented,
|
||||
}
|
||||
};
|
||||
|
||||
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?;
|
||||
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Running,
|
||||
controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
// Spin up a multi-threaded tokio runtime and run the server on it. A
|
||||
// dedicated thread runs the runtime so this function can return cleanly
|
||||
// once the SCM tells us to stop — we can't block the dispatcher thread
|
||||
// forever without preventing graceful shutdown.
|
||||
let config_path = service_config_path();
|
||||
let (server_done_tx, server_done_rx) = mpsc::channel::<()>();
|
||||
|
||||
let server_thread = std::thread::spawn(move || {
|
||||
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
log::error!("failed to build tokio runtime: {}", e);
|
||||
let _ = server_done_tx.send(());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = runtime.block_on(crate::serve::run(config_path)) {
|
||||
log::error!("numa serve exited with error: {}", e);
|
||||
}
|
||||
let _ = server_done_tx.send(());
|
||||
});
|
||||
|
||||
// Wait for the API to be ready, then ensure DNS points at localhost.
|
||||
// On first boot after install (Dnscache was disabled, reboot freed
|
||||
// port 53), the installer deferred the DNS redirect — do it now.
|
||||
let api_up = (0..20).any(|i| {
|
||||
if i > 0 {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok()
|
||||
});
|
||||
if api_up {
|
||||
if let Err(e) = crate::system_dns::redirect_dns_to_localhost() {
|
||||
log::warn!("could not redirect DNS to localhost: {}", e);
|
||||
}
|
||||
} else {
|
||||
log::error!("numa API did not start within 10s — DNS not redirected");
|
||||
}
|
||||
|
||||
// Wait for either SCM stop or server termination.
|
||||
loop {
|
||||
if shutdown_rx.recv_timeout(Duration::from_millis(500)).is_ok() {
|
||||
break;
|
||||
}
|
||||
if server_done_rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The server's tokio runtime runs detached inside server_thread. Abandon
|
||||
// it — the process is about to report Stopped and the SCM will terminate
|
||||
// us if we linger. Future work: plumb a cancellation signal into
|
||||
// serve::run() for a clean teardown of listeners and in-flight queries.
|
||||
drop(server_thread);
|
||||
|
||||
status_handle.set_service_status(ServiceStatus {
|
||||
service_type: ServiceType::OWN_PROCESS,
|
||||
current_state: ServiceState::Stopped,
|
||||
controls_accepted: ServiceControlAccept::empty(),
|
||||
exit_code: ServiceExitCode::Win32(0),
|
||||
checkpoint: 0,
|
||||
wait_hint: Duration::default(),
|
||||
process_id: None,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hand control to the SCM dispatcher. Blocks until the service stops.
|
||||
/// Call only from the `--service` command path — interactive invocations
|
||||
/// will hang here waiting for an SCM that isn't talking to them.
|
||||
pub fn run_as_service() -> windows_service::Result<()> {
|
||||
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
|
||||
}
|
||||
|
||||
/// Path to the config file used when running under SCM. SCM launches the
|
||||
/// service with SYSTEM's working directory (usually `C:\Windows\System32`),
|
||||
/// so a relative `numa.toml` lookup won't find anything meaningful.
|
||||
fn service_config_path() -> String {
|
||||
crate::data_dir()
|
||||
.join("numa.toml")
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
Reference in New Issue
Block a user