52 Commits

Author SHA1 Message Date
Eduard Ghenea
6fc59a7bd5 fix: replace truecolor ANSI codes with 256-color for consistent terminal display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:05:22 +03:00
Razvan Dimescu
f9ce82f4b0 Merge pull request #107 from razvandimescu/feat/windows-service
feat(windows): run as a real SCM service, not a Run-key autostart
2026-04-17 02:02:43 +03:00
Razvan Dimescu
1d9495c013 ci: bridge DNS gap with direct upstream instead of polling
systemd-resolved has a ~40s reconfiguration stall after restart
(systemd #22521) that breaks the GHA runner's persistent connection
to results-receiver.actions.githubusercontent.com. Polling for DNS
recovery isn't enough since the .NET runner agent caches DNS at the
connection-pool level. Replace the broken stub-resolv symlink with a
direct upstream so DNS works instantly.
2026-04-17 01:32:36 +03:00
Razvan Dimescu
34b75833b8 ci: poll for DNS recovery in cleanup, not test step
Move DNS recovery wait into the cleanup step (if: always) so it runs
regardless of test outcome. Use getent hosts loop instead of sleep+dig
to match what post-steps actually use for resolution.
2026-04-17 01:11:20 +03:00
Razvan Dimescu
99af97a67b ci: wait for DNS recovery after uninstall on Linux
systemd-resolved needs a moment to restore its stub listener after
the numa drop-in is removed. Without a wait, the runner can't resolve
GitHub's API to report job completion.
2026-04-16 20:20:53 +03:00
Razvan Dimescu
9e56054f37 ci: add integration tests for install/uninstall lifecycle
Release-build + install/verify/re-install/uninstall cycle on Linux and
macOS. Runs after lint/test passes (needs dependency). Cleanup step
uses if: always() to handle cancellation.
2026-04-16 19:56:44 +03:00
Razvan Dimescu
fe9f31616e test: add SCM output parsing and config path regression tests
Extract parse_sc_registered and parse_sc_state as testable pure
functions. 8 new tests covering: service registration detection,
service state parsing, and Windows config_dir == data_dir invariant.
2026-04-16 19:31:26 +03:00
Razvan Dimescu
9f08d8b489 fix(windows): stop service before port probe, wait for full exit
Stop the running service before disabling Dnscache so the port 53 probe
sees the real state (not Numa's own binding). Wait for SCM STOPPED
state before copying the binary to avoid os error 32 (file in use).
2026-04-16 19:21:56 +03:00
Razvan Dimescu
9bea038cb6 fix(windows): unify config/data dir and add service log file
config_dir() on Windows now returns data_dir() (ProgramData) so config,
services.json, and log file are in the same place for both interactive
and service contexts. Service mode writes logs to numa.log via
env_logger pipe. Dashboard shows correct log path per OS.
2026-04-16 19:12:42 +03:00
Razvan Dimescu
f0a1dd7106 fix(dashboard): hide logs path on Windows (no log sink yet) 2026-04-16 19:01:34 +03:00
Razvan Dimescu
6789c321bc fix(windows): defer DNS redirect until port 53 is free
Probe port 53 after disabling Dnscache instead of assuming reboot is
needed. Skip DNS redirect when port is blocked (service does it on
first boot). Fix readiness probe: TCP connect to API port instead of
broken UDP send_to that always succeeded.
2026-04-16 18:35:09 +03:00
Razvan Dimescu
da40a8dbfc ci: fetch full history on Windows so build.rs embeds git SHA 2026-04-16 18:08:48 +03:00
Razvan Dimescu
65e65028a0 fix(windows): separate service lifecycle from install flow
service start/stop/restart/status now map to proper SCM operations
instead of re-running the full install/uninstall flow. On re-install,
stop the running service first so the binary can be overwritten.
2026-04-16 16:59:54 +03:00
Razvan Dimescu
d3eab73a31 fix: use sort_by_key to satisfy clippy unnecessary_sort_by 2026-04-16 16:13:15 +03:00
Razvan Dimescu
22ec684e48 Merge remote-tracking branch 'origin/main' into feat/windows-service
# Conflicts:
#	src/main.rs
2026-04-16 16:06:49 +03:00
Razvan Dimescu
aa040fd8a4 Merge pull request #111 from razvandimescu/fix/allowlist-input-focus
fix(dashboard): allowlist input erased by polling refresh
2026-04-16 15:27:02 +03:00
Razvan Dimescu
b69cc89d38 fix(dashboard): skip allowlist re-render while input has focus
The polling refresh replaced the entire allowlist panel innerHTML every
2 seconds, destroying the input field mid-typing. Users had to
paste-and-enter faster than the refresh interval — #106 reported this
as text "timing out and erasing."

Guard: skip renderAllowlist() when allowDomainInput has focus.
2026-04-16 15:12:00 +03:00
Razvan Dimescu
ebb801650e Merge pull request #110 from razvandimescu/feat/build-version
feat: embed git SHA in version string
2026-04-16 13:41:23 +03:00
Razvan Dimescu
30bb7365c9 refactor: robust git-describe parsing for pre-release tags
Switch to --long flag so format is always TAG-N-gSHA[-dirty], then
split from the right. Handles pre-release tags (v0.14.0-rc1) that
broke the previous left-split approach. Remove ineffective directory
watch on .git/refs/tags/. Trim comments.
2026-04-16 13:18:56 +03:00
Razvan Dimescu
0118ab0f44 feat: embed git SHA in version string via build.rs
Adds a build.rs that runs `git describe --tags --always --dirty` and
sets NUMA_BUILD_VERSION at compile time. A new `numa::version()` helper
returns the build version, falling back to CARGO_PKG_VERSION when git
is unavailable (source tarballs, Docker builds without .git).

Version strings:
  tagged release:      0.13.1
  commits ahead:       0.13.1+a87f907
  uncommitted changes: 0.13.1+a87f907-dirty
  no git:              0.13.1

Replaces all 6 inline env!("CARGO_PKG_VERSION") call sites with the
single version() function.
2026-04-16 13:02:25 +03:00
Razvan Dimescu
a87f907d20 Merge pull request #109 from razvandimescu/feat/dashboard-version
feat(dashboard): version in header, restructure footer
2026-04-16 11:29:54 +03:00
Razvan Dimescu
1c5e703330 fix(dashboard): collapse header on mobile (≤700px)
Hide tagline, version tag, and Phone Setup on narrow viewports so
the header stays single-row: logo + status dot + blocking toggle.
Reduces logo font-size from 1.8rem to 1.4rem on mobile.
2026-04-16 06:39:29 +03:00
Razvan Dimescu
cc635f2f73 feat(dashboard): show version in header, restructure footer
Closes #108.

- Add `version` field to /stats (from CARGO_PKG_VERSION).
- Show `v0.13.1` next to the Numa wordmark in the dashboard header.
- Restructure the footer into two semantic rows:
  Row 1 (paths): Config · Data · Logs (platform-detected)
  Row 2 (runtime): Upstream · DNSSEC · SRTT · GitHub
- Drop Mode from the footer (redundant with Upstream label).
- Show only the matching-platform log path instead of both
  macOS and Linux unconditionally.
2026-04-16 06:15:48 +03:00
Razvan Dimescu
7bb484ada3 refactor(windows): deduplicate after simplify review
- Drop the duplicate WINDOWS_SERVICE_NAME constant; call sites use the
  single source of truth at windows_service::SERVICE_NAME.
- windows_service_exe_path and service_config_path now compose from
  crate::data_dir() instead of re-parsing %PROGRAMDATA% locally.
- Factor the 6× sc.exe invocation boilerplate into a run_sc helper.
- Replace the 200ms try_recv polling loop in the service dispatcher
  with a recv_timeout wait — cuts shutdown latency and idle CPU.
- stop_service_scm/delete_service_scm now log warnings instead of
  silently swallowing failures, so unexpected errors are visible.
2026-04-15 23:48:09 +03:00
Razvan Dimescu
b610160cd1 feat(windows): run numa as a real SCM service, drop Run-key autostart
Hooks the service-dispatcher scaffolding from the previous commit to
actually serve DNS, and replaces the HKLM\…\Run login-time autostart
with a proper Windows service created via sc.exe.

**Refactor**
- Extract main.rs's inline server body (~500 lines) into `numa::serve::run`
  so both the interactive CLI entry and the service dispatcher drive the
  same startup/serve loop. main.rs is now a thin subcommand router.
- main.rs goes sync (no #[tokio::main]); each branch that needs async
  builds its own runtime and block_on's. Required so the --service path
  can hand off to SCM without fighting tokio for the entry thread.

**Windows service wrapper**
- `numa::windows_service::run_service` now builds a multi-thread tokio
  runtime on a dedicated thread and runs `serve::run` inside it. Stop/
  Shutdown from SCM aborts the wait loop and reports SERVICE_STOPPED.
- Config path resolves to `%PROGRAMDATA%\numa\numa.toml` when running
  under SCM (SYSTEM's cwd is System32, relative paths don't work).

**Install/uninstall**
- `install_windows` now copies numa.exe to a stable
  `%PROGRAMDATA%\numa\bin\numa.exe` and registers it via `sc create`
  with start=auto, obj=LocalSystem, and a failure policy of
  restart/5000/restart/5000/restart/10000. Starts the service
  immediately when no reboot is pending.
- `uninstall_windows` stops + deletes the service and removes the
  binary copy before restoring DNS.
- Drops the old `register_autostart` / `remove_autostart` helpers that
  wrote to `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run` — that
  path runs at user login in the user's session with no stderr capture
  and no crash-restart policy, which is why we've been flying blind in
  every Windows debug session.

DNS-set bugs (netsh destructive static, IPv6 not touched, uninstall
secondary-drop) and file logging are orthogonal — tracked for follow-up.
2026-04-15 22:24:23 +03:00
Razvan Dimescu
cea4b0ef88 feat(windows): add windows-service crate + SCM dispatcher scaffold
Lets numa.exe act as a real Windows service registered with the SCM,
replacing the HKLM\...\Run login-time autostart that runs in the user
session without stderr capture.

- New `numa::windows_service` module (cfg(windows)) wraps Mullvad's
  `windows-service` crate: registers with SCM, reports Running, handles
  Stop/Shutdown, reports Stopped.
- `numa.exe --service` is the entry point SCM uses
  (`sc create … binPath="numa.exe --service"`); interactive invocations
  are unchanged.
- Dep is gated `[target.'cfg(windows)'.dependencies]` — zero impact on
  macOS/Linux builds or binary size.

Scaffold only. The service currently blocks on an mpsc channel until
Stop arrives; the actual serve loop will hook in once main.rs's inline
server body is extracted into `numa::serve(config_path)` in a follow-up.
This lets `sc start Numa` / `sc stop Numa` be verified end to end today.
2026-04-15 22:14:36 +03:00
Razvan Dimescu
43a5ca4bd5 Merge pull request #105 from razvandimescu/chore/audit-rustls-webpki
chore(deps): bump rustls-webpki to 0.103.12
2026-04-15 14:41:19 +03:00
Razvan Dimescu
b403671e11 chore(deps): bump rustls-webpki to 0.103.12
Patches RUSTSEC-2026-0098 (URI name constraints incorrectly accepted)
and RUSTSEC-2026-0099 (wildcard cert name constraints), both published
2026-04-14. Transitive via reqwest / rustls / hickory / quinn.
2026-04-15 14:27:17 +03:00
Razvan Dimescu
6f0144b237 Merge pull request #103 from razvandimescu/feat/upstream-log-label
feat: distinguish UPSTREAM vs FORWARD in logs and stats
2026-04-15 14:00:28 +03:00
Razvan Dimescu
4bd08e206d feat(dashboard): hide zero-count path and transport rows 2026-04-14 21:25:11 +03:00
Razvan Dimescu
ebb2a5db39 refactor: simplify upstream-path test — reuse pool mutex, drop narrating comment 2026-04-14 18:26:45 +03:00
Razvan Dimescu
e0e0f50838 feat: distinguish UPSTREAM vs FORWARD in logs and stats
Queries matching a [[forwarding]] suffix rule now log as FORWARD;
queries resolved via the default [upstream] pool log as UPSTREAM.
Previously both paths shared the FORWARD label, making it impossible
to tell from logs whether a rule matched.

Adds QueryPath::Upstream, a queries.upstream stats counter exposed
via /stats, plus a matching dashboard filter, bar, and path tag.

Closes part of #102.
2026-04-14 18:18:32 +03:00
Razvan Dimescu
120ba5200e chore: bump version to 0.13.1 2026-04-14 13:31:35 +03:00
Razvan Dimescu
45046bcf6e Merge pull request #101 from razvandimescu/fix/forward-tls-upstream
fix: accept tls:// and https:// in [[forwarding]] upstreams
2026-04-14 13:09:58 +03:00
Razvan Dimescu
b4b939c78b fix: accept tls:// and https:// in [[forwarding]] upstreams
Config-level forwarding rules were parsed with the UDP-only
`parse_upstream_addr` helper, silently rejecting the DoT/DoH schemes
that the rest of the forwarding pipeline already supports.

Widen `ForwardingRule.upstream` from `SocketAddr` to `Upstream` so
config rules reuse the same parser as `[upstream].address` and
`fallback`. Demote `parse_upstream_addr` to `pub(crate)` to prevent
the same mistake recurring.

Closes #100.
2026-04-14 09:22:24 +03:00
Razvan Dimescu
9a85e271ec Merge pull request #99 from razvandimescu/fix/aur-llvm-libs
fix: add llvm-libs to AUR makedepends
2026-04-13 17:09:08 +03:00
Razvan Dimescu
7dc1a0686f fix: add llvm-libs to AUR makedepends
Fixes #97 — on minimal Arch installs, rustc fails with
"error while loading shared libraries: libLLVM.so" because
llvm-libs isn't pulled in transitively.
2026-04-13 15:58:52 +03:00
Razvan Dimescu
a02722cdf9 Merge pull request #98 from razvandimescu/docker-support
feat: Docker support with multi-arch GHCR images
2026-04-13 15:53:56 +03:00
Razvan Dimescu
3b77dcff61 feat: Docker support — multi-arch GHCR images on release
Add CI workflow to build linux/amd64 + linux/arm64 images and push to
ghcr.io/razvandimescu/numa on tag. Fix Dockerfile (missing benches/),
bake container-aware config (API + proxy bind 0.0.0.0), add Docker
section to README.
2026-04-13 15:48:29 +03:00
Razvan Dimescu
7cc110a0a1 ci: skip CI and AUR builds for blog/site-only changes
Add paths-ignore for site/, blog/, drafts/, *.md, and blog scripts
so content-only pushes don't trigger cargo builds or AUR publishes.
2026-04-13 15:02:19 +03:00
Razvan Dimescu
75fe625f39 blog: drop redundant Numa intro from opening paragraph 2026-04-13 14:48:34 +03:00
Razvan Dimescu
908d076d9b blog: pain-first opening, I-voice, forward-looking close
- Open with shared reqwest pain, not the tool name
- Switch "we" to "I" for personal voice (playbook: solo dev > corporate)
- Replace Unbound feature-gap excuses with what I'm exploring next
  (persistent SRTT, aggressive NSEC, adaptive hedge delays)
- Add context line linking hero cards to the recursive section
2026-04-13 14:37:24 +03:00
Razvan Dimescu
5381e65be4 Merge pull request #96 from razvandimescu/blog/fixing-doh-tail-latency
blog: fixing DoH tail latency post
2026-04-13 14:08:40 +03:00
Razvan Dimescu
6b0a30d004 blog: add fixing DoH tail latency post + blog infrastructure
New post on reqwest HTTP/2 window tuning and request hedging
(Dean & Barroso's "The Tail at Scale" applied to DNS forwarding).
Covers DoH forwarding p99 improvement and cold recursive
resolution from 2.3s to 538ms.

Also adds blog build infrastructure: index generation script,
draft preview server, hero metrics/before-after CSS, and
normalizes date format across existing posts.
2026-04-13 13:49:40 +03:00
Razvan Dimescu
169679bfe4 Merge pull request #95 from razvandimescu/fix/forwarding-precedes-special-use
fix: forwarding rules override special-use NXDOMAIN
2026-04-13 09:37:19 +03:00
Razvan Dimescu
d3f046da4c style: assert loopback addr in subdomain test, trim verbose comment 2026-04-13 08:10:26 +03:00
Razvan Dimescu
0bdde40f40 test: verify forwarded response content from mock upstream 2026-04-13 08:07:58 +03:00
Razvan Dimescu
155c1c4da0 test: full-pipeline coverage for every resolve_query step
Test each pipeline stage in isolation through resolve_query:
- override takes precedence over all other paths
- localhost and *.localhost resolve to loopback
- local zone returns configured records
- .tld proxy resolves registered services to loopback
- blocklist sinkholes to 0.0.0.0
- cache hit returns stored response without upstream
2026-04-13 08:04:59 +03:00
Razvan Dimescu
b40004fe5e refactor: extract shared test infrastructure into testutil module
- test_ctx(): single ServerCtx builder, replaces 3 copies (ctx/api/dot)
- mock_upstream(): canned DNS response server for forwarding tests
- blackhole_upstream(): unresponsive socket for timeout tests
- Removes ~100 lines of duplicated 30-field struct literals
2026-04-13 07:56:47 +03:00
Razvan Dimescu
b8ddc16027 refactor: return QueryPath from resolve_query, add mock upstream to tests
resolve_query now returns (BytePacketBuffer, QueryPath) so callers
and tests can inspect the resolution path without reading the query
log. Production call sites (UDP, DoT, DoH) destructure and ignore it.

The forwarding test now uses a mock UDP upstream that replies with a
canned response, asserting QueryPath::Forwarded instead of != Local.
2026-04-13 07:51:14 +03:00
Razvan Dimescu
48f67be2f1 refactor: deduplicate test_ctx by delegating to test_ctx_with_forwarding 2026-04-13 07:39:55 +03:00
Razvan Dimescu
ca00846393 fix: forwarding rules override special-use NXDOMAIN for private PTR zones
Explicit [[forwarding]] rules now take precedence over the RFC 6303
special-use domain intercept. Previously, PTR queries for private
ranges (e.g. 168.192.in-addr.arpa) always returned local NXDOMAIN
even when a forwarding rule pointed them at a corporate DNS server.

Add full-pipeline resolve_query test harness (test_ctx + resolve_in_test)
and two tests covering both the default behavior and the override.

Closes #94
2026-04-13 07:36:53 +03:00
37 changed files with 2494 additions and 858 deletions

View File

@@ -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
View 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

View File

@@ -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
View File

@@ -4,3 +4,6 @@ CLAUDE.md
docs/
site/blog/posts/
ios/
drafts/
site/blog/index.html
.DS_Store

18
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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"]

View File

@@ -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

View File

@@ -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')

View File

@@ -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 |

View File

@@ -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?

View File

@@ -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.

View File

@@ -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.

View 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">&#8594;</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
View 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),
})
}

View File

@@ -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
View 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
View 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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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)
}

View File

@@ -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();

View File

@@ -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);

View File

@@ -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,

View File

@@ -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))]
{

View File

@@ -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
View 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;
}
}
}
}

View File

@@ -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!();

View File

@@ -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,

View File

@@ -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
View 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
}

View File

@@ -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
View 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()
}