4 Commits
Author SHA1 Message Date
mokhtar ba037c5958 website: plan for the nxdns.sh marketing and docs site
Gates / frontend (push) Successful in 1m8s
Gates / test (push) Successful in 2m5s
Gates / test-aarch64 (push) Successful in 7m1s
Gates / package (push) Successful in 4m3s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m28s
2026-08-16 00:17:58 +02:00
mokhtar 1e97c80f6b rename web/ to admin/, along with the web-named build and cli identifiers 2026-08-16 00:17:58 +02:00
mokhtar 5b3d1cd65c docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s
2026-08-15 16:27:36 +02:00
mokhtar 50b8fd5c61 changelog: unwrap hand-wrapped lines, release bodies render newlines as breaks
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m31s
Gates / package (push) Successful in 3m36s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 11m21s
Gates / test-aarch64 (push) Successful in 5m54s
2026-08-15 13:06:44 +02:00
173 changed files with 2958 additions and 12036 deletions
+22 -22
View File
@@ -119,39 +119,39 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
cache-dependency-path: admin/package-lock.json
- name: Install dependencies
working-directory: web
working-directory: admin
run: npm ci
- name: Check formatting
working-directory: web
working-directory: admin
run: npm run format:check
- name: Lint
working-directory: web
working-directory: admin
run: npm run lint
- name: Typecheck
working-directory: web
working-directory: admin
run: npm run typecheck
- name: Run tests
working-directory: web
working-directory: admin
run: npm test
- name: Build
working-directory: web
working-directory: admin
run: npm run build
# The licence inventory has to cover every package whose bytes ship, and
# the lockfile does not answer that question: it lists what could be
# reached, not what rollup kept. The bundle is what this reads. The logic
# lives in web/scripts/, unit-tested by `npm test`, so it runs on a laptop
# lives in admin/scripts/, unit-tested by `npm test`, so it runs on a laptop
# exactly as it runs here (milestone-14 deviation 24).
- name: Assert the packages bundled into web/dist are the recorded ones
working-directory: web
- name: Assert the packages bundled into admin/dist are the recorded ones
working-directory: admin
run: npm run assert-bundled
# The package and container jobs consume this bundle instead of building
@@ -164,16 +164,16 @@ jobs:
#
# A later move to v4 has to add `include-hidden-files: true` here.
# `npm run build` writes the freshness stamp to the hidden file
# web/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# admin/dist/.src-hash (milestone-15 ruling 5), and v4.4.0 and later drop
# dotfiles by default. It is inactive today — v3 keeps them, and the
# package job's target path skips the stamp check regardless — but a move
# to v4 that also pointed the download back at web/dist would fail with
# "web/dist is stale".
# to v4 that also pointed the download back at admin/dist would fail with
# "admin/dist is stale".
- name: Upload the built web UI
uses: actions/upload-artifact@c24449f33cd45d4826c6702db7e49f7cdb9b551d # v3.2.1-node20
with:
name: web-dist
path: web/dist
name: admin-dist
path: admin/dist
if-no-files-found: error
package:
@@ -201,22 +201,22 @@ jobs:
- name: Create the fetch temp dir zig assumes
run: mkdir -p "${ZIG_GLOBAL_CACHE_DIR:?}/tmp"
# `dist` refuses web/dist-placeholder (ruling 4), so a real bundle has to
# `dist` refuses admin/dist-placeholder (ruling 4), so a real bundle has to
# exist before the packaging gate runs. It arrives from the frontend job,
# already formatted, linted, typechecked, tested and licence-checked.
#
# The target is deliberately not `web/dist`: build.zig runs the freshness
# The target is deliberately not `admin/dist`: build.zig runs the freshness
# stamp check for that exact path and no other (milestone-15 ruling 5),
# and the check shells out to `node`. Here it would buy nothing — the
# stamp hashes the web/ sources, not the bundle, so against a checkout of
# stamp hashes the admin/ sources, not the bundle, so against a checkout of
# the same commit that built the bundle it can only agree. An explicit
# path is the case build.zig documents for a bundle built elsewhere, and
# taking it keeps node out of this job entirely.
- name: Download the web UI built by the frontend job
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
with:
name: web-dist
path: web-dist-ci
name: admin-dist
path: admin-dist-ci
# Ruling 2: build.zig.zon is the only place besides the tag that carries
# the version, and ruling 5 makes verify-dist assert the two agree. The
@@ -239,7 +239,7 @@ jobs:
zig build dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# verify-dist owns every assert the CI shell used to make: ELF static
@@ -255,7 +255,7 @@ jobs:
zig build verify-dist \
-Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dweb-dist=web-dist-ci \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
# deploy/docker/Dockerfile copies both of these trees and nothing else
+4 -4
View File
@@ -269,10 +269,10 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
cache-dependency-path: admin/package-lock.json
- name: Build the web UI
working-directory: web
working-directory: admin
run: |
npm ci
npm run build
@@ -283,7 +283,7 @@ jobs:
zig build dist
-Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist
-Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe
- name: Verify the release artifacts
@@ -291,7 +291,7 @@ jobs:
zig build verify-dist
-Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT"
-Dweb-dist=web/dist
-Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe
# Step 9. Extracted and validated before anything is pushed anywhere, so
+4 -4
View File
@@ -1,7 +1,7 @@
.zig-cache/
zig-out/
zig-pkg/
web/node_modules/
web/dist/
web/dist-sourcemap/
web-dist-ci/
admin/node_modules/
admin/dist/
admin/dist-sourcemap/
admin-dist-ci/
+19 -56
View File
@@ -2,79 +2,42 @@
## Aim
nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0.
Portfolio-grade public repo. PLAN.md is the source of truth for scope and design;
specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
nxdns: a self-hosted DNS sinkhole for a household LAN, written in Zig 0.16.0. Portfolio-grade public repo. PLAN.md is the source of truth for scope and design; specs/ holds per-milestone contracts; specs/research/ holds verified stdlib facts.
## Values
We intentionally architect this code to be robust, maintainable, pragmatic —
good craftsmanship and good engineering. We explicitly avoid tech debt, code
smells, bad architecture decisions, and brittle implementations.
We intentionally architect this code to be robust, maintainable, pragmatic — good craftsmanship and good engineering. We explicitly avoid tech debt, code smells, bad architecture decisions, and brittle implementations.
What that means in practice:
- This is a greenfield project. Breaking changes are allowed. Never keep a bad
interface for compatibility; fix it at the root.
- No versioning of scope. A feature is in scope (build it completely) or out of
scope (do not build it). No "v2 later", no stubs left behind.
- This is a greenfield project. Breaking changes are allowed. Never keep a bad interface for compatibility; fix it at the root.
- No versioning of scope. A feature is in scope (build it completely) or out of scope (do not build it). No "v2 later", no stubs left behind.
- Fix root causes, not symptoms. Do not iterate on workarounds.
- Scope is small on purpose: household scale, two targets, few dependencies.
Do not add generality nobody asked for.
- Dependencies are liabilities: stdlib first; vendored + pinned C deps
(sqlite3, mbedTLS) only where the stdlib has nothing.
- Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is
stale (std.Io migration). See specs/research/zig-0.16-api-notes.md.
- Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io,
no sockets, no clocks hidden inside.
- Every failure mode must be visible: no silent drops, no unbounded logs, no
swallowed errors. Counters + health surfaces over log spam.
- Tests are runnable acceptance criteria, not decoration. Required CI stays
deterministic — no network-dependent tests in blocking jobs.
- Comments state constraints the code cannot show. No narration, no
commented-out code.
- Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no
generated-by footers.
- Scope is small on purpose: household scale, two targets, few dependencies. Do not add generality nobody asked for.
- Dependencies are liabilities: stdlib first; vendored + pinned C deps (sqlite3, mbedTLS) only where the stdlib has nothing.
- Verify stdlib claims against ../zig at tag 0.16.0 — pre-0.16 knowledge is stale (std.Io migration). See specs/research/zig-0.16-api-notes.md.
- Pure core: dns/, filter/, local/, cache/ take bytes and return bytes — no Io, no sockets, no clocks hidden inside.
- Every failure mode must be visible: no silent drops, no unbounded logs, no swallowed errors. Counters + health surfaces over log spam.
- Tests are runnable acceptance criteria, not decoration. Required CI stays deterministic — no network-dependent tests in blocking jobs.
- Comments state constraints the code cannot show. No narration, no commented-out code.
- Git: GPG-signed commits (`git commit -S`), simple lowercase messages, no generated-by footers.
## Reading `zig build test` output
A fully passing `zig build test` still prints a line like `failed command:
.../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line
is a known upstream zig 0.16.0 labelling defect. It does not mean a test
failed, and no test binary crashed.
A fully passing `zig build test` still prints a line like `failed command: .../test --cache-dir=... --seed=... --listen=-`, and still exits 0. That line is a known upstream zig 0.16.0 labelling defect. It does not mean a test failed, and no test binary crashed.
The build runner sets a step's `result_failed_command` on every spawn
(`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints
a step's diagnostics whenever the step wrote anything to stderr, explicitly "no
matter the result" (`compiler/build_runner.zig:1381`), and that printer emits
the `failed command: ` label unconditionally when the field is set
(`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run,
because the tests that cover the warning paths log through the real sink. A
minimal reproducer with no mbedTLS and no C — one passing test whose body is a
`std.debug.print` — prints the same label and reports "3/3 steps succeeded;
1/1 tests passed"; deleting the print removes the label. No upstream issue
matched a search, so the reference is the 0.16.0 source lines above.
The build runner sets a step's `result_failed_command` on every spawn (`std/Build/Step/Run.zig:1540`) and never clears it on success. It then prints a step's diagnostics whenever the step wrote anything to stderr, explicitly "no matter the result" (`compiler/build_runner.zig:1381`), and that printer emits the `failed command: ` label unconditionally when the field is set (`compiler/build_runner.zig:1515`). Our suite writes to stderr on every run, because the tests that cover the warning paths log through the real sink. A minimal reproducer with no mbedTLS and no C — one passing test whose body is a `std.debug.print` — prints the same label and reports "3/3 steps succeeded; 1/1 tests passed"; deleting the print removes the label. No upstream issue matched a search, so the reference is the 0.16.0 source lines above.
Any *other* failure text is real. Trust the summary line: `zig build test`
exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine
failure. Do not filter, wrap, or suppress the runner's output to hide the
label — that would hide real failures with it.
Any *other* failure text is real. Trust the summary line: `zig build test` exiting non-zero, a `N failed` count, or a panic backtrace all mean a genuine failure. Do not filter, wrap, or suppress the runner's output to hide the label — that would hide real failures with it.
One trap: running a cached test binary by hand with `--listen=-` aborts with
`internal test runner failure: EndOfStream`. That is not a teardown bug; the
IPC runner is talking to a closed stdin because no build runner is on the other
end. Run the binary with no arguments to get the plain stdio report.
One trap: running a cached test binary by hand with `--listen=-` aborts with `internal test runner failure: EndOfStream`. That is not a teardown bug; the IPC runner is talking to a closed stdin because no build runner is on the other end. Run the binary with no arguments to get the plain stdio report.
## Regenerating the contract samples
`web/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized
API responses, byte-compared against the live server by a `-Dintegration`
test and type-checked by `tsc`. After a deliberate API contract change,
regenerate it with:
`admin/src/lib/contractSamples.gen.ts` is a committed golden of canonicalized API responses, byte-compared against the live server by a `-Dintegration` test and type-checked by `tsc`. After a deliberate API contract change, regenerate it with:
```
zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
```
then update `web/src/lib/types.ts` to match and commit both. Never edit the
generated file by hand.
then update `admin/src/lib/types.ts` to match and commit both. Never edit the generated file by hand.
+35 -156
View File
@@ -1,146 +1,54 @@
# Changelog
All notable changes to nxdns are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All notable changes to nxdns are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Sections are written by hand. Nothing here is generated from commit messages:
the point of the file is to say what changed for an operator, which a commit
subject rarely does.
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
## [0.0.3] - 2026-08-15
Devices name themselves: the clients table asks the router over reverse DNS
instead of waiting for the operator to type every name. The CI container gate
also moved from workflow shell into a compiled, tested tool, which fixed a
latent temp-directory bug shared with the release tool.
Devices name themselves: the clients table asks the router over reverse DNS instead of waiting for the operator to type every name. The CI container gate also moved from workflow shell into a compiled, tested tool, which fixed a latent temp-directory bug shared with the release tool.
### Added
- **Client names learned over reverse DNS.** A client row that carries no
hand-typed name gets one from the network: each tracker flush pass takes up
to 16 unnamed rows, builds each address's reverse name, matches it against
the declared `forward_zones`, and on a match sends one PTR query to that
zone's resolver, storing the answer as a *learned* name. This requires a
conditional forward zone covering the LAN's reverse space — for example
`168.192.in-addr.arpa` pointed at the router; without one, nothing is sent
anywhere. A hand-typed name always wins, learned names never appear in
`nxdns export` and are never set by `nxdns import`, and each row refreshes
once a day (an hour after a failure), so a rename can show stale for up to
24 hours. The API's `Client` object gains a `learned_name` field and the
clients page shows it.
- **Client names learned over reverse DNS.** A client row that carries no hand-typed name gets one from the network: each tracker flush pass takes up to 16 unnamed rows, builds each address's reverse name, matches it against the declared `forward_zones`, and on a match sends one PTR query to that zone's resolver, storing the answer as a *learned* name. This requires a conditional forward zone covering the LAN's reverse space — for example `168.192.in-addr.arpa` pointed at the router; without one, nothing is sent anywhere. A hand-typed name always wins, learned names never appear in `nxdns export` and are never set by `nxdns import`, and each row refreshes once a day (an hour after a failure), so a rename can show stale for up to 24 hours. The API's `Client` object gains a `learned_name` field and the clients page shows it.
### Changed
- The container CI gate — image build, image-contents assertion against the
packaged artifacts, and the startup/shutdown smoke test — moved from
workflow shell into `tools/container_check.zig`, compiled and unit-tested by
`zig build test` and runnable on a laptop against a local docker daemon.
The health probe now runs under a real 60-second deadline (the shell loop's
"30 seconds" could stretch past three minutes), and the gate's docker
objects carry an ownership label so anything a dead runner leaks is
discoverable. The version in CI is parsed from `build.zig.zon` through the
zon grammar, once, instead of by two copies of a `sed` regex.
- The container CI gate — image build, image-contents assertion against the packaged artifacts, and the startup/shutdown smoke test — moved from workflow shell into `tools/container_check.zig`, compiled and unit-tested by `zig build test` and runnable on a laptop against a local docker daemon. The health probe now runs under a real 60-second deadline (the shell loop's "30 seconds" could stretch past three minutes), and the gate's docker objects carry an ownership label so anything a dead runner leaks is discoverable. The version in CI is parsed from `build.zig.zon` through the zon grammar, once, instead of by two copies of a `sed` regex.
### Fixed
- The release tool's temporary-directory claim was not exclusive: the
"create" it relied on succeeds on a directory that already exists, so a
stale or concurrent directory could be silently adopted, written into, and
deleted on exit. Both the release tool and the new container gate now claim
their directories exclusively and retry on collision.
- The release tool's temporary-directory claim was not exclusive: the "create" it relied on succeeds on a directory that already exists, so a stale or concurrent directory could be silently adopted, written into, and deleted on exit. Both the release tool and the new container gate now claim their directories exclusively and retry on collision.
## [0.0.2] - 2026-08-14
Configuration can now be a file that every boot converges to, filtering gains
regex rules and honors blocklist exception lines, and two refresh bugs that
silently kept stale state are fixed. Note the three breaking changes below if
you script against `nxdns import` or run with `--config`.
Configuration can now be a file that every boot converges to, filtering gains regex rules and honors blocklist exception lines, and two refresh bugs that silently kept stale state are fixed. Note the three breaking changes below if you script against `nxdns import` or run with `--config`.
### Added
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the
file the sole source of configuration: every boot converges the database to
it in one transaction, preserving blocklist downloads, compiled lists and
client history, so an unchanged file costs zero downloads and zero writes.
Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as
before. In file mode the web UI is read-only for configuration and says so;
runtime actions (pause, blocklist refresh, certificate reload) stay live.
`GET /api/settings` reports which authority governs the process.
- `nxdns import` now refuses a file whose application would delete
configuration rows, names the tables and counts, and applies it only with
the new `--allow-delete` flag. Additive and edit-in-place imports need no
flag.
- **Regex rules.** Rules gain a third kind, `regex`, beside `exact` and
`wildcard`, for per-group allow and block patterns such as `^ad[0-9]+-`. The
engine is homegrown and linear-time by construction, so no pattern can make
matching blow up; backreferences and lookaround do not exist, and a bad
pattern is refused at insert time with the limit it hit. Matches appear in
`/api/lookup` and the query log as `rule_allow_regex` / `rule_block_regex`.
Regex still comes only from you: regex lines in downloaded lists stay
counted and skipped.
- **Blocklist exception lines are honored.** An Adblock-Plus `@@||name^` line
in a downloaded list now lifts that name — and its subdomains — out of what
the attached lists block. Exceptions sit below every rule you wrote: a
downloaded list can reopen only a hole another downloaded list dug, never
override an operator decision. Each source reports how many it carried.
- **Browser-only lines are counted where you can see them.** Every source now
reports how many of its lines nxdns skipped as syntax with no DNS meaning —
cosmetic filters, `$`-modifier rules — beside the existing skipped-regex
count. Both blocklist tables show the number and the UI explains the
difference: a list whose skipped-unsupported count dwarfs its domain count
is written for browser extensions, and its DNS or hosts variant will block
more. Previously such a list compiled to almost nothing and looked clean.
- **Declarative configuration for IaC.** `nxdns run --config=<file>` makes the file the sole source of configuration: every boot converges the database to it in one transaction, preserving blocklist downloads, compiled lists and client history, so an unchanged file costs zero downloads and zero writes. Bare `nxdns run` keeps the database (and the web UI) in charge, exactly as before. In file mode the web UI is read-only for configuration and says so; runtime actions (pause, blocklist refresh, certificate reload) stay live. `GET /api/settings` reports which authority governs the process.
- `nxdns import` now refuses a file whose application would delete configuration rows, names the tables and counts, and applies it only with the new `--allow-delete` flag. Additive and edit-in-place imports need no flag.
- **Regex rules.** Rules gain a third kind, `regex`, beside `exact` and `wildcard`, for per-group allow and block patterns such as `^ad[0-9]+-`. The engine is homegrown and linear-time by construction, so no pattern can make matching blow up; backreferences and lookaround do not exist, and a bad pattern is refused at insert time with the limit it hit. Matches appear in `/api/lookup` and the query log as `rule_allow_regex` / `rule_block_regex`. Regex still comes only from you: regex lines in downloaded lists stay counted and skipped.
- **Blocklist exception lines are honored.** An Adblock-Plus `@@||name^` line in a downloaded list now lifts that name — and its subdomains — out of what the attached lists block. Exceptions sit below every rule you wrote: a downloaded list can reopen only a hole another downloaded list dug, never override an operator decision. Each source reports how many it carried.
- **Browser-only lines are counted where you can see them.** Every source now reports how many of its lines nxdns skipped as syntax with no DNS meaning — cosmetic filters, `$`-modifier rules — beside the existing skipped-regex count. Both blocklist tables show the number and the UI explains the difference: a list whose skipped-unsupported count dwarfs its domain count is written for browser extensions, and its DNS or hosts variant will block more. Previously such a list compiled to almost nothing and looked clean.
### Changed
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed
the database once and then ignore the file; it now makes the file the
authority on every boot, which deletes any configuration the file does not
declare — including edits made through the web UI since the seed. Before
upgrading a unit that carries `--config`: either drop the flag to keep the
database in charge, or adopt file mode with the sequence in the upgrade
guide. Order matters there: export the file with the NEW binary (stopped).
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1
`nxdns export` writes both `.password = ""` and the stored
`.password_hash`, and this version refuses a file that carries both. This
bites any old export — an adoption file or a configuration backup fed to
`nxdns import` alike. Fix an existing export by deleting its
`.password = ""` line (keep the `.password_hash` line). Take fresh backups
with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting
`.password = "new"` together with `.password_hash = ""` is now refused
(empty `password_hash` is an explicit "disable authentication", and the two
fields cannot both be present). To change the password in the file: set
`.password` and delete the `.password_hash` line entirely.
- **Breaking: `nxdns run --config <file>` changed meaning.** It used to seed the database once and then ignore the file; it now makes the file the authority on every boot, which deletes any configuration the file does not declare — including edits made through the web UI since the seed. Before upgrading a unit that carries `--config`: either drop the flag to keep the database in charge, or adopt file mode with the sequence in the upgrade guide. Order matters there: export the file with the NEW binary (stopped).
- **Breaking: 0.0.1 exports are refused by this version.** A 0.0.1 `nxdns export` writes both `.password = ""` and the stored `.password_hash`, and this version refuses a file that carries both. This bites any old export — an adoption file or a configuration backup fed to `nxdns import` alike. Fix an existing export by deleting its `.password = ""` line (keep the `.password_hash` line). Take fresh backups with the new binary.
- **Breaking: the offline password-change recipe changed.** Setting `.password = "new"` together with `.password_hash = ""` is now refused (empty `password_hash` is an explicit "disable authentication", and the two fields cannot both be present). To change the password in the file: set `.password` and delete the `.password_hash` line entirely.
- `nxdns import --force` is renamed `--allow-delete`.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence.
Use `nxdns import` once, or run in file mode with `--config`.
- The admin UI's internals moved to TypeScript 7 and replaced Tailwind with
StyleX and React Aria. The visible change is small: selects are real
widgets with working keyboard focus; everything else renders as before.
- The `config.db` schema is a single baseline definition again; numbered
migration steps start accumulating at v0.1.
- A fresh install no longer seeds from `/etc/nxdns/config.zon` by presence. Use `nxdns import` once, or run in file mode with `--config`.
- The admin UI's internals moved to TypeScript 7 and replaced Tailwind with StyleX and React Aria. The visible change is small: selects are real widgets with working keyboard focus; everything else renders as before.
- The `config.db` schema is a single baseline definition again; numbered migration steps start accumulating at v0.1.
### Fixed
- **A list switching a name between its exact and wildcard forms never took
effect.** The compiled-list checksum hashed the exact and wildcard bodies as
one unseparated byte stream, so a list carrying `a.example` and the same
list carrying `*.a.example` produced the same digest, and the refresh kept
the old compiled files. The checksum now separates the bodies. Every source
recompiles once on its first refresh after the upgrade; no re-download of
unchanged content is forced beyond the refresh's normal fetch.
- **A refresh could store stale skip counts.** When a refresh found the list
content unchanged, it wrote the previously stored skip counters back to the
database while showing the fresh ones in the UI, and the next restart
reverted the numbers to the stale copy. All counters now persist from the
fresh compile.
- An Adblock-Plus entry with embedded whitespace
(`||good.example bad.example^`) compiled into an entry no query could ever
match. Such lines are now counted as unsupported instead.
- **A list switching a name between its exact and wildcard forms never took effect.** The compiled-list checksum hashed the exact and wildcard bodies as one unseparated byte stream, so a list carrying `a.example` and the same list carrying `*.a.example` produced the same digest, and the refresh kept the old compiled files. The checksum now separates the bodies. Every source recompiles once on its first refresh after the upgrade; no re-download of unchanged content is forced beyond the refresh's normal fetch.
- **A refresh could store stale skip counts.** When a refresh found the list content unchanged, it wrote the previously stored skip counters back to the database while showing the fresh ones in the UI, and the next restart reverted the numbers to the stale copy. All counters now persist from the fresh compile.
- An Adblock-Plus entry with embedded whitespace (`||good.example bad.example^`) compiled into an entry no query could ever match. Such lines are now counted as unsupported instead.
## [0.0.1] - 2026-08-09
@@ -148,47 +56,18 @@ First release. Everything below is new.
### Added
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and
encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client
rate limiting and a `pause` control that stops filtering without stopping
resolution.
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool
that tracks per-upstream health and fails over, with SNI and certificate
verification driven by a per-upstream TLS name.
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a
certificate store that reloads on disk changes and through the API, so renewals
do not need a restart.
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and
Adblock-Plus-style formats, compiled into a compact matcher; per-group allow
and block rules with wildcards; safe-search enforcement.
- **Per-client policy groups.** Clients are identified by address and assigned to
groups, so the filtering a device gets depends on which device it is.
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of
internal zones to another resolver.
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires
entries rather than serving them stale.
- **Query log.** Queries land in SQLite under a retention policy in both rows and
days, with disk-full self-protection that degrades instead of corrupting, and a
live SSE stream of the same events.
- **Web UI and REST API.** A React single-page admin UI embedded in the binary,
a REST API with a served OpenAPI document, session authentication, API rate
limiting and Prometheus-style `/metrics`.
- **Configuration.** A ZON configuration file seeds the database on first boot;
after that the database is the truth, and `nxdns export` / `nxdns import` move
configuration in and out. `nxdns check` validates a file without starting.
- **Forwarding DNS server.** UDP and TCP listeners with a wire-format parser and encoder written against RFC 1035 and EDNS(0), a bounded worker model, per-client rate limiting and a `pause` control that stops filtering without stopping resolution.
- **Encrypted upstreams.** DNS-over-HTTPS and DNS-over-TLS clients over a pool that tracks per-upstream health and fails over, with SNI and certificate verification driven by a per-upstream TLS name.
- **DoH and DoT endpoints.** nxdns also answers as an encrypted resolver, with a certificate store that reloads on disk changes and through the API, so renewals do not need a restart.
- **Blocklist filtering.** Subscriptions in hosts, plain-domain and Adblock-Plus-style formats, compiled into a compact matcher; per-group allow and block rules with wildcards; safe-search enforcement.
- **Per-client policy groups.** Clients are identified by address and assigned to groups, so the filtering a device gets depends on which device it is.
- **Local DNS.** Local A/AAAA/CNAME/PTR records and conditional forwarding of internal zones to another resolver.
- **Cache.** A bounded in-memory cache that respects upstream TTLs and expires entries rather than serving them stale.
- **Query log.** Queries land in SQLite under a retention policy in both rows and days, with disk-full self-protection that degrades instead of corrupting, and a live SSE stream of the same events.
- **Web UI and REST API.** A React single-page admin UI embedded in the binary, a REST API with a served OpenAPI document, session authentication, API rate limiting and Prometheus-style `/metrics`.
- **Configuration.** A ZON configuration file seeds the database on first boot; after that the database is the truth, and `nxdns export` / `nxdns import` move configuration in and out. `nxdns check` validates a file without starting.
- **CLI.** `run`, `check`, `export`, `import`, `version` and `help`.
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a
`FROM scratch` container image holding the binary, a CA bundle and the licence
files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing
from Alpine ships in the published image except that CA bundle.
- **Releases.** Tags publish five assets — static musl tarballs for
`x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` over those
three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file.
`zig build dist` and `zig build verify-dist` produce and check the same
artifacts on a laptop.
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and
image assembled from a reviewed inventory of what the artifacts contain.
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation —
with drift guards that fail the build when the reference pages fall behind the
code.
- **Packaging.** A hardened systemd unit with a sysusers fragment, and a `FROM scratch` container image holding the binary, a CA bundle and the licence files, assembled by a builder stage pinned to `alpine:3.22` by digest. Nothing from Alpine ships in the published image except that CA bundle.
- **Releases.** Tags publish five assets — static musl tarballs for `x86_64-linux-musl` and `aarch64-linux-musl`, `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` over those three, and `SHA256SUMS.txt.asc`, a detached signature over the checksum file. `zig build dist` and `zig build verify-dist` produce and check the same artifacts on a laptop.
- **Licensing.** EUPL-1.2, with a `THIRD-PARTY-NOTICES` file in every tarball and image assembled from a reviewed inventory of what the artifacts contain.
- **Documentation.** A Diátaxis split — tutorial, how-to, reference, explanation — with drift guards that fail the build when the reference pages fall behind the code.
+11 -35
View File
@@ -11,12 +11,9 @@ This directory is an nxdns release for one architecture. It holds:
| `THIRD-PARTY-NOTICES` | Licences of everything compiled or bundled in |
| `INSTALL.md` | This file |
The binary is statically linked against musl and needs nothing installed on the
target host.
The binary is statically linked against musl and needs nothing installed on the target host.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in
the repository covers where the public key comes from, what fingerprint to
expect, and what the signature does and does not prove.
Verify the download before you trust it. `docs/how-to/verify-a-release.md` in the repository covers where the public key comes from, what fingerprint to expect, and what the signature does and does not prove.
## 1. Install the binary, the user and the unit
@@ -34,12 +31,9 @@ systemctl daemon-reload
mkdir -p -m 0755 /etc/nxdns
```
`nxdns.conf` ships under the name it is installed as, so there is no rename to
get wrong.
`nxdns.conf` ships under the name it is installed as, so there is no rename to get wrong.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's
`StateDirectory` and `LogsDirectory` settings make systemd create them on first
start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
Do not create `/var/lib/nxdns` or `/var/log/nxdns` by hand. The unit's `StateDirectory` and `LogsDirectory` settings make systemd create them on first start, `/var/lib/nxdns` at mode 0700 owned by `nxdns`.
## 2. Write the configuration
@@ -53,20 +47,14 @@ nxdns will not start with nothing to forward to. Write `/etc/nxdns/config.zon`:
}
```
That file holds a password in plain text. Root's umask is 022 on most
distributions, so restrict it as soon as you have written it:
That file holds a password in plain text. Root's umask is 022 on most distributions, so restrict it as soon as you have written it:
```sh
chown root:nxdns /etc/nxdns/config.zon
chmod 0640 /etc/nxdns/config.zon
```
0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and
systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good.
Under `run --config` the service reads this file on **every** start, not once,
so tightening the mode after the first boot breaks the next restart. Under
database authority it is `nxdns import` that reads the file, as whoever runs
that command, and a bare `nxdns run` never reads it at all.
0640 with group `nxdns` rather than 0600: the service runs as `nxdns`, and systemd leaves `/etc/nxdns` owned by root. Keep that group read bit for good. Under `run --config` the service reads this file on **every** start, not once, so tightening the mode after the first boot breaks the next restart. Under database authority it is `nxdns import` that reads the file, as whoever runs that command, and a bare `nxdns run` never reads it at all.
Check it before starting the service:
@@ -74,9 +62,7 @@ Check it before starting the service:
nxdns check --config /etc/nxdns/config.zon
```
A good file ends with `OK: no problems found`. Exit 2 means `check` found
something to fix and printed every problem it found. The upstream probe sends a
real query, so this needs working DNS on the host.
A good file ends with `OK: no problems found`. Exit 2 means `check` found something to fix and printed every problem it found. The upstream probe sends a real query, so this needs working DNS on the host.
Load it into the database:
@@ -84,10 +70,7 @@ Load it into the database:
nxdns import /etc/nxdns/config.zon
```
The packaged unit runs `nxdns run` with no `--config`, so from here the database
is the configuration and nothing reads the file again. `web.password` is hashed
and the plaintext is never stored, so once you have logged in you can delete the
file:
The packaged unit runs `nxdns run` with no `--config`, so from here the database is the configuration and nothing reads the file again. `web.password` is hashed and the plaintext is never stored, so once you have logged in you can delete the file:
```sh
rm /etc/nxdns/config.zon
@@ -95,10 +78,7 @@ rm /etc/nxdns/config.zon
A kept file is not a backup. `nxdns export` is.
To keep the file as the configuration instead — converged at every start, with
the UI refusing configuration edits — do not delete it, and add a drop-in that
appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See
`docs/how-to/install-with-systemd.md`.
To keep the file as the configuration instead — converged at every start, with the UI refusing configuration edits — do not delete it, and add a drop-in that appends `--config=/etc/nxdns/config.zon` to `ExecStart`. See `docs/how-to/install-with-systemd.md`.
## 3. Start it
@@ -107,9 +87,7 @@ systemctl enable --now nxdns
journalctl -u nxdns -f
```
A healthy start logs a line naming every socket it bound. Port 53 is
privileged, and the unit grants `CAP_NET_BIND_SERVICE` through
`AmbientCapabilities`.
A healthy start logs a line naming every socket it bound. Port 53 is privileged, and the unit grants `CAP_NET_BIND_SERVICE` through `AmbientCapabilities`.
## 4. Confirm it answers
@@ -119,9 +97,7 @@ From another machine on the LAN:
dig @<server-ip> example.com A +short
```
The admin interface is on port 8080 by default; log in with the password from
the configuration file. `http://<server-ip>:8080/api/health` reports upstream
availability and disk state without a login.
The admin interface is on port 8080 by default; log in with the password from the configuration file. `http://<server-ip>:8080/api/health` reports upstream availability and disk state without a login.
## More
+22 -64
View File
@@ -1,7 +1,6 @@
# nxdns — Implementation Plan v3.0 (Zig 0.16.0 Stable)
Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable.
All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
Source of truth for **nxdns**, a self-hosted DNS sinkhole written in Zig 0.16.0 stable. All stdlib claims in this document are verified against the `0.16.0` tag of the Zig repo (`../zig`).
There is no v1/v2 versioning. Scope is binary: a feature is in scope (and gets built) or out of scope (and does not). "Done" = everything in scope implemented, tested, documented.
@@ -121,16 +120,9 @@ Rule kinds: `exact`, parent-walk (implicit via candidate chain), `wildcard` (`*`
Tie-break at same specificity: **allow wins**.
Each level is checked against the whole candidate chain before the next level is
checked against any, which is what makes an allow rule on a parent beat a block
rule on the child.
Each level is checked against the whole candidate chain before the next level is checked against any, which is what makes an allow rule on a parent beat a block rule on the child.
Two positions carry an argument rather than a preference. The regex levels come
last among the operator rules because they are the only ones that are not a set
lookup or a label walk: a regex runs only once every cheaper level has missed.
Blocklist exceptions come below **every** operator level because a downloaded
list may cancel what another list blocked and must never cancel what the
operator decided — no list can open an allow hole the operator did not open.
Two positions carry an argument rather than a preference. The regex levels come last among the operator rules because they are the only ones that are not a set lookup or a label walk: a regex runs only once every cheaper level has missed. Blocklist exceptions come below **every** operator level because a downloaded list may cancel what another list blocked and must never cancel what the operator decided — no list can open an allow hole the operator did not open.
### 3.11 Network Posture
@@ -250,7 +242,7 @@ src/
rules.zig local.zig lookup.zig pause.zig settings.zig
upstream_health.zig certs.zig health.zig version.zig
web/ # Vite + React + TS + StyleX + React Aria + TanStack
admin/ # Vite + React + TS + StyleX + React Aria + TanStack
vendor/ # sqlite3 amalgamation, mbedtls (pinned)
docs/ # tutorial/ how-to/ reference/ explanation/ (Diátaxis)
tests/ # dns/ integration/ fuzz/
@@ -290,8 +282,7 @@ Walk chain to depth 8; any target hitting block logic → synthesize blocked res
### 7.1 Evaluation
For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not
for matching):
For `{domain, group_id}` (the qtype travels with the query for logging and response synthesis, not for matching):
1. Normalize: lowercase, trim trailing dot.
2. Build candidate chain (full, parent1, parent2, …).
3. Explicit rules per §3.10 precedence, allow before block at each level: exact rules against every candidate in the chain, then wildcard patterns and then regex patterns against the whole name (both kinds express their own reach, so neither walks the chain).
@@ -300,10 +291,7 @@ for matching):
6. Group's blocklist wildcards, matched against every proper parent of the query name.
7. No match → allow.
Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard
entries match every proper parent, and exception entries walk the candidate chain the way rules do
(§3.9), so `@@||good.ads.example^` also lifts `y.good.ads.example`. ABP `||x.y^` emits both a domain
entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
Blocklist *domain* entries do not parent-walk: they are matched against the query name alone. Wildcard entries match every proper parent, and exception entries walk the candidate chain the way rules do (§3.9), so `@@||good.ads.example^` also lifts `y.good.ads.example`. ABP `||x.y^` emits both a domain entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomains semantics.
### 7.2 Group Assignment
@@ -314,12 +302,7 @@ entry `x.y` and a wildcard entry `x.y`, which together give domain-and-subdomain
### 7.3 Reload
New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a
generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer
takes the exclusive lock only for the swap, and the source status table is installed in the same
critical section, so a failed reload publishes neither. (Deliberate deviation from "readers
lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at
household scale — see specs/milestone-5.md S8.3.)
New immutable matcher built from DB + compiled list files → swap under an `std.Io.RwLock` with a generation counter. Readers take the shared lock for the microseconds of one evaluate; the writer takes the exclusive lock only for the swap, and the source status table is installed in the same critical section, so a failed reload publishes neither. (Deliberate deviation from "readers lock-free": freeing the old snapshot without a lock needs epoch-based reclamation, unjustifiable at household scale — see specs/milestone-5.md S8.3.)
### 7.4 Safe-Search
@@ -365,14 +348,9 @@ Per-group boolean. Rewrites known engine domains to their safe-search CNAME targ
### 11.2 config.db Schema (v1 baseline)
The DDL below is the live schema, kept byte-identical to
`src/storage/config_schema.zig`. `src/storage/migrations.zig` carries it as its
one and only step, so a database is at **version 1** or it does not exist.
The DDL below is the live schema, kept byte-identical to `src/storage/config_schema.zig`. `src/storage/migrations.zig` carries it as its one and only step, so a database is at **version 1** or it does not exist.
Until nxdns reaches v0.1 this baseline is **editable**: a schema change edits
this section and `config_schema.zig` together and adds no migration step. nxdns
has no installs, so there is no database for a step to reconcile. At v0.1 the
baseline freezes and every later change becomes an append-only step.
Until nxdns reaches v0.1 this baseline is **editable**: a schema change edits this section and `config_schema.zig` together and adds no migration step. nxdns has no installs, so there is no database for a step to reconcile. At v0.1 the baseline freezes and every later change becomes an append-only step.
```sql
CREATE TABLE schema_version (version INTEGER NOT NULL);
@@ -509,14 +487,7 @@ Periodic delete of rows older than `retention_days`; scheduled checkpoint/VACUUM
### 12.1 Config ZON Shape
The canonical shape is not duplicated here. It lives in
[docs/reference/configuration.md](docs/reference/configuration.md), which is
handwritten against `config/model.zig` and only partly guarded (the drift test
covers settings-key rows, not the whole shape, so a new collection can go
undocumented while the guard stays green), and `nxdns export` emits it. A copy in
this document is how §12.1 came to describe an `.upstream.servers` field that
never existed and to omit the required `.groups` and `.upstreams` — a sample
nobody could load. The skeleton, for orientation only:
The canonical shape is not duplicated here. It lives in [docs/reference/configuration.md](docs/reference/configuration.md), which is handwritten against `config/model.zig` and only partly guarded (the drift test covers settings-key rows, not the whole shape, so a new collection can go undocumented while the guard stays green), and `nxdns export` emits it. A copy in this document is how §12.1 came to describe an `.upstream.servers` field that never existed and to omit the required `.groups` and `.upstreams` — a sample nobody could load. The skeleton, for orientation only:
```zon
.{
@@ -584,48 +555,37 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
## 16. Implementation Order
### Phase 0 — Build Baseline
Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build).
Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
Scaffold tree; `build.zig` with 0.16 assertion, musl targets, vendored sqlite3 + mbedtls compiling; version plumbing; Gitea Actions workflows (test, integration, fuzz smoke, OpenAPI lint, frontend build). Exit: cross-compiled hello-world linking both C deps on both targets; CI green.
### Phase 1 — Platform Layer
`platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`).
Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
`platform/address.zig` (v4/v6 parity + canonical keys); `platform/tls_client.zig` (deadlines, error classes); `platform/tls_server.zig` (mbedTLS handshake → `std.Io.Reader`/`Writer`). Exit: UDP echo over `std.Io`; TLS client handshake against a real host; mbedTLS server terminating a loopback TLS connection.
### Phase 2 — DNS Core
Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough.
Exit: unit + fuzz smoke pass.
Types/header/name/question/record/packet; parser + encoder tests + fuzz target; EDNS + DO passthrough. Exit: unit + fuzz smoke pass.
### Phase 3 — Resolver Transport
UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health.
Exit: A/AAAA forwarding over UDP + TCP; health populated.
UDP server, TCP server, DoH + DoT upstream clients, pool + failover/backoff + health. Exit: A/AAAA forwarding over UDP + TCP; health populated.
### Phase 4 — Storage + Config
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`.
Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
SQLite wrapper; config.db schema + migration runner; querylog.db schema + recreate-on-mismatch; repositories; ZON loading + import/export; `nxdns check`. Exit: export → import round-trips byte-stable. (The ZON bootstrap this phase shipped was replaced in m20 by the two authority modes above.)
### Phase 5 — Filtering + Local DNS
Rule matcher (exact/parent/wildcard; `regex` added in m21); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones.
Exit: precedence table validated by tests; local zone answers + conditional forwards work.
Rule matcher (exact/parent/wildcard; `regex` added in m21); blocklist parsers → compiled file format; fetcher + scheduled update; RCU swap; per-group safe-search; local records; forward zones. Exit: precedence table validated by tests; local zone answers + conditional forwards work.
### Phase 6 — Cache + Rate Limit + Logging + Disk Monitor
TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup.
Exit: disk thresholds trigger degradation + drop counters in integration test.
TTL cache + reconstruction; v4/v6 rate limiter; async query logger + backpressure + retention; DiskMonitor + rotation + error-log dedup. Exit: disk thresholds trigger degradation + drop counters in integration test.
### Phase 7 — Handler Integration
Full pipeline composition; CNAME uncloaking; pause/resume.
Exit: end-to-end DNS flow with blocking, local records, cache, failover.
Full pipeline composition; CNAME uncloaking; pause/resume. Exit: end-to-end DNS flow with blocking, local records, cache, failover.
### Phase 8 — Web / API / SSE / Auth / Metrics
HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving.
Exit: frontend fully drives config and operations; contract tests green.
HTTP server + router; handlers; SSE; optional auth; API rate limiting; `/metrics`; OpenAPI served + contract tests; embedded frontend + dev-mode disk serving. Exit: frontend fully drives config and operations; contract tests green.
### Phase 9 — Local DoH/DoT Endpoints
DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload.
Exit: LAN client resolves via DoH and DoT against local certs.
DoH server + DoT server on `platform/tls_server.zig`; cert watcher + reload. Exit: LAN client resolves via DoH and DoT against local certs.
### Phase 10 — Packaging + Ops + Docs
systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs.
Exit: documented deployment works end-to-end on the Pi 5.
systemd unit (`AmbientCapabilities=CAP_NET_BIND_SERVICE`, hardened, writable `/var/lib/nxdns` + optional `/var/log/nxdns`); Dockerfile + compose (53/udp+tcp, 8080; mounts `/etc/nxdns`, `/var/lib/nxdns`); operator/architecture/config-reference/API docs. Exit: documented deployment works end-to-end on the Pi 5.
---
@@ -660,9 +620,7 @@ Exit: documented deployment works end-to-end on the Pi 5.
## 20. Publication
The project publishes released binaries and container images from its own Gitea
instance. Building from source stays fully supported and documented; it is no
longer the only path.
The project publishes released binaries and container images from its own Gitea instance. Building from source stays fully supported and documented; it is no longer the only path.
- **Trigger.** Pushing an annotated, GPG-signed tag `vX.Y.Z` to `git.mial.net/mokhtar/nxdns`. Nothing else publishes. Pre-release tags are rejected.
- **Version.** The tag is authoritative. `build.zig.zon`'s `.version` must equal the tag, and the packaging gate asserts it. Nowhere else stores a version.
+31 -89
View File
@@ -1,7 +1,6 @@
# nxdns
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and
forwards the rest over an encrypted connection.
nxdns is a DNS sinkhole for your LAN. It blocks the names you do not want, and forwards the rest over an encrypted connection.
```mermaid
flowchart LR
@@ -12,49 +11,30 @@ flowchart LR
nxdns -- "blocked" --> sink["0.0.0.0 / NXDOMAIN"]
```
One static Zig binary. SQLite holds the state and the query log, which the web
UI, the REST API and `/metrics` read.
One static Zig binary. SQLite holds the state and the query log, which the web UI, the REST API and `/metrics` read.
## Features
- Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists —
whose `@@` exception lines are honoured — plus your own allow and block rules,
exact, wildcard (`*.example.com`) or regular expression
- Two configuration modes: a database the web UI edits, or a ZON file you keep
in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and
your workstation
- Blocklist filtering: subscribe to hosts, domain and Adblock Plus lists — whose `@@` exception lines are honoured — plus your own allow and block rules, exact, wildcard (`*.example.com`) or regular expression
- Two configuration modes: a database the web UI edits, or a ZON file you keep in git and converge onto at every start
- Per-client policy groups: different filtering for the kids' tablet and your workstation
- Local DNS records and conditional forwarding for internal zones
- Encrypted upstreams: DNS-over-HTTPS and DNS-over-TLS with failover
- Built-in DoH and DoT server endpoints, with certificate hot-reload
- Bounded in-memory DNS cache with TTL-respecting expiry
- Query log with retention limits, live-streamed over SSE
- Web UI (embedded in the binary) and a REST API with a served OpenAPI spec
- Prometheus-style `/metrics`, per-client rate limiting, disk-full
self-protection
- Prometheus-style `/metrics`, per-client rate limiting, disk-full self-protection
## Install
**No release exists yet.** This repository has no tags, nothing has been
published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container
image has been pushed. Every release URL on this page and in the how-to guides
is a 404 today, and `docker pull` finds nothing. Until the first tag ships,
building from source is the only way to get nxdns.
**No release exists yet.** This repository has no tags, nothing has been published to <https://git.mial.net/mokhtar/nxdns/releases>, and no container image has been pushed. Every release URL on this page and in the how-to guides is a 404 today, and `docker pull` finds nothing. Until the first tag ships, building from source is the only way to get nxdns.
What a tag will publish, once one exists: five assets — two static musl
tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`,
`nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the
multi-architecture container image by digest, `SHA256SUMS.txt` covering those
three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the
checksum file. Verify what you downloaded before you run it:
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also
says what that signature does and does not prove.
What a tag will publish, once one exists: five assets — two static musl tarballs (`nxdns-<version>-x86_64-linux-musl.tar.gz`, `nxdns-<version>-aarch64-linux-musl.tar.gz`), `IMAGE-DIGEST.txt` naming the multi-architecture container image by digest, `SHA256SUMS.txt` covering those three files, and `SHA256SUMS.txt.asc`, a detached OpenPGP signature over the checksum file. Verify what you downloaded before you run it: [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md), which also says what that signature does and does not prove.
## Quickstart (docker compose)
Write a minimal configuration and start the published image. This is what the
first release will make possible; it does not work today, because there is no
image in the registry to pull:
Write a minimal configuration and start the published image. This is what the first release will make possible; it does not work today, because there is no image in the registry to pull:
```sh
cd deploy/docker
@@ -69,90 +49,52 @@ EOF
NXDNS_VERSION=<version> docker compose up -d
```
The compose file defaults to `:latest`; pin a version for anything you intend
to keep running. To run it before a release exists, build the image yourself and
name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md)
describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file defaults to `:latest`; pin a version for anything you intend to keep running. To run it before a release exists, build the image yourself and name it — `NXDNS_IMAGE=nxdns docker compose up -d` — as [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md) describes. DNS is on port 53, the web UI on <http://localhost:8080>.
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes
that file the configuration: every start reconciles the database onto it, and
the UI refuses configuration edits. Edit the file and restart to change
anything. Drop the `command:` line to run bare `nxdns run` instead, where the
database is the configuration and changes go through the UI, the API, or
`nxdns export` / `nxdns import` — the packaged systemd unit does that. Which
mode is live is printed at every start (`authority: database` /
`authority: file (<path>)`); see
[docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
The compose file runs `nxdns run --config=/etc/nxdns/config.zon`, which makes that file the configuration: every start reconciles the database onto it, and the UI refuses configuration edits. Edit the file and restart to change anything. Drop the `command:` line to run bare `nxdns run` instead, where the database is the configuration and changes go through the UI, the API, or `nxdns export` / `nxdns import` — the packaged systemd unit does that. Which mode is live is printed at every start (`authority: database` / `authority: file (<path>)`); see [docs/explanation/configuration-model.md](docs/explanation/configuration-model.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are
in
[docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and
[docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
Full install instructions, including the systemd path and the Pi 5 recipe, are in [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) and [docs/how-to/install-with-docker.md](docs/how-to/install-with-docker.md).
## Building from source
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for
the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by
`zig build`.
Requires [Zig 0.16.0](https://ziglang.org/download/) and Node.js 24 (for the web UI). C dependencies (SQLite, mbedTLS) are vendored and built by `zig build`.
```sh
(cd web && npm ci && npm run build) # web UI -> web/dist
zig build -Dweb-dist=web/dist # native binary -> zig-out/bin/nxdns
(cd admin && npm ci && npm run build) # web UI -> admin/dist
zig build -Dadmin-dist=admin/dist # native binary -> zig-out/bin/nxdns
zig build test --summary all # unit tests
```
The release artifacts come out of the same build graph, so the whole release
build runs on a laptop exactly as it runs on the CI runner:
The release artifacts come out of the same build graph, so the whole release build runs on a laptop exactly as it runs on the CI runner:
```sh
(cd web && npm ci && npm run build) # required: dist refuses the placeholder
(cd admin && npm ci && npm run build) # required: dist refuses the placeholder
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \
-Dweb-dist=web/dist -Doptimize=ReleaseSafe # the release checks
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks
```
The version comes from `build.zig.zon` because `verify-dist` asserts the two
agree; a tag sets both.
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
That is not a claim that your tarball will hash the same as a published one.
Nothing in this project measures whether two builds of the same commit on two
different machines land on the same bytes, so no document here describes the
build as reproducible. The gate that would settle it is a recorded deferral —
`specs/milestone-14.md` ruling 12 — and
[docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what
a matching or differing hash is worth in the meantime.
That is not a claim that your tarball will hash the same as a published one. Nothing in this project measures whether two builds of the same commit on two different machines land on the same bytes, so no document here describes the build as reproducible. The gate that would settle it is a recorded deferral — `specs/milestone-14.md` ruling 12 — and [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what a matching or differing hash is worth in the meantime.
## Documentation
Start at [docs/README.md](docs/README.md), which splits the documentation
into a tutorial, how-to guides, reference and explanation.
Start at [docs/README.md](docs/README.md), which splits the documentation into a tutorial, how-to guides, reference and explanation.
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve
a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) —
a real install, including the Raspberry Pi 5
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking
the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every
configuration field
- [docs/tutorial/first-run.md](docs/tutorial/first-run.md) — build it, resolve a name, block a domain, on a scratch directory
- [docs/how-to/install-with-systemd.md](docs/how-to/install-with-systemd.md) — a real install, including the Raspberry Pi 5
- [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) — checking the hashes and the signature before you install
- [docs/reference/configuration.md](docs/reference/configuration.md) — every configuration field
- [docs/reference/api.md](docs/reference/api.md) — REST API, auth and SSE
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit
codes
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module
map and design
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and
per-milestone contracts
- [docs/reference/cli.md](docs/reference/cli.md) — subcommands, flags and exit codes
- [docs/explanation/architecture.md](docs/explanation/architecture.md) — module map and design
- [PLAN.md](PLAN.md) and [specs/](specs/) — scope, design decisions and per-milestone contracts
## Licence
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union
Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Copyright (c) 2026 Mokhtar Mial. nxdns is licensed under the European Union Public Licence v. 1.2 (`EUPL-1.2`); the full text is in [LICENSE](LICENSE).
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled
from the reviewed inventory in [licenses/](licenses/), which covers what the
artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its
vendored Everest and p256-m code, and the JavaScript and CSS bundled into the
admin UI.
Every released tarball and image carries a `THIRD-PARTY-NOTICES` file assembled from the reviewed inventory in [licenses/](licenses/), which covers what the artifacts actually contain: musl, the Zig runtime, SQLite, Mbed TLS and its vendored Everest and p256-m code, and the JavaScript and CSS bundled into the admin UI.

Before

Width:  |  Height:  |  Size: 303 B

After

Width:  |  Height:  |  Size: 303 B

View File
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "nxdns-web",
"name": "nxdns-admin",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nxdns-web",
"name": "nxdns-admin",
"version": "0.0.0",
"dependencies": {
"@stylexjs/stylex": "0.19.0",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "nxdns-web",
"name": "nxdns-admin",
"private": true,
"version": "0.0.0",
"type": "module",

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

@@ -1,11 +1,11 @@
#!/usr/bin/env node
// The set of npm packages whose bytes reach web/dist must be exactly the set
// The set of npm packages whose bytes reach admin/dist must be exactly the set
// recorded in licenses/dependency-identity.txt (milestone-14 ruling 3).
//
// The shipped build carries no sourcemaps, so this makes a second build with
// them into its own directory: the `sources` list of each chunk names the
// modules that went into it, and the artifact `npm run build` produced stays
// untouched. Runs from web/ as `npm run assert-bundled`, on a laptop exactly as
// untouched. Runs from admin/ as `npm run assert-bundled`, on a laptop exactly as
// on the runner.
import { execFileSync } from "node:child_process";
@@ -85,10 +85,10 @@ try {
const recorded = recordedPackages(identity);
if (recorded === null) {
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into web/dist]' section");
fail("assert-bundled: licenses/dependency-identity.txt has no '[npm packages bundled into admin/dist]' section");
}
if (recorded.length === 0) {
fail("assert-bundled: the '[npm packages bundled into web/dist]' section is empty");
fail("assert-bundled: the '[npm packages bundled into admin/dist]' section is empty");
}
const { added, removed } = comparePackages(recorded, bundled);
@@ -96,12 +96,12 @@ if (added.length !== 0 || removed.length !== 0) {
process.stderr.write(`${formatDiff(recorded, bundled)}\n\n`);
fail(
[
"the set of npm packages in web/dist has changed (-recorded +current).",
"the set of npm packages in admin/dist has changed (-recorded +current).",
"Work out what the change means for licenses/inventory.zon first, then record",
"the new list in that section of licenses/dependency-identity.txt.",
].join("\n"),
);
}
process.stdout.write(`web/dist bundles exactly the ${bundled.length} recorded packages:\n`);
process.stdout.write(`admin/dist bundles exactly the ${bundled.length} recorded packages:\n`);
for (const name of bundled) process.stdout.write(`${name}\n`);
@@ -14,7 +14,7 @@
// brace inside a string blinds the stripper; and with several stylesheets it
// judges each alone, not their load order in the document.
//
// This check runs from web/ as part of `npm run build`.
// This check runs from admin/ as part of `npm run build`.
import { readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -110,7 +110,7 @@ for (const sheet of sheets) {
}
if (failed) {
console.error("Wrap it in a layer declared before StyleX's, as web/src/styles.css does.");
console.error("Wrap it in a layer declared before StyleX's, as admin/src/styles.css does.");
process.exit(1);
}
console.log(
@@ -8,7 +8,7 @@
// lockfile, no version and no dependency set changes — only the bundle does. So
// the bundle is what this reads.
const sectionHeading = "[npm packages bundled into web/dist]";
const sectionHeading = "[npm packages bundled into admin/dist]";
// A sourcemap `sources` entry for a dependency ends in
// `node_modules/<name>/<file>` or `node_modules/@<scope>/<name>/<file>`. Only
@@ -48,7 +48,7 @@ describe("recordedPackages", () => {
"[some earlier section]",
"ignored",
"",
"[npm packages bundled into web/dist]",
"[npm packages bundled into admin/dist]",
"react",
"@tanstack/react-query",
"",
@@ -64,7 +64,7 @@ describe("recordedPackages", () => {
it("distinguishes a missing section from an empty one", () => {
expect(recordedPackages("[other]\nx\n")).toBeNull();
expect(recordedPackages("[npm packages bundled into web/dist]\n\n[next]\n")).toEqual([]);
expect(recordedPackages("[npm packages bundled into admin/dist]\n\n[next]\n")).toEqual([]);
});
});
@@ -1,6 +1,6 @@
#!/usr/bin/env node
// Freshness stamp for web/dist (milestone-15 ruling 5). A stale dist has
// already shipped a crashing settings page once. Write mode runs from web/ as
// Freshness stamp for admin/dist (milestone-15 ruling 5). A stale dist has
// already shipped a crashing settings page once. Write mode runs from admin/ as
// part of `npm run build`; check mode runs from the repository root as a
// build.zig system command. Every path resolves from this file's own location
// so both working directories hash the same set.
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
const webRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const distDir = join(webRoot, "dist");
const stampFile = join(distDir, ".src-hash");
const stampRelative = "web/dist/.src-hash";
const stampRelative = "admin/dist/.src-hash";
const inputDirs = ["src", "public"];
const inputFiles = [
@@ -26,7 +26,7 @@ const inputFiles = [
"tsconfig.node.json",
];
const staleMessage = "web/dist is stale: rebuild the frontend (npm run build)";
const staleMessage = "admin/dist is stale: rebuild the frontend (npm run build)";
function fail(message) {
process.stderr.write(`${message}\n`);
@@ -39,7 +39,7 @@ function walk(relativeDir) {
try {
entries = readdirSync(absolute, { withFileTypes: true });
} catch (err) {
fail(`stamp-dist: cannot read web/${relativeDir}: ${err.message}`);
fail(`stamp-dist: cannot read admin/${relativeDir}: ${err.message}`);
}
const found = [];
for (const entry of entries) {
@@ -57,9 +57,9 @@ function inputSet() {
const paths = [...inputFiles, ...inputDirs.flatMap(walk)];
for (const path of inputFiles) {
try {
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: web/${path} is not a file`);
if (!statSync(join(webRoot, path)).isFile()) fail(`stamp-dist: admin/${path} is not a file`);
} catch (err) {
fail(`stamp-dist: cannot stat web/${path}: ${err.message}`);
fail(`stamp-dist: cannot stat admin/${path}: ${err.message}`);
}
}
// Sorted by path so the digest does not depend on directory order.
@@ -9,7 +9,7 @@
// declare, and a string outside a literal union.
//
// Regenerate with:
// zig build test -Dintegration -Dcontract-samples-out="$PWD/web/src/lib/contractSamples.gen.ts"
// zig build test -Dintegration -Dcontract-samples-out="$PWD/admin/src/lib/contractSamples.gen.ts"
import type {
Blocklist,
@@ -6,4 +6,4 @@
pub const bytes = @embedFile("contractSamples.gen.ts");
/// Repo-relative path, so a failing assertion names the file to regenerate.
pub const path = "web/src/lib/contractSamples.gen.ts";
pub const path = "admin/src/lib/contractSamples.gen.ts";

Some files were not shown because too many files have changed in this diff Show More