Compare commits
1 Commits
main
...
fix/self-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ebdb311f |
4
.github/workflows/publish-aur.yml
vendored
4
.github/workflows/publish-aur.yml
vendored
@@ -126,10 +126,6 @@ jobs:
|
|||||||
# ssh://aur@aur.archlinux.org/<package-name>.git
|
# ssh://aur@aur.archlinux.org/<package-name>.git
|
||||||
git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo
|
git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo
|
||||||
|
|
||||||
# AUR's git server no longer advertises HEAD's symref, so clone
|
|
||||||
# lands in detached HEAD. Attach to master before committing.
|
|
||||||
git -C aur-repo checkout master
|
|
||||||
|
|
||||||
cp PKGBUILD aur-repo/
|
cp PKGBUILD aur-repo/
|
||||||
cd aur-repo
|
cd aur-repo
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,7 +1,6 @@
|
|||||||
/target
|
/target
|
||||||
/build-dir
|
/build-dir
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.claude/
|
|
||||||
docs/
|
docs/
|
||||||
site/blog/posts/
|
site/blog/posts/
|
||||||
ios/
|
ios/
|
||||||
|
|||||||
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -1547,7 +1547,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.14.2"
|
version = "0.14.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -2130,9 +2130,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.13"
|
version = "0.103.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.14.2"
|
version = "0.14.1"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
|
|||||||
@@ -125,10 +125,6 @@ docker run -d --name numa --network host \
|
|||||||
|
|
||||||
Multi-arch: `linux/amd64` and `linux/arm64`.
|
Multi-arch: `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
Turnkey compose recipes:
|
|
||||||
- [`packaging/client/`](packaging/client/) — ODoH client mode (anonymous DNS), Numa + starter `numa.toml`.
|
|
||||||
- [`packaging/relay/`](packaging/relay/) — public ODoH relay, Numa + Caddy + ACME.
|
|
||||||
|
|
||||||
## How It Compares
|
## How It Compares
|
||||||
|
|
||||||
| | Pi-hole | AdGuard Home | Unbound | Numa |
|
| | Pi-hole | AdGuard Home | Unbound | Numa |
|
||||||
|
|||||||
29
numa.toml
29
numa.toml
@@ -22,7 +22,6 @@ api_port = 5380
|
|||||||
# [upstream]
|
# [upstream]
|
||||||
# mode = "forward" # "forward" (default) — relay to upstream
|
# mode = "forward" # "forward" (default) — relay to upstream
|
||||||
# # "recursive" — resolve from root hints (no address needed)
|
# # "recursive" — resolve from root hints (no address needed)
|
||||||
# # "odoh" — Oblivious DoH (see ODoH block below)
|
|
||||||
# address = "9.9.9.9" # single upstream (plain UDP)
|
# address = "9.9.9.9" # single upstream (plain UDP)
|
||||||
# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest
|
# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest
|
||||||
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
|
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
|
||||||
@@ -30,29 +29,11 @@ api_port = 5380
|
|||||||
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
|
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
|
||||||
# port = 53 # default port for addresses without :port
|
# port = 53 # default port for addresses without :port
|
||||||
# timeout_ms = 3000
|
# timeout_ms = 3000
|
||||||
# hedge_ms = 0 # request hedging delay (ms). Default: 0 (off).
|
# hedge_ms = 10 # request hedging delay (ms). After this delay
|
||||||
# # Set to e.g. 10 to fire a parallel upstream
|
# # without a response, fires a parallel request
|
||||||
# # request after 10ms of silence — rescues packet
|
# # to the same upstream. Rescues packet loss (UDP),
|
||||||
# # loss (UDP), dispatch spikes (DoH), TLS stalls
|
# # dispatch spikes (DoH), TLS stalls (DoT).
|
||||||
# # (DoT). Doubles the upstream query count, so
|
# # Set to 0 to disable. Default: 10
|
||||||
# # leave off for quota'd providers (NextDNS,
|
|
||||||
# # Control D).
|
|
||||||
|
|
||||||
# ODoH (Oblivious DNS-over-HTTPS, RFC 9230). The relay sees your IP but
|
|
||||||
# not the question; the target sees the question but not your IP. Numa
|
|
||||||
# refuses same-operator relay+target configs by default (eTLD+1 check).
|
|
||||||
# [upstream]
|
|
||||||
# mode = "odoh"
|
|
||||||
# relay = "https://odoh-relay.numa.rs/relay"
|
|
||||||
# target = "https://odoh.cloudflare-dns.com/dns-query"
|
|
||||||
# strict = true # default: refuse to downgrade to `fallback`
|
|
||||||
# # on relay failure. Set false to allow a
|
|
||||||
# # non-oblivious fallback path.
|
|
||||||
# relay_ip = "178.104.229.30" # optional: pin IPs so numa doesn't leak the
|
|
||||||
# target_ip = "104.16.249.249" # relay/target hostnames via the bootstrap
|
|
||||||
# # resolver on cold boot when numa is its
|
|
||||||
# # own system DNS. See
|
|
||||||
# # recipes/odoh-upstream.md.
|
|
||||||
# root_hints = [ # only used in recursive mode
|
# root_hints = [ # only used in recursive mode
|
||||||
# "198.41.0.4", # a.root-servers.net (Verisign)
|
# "198.41.0.4", # a.root-servers.net (Verisign)
|
||||||
# "199.9.14.201", # b.root-servers.net (USC-ISI)
|
# "199.9.14.201", # b.root-servers.net (USC-ISI)
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# Numa ODoH Client — Docker deploy
|
|
||||||
|
|
||||||
Single-container deploy that runs Numa as an ODoH (RFC 9230) client: every
|
|
||||||
DNS query routes through an independent relay + target so neither operator
|
|
||||||
sees both your IP and your question. See the [ODoH upstream recipe][odoh]
|
|
||||||
for the protocol details and the bootstrap-pinning trade-offs.
|
|
||||||
|
|
||||||
[odoh]: ../../recipes/odoh-upstream.md
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Docker + Docker Compose v2.
|
|
||||||
- Port 53 (UDP+TCP) free on the host — Numa listens there for DNS
|
|
||||||
clients on your LAN.
|
|
||||||
|
|
||||||
## Configure
|
|
||||||
|
|
||||||
The shipped `numa.toml` points at Numa's own public relay
|
|
||||||
(`odoh-relay.numa.rs`) paired with Cloudflare's ODoH target
|
|
||||||
(`odoh.cloudflare-dns.com`). That's two independent operators with
|
|
||||||
distinct eTLD+1s — the default configuration passes Numa's same-operator
|
|
||||||
check and works out of the box.
|
|
||||||
|
|
||||||
To use a different relay or target, edit `numa.toml` and adjust the URLs.
|
|
||||||
The `relay` and `target` must resolve to distinct operators or Numa
|
|
||||||
refuses to start.
|
|
||||||
|
|
||||||
## Deploy
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose up -d
|
|
||||||
docker compose logs -f numa # watch startup
|
|
||||||
```
|
|
||||||
|
|
||||||
The first query fires the bootstrap resolver + ODoH config fetch;
|
|
||||||
subsequent queries reuse the warm HTTP/2 connection.
|
|
||||||
|
|
||||||
## Point your devices at it
|
|
||||||
|
|
||||||
Set each device's DNS server to the IP of the Docker host. For a LAN-wide
|
|
||||||
rollout, set the DNS server in your router's DHCP config so every device
|
|
||||||
picks it up automatically.
|
|
||||||
|
|
||||||
Verify a query landed on the ODoH path:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dig @<host-ip> example.com
|
|
||||||
curl http://<host-ip>:5380/stats | jq '.upstream_transport.odoh'
|
|
||||||
```
|
|
||||||
|
|
||||||
`upstream_transport.odoh` should increment on each query.
|
|
||||||
|
|
||||||
## What this does NOT buy you
|
|
||||||
|
|
||||||
ODoH protects the *path*, not the content:
|
|
||||||
|
|
||||||
- **The target (Cloudflare here) still sees the question.** It just
|
|
||||||
doesn't know it's you asking. If Cloudflare logs every ODoH query, the
|
|
||||||
query is still visible — it's simply unattributed.
|
|
||||||
- **The relay is a trusted party for availability.** A malicious relay
|
|
||||||
can drop or delay queries; it just can't read them.
|
|
||||||
- **Traffic analysis defeats small relays.** If you're the only client
|
|
||||||
talking to a relay, timing alone re-identifies you. Shared, busy relays
|
|
||||||
give better anonymity sets.
|
|
||||||
|
|
||||||
See the [ODoH integration doc][odoh] for more.
|
|
||||||
|
|
||||||
## Relay operator?
|
|
||||||
|
|
||||||
If you'd rather run your own relay (same binary, different mode), see
|
|
||||||
[`../relay/`](../relay/) — that package spins up a public-facing relay
|
|
||||||
with Caddy + ACME in front of it.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
services:
|
|
||||||
numa:
|
|
||||||
image: ghcr.io/razvandimescu/numa:latest
|
|
||||||
command: ["/etc/numa/numa.toml"]
|
|
||||||
ports:
|
|
||||||
- "53:53/udp"
|
|
||||||
- "53:53/tcp"
|
|
||||||
- "5380:5380/tcp" # dashboard + REST API
|
|
||||||
volumes:
|
|
||||||
- ./numa.toml:/etc/numa/numa.toml:ro
|
|
||||||
- numa_data:/var/lib/numa
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
numa_data:
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Numa — ODoH client mode (docker-compose starter).
|
|
||||||
# Sends every DNS query through an independent relay + target pair so
|
|
||||||
# neither operator sees both your IP and your question. See
|
|
||||||
# recipes/odoh-upstream.md for the protocol details and
|
|
||||||
# packaging/client/README.md for deploy notes.
|
|
||||||
|
|
||||||
[server]
|
|
||||||
bind_addr = "0.0.0.0:53"
|
|
||||||
api_bind_addr = "0.0.0.0"
|
|
||||||
data_dir = "/var/lib/numa"
|
|
||||||
|
|
||||||
[upstream]
|
|
||||||
mode = "odoh"
|
|
||||||
# Numa's own relay (Hetzner, systemd + Caddy). Swap to any other public
|
|
||||||
# ODoH relay if you'd rather not depend on a single operator; the protocol
|
|
||||||
# tolerates it, and Numa refuses same-operator relay+target by default.
|
|
||||||
relay = "https://odoh-relay.numa.rs/relay"
|
|
||||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
|
||||||
# strict = true (default). Relay failure → SERVFAIL, never silent downgrade.
|
|
||||||
|
|
||||||
[blocking]
|
|
||||||
enabled = true
|
|
||||||
# Default blocklist (Hagezi Pro). Edit the `lists` array to taste.
|
|
||||||
@@ -39,3 +39,10 @@ curl https://<hostname>/health
|
|||||||
|
|
||||||
Then point any ODoH client at `https://<hostname>/relay` and watch the
|
Then point any ODoH client at `https://<hostname>/relay` and watch the
|
||||||
counters tick.
|
counters tick.
|
||||||
|
|
||||||
|
## Listing on the public ecosystem
|
||||||
|
|
||||||
|
DNSCrypt's [v3/odoh-relays.md](https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-relays.md)
|
||||||
|
is the canonical list. The pruned 2025-09-16 commit shows one public ODoH
|
||||||
|
relay survived the cull — running this compose file doubles global supply.
|
||||||
|
Open a PR there once your relay has been up for ~24 hours.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Recipes
|
|
||||||
|
|
||||||
Scenario-driven configs for common Numa deployments. Each recipe is self-contained: copy the snippet, adjust the marked fields, reload.
|
|
||||||
|
|
||||||
## Transport / encryption
|
|
||||||
|
|
||||||
- [DoH on the LAN](doh-on-lan.md) — expose Numa's built-in DNS-over-HTTPS to local clients.
|
|
||||||
- [dnsdist in front of Numa](dnsdist-front.md) — terminate public TLS externally, keep Numa on loopback.
|
|
||||||
- [ODoH upstream with bootstrap pinning](odoh-upstream.md) — oblivious DNS client mode without leaking the relay/target hostnames.
|
|
||||||
|
|
||||||
Missing a scenario? Open an issue or PR — these are plain Markdown with no build step.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# dnsdist in front of Numa
|
|
||||||
|
|
||||||
For public DoH with a real (ACME-signed) cert, terminate TLS outside Numa and forward plain DNS (or loopback-only DoH) to the resolver. Cert renewal, rate-limiting, and load-balancing live in the front-end; Numa stays focused on resolution.
|
|
||||||
|
|
||||||
## When to use this
|
|
||||||
|
|
||||||
- Public hostname (`dns.example.com`) with a Let's Encrypt or internal PKI cert.
|
|
||||||
- You want a dedicated front-end for DoH/DoT/DoQ while Numa stays loopback-bound.
|
|
||||||
- You plan to run multiple Numa instances behind one endpoint.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
public 443/DoH ┐
|
|
||||||
public 853/DoT ├─► dnsdist ─► 127.0.0.1:53 (Numa UDP/TCP)
|
|
||||||
public 443/DoQ ┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## dnsdist config
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- /etc/dnsdist/dnsdist.conf
|
|
||||||
|
|
||||||
newServer({address="127.0.0.1:53", name="numa", checkType="A", checkName="numa.rs."})
|
|
||||||
|
|
||||||
addDOHLocal(
|
|
||||||
"0.0.0.0:443",
|
|
||||||
"/etc/letsencrypt/live/dns.example.com/fullchain.pem",
|
|
||||||
"/etc/letsencrypt/live/dns.example.com/privkey.pem",
|
|
||||||
"/dns-query",
|
|
||||||
{doTCP=true, reusePort=true}
|
|
||||||
)
|
|
||||||
|
|
||||||
addTLSLocal(
|
|
||||||
"0.0.0.0:853",
|
|
||||||
"/etc/letsencrypt/live/dns.example.com/fullchain.pem",
|
|
||||||
"/etc/letsencrypt/live/dns.example.com/privkey.pem"
|
|
||||||
)
|
|
||||||
|
|
||||||
addAction(AllRule(), PoolAction("", false))
|
|
||||||
```
|
|
||||||
|
|
||||||
## Numa config
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[proxy]
|
|
||||||
enabled = true # keep if you still use *.numa service routing
|
|
||||||
bind_addr = "127.0.0.1" # stays default
|
|
||||||
```
|
|
||||||
|
|
||||||
No changes to `[server]` — Numa keeps serving plain DNS on UDP/TCP 53, which dnsdist forwards.
|
|
||||||
|
|
||||||
## Caveat: client IPs
|
|
||||||
|
|
||||||
Without PROXY protocol support in Numa, the query log shows the front-end's IP on every query, not the real client. dnsdist can emit PROXY v2 (`useProxyProtocol=true` on `newServer`), but Numa doesn't yet parse it — tracked in the wish-list under #143. Until then, accept the blind spot or correlate against dnsdist's own logs.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kdig +https @dns.example.com example.com
|
|
||||||
kdig +tls @dns.example.com example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Both should return clean answers. Numa's `/queries` API should show the request landing, sourced from the front-end IP.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# DoH on the LAN
|
|
||||||
|
|
||||||
Numa ships an RFC 8484 DoH endpoint (`POST /dns-query`) on the `[proxy]` HTTPS listener. By default it binds `127.0.0.1:443` with a self-signed cert — invisible to anything off the box. Three changes make it reachable from the LAN.
|
|
||||||
|
|
||||||
## When to use this
|
|
||||||
|
|
||||||
- Your phone/laptop is on the same network as Numa and you want encrypted DNS without a cloud resolver.
|
|
||||||
- You're OK installing Numa's self-signed CA on every client (one-time, via `/ca.pem` + the mobileconfig flow).
|
|
||||||
|
|
||||||
For a publicly-trusted cert, see [dnsdist in front of Numa](dnsdist-front.md) instead.
|
|
||||||
|
|
||||||
## Minimal config
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[proxy]
|
|
||||||
enabled = true # default
|
|
||||||
bind_addr = "0.0.0.0" # was 127.0.0.1 — expose to LAN
|
|
||||||
tls_port = 443 # default; DoH is served here
|
|
||||||
tld = "numa" # default — self-resolving, see below
|
|
||||||
```
|
|
||||||
|
|
||||||
`tld` is the DoH gate: Numa accepts the DoH request only when the `Host` header is loopback or equals (or is a subdomain of) `tld`. Clients therefore dial `https://numa/dns-query`.
|
|
||||||
|
|
||||||
With the default `tld = "numa"`, there's no DNS bootstrap to configure: Numa already resolves `numa` and `*.numa` to its own LAN IP for remote clients (that's how the `*.numa` service-proxy feature works). Any client that uses Numa as its resolver will resolve `numa` correctly on first try.
|
|
||||||
|
|
||||||
If you'd rather use a hostname that resolves via normal DNS (e.g. you want DoH-only clients that never talk plain DNS to Numa), set `tld = "dns.example.com"` and add a matching A record in whichever DNS your clients consult before reaching Numa.
|
|
||||||
|
|
||||||
## Trust the CA on each client
|
|
||||||
|
|
||||||
Numa generates a self-signed CA at startup. Fetch it once, import it wherever you'll run the DoH client:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -o numa-ca.pem http://<numa-ip>:5380/ca.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
- **macOS** — `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain numa-ca.pem`
|
|
||||||
- **iOS** — install the mobileconfig from the API (same CA, signed profile). Flip *Settings → General → About → Certificate Trust Settings* on after install.
|
|
||||||
- **Linux** — drop into `/usr/local/share/ca-certificates/` and run `sudo update-ca-certificates`.
|
|
||||||
- **Android** — requires the user-installed CA path; browsers may still refuse it for DoH. Consider the [dnsdist front](dnsdist-front.md) route instead.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kdig +https @numa example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Without `+https` kdig uses plain DNS. With `+https` the same answers should flow over port 443.
|
|
||||||
|
|
||||||
Raw check:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H 'accept: application/dns-message' \
|
|
||||||
--data-binary @query.bin \
|
|
||||||
https://numa/dns-query
|
|
||||||
```
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- Port 443 is privileged on Linux/macOS. Run Numa via the provided service units, or grant `CAP_NET_BIND_SERVICE` (`sudo setcap 'cap_net_bind_service=+ep' /path/to/numa`).
|
|
||||||
- Non-matching `Host` header → HTTP 404 from the proxy's fallback handler. Double-check `tld`.
|
|
||||||
- ChromeOS enrollment rejects user-installed CAs for some flows — known pain point, see issue #136.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# ODoH upstream with bootstrap pinning
|
|
||||||
|
|
||||||
Numa can run as an Oblivious DoH (RFC 9230) client: the relay sees your IP but not the question, the target sees the question but not your IP. Neither party alone can re-identify a query. This recipe covers the minimal config and the bootstrap leak that `relay_ip` / `target_ip` close.
|
|
||||||
|
|
||||||
## When to use this
|
|
||||||
|
|
||||||
- You want split-trust encrypted DNS without a single provider seeing both who you are and what you asked.
|
|
||||||
- Numa is your system resolver (so there's no "other" DNS to ask).
|
|
||||||
|
|
||||||
## Minimal config
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[upstream]
|
|
||||||
mode = "odoh"
|
|
||||||
relay = "https://odoh-relay.numa.rs/relay"
|
|
||||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
|
||||||
strict = true # refuse to fall back to a non-oblivious path on relay failure
|
|
||||||
```
|
|
||||||
|
|
||||||
`strict = true` means a relay-level HTTPS failure returns SERVFAIL instead of silently downgrading. Set it to `false` and configure `[upstream].fallback` if you'd rather keep resolving (at the cost of the oblivious property).
|
|
||||||
|
|
||||||
## The bootstrap leak
|
|
||||||
|
|
||||||
When Numa is the system resolver and needs to reach the relay/target, *something* has to translate `odoh-relay.numa.rs` → IP. If Numa asks itself, you deadlock. If Numa asks a bootstrap resolver (1.1.1.1, 9.9.9.9), that resolver learns which ODoH endpoint you use in cleartext — it can't see your questions, but it sees the destination. That's the leak ODoH was supposed to close.
|
|
||||||
|
|
||||||
`relay_ip` and `target_ip` tell Numa the IPs directly, so it never asks anyone:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[upstream]
|
|
||||||
mode = "odoh"
|
|
||||||
relay = "https://odoh-relay.numa.rs/relay"
|
|
||||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
|
||||||
relay_ip = "178.104.229.30" # pin the relay — no hostname lookup
|
|
||||||
target_ip = "104.16.249.249" # pin the target — no hostname lookup
|
|
||||||
```
|
|
||||||
|
|
||||||
Numa still validates TLS against the hostnames in `relay` / `target`, so a hijacked IP can't masquerade — pinning skips only the DNS step.
|
|
||||||
|
|
||||||
## Finding current IPs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dig +short odoh-relay.numa.rs
|
|
||||||
dig +short odoh.cloudflare-dns.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-pin when an operator rotates. The community-maintained list at <https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v3/odoh-relays.md> is a useful cross-reference.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kdig @127.0.0.1 example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Numa's `/queries` API and startup banner should label the upstream as `odoh://`. Look for `ODoH relay returned ...` errors in the logs if routing fails.
|
|
||||||
|
|
||||||
## Known gotchas
|
|
||||||
|
|
||||||
- **Same-operator refused.** Numa's eTLD+1 check blocks configs where the relay and target belong to the same operator (pointless — same party sees both sides). Override only when testing.
|
|
||||||
- **Single relay.** Current config accepts one relay and one target. Multi-entry rotation/failover is tracked in #140.
|
|
||||||
@@ -1,41 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Dev server for site/: regenerates drafts on each MD change, reloads the
|
|
||||||
# browser on each rendered HTML/CSS/JS change. Port is the first numeric arg
|
|
||||||
# (default 9000); any other args are ignored for back-compat.
|
|
||||||
#
|
|
||||||
# First run downloads chokidar-cli + browser-sync into the npm cache — slow
|
|
||||||
# once, instant after that.
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PORT=9000
|
PORT="${1:-9000}"
|
||||||
for arg in "$@"; do
|
|
||||||
if [[ "$arg" =~ ^[0-9]+$ ]]; then
|
|
||||||
PORT="$arg"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
command -v npx >/dev/null || { echo "npx not found. Install Node.js: https://nodejs.org" >&2; exit 1; }
|
if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then
|
||||||
command -v pandoc >/dev/null || { echo "pandoc not found (required by 'make blog-drafts')." >&2; exit 1; }
|
PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg
|
||||||
|
make blog-drafts
|
||||||
|
else
|
||||||
|
make blog
|
||||||
|
fi
|
||||||
|
|
||||||
# Initial render so the first page load has everything.
|
echo "Serving site at http://localhost:$PORT"
|
||||||
make blog-drafts
|
cd site && python3 -m http.server "$PORT"
|
||||||
|
|
||||||
echo "Serving site at http://localhost:$PORT (drafts included, live reload)"
|
|
||||||
|
|
||||||
# Kill child processes on exit so re-runs don't leave orphaned watchers.
|
|
||||||
trap 'kill $(jobs -p) 2>/dev/null' EXIT INT TERM
|
|
||||||
|
|
||||||
# Regenerate HTML when MD sources or the blog template change.
|
|
||||||
npx --yes chokidar-cli \
|
|
||||||
"drafts/*.md" "blog/*.md" "site/blog-template.html" \
|
|
||||||
-c "make blog-drafts" &
|
|
||||||
|
|
||||||
# Serve + reload on rendered-asset changes.
|
|
||||||
cd site && exec npx --yes browser-sync start \
|
|
||||||
--server . \
|
|
||||||
--port "$PORT" \
|
|
||||||
--files "**/*.html,**/*.css,**/*.js" \
|
|
||||||
--no-open \
|
|
||||||
--no-notify
|
|
||||||
|
|||||||
14
src/api.rs
14
src/api.rs
@@ -83,13 +83,8 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn dashboard() -> impl IntoResponse {
|
async fn dashboard() -> impl IntoResponse {
|
||||||
// Revalidate each load so browsers don't keep serving a stale
|
|
||||||
// dashboard across numa upgrades.
|
|
||||||
(
|
(
|
||||||
[
|
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
|
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
|
||||||
],
|
|
||||||
DASHBOARD_HTML,
|
DASHBOARD_HTML,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1249,13 +1244,6 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), 200);
|
assert_eq!(resp.status(), 200);
|
||||||
assert_eq!(
|
|
||||||
resp.headers()
|
|
||||||
.get(header::CACHE_CONTROL)
|
|
||||||
.map(|v| v.to_str().unwrap()),
|
|
||||||
Some("no-cache"),
|
|
||||||
"dashboard must revalidate to avoid stale HTML across upgrades"
|
|
||||||
);
|
|
||||||
let body = axum::body::to_bytes(resp.into_body(), 100000)
|
let body = axum::body::to_bytes(resp.into_body(), 100000)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
//! relay/target, blocklist CDN). When numa is its own system resolver
|
//! relay/target, blocklist CDN). When numa is its own system resolver
|
||||||
//! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container),
|
//! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container),
|
||||||
//! the default `getaddrinfo` path loops back through numa before numa can
|
//! the default `getaddrinfo` path loops back through numa before numa can
|
||||||
//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122.
|
//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122 and
|
||||||
|
//! `docs/implementation/bootstrap-resolver.md`.
|
||||||
//!
|
//!
|
||||||
//! Resolution order per hostname:
|
//! Resolution order per hostname:
|
||||||
//! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return
|
//! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return
|
||||||
|
|||||||
@@ -451,12 +451,8 @@ fn default_upstream_port() -> u16 {
|
|||||||
fn default_timeout_ms() -> u64 {
|
fn default_timeout_ms() -> u64 {
|
||||||
5000
|
5000
|
||||||
}
|
}
|
||||||
/// Off by default: hedging fires a second upstream query, which silently
|
|
||||||
/// doubles the count at the provider — hurts quota'd DNS (NextDNS, Control
|
|
||||||
/// D). Opt in with `hedge_ms = 10` for tail-latency rescue on flaky nets
|
|
||||||
/// or handshake-slow DoT.
|
|
||||||
fn default_hedge_ms() -> u64 {
|
fn default_hedge_ms() -> u64 {
|
||||||
0
|
10
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
130
src/ctx.rs
130
src/ctx.rs
@@ -408,33 +408,6 @@ fn cache_and_parse(
|
|||||||
/// Used for both stale-entry refresh and proactive cache warming.
|
/// Used for both stale-entry refresh and proactive cache warming.
|
||||||
pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) {
|
pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) {
|
||||||
let query = DnsPacket::query(0, qname, qtype);
|
let query = DnsPacket::query(0, qname, qtype);
|
||||||
|
|
||||||
// Forwarding rules must win here, mirroring `resolve_query` — otherwise
|
|
||||||
// refresh re-resolves private zones through the default upstream and
|
|
||||||
// poisons the cache with NXDOMAIN.
|
|
||||||
if let Some(pool) = crate::system_dns::match_forwarding_rule(qname, &ctx.forwarding_rules) {
|
|
||||||
let mut buf = BytePacketBuffer::new();
|
|
||||||
if query.write(&mut buf).is_ok() {
|
|
||||||
if let Ok(wire) = forward_with_failover_raw(
|
|
||||||
buf.filled(),
|
|
||||||
pool,
|
|
||||||
&ctx.srtt,
|
|
||||||
ctx.timeout,
|
|
||||||
ctx.hedge_delay,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
ctx.cache.write().unwrap().insert_wire(
|
|
||||||
qname,
|
|
||||||
qtype,
|
|
||||||
&wire,
|
|
||||||
DnssecStatus::Indeterminate,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.upstream_mode == UpstreamMode::Recursive {
|
if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||||
if let Ok(resp) = crate::recursive::resolve_recursive(
|
if let Ok(resp) = crate::recursive::resolve_recursive(
|
||||||
qname,
|
qname,
|
||||||
@@ -1271,8 +1244,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_filter_aaaa_leaves_a_queries_alone() {
|
async fn pipeline_filter_aaaa_leaves_a_queries_alone() {
|
||||||
let upstream_resp =
|
let mut upstream_resp = DnsPacket::new();
|
||||||
crate::testutil::a_record_response("example.com", Ipv4Addr::new(93, 184, 216, 34), 300);
|
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 upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||||
|
|
||||||
let mut ctx = crate::testutil::test_ctx().await;
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
@@ -1492,8 +1471,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_forwarding_returns_upstream_answer() {
|
async fn pipeline_forwarding_returns_upstream_answer() {
|
||||||
let upstream_resp =
|
let mut upstream_resp = DnsPacket::new();
|
||||||
crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 1, 2, 3), 600);
|
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 upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||||
|
|
||||||
let mut ctx = crate::testutil::test_ctx().await;
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
@@ -1520,8 +1505,14 @@ mod tests {
|
|||||||
async fn pipeline_forwarding_fails_over_to_second_upstream() {
|
async fn pipeline_forwarding_fails_over_to_second_upstream() {
|
||||||
let dead = crate::testutil::blackhole_upstream();
|
let dead = crate::testutil::blackhole_upstream();
|
||||||
|
|
||||||
let live_resp =
|
let mut live_resp = DnsPacket::new();
|
||||||
crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 9, 9, 9), 600);
|
live_resp.header.response = true;
|
||||||
|
live_resp.header.rescode = ResultCode::NOERROR;
|
||||||
|
live_resp.answers.push(DnsRecord::A {
|
||||||
|
domain: "internal.corp".to_string(),
|
||||||
|
addr: Ipv4Addr::new(10, 9, 9, 9),
|
||||||
|
ttl: 600,
|
||||||
|
});
|
||||||
let live = crate::testutil::mock_upstream(live_resp).await;
|
let live = crate::testutil::mock_upstream(live_resp).await;
|
||||||
|
|
||||||
let mut ctx = crate::testutil::test_ctx().await;
|
let mut ctx = crate::testutil::test_ctx().await;
|
||||||
@@ -1543,8 +1534,14 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pipeline_default_pool_reports_upstream_path() {
|
async fn pipeline_default_pool_reports_upstream_path() {
|
||||||
let upstream_resp =
|
let mut upstream_resp = DnsPacket::new();
|
||||||
crate::testutil::a_record_response("example.com", Ipv4Addr::new(93, 184, 216, 34), 300);
|
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 upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
|
||||||
|
|
||||||
let ctx = crate::testutil::test_ctx().await;
|
let ctx = crate::testutil::test_ctx().await;
|
||||||
@@ -1559,67 +1556,4 @@ mod tests {
|
|||||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||||
assert_eq!(resp.answers.len(), 1);
|
assert_eq!(resp.answers.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn refresh_entry_honors_forwarding_rule() {
|
|
||||||
let rule_resp =
|
|
||||||
crate::testutil::a_record_response("internal.corp", Ipv4Addr::new(10, 0, 0, 42), 300);
|
|
||||||
let rule_upstream = crate::testutil::mock_upstream(rule_resp).await;
|
|
||||||
|
|
||||||
let mut ctx = crate::testutil::test_ctx().await;
|
|
||||||
ctx.forwarding_rules = vec![ForwardingRule::new(
|
|
||||||
"corp".to_string(),
|
|
||||||
UpstreamPool::new(vec![Upstream::Udp(rule_upstream)], vec![]),
|
|
||||||
)];
|
|
||||||
// Default pool points at a blackhole — if the refresh queries it
|
|
||||||
// instead of the rule, the test fails because nothing is cached.
|
|
||||||
ctx.upstream_pool
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.set_primary(vec![Upstream::Udp(crate::testutil::blackhole_upstream())]);
|
|
||||||
let ctx = Arc::new(ctx);
|
|
||||||
|
|
||||||
refresh_entry(&ctx, "internal.corp", QueryType::A).await;
|
|
||||||
|
|
||||||
let cached = ctx
|
|
||||||
.cache
|
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
.lookup("internal.corp", QueryType::A)
|
|
||||||
.expect("refresh must populate cache via forwarding rule");
|
|
||||||
match &cached.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 refresh_entry_prefers_forwarding_rule_over_recursive() {
|
|
||||||
let rule_resp =
|
|
||||||
crate::testutil::a_record_response("db.internal.corp", Ipv4Addr::new(10, 0, 0, 7), 300);
|
|
||||||
let rule_upstream = crate::testutil::mock_upstream(rule_resp).await;
|
|
||||||
|
|
||||||
let mut ctx = crate::testutil::test_ctx().await;
|
|
||||||
ctx.upstream_mode = UpstreamMode::Recursive;
|
|
||||||
ctx.forwarding_rules = vec![ForwardingRule::new(
|
|
||||||
"corp".to_string(),
|
|
||||||
UpstreamPool::new(vec![Upstream::Udp(rule_upstream)], vec![]),
|
|
||||||
)];
|
|
||||||
// No root_hints — recursion would fail immediately, proving that
|
|
||||||
// the rule branch fired instead.
|
|
||||||
let ctx = Arc::new(ctx);
|
|
||||||
|
|
||||||
refresh_entry(&ctx, "db.internal.corp", QueryType::A).await;
|
|
||||||
|
|
||||||
let cached = ctx
|
|
||||||
.cache
|
|
||||||
.read()
|
|
||||||
.unwrap()
|
|
||||||
.lookup("db.internal.corp", QueryType::A)
|
|
||||||
.expect("recursive-mode refresh must still consult forwarding rules");
|
|
||||||
match &cached.answers[0] {
|
|
||||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 7)),
|
|
||||||
other => panic!("expected A record, got {:?}", other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -882,28 +882,6 @@ fn record_rdata_canonical(record: &DnsRecord) -> Vec<u8> {
|
|||||||
rdata.extend(type_bitmap);
|
rdata.extend(type_bitmap);
|
||||||
rdata
|
rdata
|
||||||
}
|
}
|
||||||
DnsRecord::SOA {
|
|
||||||
mname,
|
|
||||||
rname,
|
|
||||||
serial,
|
|
||||||
refresh,
|
|
||||||
retry,
|
|
||||||
expire,
|
|
||||||
minimum,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let mname_wire = name_to_wire(mname);
|
|
||||||
let rname_wire = name_to_wire(rname);
|
|
||||||
let mut rdata = Vec::with_capacity(mname_wire.len() + rname_wire.len() + 20);
|
|
||||||
rdata.extend(&mname_wire);
|
|
||||||
rdata.extend(&rname_wire);
|
|
||||||
rdata.extend(&serial.to_be_bytes());
|
|
||||||
rdata.extend(&refresh.to_be_bytes());
|
|
||||||
rdata.extend(&retry.to_be_bytes());
|
|
||||||
rdata.extend(&expire.to_be_bytes());
|
|
||||||
rdata.extend(&minimum.to_be_bytes());
|
|
||||||
rdata
|
|
||||||
}
|
|
||||||
DnsRecord::UNKNOWN { data, .. } => data.clone(),
|
DnsRecord::UNKNOWN { data, .. } => data.clone(),
|
||||||
DnsRecord::RRSIG { .. } => Vec::new(),
|
DnsRecord::RRSIG { .. } => Vec::new(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,8 @@ pub fn parse_upstream(
|
|||||||
///
|
///
|
||||||
/// Uses the system resolver. Callers running inside `serve::run` pass the
|
/// Uses the system resolver. Callers running inside `serve::run` pass the
|
||||||
/// shared [`crate::bootstrap_resolver::NumaResolver`] via
|
/// shared [`crate::bootstrap_resolver::NumaResolver`] via
|
||||||
/// [`build_https_client_with_resolver`] to avoid the self-loop (issue #122).
|
/// [`build_https_client_with_resolver`] to avoid the self-loop documented
|
||||||
|
/// in `docs/implementation/bootstrap-resolver.md`.
|
||||||
pub fn build_https_client() -> reqwest::Client {
|
pub fn build_https_client() -> reqwest::Client {
|
||||||
build_https_client_with_resolver(1, None)
|
build_https_client_with_resolver(1, None)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
|
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
|
||||||
//! response from `HealthMeta` + live inputs.
|
//! response from `HealthMeta` + live inputs.
|
||||||
//!
|
//!
|
||||||
//! The iOS companion app's `HealthInfo` struct is the canonical consumer;
|
//! JSON schema is documented in `docs/implementation/ios-companion-app.md`
|
||||||
//! any change to this response must keep that struct decoding cleanly (all
|
//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical
|
||||||
//! consumed fields are optional on the Swift side, but `lan_ip` is
|
//! consumer; any change to this response must keep that struct decoding
|
||||||
//! load-bearing for the pipeline).
|
//! cleanly (all consumed fields are optional on the Swift side, but
|
||||||
|
//! `lan_ip` is load-bearing for the pipeline).
|
||||||
|
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ pub enum DnsRecord {
|
|||||||
host: String,
|
host: String,
|
||||||
ttl: u32,
|
ttl: u32,
|
||||||
},
|
},
|
||||||
SOA {
|
|
||||||
domain: String,
|
|
||||||
mname: String,
|
|
||||||
rname: String,
|
|
||||||
serial: u32,
|
|
||||||
refresh: u32,
|
|
||||||
retry: u32,
|
|
||||||
expire: u32,
|
|
||||||
minimum: u32,
|
|
||||||
ttl: u32,
|
|
||||||
},
|
|
||||||
CNAME {
|
CNAME {
|
||||||
domain: String,
|
domain: String,
|
||||||
host: String,
|
host: String,
|
||||||
@@ -111,7 +100,6 @@ impl DnsRecord {
|
|||||||
| DnsRecord::RRSIG { domain, .. }
|
| DnsRecord::RRSIG { domain, .. }
|
||||||
| DnsRecord::NSEC { domain, .. }
|
| DnsRecord::NSEC { domain, .. }
|
||||||
| DnsRecord::NSEC3 { domain, .. }
|
| DnsRecord::NSEC3 { domain, .. }
|
||||||
| DnsRecord::SOA { domain, .. }
|
|
||||||
| DnsRecord::UNKNOWN { domain, .. } => domain,
|
| DnsRecord::UNKNOWN { domain, .. } => domain,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +111,6 @@ impl DnsRecord {
|
|||||||
DnsRecord::NS { .. } => QueryType::NS,
|
DnsRecord::NS { .. } => QueryType::NS,
|
||||||
DnsRecord::CNAME { .. } => QueryType::CNAME,
|
DnsRecord::CNAME { .. } => QueryType::CNAME,
|
||||||
DnsRecord::MX { .. } => QueryType::MX,
|
DnsRecord::MX { .. } => QueryType::MX,
|
||||||
DnsRecord::SOA { .. } => QueryType::SOA,
|
|
||||||
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
|
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
|
||||||
DnsRecord::DS { .. } => QueryType::DS,
|
DnsRecord::DS { .. } => QueryType::DS,
|
||||||
DnsRecord::RRSIG { .. } => QueryType::RRSIG,
|
DnsRecord::RRSIG { .. } => QueryType::RRSIG,
|
||||||
@@ -145,7 +132,6 @@ impl DnsRecord {
|
|||||||
| DnsRecord::RRSIG { ttl, .. }
|
| DnsRecord::RRSIG { ttl, .. }
|
||||||
| DnsRecord::NSEC { ttl, .. }
|
| DnsRecord::NSEC { ttl, .. }
|
||||||
| DnsRecord::NSEC3 { ttl, .. }
|
| DnsRecord::NSEC3 { ttl, .. }
|
||||||
| DnsRecord::SOA { ttl, .. }
|
|
||||||
| DnsRecord::UNKNOWN { ttl, .. } => *ttl,
|
| DnsRecord::UNKNOWN { ttl, .. } => *ttl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,12 +172,6 @@ impl DnsRecord {
|
|||||||
+ next_hashed_owner.capacity()
|
+ next_hashed_owner.capacity()
|
||||||
+ type_bitmap.capacity()
|
+ type_bitmap.capacity()
|
||||||
}
|
}
|
||||||
DnsRecord::SOA {
|
|
||||||
domain,
|
|
||||||
mname,
|
|
||||||
rname,
|
|
||||||
..
|
|
||||||
} => domain.capacity() + mname.capacity() + rname.capacity(),
|
|
||||||
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
|
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +188,6 @@ impl DnsRecord {
|
|||||||
| DnsRecord::RRSIG { ttl, .. }
|
| DnsRecord::RRSIG { ttl, .. }
|
||||||
| DnsRecord::NSEC { ttl, .. }
|
| DnsRecord::NSEC { ttl, .. }
|
||||||
| DnsRecord::NSEC3 { ttl, .. }
|
| DnsRecord::NSEC3 { ttl, .. }
|
||||||
| DnsRecord::SOA { ttl, .. }
|
|
||||||
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
|
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,31 +365,8 @@ impl DnsRecord {
|
|||||||
ttl,
|
ttl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
QueryType::SOA => {
|
|
||||||
// MNAME/RNAME compressible per RFC 1035 §3.3.13 — decompress to avoid stale pointers on re-emit.
|
|
||||||
let mut mname = String::with_capacity(64);
|
|
||||||
buffer.read_qname(&mut mname)?;
|
|
||||||
let mut rname = String::with_capacity(64);
|
|
||||||
buffer.read_qname(&mut rname)?;
|
|
||||||
let serial = buffer.read_u32()?;
|
|
||||||
let refresh = buffer.read_u32()?;
|
|
||||||
let retry = buffer.read_u32()?;
|
|
||||||
let expire = buffer.read_u32()?;
|
|
||||||
let minimum = buffer.read_u32()?;
|
|
||||||
Ok(DnsRecord::SOA {
|
|
||||||
domain,
|
|
||||||
mname,
|
|
||||||
rname,
|
|
||||||
serial,
|
|
||||||
refresh,
|
|
||||||
retry,
|
|
||||||
expire,
|
|
||||||
minimum,
|
|
||||||
ttl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
// TXT, SRV, HTTPS, SVCB, etc. — stored as opaque bytes until parsed natively
|
// SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively
|
||||||
let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
|
let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
|
||||||
buffer.step(data_len as usize)?;
|
buffer.step(data_len as usize)?;
|
||||||
Ok(DnsRecord::UNKNOWN {
|
Ok(DnsRecord::UNKNOWN {
|
||||||
@@ -474,30 +430,6 @@ impl DnsRecord {
|
|||||||
let size = buffer.pos() - (pos + 2);
|
let size = buffer.pos() - (pos + 2);
|
||||||
buffer.set_u16(pos, size as u16)?;
|
buffer.set_u16(pos, size as u16)?;
|
||||||
}
|
}
|
||||||
DnsRecord::SOA {
|
|
||||||
ref domain,
|
|
||||||
ref mname,
|
|
||||||
ref rname,
|
|
||||||
serial,
|
|
||||||
refresh,
|
|
||||||
retry,
|
|
||||||
expire,
|
|
||||||
minimum,
|
|
||||||
ttl,
|
|
||||||
} => {
|
|
||||||
write_header(buffer, domain, QueryType::SOA.to_num(), ttl)?;
|
|
||||||
let rdlen_pos = buffer.pos();
|
|
||||||
buffer.write_u16(0)?;
|
|
||||||
buffer.write_qname(mname)?;
|
|
||||||
buffer.write_qname(rname)?;
|
|
||||||
buffer.write_u32(serial)?;
|
|
||||||
buffer.write_u32(refresh)?;
|
|
||||||
buffer.write_u32(retry)?;
|
|
||||||
buffer.write_u32(expire)?;
|
|
||||||
buffer.write_u32(minimum)?;
|
|
||||||
let rdlen = buffer.pos() - (rdlen_pos + 2);
|
|
||||||
buffer.set_u16(rdlen_pos, rdlen as u16)?;
|
|
||||||
}
|
|
||||||
DnsRecord::AAAA {
|
DnsRecord::AAAA {
|
||||||
ref domain,
|
ref domain,
|
||||||
ref addr,
|
ref addr,
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
|||||||
// Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist
|
// Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist
|
||||||
// CDN) away from the system resolver so lookups don't loop back through
|
// CDN) away from the system resolver so lookups don't loop back through
|
||||||
// numa when it's its own system DNS.
|
// numa when it's its own system DNS.
|
||||||
|
// See `docs/implementation/bootstrap-resolver.md`.
|
||||||
let resolver_overrides = match config.upstream.mode {
|
let resolver_overrides = match config.upstream.mode {
|
||||||
crate::config::UpstreamMode::Odoh => config
|
crate::config::UpstreamMode::Odoh => config
|
||||||
.upstream
|
.upstream
|
||||||
@@ -342,13 +343,12 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Title row: center within the box
|
// Title row: center within the box
|
||||||
let tag_line = "DNS that governs itself";
|
|
||||||
let title = format!(
|
let title = format!(
|
||||||
"{b}NUMA{r} {it}{tag_line}{r} {d}v{}{r}",
|
"{b}NUMA{r} {it}DNS that governs itself{r} {d}v{}{r}",
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
);
|
);
|
||||||
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
|
// The title contains ANSI codes; visible length is ~38 chars. Pad to fill the box.
|
||||||
let title_visible_len = 4 + 2 + tag_line.len() + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
|
let title_visible_len = 4 + 2 + 24 + 2 + 1 + env!("CARGO_PKG_VERSION").len() + 1;
|
||||||
let title_pad = w.saturating_sub(title_visible_len);
|
let title_pad = w.saturating_sub(title_visible_len);
|
||||||
eprintln!("\n{o} ╔{bar_top}╗{r}");
|
eprintln!("\n{o} ╔{bar_top}╗{r}");
|
||||||
eprint!("{o} ║{r} {title}");
|
eprint!("{o} ║{r} {title}");
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ use crate::cache::DnsCache;
|
|||||||
use crate::config::UpstreamMode;
|
use crate::config::UpstreamMode;
|
||||||
use crate::ctx::ServerCtx;
|
use crate::ctx::ServerCtx;
|
||||||
use crate::forward::{Upstream, UpstreamPool};
|
use crate::forward::{Upstream, UpstreamPool};
|
||||||
use crate::header::ResultCode;
|
|
||||||
use crate::health::HealthMeta;
|
use crate::health::HealthMeta;
|
||||||
use crate::lan::PeerStore;
|
use crate::lan::PeerStore;
|
||||||
use crate::override_store::OverrideStore;
|
use crate::override_store::OverrideStore;
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
use crate::query_log::QueryLog;
|
use crate::query_log::QueryLog;
|
||||||
use crate::record::DnsRecord;
|
|
||||||
use crate::service_store::ServiceStore;
|
use crate::service_store::ServiceStore;
|
||||||
use crate::srtt::SrttCache;
|
use crate::srtt::SrttCache;
|
||||||
use crate::stats::ServerStats;
|
use crate::stats::ServerStats;
|
||||||
@@ -69,20 +67,6 @@ pub async fn test_ctx() -> ServerCtx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a NOERROR response containing a single A record — the shape used
|
|
||||||
/// repeatedly by pipeline/forwarding tests to seed `mock_upstream`.
|
|
||||||
pub fn a_record_response(domain: &str, addr: Ipv4Addr, ttl: u32) -> DnsPacket {
|
|
||||||
let mut pkt = DnsPacket::new();
|
|
||||||
pkt.header.response = true;
|
|
||||||
pkt.header.rescode = ResultCode::NOERROR;
|
|
||||||
pkt.answers.push(DnsRecord::A {
|
|
||||||
domain: domain.to_string(),
|
|
||||||
addr,
|
|
||||||
ttl,
|
|
||||||
});
|
|
||||||
pkt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a UDP socket that replies to the first DNS query with the given
|
/// 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.
|
/// response packet (patching the query ID to match). Returns the socket address.
|
||||||
pub async fn mock_upstream(response: DnsPacket) -> SocketAddr {
|
pub async fn mock_upstream(response: DnsPacket) -> SocketAddr {
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
//! Regression test for issue #128: SOA with compressed MNAME/RNAME must
|
|
||||||
//! survive Numa's round-trip — compression pointers reference the upstream
|
|
||||||
//! packet's byte layout, so we have to decompress on read and re-compress
|
|
||||||
//! on write.
|
|
||||||
|
|
||||||
use numa::buffer::BytePacketBuffer;
|
|
||||||
use numa::packet::DnsPacket;
|
|
||||||
|
|
||||||
const COMPRESSION_FLAG: u16 = 0xC000;
|
|
||||||
|
|
||||||
fn upstream_packet() -> Vec<u8> {
|
|
||||||
let mut p = Vec::<u8>::new();
|
|
||||||
|
|
||||||
p.extend_from_slice(&[
|
|
||||||
0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00,
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(p.len(), 12);
|
|
||||||
write_name(&mut p, &["odin", "adobe", "com"]);
|
|
||||||
p.extend_from_slice(&[0x00, 0x41, 0x00, 0x01]);
|
|
||||||
|
|
||||||
p.extend_from_slice(&[0xC0, 0x0C]);
|
|
||||||
p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]);
|
|
||||||
let rdlen_pos_1 = p.len();
|
|
||||||
p.extend_from_slice(&[0x00, 0x00]);
|
|
||||||
let cname1_start = p.len();
|
|
||||||
write_name(&mut p, &["cdn", "adobeaemcloud", "com"]);
|
|
||||||
let rdlen_1 = (p.len() - cname1_start) as u16;
|
|
||||||
p[rdlen_pos_1..rdlen_pos_1 + 2].copy_from_slice(&rdlen_1.to_be_bytes());
|
|
||||||
|
|
||||||
p.extend_from_slice(&(COMPRESSION_FLAG | cname1_start as u16).to_be_bytes());
|
|
||||||
p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]);
|
|
||||||
let rdlen_pos_2 = p.len();
|
|
||||||
p.extend_from_slice(&[0x00, 0x00]);
|
|
||||||
let cname2_start = p.len();
|
|
||||||
p.push(9);
|
|
||||||
p.extend_from_slice(b"adobe-aem");
|
|
||||||
let map_label_off = p.len();
|
|
||||||
p.push(3);
|
|
||||||
p.extend_from_slice(b"map");
|
|
||||||
let fastly_label_off = p.len();
|
|
||||||
p.push(6);
|
|
||||||
p.extend_from_slice(b"fastly");
|
|
||||||
p.push(3);
|
|
||||||
p.extend_from_slice(b"net");
|
|
||||||
p.push(0);
|
|
||||||
let rdlen_2 = (p.len() - cname2_start) as u16;
|
|
||||||
p[rdlen_pos_2..rdlen_pos_2 + 2].copy_from_slice(&rdlen_2.to_be_bytes());
|
|
||||||
|
|
||||||
p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes());
|
|
||||||
p.extend_from_slice(&[0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x07, 0x08]);
|
|
||||||
let rdlen_pos_soa = p.len();
|
|
||||||
p.extend_from_slice(&[0x00, 0x00]);
|
|
||||||
let soa_rdata_start = p.len();
|
|
||||||
p.extend_from_slice(&(COMPRESSION_FLAG | map_label_off as u16).to_be_bytes());
|
|
||||||
p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes());
|
|
||||||
p.extend_from_slice(&1u32.to_be_bytes());
|
|
||||||
p.extend_from_slice(&7200u32.to_be_bytes());
|
|
||||||
p.extend_from_slice(&3600u32.to_be_bytes());
|
|
||||||
p.extend_from_slice(&1209600u32.to_be_bytes());
|
|
||||||
p.extend_from_slice(&1800u32.to_be_bytes());
|
|
||||||
let rdlen_soa = (p.len() - soa_rdata_start) as u16;
|
|
||||||
p[rdlen_pos_soa..rdlen_pos_soa + 2].copy_from_slice(&rdlen_soa.to_be_bytes());
|
|
||||||
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_name(p: &mut Vec<u8>, labels: &[&str]) {
|
|
||||||
for l in labels {
|
|
||||||
p.push(l.len() as u8);
|
|
||||||
p.extend_from_slice(l.as_bytes());
|
|
||||||
}
|
|
||||||
p.push(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compressed_soa_survives_numa_round_trip() {
|
|
||||||
let upstream = upstream_packet();
|
|
||||||
|
|
||||||
let hickory_in = hickory_proto::op::Message::from_vec(&upstream)
|
|
||||||
.expect("hand-crafted upstream must be valid");
|
|
||||||
let soa_in_rd = hickory_in.name_servers()[0]
|
|
||||||
.data()
|
|
||||||
.clone()
|
|
||||||
.into_soa()
|
|
||||||
.expect("SOA rdata");
|
|
||||||
assert_eq!(soa_in_rd.mname().to_string(), "map.fastly.net.");
|
|
||||||
assert_eq!(soa_in_rd.rname().to_string(), "fastly.net.");
|
|
||||||
|
|
||||||
let mut in_buf = BytePacketBuffer::from_bytes(&upstream);
|
|
||||||
let pkt = DnsPacket::from_buffer(&mut in_buf).expect("numa parses upstream");
|
|
||||||
assert_eq!(pkt.answers.len(), 2);
|
|
||||||
assert_eq!(pkt.authorities.len(), 1);
|
|
||||||
|
|
||||||
let mut out_buf = BytePacketBuffer::new();
|
|
||||||
pkt.write(&mut out_buf).expect("numa writes");
|
|
||||||
let out = out_buf.filled().to_vec();
|
|
||||||
|
|
||||||
let hickory_out =
|
|
||||||
hickory_proto::op::Message::from_vec(&out).expect("numa re-emission must parse strictly");
|
|
||||||
|
|
||||||
let soa_out_rd = hickory_out.name_servers()[0]
|
|
||||||
.data()
|
|
||||||
.clone()
|
|
||||||
.into_soa()
|
|
||||||
.expect("SOA rdata on output");
|
|
||||||
|
|
||||||
assert_eq!(soa_out_rd.mname().to_string(), "map.fastly.net.");
|
|
||||||
assert_eq!(soa_out_rd.rname().to_string(), "fastly.net.");
|
|
||||||
assert_eq!(soa_out_rd.serial(), 1);
|
|
||||||
assert_eq!(soa_out_rd.refresh(), 7200);
|
|
||||||
assert_eq!(soa_out_rd.retry(), 3600);
|
|
||||||
assert_eq!(soa_out_rd.expire(), 1209600);
|
|
||||||
assert_eq!(soa_out_rd.minimum(), 1800);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user