From 640b64bf7e1be666d2eb645708924f0cc7f2c1b9 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:50:21 +0300 Subject: [PATCH 1/3] chore(site): live-reload dev server via chokidar + browser-sync Replaces the plain python3 http.server + one-shot make blog with a watcher pipeline: chokidar regenerates HTML on MD/template changes, browser-sync serves the site and reloads the browser on rendered-asset changes. First run downloads both via npx; subsequent runs are instant. Preflight checks for npx and pandoc. Port arg parsing is tolerant of legacy --drafts flag ordering (drafts are always included now, since that's what the dev loop actually wants). Cleanup trap kills the watcher on exit so re-runs don't leave orphans. --- scripts/serve-site.sh | 45 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/scripts/serve-site.sh b/scripts/serve-site.sh index 23854ff..18fc4a9 100755 --- a/scripts/serve-site.sh +++ b/scripts/serve-site.sh @@ -1,14 +1,41 @@ #!/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 -PORT="${1:-9000}" +PORT=9000 +for arg in "$@"; do + if [[ "$arg" =~ ^[0-9]+$ ]]; then + PORT="$arg" + break + fi +done -if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then - PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg - make blog-drafts -else - make blog -fi +command -v npx >/dev/null || { echo "npx not found. Install Node.js: https://nodejs.org" >&2; exit 1; } +command -v pandoc >/dev/null || { echo "pandoc not found (required by 'make blog-drafts')." >&2; exit 1; } -echo "Serving site at http://localhost:$PORT" -cd site && python3 -m http.server "$PORT" +# Initial render so the first page load has everything. +make blog-drafts + +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 -- 2.34.1 From 2e461ccc0f71098427fec0f21fdf153c9966d65f Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:49:39 +0300 Subject: [PATCH 2/3] docs(config): add ODoH upstream examples with relay_ip/target_ip pinning Complements the bootstrap resolver fix (#122, #126) by documenting the ODoH knobs in the commented config template. Explains relay_ip/target_ip as the way to prevent plain-DNS leaks of the relay/target hostnames via the bootstrap resolver on cold boot when numa is its own system DNS. --- numa.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/numa.toml b/numa.toml index c25654a..93418ea 100644 --- a/numa.toml +++ b/numa.toml @@ -22,6 +22,7 @@ api_port = 5380 # [upstream] # mode = "forward" # "forward" (default) — relay to upstream # # "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 = ["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) @@ -34,6 +35,22 @@ api_port = 5380 # # to the same upstream. Rescues packet loss (UDP), # # dispatch spikes (DoH), TLS stalls (DoT). # # Set to 0 to disable. Default: 10 + +# 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/proxy" +# 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 docs/implementation/ +# # bootstrap-resolver.md. # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) # "199.9.14.201", # b.root-servers.net (USC-ISI) -- 2.34.1 From 26b1cd5917a9909cc2e28b23311db7d7e89005cb Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 22 Apr 2026 15:50:13 +0300 Subject: [PATCH 3/3] feat(packaging): ODoH client Docker deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-container docker-compose recipe for running numa in ODoH client mode. Ships with a starter numa.toml pointing at odoh-relay.numa.rs paired with Cloudflare's ODoH target — two independent operators with distinct eTLD+1s, so the default passes numa's same-operator check. Exposes :53 UDP+TCP for LAN clients and :5380 for the dashboard + REST API. README covers prerequisites, deploy, verification, and the ODoH privacy boundary (relay sees IP, target sees query, neither sees both). Advertised alongside packaging/relay/ in the main README Docker section. --- README.md | 4 ++ packaging/client/README.md | 72 +++++++++++++++++++++++++++++ packaging/client/docker-compose.yml | 15 ++++++ packaging/client/numa.toml | 23 +++++++++ 4 files changed, 114 insertions(+) create mode 100644 packaging/client/README.md create mode 100644 packaging/client/docker-compose.yml create mode 100644 packaging/client/numa.toml diff --git a/README.md b/README.md index 905cd02..3632638 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,10 @@ docker run -d --name numa --network host \ 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 | | Pi-hole | AdGuard Home | Unbound | Numa | diff --git a/packaging/client/README.md b/packaging/client/README.md new file mode 100644 index 0000000..f6e76c0 --- /dev/null +++ b/packaging/client/README.md @@ -0,0 +1,72 @@ +# 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 integration doc][odoh] +for the full protocol and privacy trade-offs. + +[odoh]: ../../docs/implementation/odoh-integration.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 @ example.com +curl http://: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. diff --git a/packaging/client/docker-compose.yml b/packaging/client/docker-compose.yml new file mode 100644 index 0000000..361f5db --- /dev/null +++ b/packaging/client/docker-compose.yml @@ -0,0 +1,15 @@ +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: diff --git a/packaging/client/numa.toml b/packaging/client/numa.toml new file mode 100644 index 0000000..039d723 --- /dev/null +++ b/packaging/client/numa.toml @@ -0,0 +1,23 @@ +# 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 +# docs/implementation/odoh-integration.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. -- 2.34.1