Introduction
DNSY is a single-binary DNS server written in Rust: UDP/TCP, DoT with mTLS, DoH with tokens, a caching and validating forwarder, authoritative JSON zones, DNSSEC signing, filtering, a query log with stats, a REST API and a web UI.
This book is the reference for operating and extending it.
Where to start
- New here: Install, then the Quickstart.
- Configuring: the Configuration reference, Zones, Resolver, upstreams and cache, Filtering.
- Encrypted DNS: DoT and mTLS and DoH and tokens.
- DNSSEC: the concepts and the operator guide.
- Managing it: the Web UI, the REST API and the API reference.
- Running it: Operations, Logging and metrics, Backup and restore, Upgrade, Troubleshooting.
- Understanding it: Architecture, Security, FAQ, Glossary.
- Contributing: Contributing.
Install
DNSY is one binary, dnsy. It needs no runtime dependencies: SQLite is bundled and TLS uses
rustls.
Prebuilt binaries
Every tagged release publishes archives and installers for Linux, macOS and Windows with the web UI embedded; see Releases for the targets, installers and checksum verification.
On Debian, Ubuntu, RHEL, Rocky, Alma and Fedora, the release also has .deb and .rpm packages that
install the binary, a sample config and the systemd unit (they never start the service):
sudo apt install ./dnsy_<ver>_amd64.deb # or arm64
sudo dnf install ./dnsy-<ver>-1.x86_64.rpm # or aarch64
See Linux packages and Headless and service install.
Every archive also holds dnsy.toml, an annotated sample configuration. Copy it to
/etc/dnsy/dnsy.toml or let the binary write it for you, see Starter configuration.
From source
git clone https://github.com/repsyio/dnsy && cd dnsy
cargo build --release -p dnsy # no node needed; the UI paths answer 404
To embed the web UI, build it first. just build does both steps (it needs node and pnpm):
just build # pnpm build in web/, then cargo build --features embed-ui
./target/release/dnsy version
The UI is compiled into the binary with the embed-ui feature from web/dist/dnsy/browser.
Building that feature without web/dist prints a warning and fails to compile, so run
just build rather than passing the feature by hand.
Starter configuration
A fresh install has no config file, and the defaults assume root (port 53, /etc/dnsy,
/var/lib/dnsy). dnsy config init writes the annotated starter file to the --config path
(default /etc/dnsy/dnsy.toml, parent directories are created):
sudo dnsy config init # the annotated defaults, the same text as the archive's dnsy.toml
dnsy --config dnsy.toml config init --dev
dnsy --config dnsy.toml serve
--dev writes a configuration that runs without privileges: DNS on 127.0.0.1:5353, the API on
127.0.0.1:5380, state under ./dnsy-data (relative to the working directory) and the public
upstreams 1.1.1.1 and 9.9.9.9, so queries resolve. Try it with dig @127.0.0.1 -p 5353 example.com.
An existing file is never overwritten unless you pass --force.
Release profile
cargo build --release uses fat LTO, one codegen unit, stripped symbols and overflow checks
(integer overflow panics instead of wrapping). Panics unwind: a panic ends one connection task,
not the process. A release binary with the UI embedded is about 17 MB (29 MB before the profile),
and a clean release build takes roughly three minutes on a laptop-class machine.
The embedded UI ships a brotli and a gzip copy of every text asset (written by pnpm build in
web/); the server picks one from Accept-Encoding and sends Vary: Accept-Encoding.
Files and users
| Path | Purpose |
|---|---|
/etc/dnsy/dnsy.toml | configuration (override with --config or DNSY_CONFIG) |
<server.data_dir>/dnsy.db | SQLite database: users, tokens, rules, keys, query log |
<zones.dir>/*.json | zone files |
Binding port 53 or 853 needs privilege: run as a dedicated non-root user and grant
CAP_NET_BIND_SERVICE (or listen on high ports behind a forwarder). On systemd hosts,
systemd-resolved may already hold 127.0.0.53:53; see
Port 53 and systemd-resolved. The data directory, the
key passphrase file and TLS private keys should be readable by that user only.
Build time
Every binary embeds its build timestamp (dnsy version prints it, SOURCE_DATE_EPOCH makes it
reproducible). DNSSEC validation uses it as a lower bound for a sane system clock.
Releases
DNSY is released as prebuilt binaries for Linux, macOS and Windows, built by
cargo-dist (dist) in GitHub Actions when a v* tag is
pushed. Every binary has the web UI compiled in. There is no container image and no Homebrew tap
in v1.
What a release contains
| Target | Archive | Runner |
|---|---|---|
x86_64-unknown-linux-gnu | dnsy-x86_64-unknown-linux-gnu.tar.xz | ubuntu-22.04, manylinux_2_28 container |
x86_64-unknown-linux-musl | dnsy-x86_64-unknown-linux-musl.tar.xz | ubuntu-22.04 |
aarch64-unknown-linux-gnu | dnsy-aarch64-unknown-linux-gnu.tar.xz | ubuntu-24.04-arm, manylinux_2_28 container |
aarch64-unknown-linux-musl | dnsy-aarch64-unknown-linux-musl.tar.xz | ubuntu-24.04-arm |
x86_64-apple-darwin | dnsy-x86_64-apple-darwin.tar.xz | macos-15-intel |
aarch64-apple-darwin | dnsy-aarch64-apple-darwin.tar.xz | macos-14 |
x86_64-pc-windows-msvc | dnsy-x86_64-pc-windows-msvc.zip | windows-2022 |
Native installers sit beside the archives:
| Target | Installer | Runner |
|---|---|---|
x86_64-apple-darwin | dnsy-x86_64-apple-darwin.pkg | macos-15-intel |
aarch64-apple-darwin | dnsy-aarch64-apple-darwin.pkg | macos-14 |
x86_64-pc-windows-msvc | dnsy-x86_64-pc-windows-msvc.msi | windows-2022 |
Linux distribution packages are built from the two gnu archives after the release exists:
| Package | File | Built by |
|---|---|---|
| Debian and Ubuntu, x86_64 | dnsy_<ver>_amd64.deb | package-linux.yml on ubuntu-24.04 |
| Debian and Ubuntu, arm64 | dnsy_<ver>_arm64.deb | package-linux.yml on ubuntu-24.04 |
| RHEL, Rocky, Alma, Fedora, x86_64 | dnsy-<ver>-1.x86_64.rpm | package-linux.yml on ubuntu-24.04 |
| RHEL, Rocky, Alma, Fedora, arm64 | dnsy-<ver>-1.aarch64.rpm | package-linux.yml on ubuntu-24.04 |
Each archive holds dnsy (dnsy.exe on Windows), LICENSE, README.md, CHANGELOG.md and the sample config dnsy.toml.
Next to the archives the release has dnsy-installer.sh (Linux and macOS), dnsy-installer.ps1
(Windows), the two .pkg files and the .msi above, the four Linux packages (each with its own
.sha256), source.tar.gz, one <file>.sha256 per archive and package, and a combined sha256.sum.
sha256.sum covers the seven archives, source.tar.gz and the four Linux packages. dist writes it
before the packages exist, so package-linux.yml adds their lines afterwards
(scripts/update-sha256sum.sh: same <sha256> *<file> format, sorted by name, an existing line for
the same file is replaced, so a re-run never duplicates) and re-uploads it. The two script installers,
the .pkg files and the .msi are not in sha256.sum and have no .sha256 file: dist
checksums archives only, as the installers of v0.1.0 show, and it treats .pkg and .msi as
installers too (not confirmed against a release that has them). For those, rely on the
HTTPS download from the release page. To verify a download, put it next to sha256.sum and run
sha256sum -c sha256.sum --ignore-missing (macOS: shasum -a 256 -c sha256.sum --ignore-missing). The macOS and Windows packages hold only the binary (no LICENSE, README.md or sample
config; use dnsy config init to write a config).
The gnu builds (x86_64 and aarch64) are built inside quay.io/pypa/manylinux_2_28_<arch>
containers and need glibc 2.28 or newer: RHEL, Rocky and Alma 8+, Debian 10+, Ubuntu 20.04+. Use
the musl builds on older or glibc-free distributions such as Alpine. The glibc-floor job in
release-verify.yml builds the same binary in the same images and runs
scripts/check-glibc-floor.sh, which fails the release if objdump -T shows any GLIBC_ symbol
version above 2.28. Binaries and packages are not code-signed or notarised
(see Unsigned packages).
Installing a release
# Linux and macOS: asks where to install and whether to write a starter config
curl -fsSL https://dnsy.dev/install.sh | sh
# Windows
irm https://dnsy.dev/install.ps1 | iex
dnsy.dev/install.sh and dnsy.dev/install.ps1 are small wrappers (sources in docs/landing/)
that run the installer attached to the latest release, so they never go stale. They ask two questions
on a terminal (they read the answers from /dev/tty, so curl | sh works):
- Where to install.
install.shoffers system-wide (/usr/local/bin, usessudounless you are root), just you (~/.local/bin) or a local folder (./dnsy, nothing outside it is touched). On Windows the choices are just you (%USERPROFILE%\.cargo\bin, added to your PATH) or a local folder. The binary goes only into that folder and no shell profile is edited; if it is not on your PATH the script prints the line to add. - A starter config. If you say yes it runs
dnsy config init: the annotated system config at/etc/dnsy/dnsy.tomlfor a system-wide install, and a--devconfig (port 5353 on loopback, no root needed) at~/.config/dnsy/dnsy.tomlor next to a local install. A release older than the one that addedconfig initskips this step.
To skip the questions, pass flags after sh -s -- or set environment variables:
curl -fsSL https://dnsy.dev/install.sh | sh -s -- --user --no-config
# --global | --user | --local DNSY_INSTALL_MODE=global|user|local
# --config | --no-config DNSY_INIT_CONFIG=yes|no
$env:DNSY_INSTALL_MODE = 'local'; $env:DNSY_INIT_CONFIG = 'no'; irm https://dnsy.dev/install.ps1 | iex
Without a terminal to ask, the defaults are a user install and no config. The direct commands work
too, and are what the wrappers run (they install to $CARGO_HOME/bin):
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/repsyio/dnsy/releases/latest/download/dnsy-installer.sh | sh
powershell -ExecutionPolicy Bypass -c "irm https://github.com/repsyio/dnsy/releases/latest/download/dnsy-installer.ps1 | iex"
macOS package
There is one .pkg per architecture (aarch64 for Apple silicon, x86_64 for Intel). Download it
from the release page and install it for all users:
sudo installer -pkg dnsy-aarch64-apple-darwin.pkg -target /
dnsy version
The package identifier is dev.dnsy.dnsy and it installs dnsy under /usr/local/bin
(pkgutil --files dev.dnsy.dnsy lists what it installed). macOS has no uninstaller for .pkg
files: delete /usr/local/bin/dnsy and run sudo pkgutil --forget dev.dnsy.dnsy.
Windows package
msiexec /i dnsy-x86_64-pc-windows-msvc.msi /qn
The MSI is per machine (run it from an elevated prompt). It installs dnsy.exe to
C:\Program Files\dnsy\bin and adds that folder to the system PATH; open a new terminal before
running dnsy. Upgrades replace the previous version in place (the upgrade code is fixed in
crates/dnsy/Cargo.toml and must never change). Remove it with msiexec /x dnsy-x86_64-pc-windows-msvc.msi /qn or from Apps in Settings. Without /qn the installer shows a
wizard where the PATH entry can be deselected.
Linux packages (.deb and .rpm)
Download the file for your architecture from the release page (the version in the name has no
leading v; a pre-release such as 0.2.0-rc.1 is 0.2.0~rc.1 in the file name, so that it sorts
before 0.2.0) and install it with the package manager, which resolves nothing extra (the binary
has no runtime dependencies):
sudo apt install ./dnsy_0.1.0_amd64.deb # Debian, Ubuntu (arm64: _arm64.deb)
sudo dnf install ./dnsy-0.1.0-1.x86_64.rpm # RHEL, Rocky, Alma, Fedora (arm64: .aarch64.rpm)
dnsy version
They need glibc 2.28 or newer, like the gnu archives (see above). Each package installs:
| Path | What |
|---|---|
/usr/bin/dnsy | the binary (the same one as in the gnu archive) |
/etc/dnsy/dnsy.toml | the annotated sample config, mode 0640; a conffile (deb) or %config(noreplace) (rpm), so your edits survive upgrades |
/usr/lib/systemd/system/dnsy.service | the hardened unit |
/usr/lib/sysusers.d/dnsy.conf | creates the dnsy user and group |
The install script creates the dnsy user with systemd-sysusers and reloads systemd, then prints
what to do next. It never enables or starts the service: on many hosts systemd-resolved
already holds port 53, so edit /etc/dnsy/dnsy.toml, free the port if needed, and run
sudo systemctl enable --now dnsy. See Headless and service install. Without systemd
(a container) the user is not created and nothing else is attempted.
- Upgrade (
apt install ./newer.deb,dnf install ./newer.rpm): replaces the binary and unit; keeps and does not restart a running service until you restart it; keepsdnsy.toml(dpkg asks if you and the package both changed it, rpm writesdnsy.toml.rpmnew). - Remove (
sudo apt remove dnsy,sudo dnf remove dnsy): stops and disables the service and deletes the binary and unit./etc/dnsy, thednsyuser and/var/lib/dnsystay (rpm saves an edited config asdnsy.toml.rpmsave). - Purge (
sudo apt purge dnsy, Debian only): additionally deletes the config, thednsyuser and group, and/var/lib/dnsy, including the database and DNSSEC keys. rpm has no purge and never deletes the user or/var/lib/dnsy.
What the packages do not do: they are not signed and there is no apt or yum repository yet, so
apt upgrade will not find new versions (download the new file); they do not enable, start or
restart the service; they do not touch systemd-resolved, the firewall or DNS settings; and they
install no man page, shell completions or LICENSE file. The Debian maintainer field is the
placeholder DNSY maintainers <firat@repsy.io>.
Unsigned packages
The .pkg and .msi files are not signed (no Apple Developer ID, no Windows code-signing
certificate), so both operating systems warn (the .deb and .rpm files are unsigned too, and
dnf prints a warning about that for a local file):
- macOS 15 (Sequoia) and later: Gatekeeper blocks a quarantined unsigned
.pkgopened from the Finder or installed from a browser download. Open System Settings > Privacy & Security, scroll to the message aboutdnsy-...pkgand choose Open Anyway. Runningsudo installerin a terminal on a file fetched withcurl(which sets no quarantine flag) is not blocked; for a browser download you can instead runxattr -d com.apple.quarantine <file>.pkg. - Windows: SmartScreen shows “Windows protected your PC” for the
.msi; choose More info then Run anyway.msiexec /qndoes not show the prompt.
Verify the download against sha256.sum (below) before overriding either warning.
Or download an archive by hand and verify it:
v=v0.1.0
t=x86_64-unknown-linux-gnu
curl -LO https://github.com/repsyio/dnsy/releases/download/$v/dnsy-$t.tar.xz
curl -LO https://github.com/repsyio/dnsy/releases/download/$v/dnsy-$t.tar.xz.sha256
sha256sum -c dnsy-$t.tar.xz.sha256 # macOS: shasum -a 256 -c ...
tar xf dnsy-$t.tar.xz
dnsy-$t/dnsy version
Checksums protect against corrupted downloads; they are published beside the archives, so they are
not a signature. Inspect dnsy-installer.sh before piping it to a shell if that matters to you.
How the pipeline works
Configuration lives in dist-workspace.toml; the workflow is .github/workflows/release.yml.
planrunsdist host --steps=createand computes the build matrix from the tag. It fails if the tag does not match the version inCargo.toml.custom-release-verify(release-verify.yml) builds the web UI and runs the embedded-UI testui_serves_brotli_when_acceptedwith--features embed-uiin release mode; itsglibc-floorjob buildsdnsyin the two manylinux_2_28 images and checks the glibc floor. No artifact is built when either fails.build-local-artifactsruns once per target. Its prerequisite steps (.github/build-setup.yml) install pnpm and Node 24, runpnpm install --frozen-lockfile && pnpm buildinweb/(producingweb/dist/dnsy/browser) and, outside the manylinux containers (noapt-getthere), installmusl-tools; thendist buildcompilesdnsy --features embed-uiwith thedistprofile, which inherits[profile.release]unchanged (fat LTO, one codegen unit, stripped, overflow checks,panic = "unwind"). On the macOS and Windows runners the samedist buildalso produces the.pkg(pkgbuildandproductbuild, identifier from[dist.mac-pkg-config]) and the.msi(WiX 3 via cargo-wix, fromcrates/dnsy/wix/main.wxs; the WiX upgrade and PATH GUIDs are in[package.metadata.wix]).build-global-artifactscreates the installers,source.tar.gzandsha256.sum.hostuploads everything and creates the GitHub Release (it hascontents: write). Tags with a suffix such asv0.2.0-rc.1are marked as pre-releases.custom-package-linux(package-linux.yml, a distpublish-jobsentry, the only other job withcontents: write) runs afterhostand only for a stable tag (dist skips publish jobs for pre-releases). It downloadsdnsy-x86_64-unknown-linux-gnu.tar.xzand the aarch64 one from the release, checks them against their.sha256, runsscripts/package-linux.sh(nfpm 2.48.0, downloaded byscripts/install-nfpm.shand verified against the sha256 pinned in that script; configuration inpackaging/nfpm.yaml, maintainer scripts inpackaging/scripts/) and uploads the four packages and their.sha256files withgh release upload --clobber, then downloads the release’ssha256.sum, adds the package lines withscripts/update-sha256sum.sh, checks every line of the result withsha256sum -cagainst the release’s files, and uploads it back with--clobber. It fails if the release has nosha256.sum(it never creates one that lists only the packages). Nothing is compiled again. If it fails the release still exists; fix the cause and run Package Linux from the Actions tab (workflow_dispatch) with the tag, which rebuilds and replaces the packages using the packaging files of the branch you run it on, without moving the tag.
The host and announce jobs explicitly require custom-release-verify to have succeeded (dist’s
default always() condition would otherwise accept skipped build jobs and publish after a failed
verify); the generator script re-applies this.
All actions are pinned by commit SHA. dist emits floating tags and write permissions, so
scripts/generate-release-workflow.sh runs dist generate and then re-applies the pins and the
contents: read default (and contents: write for host and custom-package-linux). Never edit release.yml by hand; change dist-workspace.toml or the
script and regenerate (needs dist 0.33.1 on PATH). allow-dirty = ["ci"] makes dist accept
the hardened file.
Cutting v0.1.0 (maintainer steps)
One-time repository setup: Actions enabled, workflow permissions allow the GITHUB_TOKEN to create
releases (the host job requests contents: write itself), and the repository is public (the
free ubuntu-24.04-arm runners need that) or has larger arm runners.
-
On
mainwith a green CI, move the[Unreleased]section ofCHANGELOG.mdunder## [0.1.0] - <date>and keep an empty[Unreleased]above it. The release body uses this file plus dist’s artifact table. Commit through a PR. -
Check that
versionin the rootCargo.toml([workspace.package]) is0.1.0. Bumping it is a normal PR that also updatesCargo.lock. -
Run the
release-pre-checkworkflow onmain(Actions, release-pre-check, Run workflow) and wait for it to be green. It builds and tests on macOS and Windows and builds and installs the.pkgand.msi; none of that runs on pull requests, so this is where a platform break shows up before a tag. It also runs weekly onmain. The release workflow does not wait for it, so a hotfix tag is never blocked by it. -
Optional dry run on your machine (no tag, nothing published): with
dist0.33.1 installed rundist plananddist generate --check. Adddist build --artifacts=local --target=<triple>aftercd web && pnpm install --frozen-lockfile && pnpm buildto build one archive. -
Tag the merged commit and push the tag:
git checkout main && git pull --ff-only git tag -a v0.1.0 -m "DNSY v0.1.0" git push origin v0.1.0 -
Watch the
Releaseworkflow. When it is green the releasev0.1.0has the 7 archives, 2 script installers, 2.pkgfiles, the.msi, the 2.deband 2.rpmpackages,source.tar.gz, per-file.sha256files andsha256.sum. -
Verify the packages by hand (
release-pre-checkdoes the same on a runner, but a release is the real test):- macOS (a Mac with no
dnsyinstalled): downloaddnsy-<arch>.pkgin a browser, expect the Gatekeeper block, Open Anyway, finish the installer, thendnsy versionprints0.1.0andwhich dnsyis/usr/local/bin/dnsy. Repeat the other architecture if you have the hardware. - Windows: download the
.msi, expect the SmartScreen warning, install it (wizard and/qn), open a new terminal and rundnsy version; checkC:\Program Files\dnsy\bin\dnsy.exeand the systemPATH; install it again over itself and over an older build (no second Apps entry); uninstall and check both are gone.
- macOS (a Mac with no
-
Verify the Linux packages:
apt install ./dnsy_0.1.0_amd64.debon a clean Ubuntu or Debian host anddnf install ./dnsy-0.1.0-1.x86_64.rpmon a clean Rocky or Fedora host;dnsy versionprints0.1.0,systemctl status dnsyshows the unit loaded but inactive and disabled, andapt purge dnsy/dnf remove dnsybehave as described above. Thepackagesworkflow runs these steps in containers on pull requests, but only a tag exercises the download from the release and the upload. -
Verify: download
sha256.sumand the archives and runsha256sum -c sha256.sum --ignore-missing; run the installer on a clean Linux and a clean Windows machine;dnsy versionmust print0.1.0and/on the web port must serve the UI (embedded).
If a build fails, fix forward and move to a new tag (v0.1.1); a half-created release can be
deleted in the GitHub UI, after which the tag may be deleted and re-pushed only if no one has
consumed it.
Quickstart
-
Copy the sample configuration and adjust the paths and listen addresses:
sudo install -d /etc/dnsy /var/lib/dnsy /var/lib/dnsy/zones sudo cp examples/dnsy.toml /etc/dnsy/dnsy.toml dnsy config checkconfig checkvalidates the file (unknown keys are errors), the zone files, and that the referenced certificate, key and passphrase files exist.Set
[resolver] upstreamsto the resolvers DNSY forwards to, for exampleupstreams = ["1.1.1.1:53", "9.9.9.9:53"]. With no upstreams DNSY logs a warning at startup, and every forwarded query answers SERVFAIL. Upstreams are seeded into the database on the first start only; after that, change them through the web UI or the API.To try without root, set
[dns] udpandtcpto127.0.0.1:5353and[server] data_dirto a directory you own. The same overrides work from the environment, without editing the file:export DNSY_DNS__UDP='["127.0.0.1:5353"]' DNSY_DNS__TCP='["127.0.0.1:5353"]' export DNSY_SERVER__DATA_DIR="$HOME/dnsy-data" -
Create the first user (a random password is printed once, or use
--password-stdin):dnsy user create admin --role adminStarting the server with no users also prints a one-time bootstrap code for the first-run screen of the web UI.
-
Run it:
dnsy serveThe DNS listeners default to
0.0.0.0:53, which needs root orCAP_NET_BIND_SERVICE; with the unprivileged override above they listen on127.0.0.1:5353. If the server exits withaddress already in useon port 53, see Port 53 and systemd-resolved.The API and UI listen on
[api] listen(default127.0.0.1:5380).GET /healthzis liveness,GET /readyzchecks that the database answers. -
Query it:
dig @127.0.0.1 -p 5353 example.comBy default DNSY serves only loopback, private (RFC 1918, 100.64.0.0/10, ULA) and link-local clients, so a query from a public address is answered REFUSED. This keeps a fresh install from becoming an open resolver for the internet. To serve other networks add an ACL rule for them (
POST /acl), or set[limits] open_resolver = true(with a non-zeroper_client_qps, 50 by default) to answer everyone. -
Add a zone by dropping a JSON file into
[zones] dir(see Zones); it is picked up without a restart. Export it withdnsy zone export example.com. -
Create a token for scripts or DNS-over-HTTPS:
dnsy token create --user admin --name laptop --scope dns:query --scope api:writeThe secret is printed once.
dns:querytokens authenticate DoH clients;api:*tokens call the REST API.
Port 53 and systemd-resolved
On Ubuntu and other systemd hosts, systemd-resolved answers on a stub listener at
127.0.0.53:53. Binding DNSY to 0.0.0.0:53 then fails with address already in use. Either:
-
listen on an address the stub does not hold, for example your LAN address:
[dns] udp = ["192.168.1.10:53"] tcp = ["192.168.1.10:53"] -
or turn the stub off and restart the resolver:
sudo mkdir -p /etc/systemd/resolved.conf.d printf '[Resolve]\nDNSStubListener=no\n' | sudo tee /etc/systemd/resolved.conf.d/dnsy.conf sudo systemctl restart systemd-resolvedThe host’s
/etc/resolv.confstill points at127.0.0.53afterwards, so point it at DNSY (for examplenameserver 127.0.0.1) or name resolution on this host stops working.
Binding port 53 itself needs root or CAP_NET_BIND_SERVICE. Running on a high port, as in step 1,
avoids both.
Command reference
| Command | What it does |
|---|---|
dnsy serve [--ready-file F] | run the DNS listeners and the API; --ready-file writes the bound addresses as JSON (useful with port 0) |
dnsy config check | validate the configuration |
dnsy migrate [--status] | apply database migrations, or only report them |
dnsy user create|reset-password|disable|enable|list | manage users (--password-stdin, else DNSY_PASSWORD, else generated); a disabled user cannot log in and their sessions and tokens stop working |
dnsy token create --user U --name N --scope S [--expires-days D] | issue a token |
dnsy zone export <name> [--signed] | print a zone as BIND text; --signed includes DNSSEC records |
dnsy dnssec keys list|generate|rollover|ds <zone> | inspect and manage signing keys, print DS records for the registrar |
dnsy dnssec ds-confirmed <zone>, dnsy dnssec rollover abort <zone> | confirm the registrar published the new DS; abort a rollover that has not switched keys |
dnsy dnssec keys export|import <file>, dnsy dnssec passphrase change | encrypted key backup and restore; re-encrypt all keys under a new passphrase |
dnsy cache flush [<name>] [--subtree] | flush a running server’s cache through the API (--token or DNSY_TOKEN, --url to override) |
dnsy openapi | print the OpenAPI document |
dnsy version | print the version and build time |
All commands take --config <file> (default /etc/dnsy/dnsy.toml, or DNSY_CONFIG).
Administration commands open the database directly, so they work while the server runs.
Headless and service install
This page covers running DNSY as a systemd service on a server with no desktop: a dedicated service user, the hardened unit, freeing port 53, and reaching the web panel over SSH.
The unit and the user definition live in the repository as
examples/dnsy.service and
examples/dnsy.sysusers.
Distribution packages install them to /usr/lib/systemd/system/dnsy.service and
/usr/lib/sysusers.d/dnsy.conf. For a tarball or cargo install setup, copy them there yourself
(or to /etc/systemd/system/dnsy.service and /etc/sysusers.d/dnsy.conf).
With a .deb or .rpm (Linux packages) everything below
is already in place except enabling the service: the package creates the dnsy user, installs the
unit and a sample /etc/dnsy/dnsy.toml, and leaves the service disabled and stopped. Edit the
config, deal with port 53 if needed, then
sudo systemctl enable --now dnsy.
Service user
dnsy.sysusers is a sysusers.d
file that creates a system user and group named dnsy with /var/lib/dnsy as its home:
u dnsy - "DNSY DNS server" /var/lib/dnsy
sudo install -m 0644 examples/dnsy.sysusers /etc/sysusers.d/dnsy.conf
sudo systemd-sysusers
Install and enable the unit
The unit runs /usr/bin/dnsy --config /etc/dnsy/dnsy.toml serve. --config is a global flag, and
/etc/dnsy/dnsy.toml is also its default. If the binary is elsewhere, for example
/usr/local/bin/dnsy from a tarball install, change that one ExecStart= line (or override it
with sudo systemctl edit dnsy).
sudo install -m 0644 examples/dnsy.service /etc/systemd/system/dnsy.service
sudo install -d -o root -g dnsy -m 0750 /etc/dnsy
sudo install -m 0640 -g dnsy examples/dnsy.toml /etc/dnsy/dnsy.toml
sudo systemctl daemon-reload
sudo systemctl enable --now dnsy
The unit sets StateDirectory=dnsy (so /var/lib/dnsy, the default server.data_dir, is created
and owned by the dnsy user) and ConfigurationDirectory=dnsy (/etc/dnsy). Keep data_dir and
zones.dir under /var/lib/dnsy: with ProtectSystem=strict that is the only writable path.
Hardening in the unit: the process runs as dnsy, never as root; AmbientCapabilities and
CapabilityBoundingSet hold only CAP_NET_BIND_SERVICE, which is what lets it bind port 53, 853
and 443; NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp,
ProtectKernel*, RestrictAddressFamilies (IPv4, IPv6 and Unix sockets only) and
SystemCallFilter=@system-service limit the rest. The unit restarts on failure.
Check it with systemctl status dnsy. After editing dnsy.toml, sudo systemctl restart dnsy.
Port 53 and systemd-resolved
On Ubuntu and similar hosts systemd-resolved already holds 127.0.0.53:53, and DNSY fails to
start with address already in use. Follow
Port 53 and systemd-resolved to bind a different
address or turn the stub listener off, then sudo systemctl restart dnsy.
The first-run bootstrap code
On a fresh data directory no users exist. Once every listener has bound, DNSY writes a one-time bootstrap code (valid one hour, shown once) to standard error, which systemd sends to the journal:
journalctl -u dnsy | grep 'bootstrap code'
A start that fails to bind prints no code, so fix the bind error first (see Troubleshooting) and look again. Use the code in the web panel or API to create the first administrator, as described in Users, tokens and sessions.
Reaching the web panel and API
The API and web panel listen on loopback only (api.listen = ["127.0.0.1:5380"] by default), so
they are not reachable from other machines. From your workstation, forward the port over SSH:
ssh -L 5380:127.0.0.1:5380 user@dns-host
Then open http://127.0.0.1:5380 locally. The REST API is served on the same port. Keep the API
on loopback unless you add TLS and a firewall rule; see Security.
Configuration reference
DNSY reads a single TOML file (dnsy.toml). Every key is optional, unknown keys are errors, and a
complete annotated example lives in examples/dnsy.toml. Any key can be overridden from the
environment as DNSY_SECTION__KEY (sections separated by a double underscore), for example
DNSY_CACHE__SERVE_STALE__ENABLED=false or DNSY_DNS__UDP='["127.0.0.1:5353"]'. Values are read
as TOML (numbers, booleans, arrays) and fall back to plain strings.
| Section | Keys (defaults) |
|---|---|
server | data_dir (/var/lib/dnsy), udp_sockets_per_address (available parallelism on Linux, 1 elsewhere; see Operations), worker_threads (available parallelism; at least 1) |
dns | udp, tcp listen lists (0.0.0.0:53; empty disables) |
dot | listen (off), cert, key, client_ca, client_crl, mtls = off/optional/required |
doh | listen (off), cert, key, path (/dns-query), require_token (true), trusted_proxies (empty; CIDRs whose X-Forwarded-For is honoured, see Security) |
api | listen (127.0.0.1:5380), tls_cert, tls_key, public_url, secure_cookies (auto, true or false; see Security), login_hash_per_client (16), login_hash_wait_ms (5000; see Authentication), max_conns (256), max_conns_per_client (64), trusted_proxies (empty; CIDRs whose X-Forwarded-For is honoured, see Security) |
zones | dir (/var/lib/dnsy/zones) |
resolver | timeout_ms (2000), attempts (2), upstreams (seed upstreams), case_randomization (false; 0x20, see Resolver) |
cache | enabled (true), max_entries (100000), min_ttl (0), max_ttl (86400), negative_max_ttl (3600), servfail_ttl (5), bogus_ttl (60) |
cache.serve_stale | enabled (true), max_stale (86400), answer_ttl (30), client_timeout_ms (1800) |
cache.prefetch | enabled (true), min_hits (5), remaining_ttl_pct (10) |
dnssec | validation = off/permissive/strict (strict), trust_anchors (root KSK-2017 and KSK-2024 DS), trust_anchor_file, negative_trust_anchors, clock_skew_s (300), key_passphrase_file |
filter | block_mode = null_ip/nxdomain/refused, blocklists, refresh_interval (seconds, 86400) |
limits | per_client_qps (50; 0 = unlimited), open_resolver (false), tcp_idle_ms (10000), max_tcp_conns (1024), max_conns_per_client (16), udp_in_flight (4096), udp_in_flight_per_client (64), rate_limit_slip (2; 0 = never; see Security) |
| filter | block_mode = null_ip/nxdomain/refused, blocklists, refresh_interval (seconds, 86400), response_ip_blocklist (CIDRs; see Filtering) |
| limits | per_client_qps (50; 0 = unlimited), open_resolver (false), tcp_idle_ms (10000), max_tcp_conns (1024), max_conns_per_client (16), udp_in_flight (4096), udp_in_flight_per_client (64; see Security) |
| querylog | enabled (true), retention_days (30), redact_client_ip (false) |
| limits | per_client_qps (50; 0 = unlimited), open_resolver (false), tcp_idle_ms (10000), max_tcp_conns (1024) |
| querylog | enabled (true), retention_days (30), redact_client_ip (false), sample (1), max_size_mb (0) |
| logging | level = trace..error (info), format = pretty/json |
Behaviour change: private-only by default. With limits.open_resolver = false (the default)
DNSY answers only loopback, RFC 1918, 100.64.0.0/10, ULA (fc00::/7) and link-local clients and
refuses every other source address. ACL rules stored through the API still apply and win by
longest prefix, so 203.0.113.0/24 allow admits that network. Set open_resolver = true to
restore the old allow-all behaviour; that is only sensible together with a non-zero
per_client_qps (now 50 by default; 0 disables the limit) and ACL rules. dnsy serve and
dnsy config check warn when a non-loopback listener runs with open_resolver = true and
per_client_qps = 0.
The DNSSEC key passphrase can also be supplied in the DNSY_DNSSEC_PASSPHRASE environment variable.
Validation rules
dot.listenneedsdot.certanddot.key;dot.mtlsother thanoffneedsdot.client_ca.doh.listenneedsdoh.certanddoh.key;doh.pathstarts with/.api.tls_certandapi.tls_keyare set together.cache.min_ttlmust not exceedcache.max_ttl;cache.max_entriesis at least 1;cache.prefetch.remaining_ttl_pctis 1 to 100.- Trust anchors are DS records:
keytag algorithm digest-type digest-hex. With validation on, at least one trust anchor (or atrust_anchor_file) is required.
Zones
Each zone is one JSON file in the zones directory (<zones_dir>/*.json), the source of truth
for that zone.
File name
The file name is derived from the zone name: lowercase, no trailing dot, only [a-z0-9.-],
for example example.com.json. The root zone is _root.json. Names that cannot be written
this way (underscores, escapes, non-ASCII; use punycode for IDN) are rejected. A file whose
name does not match its origin is rejected.
Format
{
"schema": 1,
"origin": "example.com.",
"default_ttl": 3600,
"soa": {"mname": "ns1.example.com.", "rname": "hostmaster.example.com.",
"serial": 1, "refresh": 7200, "retry": 3600, "expire": 1209600, "minimum": 300},
"records": [
{"name": "www", "type": "A", "ttl": 300, "value": "192.0.2.1"}
],
"dnssec": {"enabled": false, "algorithm": 13, "key_layout": "csk", "denial": "nsec3",
"nsec3": {"iterations": 0, "salt": "", "opt_out": false},
"signature_validity_days": 14, "refresh_days": 5, "dnskey_ttl": 3600,
"propagation_delay": 3600}
}
nameis relative to the origin,@is the origin, and a trailing dot makes it absolute. It must lie inside the zone.valueis RFC 1035 presentation format. Names inside values are absolute.ttlis optional and defaults todefault_ttl. The SOA takes an optionalttltoo.- Supported types: A, AAAA, NS, CNAME, PTR, MX, TXT, SRV, CAA and DS. The SOA lives in the
soablock only. - DS is only allowed at a delegation point (a name below the apex with NS records): it is the parent’s half of a secure delegation and is signed with the zone.
- A CNAME cannot share a name with any other record (including the apex), nor repeat.
- DNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, CDS and CDNSKEY are generated by signing and are rejected when written by hand.
DNSSEC policy
The optional dnssec block is validated even when enabled is false: algorithm 13 or 15,
key_layout csk or ksk_zsk, denial nsec or nsec3, refresh_days below
signature_validity_days, NSEC3 salt as hex. NSEC3 iterations must be 0 (RFC 9276) unless
allow_nonzero_iterations is set, which is accepted with a warning (at most 100, the validator’s own cap: a zone above it would be insecure to validators).
Writes and hot reload
Writes are atomic (temp file, fsync, rename). The server watches the directory and reloads
250 ms after the last change. An invalid file never replaces a working zone: the previous
version keeps serving and the error is kept as the zone’s load_error, visible in the logs
and through the API.
A reload reads every file but parses only the ones whose content changed since the last
reload: an unchanged zone keeps its parsed Arc<Zone> and its lookup index (and an unchanged
invalid file keeps its recorded error), so editing one of many zone files costs one parse and
one index build.
Answering queries
The server answers authoritatively for every name inside a loaded zone, following RFC 1034 section 4.3.2 and RFC 4592. Queries outside all zones, or for a class other than IN (or ANY), are not answered from zones.
- Closest enclosing zone. When zones nest (
example.com.anddev.example.com.), the most specific zone answers. - Exact match. The RRset of the asked type, plus A/AAAA records from the zone for the names that NS, MX and SRV answers point at (additional section). ANY returns every RRset of the name, including the SOA at the apex.
- CNAME. A CNAME is followed while its target stays in the same zone, for at most 8 links; a loop stops at the first repeated name. The answer holds every CNAME followed and then the target’s data. A target outside the zone (or in another zone) ends the chain and the client resolves the rest. A chain that ends in a missing name or type carries the SOA in the authority section. Asking for CNAME (or ANY) returns the CNAME itself without following it.
- Wildcards. A name that does not exist is answered from
*.<closest encloser>, with the owner rewritten to the query name. A wildcard never applies to a name that exists, and empty non-terminals exist: with only_ssh._tcp.host2.example.present,host2.example.and_tcp.host2.example.are NODATA, not wildcard matches. A*label asked for directly is matched literally, and a wildcard CNAME is synthesised and then followed. - Delegation. An NS RRset below the apex is a zone cut. Any query at or below it gets a referral: the cut’s NS records plus glue, the A/AAAA records the zone holds for in-zone name servers. Records below a cut are only served as glue. DS at the cut itself is answered by the parent side (NODATA while the zone is unsigned).
- NODATA and NXDOMAIN. A name that exists without the asked type is NODATA; a name that
does not exist and has no wildcard is NXDOMAIN. Both carry the zone SOA in the authority
section with TTL = min(SOA TTL, SOA
minimum) (RFC 2308). - Rotation. Every query rotates the order of each RRset by one, so clients that pick the first address spread across all of them. The counter is kept per thread (one cache line each), so each worker rotates independently and cores do not contend on it.
- Signed answers. A zone served signed keeps its RRsets as shared slices and, for NSEC3, the hash of every name in the zone, computed once when the index is built; only names that do not exist (the next closer name, the wildcard) are hashed per query.
BIND export
dnsy_zone::export_bind(&zone, signed) renders a zone as a BIND master file: $ORIGIN, $TTL,
a multi-line SOA first, then the records grouped by owner with owner names relative to the
origin (@ for the apex). A record’s TTL is written only when it differs from $TTL. With a
signed zone (a SignedZone from the zone signer, see DNSSEC) the DNSKEY,
CDS, CDNSKEY, RRSIG, NSEC/NSEC3 and NSEC3PARAM records follow in canonical order.
The output is safe to hand to other tools. Owner names, rdata names and character-strings are
escaped: . \ ; ( ) " $ @ get a backslash and non-printable bytes become \DDD, so a record
cannot end its line, start a comment or inject a directive such as $INCLUDE. TXT strings
longer than 255 octets are split into several strings, and a CAA record whose tag is not
alphanumeric is written in the RFC 3597 generic form (\# len hex).
The export is checked with named-checkzone when it is installed (the test is skipped
otherwise).
Managing zones over the API
Zones, records and the BIND export are available under /api/v1/zones (see
REST API). The API writes the same JSON files described
above, atomically, bumps the SOA serial on every change and keeps the dnssec block of the file.
Records are addressed by a stable id derived from name, type and value. GET /zones/{name} shows
load_error when the file on disk is currently invalid (the previous version keeps serving).
Authoritative answers are not cached, so a change is visible to queries as soon as the write
returns.
Serial semantics and request bodies
Every edit goes through one place in dnsy-zone (ZoneStore::update): it takes the store’s
write lock, applies the change to a copy of the stored zone, validates it exactly like a file,
bumps the SOA serial by one (wrapping at 2^32) and writes the file atomically. Any successful
write bumps the serial, including DNSSEC policy changes; a failed edit writes nothing and keeps
the serial. A serial supplied in a PUT body is ignored; POST /zones uses it as the initial
serial (default 1). An If-Match that no longer matches is answered 412 and nothing is written,
even when another writer got in between the check and the write.
POST /zones and PUT /zones/{name} reject unknown top-level fields (and unknown fields in
soa and in records) with 422 instead of ignoring them, so a misspelled default_tll is an
error rather than a silently missing setting.
Resolver, upstreams and cache
Upstream selection and load balancing are described under Upstream groups. The cache is described below.
Upstream groups
Upstreams are organised into named groups, stored in the upstream_groups and upstreams tables
(migration 0005). On the first start, when no group exists, [resolver] upstreams seeds one
round-robin group named default; after that the stored groups are authoritative and config
changes do not overwrite them. Exactly one group is the default and receives every query that no
domain pattern matches.
Strategies. Each group has one strategy that orders its healthy members; a query walks that order until a member answers.
| Strategy | Order |
|---|---|
round_robin | rotating start, one step per query |
weighted | random, each member chosen with probability proportional to its weight (>= 1) |
lowest_latency | ascending EWMA latency (alpha 0.3); members not yet measured go first |
failover | configured order; later members only when earlier ones are down or fail |
Routing. A group may list domain patterns (corp.example or *.corp.example). Both forms
match the name itself and everything below it, case-insensitively. The pattern with the most
labels wins; a pattern may belong to one group only.
Health. Transport failures (timeout, I/O, TLS, malformed or mismatched response) count against
an upstream; any DNS response, whatever its RCODE, counts as success and resets the count. After 3
consecutive failures the upstream is ejected for 5 s; each further failed probe doubles the
ejection (10 s, 20 s, … up to 5 min). Ejected members are skipped. An active probe (a root NS
query, issued by GroupSet::run_probes once the backoff has elapsed) re-admits an upstream on
success. If every member of the chosen group is ejected the query fails with
NoAvailableUpstream.
Determinism. GroupSet takes a Clock and an Rng, so tests drive time and randomness
explicitly.
Cache
The cache lives in memory only and is not persisted across restarts. It is tuned by the [cache]
section (enabled, max_entries, min_ttl, max_ttl, negative_max_ttl, servfail_ttl,
bogus_ttl).
Keying. An entry is identified by the lowercased query name, type, class, the client’s DO bit and the client’s CD bit. DO=0 and DO=1 clients never share entries, and CD=1 entries live in their own key space, so unvalidated data cannot reach a validating client.
Positive answers. The lifetime is the smallest TTL across the cached records, clamped to
[min_ttl, max_ttl]. A lifetime of zero is not cached. The answer to the miss itself is the copy
the cache stored, so the clamps show from the first reply on, not only on later hits.
Coalesced misses. Concurrent misses for the same key (name, type, class, DO and CD) share one upstream exchange: the first request forwards, validates and caches; the others wait for its result, success or failure, and are answered from it with their own id and question. A result is shared only while it is in flight (a failure is not remembered afterwards), a waiter whose leader is cancelled takes the exchange over, and no waiter waits longer than twice the resolver deadline. At most 256 requests wait on one key; beyond that a miss is answered SERVFAIL (EDE 22) at once, so a flood of one slow name cannot hold every listener slot. Serve-stale refreshes and prefetches use the same per-key slots and hand their result to any miss that waits on them.
Negative answers (RFC 2308). NXDOMAIN and NODATA are cached for the smaller of the SOA record
TTL, the SOA minimum field and negative_max_ttl; a negative answer without an SOA is not
cached. An NXDOMAIN is stored by name and answers every query type for that name (unless the name
was reached through a CNAME chain). SERVFAIL is cached for servfail_ttl. Other response codes,
truncated (TC) responses and authoritative (AA) responses from local zones are never cached.
TTL decay. Served records carry the remaining lifetime, rounded down to whole seconds and never below zero. An expired entry is a miss and is dropped.
Security state. Each entry stores its DNSSEC status. A Bogus entry lives at most bogus_ttl, is
only ever returned to a CD=0 query as SERVFAIL, and never replaces a live Secure entry.
Bounded size. The cache is split into max(16, 4 x cores) shards (fewer for a small cache: each shard keeps
about 64 entries at least), each with its own lock, its own recency clock and its own hit, miss and
insert counters (summed when the stats are read), so concurrent readers share no global lock or
counter. A hit takes a reference to the stored message under the shard lock and copies it, with
the TTLs decremented, after releasing it. The name-level NXDOMAIN entry is looked up only when the
exact entry does not answer. max_entries bounds the total number of entries across
shards. When an insert takes it over, the inserting shard evicts the least recently used of 8
entries sampled at random positions, as long as it holds more than its share (max_entries / shards); otherwise the first other shard over its share (visited one lock at a time from a random
start) does. No insert ever locks all shards. Evictions are counted.
Flush and statistics. The cache can be flushed entirely or by name (optionally the whole subtree below it). Statistics report entries, capacity, hits, misses, inserts, evictions and expired entries, stale answers served and prefetches started.
Serve-stale (RFC 8767). With [cache.serve_stale] enabled, an expired entry is kept for
max_stale seconds more (memory stays bounded by max_entries; stale entries are evicted like any
other). A query that finds only a stale entry refreshes it upstream first and answers with the new
data. If the refresh fails, comes back SERVFAIL, or takes longer than client_timeout_ms, the
client gets the stale answer instead, with every TTL set to answer_ttl and Extended DNS Error 3
(Stale Answer), or 19 (Stale NXDOMAIN Answer) for a stale NXDOMAIN. The query log records these as
source cache_stale and stale_served counts them. At most one refresh runs per cache key: while
it is in flight, other clients asking for the same name are answered stale at once instead of
querying upstream again. A refresh that outlasts client_timeout_ms is abandoned with the client’s
request. Never served stale: Bogus entries and cached SERVFAILs. A stale answer that was Secure is
no longer reported as validated (no AD bit): validation does not outlive the lifetime the
validator allowed. A successful refresh replaces the stale entry.
Prefetch. With [cache.prefetch] enabled, a live entry that has served at least min_hits
hits and whose remaining lifetime has fallen to remaining_ttl_pct percent is refreshed in the
background, once per entry, so popular names never expire under load. The client that triggers it
is answered from the cache as usual. Refreshes go through the normal forwarding and DNSSEC
validation path, wait in a queue of 64 (extra ones are dropped and the entry simply expires; jobs older than 30 s
are skipped) and run four at a time. A queued job holds no refresh slot: a worker claims the key’s
slot only when it starts, and skips the job if a client-driven refresh already holds it, so a
backlog never keeps a client from refreshing a stale entry. Engine::run_prefetch is the worker
loop; dnsy serve spawns it when prefetch is enabled, and nothing is queued while it is not
running. A failed prefetch keeps the existing
entry.
Clock. Cache operations take the current time as an argument, and the engine’s time source can
be replaced (EngineBuilder::clock), so tests move time by hand.
Upstream client
dnsy-resolver talks to upstream resolvers through UpstreamClient::query(&Upstream, &Message).
An upstream is written as a URL:
| Form | Transport |
|---|---|
udp://host[:port] | UDP (default port 53); a truncated answer (TC) is retried over TCP |
tcp://host[:port] | TCP with 2-byte framing (default port 53) |
tls://host[:port][?pin-sha256=BASE64][#sni] | DNS over TLS, RFC 7858 (default port 853); optional SPKI pin |
IPv6 literals use brackets, for example tcp://[2001:db8::1]:53.
Behaviour:
-
SPKI pin.
?pin-sha256=takes the base64 SHA-256 of the server certificate’sSubjectPublicKeyInfo(the RFC 7469 form, 44 characters). It is checked after the normal certificate and host-name verification, on every new connection; a mismatch fails the attempt with a TLS error, which counts against the upstream’s health like any transport error. Example:tls://9.9.9.9:853?pin-sha256=...#dns.quad9.net. The pin is part of the upstream’s identity. -
0x20 case randomisation. With
resolver.case_randomization = true(default false: it is opt-in) the query name sent toudp://andtcp://upstreams has random upper/lower case letters, and the answer must echo exactly that case, adding entropy against spoofing. Callers and the cache see the original case. An upstream that answers with another case (it lowercases) is retried at once without 0x20 and then queried without it for 10 minutes before being probed again, so such upstreams keep working.tls://upstreams are not randomised (authenticated transport). The setting is global, not per group: groups live in the database and adding a per-group flag would need a migration. -
Loop guard. An upstream (IP literal or host name) that resolves to one of DNSY’s own
dns.udp/dns.tcplisten addresses is refused with an error instead of queried, which stops a host whose system resolver points at DNSY from looping. A wildcard listen address (0.0.0.0) covers loopback and wildcard addresses on its port. The refusal is a failed attempt, so the upstream’s health reflects it. -
Every attempt uses a fresh query ID from the CSPRNG.
-
UDP queries go over a small pool of connected sockets per upstream. The pool keeps these anti-spoofing invariants:
- each socket is bound to an OS-chosen random source port and connected to the upstream address, so the kernel drops datagrams from any other source; a socket is never reused for another upstream address;
- a socket serves one query at a time; concurrent queries take other sockets, and a query finding no idle socket opens a new one;
- a socket is closed after 16 queries, 5 s after it was opened or 2 s idle, whichever comes
first; at most 16 idle sockets are kept per upstream (constants
UDP_ROTATE_QUERIES,UDP_ROTATE_AGE,UDP_IDLE_LIFETIMEandUDP_POOL_IDLEinupstream/mod.rs); - before a pooled socket sends a new query, everything queued on it is discarded, so only datagrams arriving after the new ID goes out can match, exactly as with a fresh socket;
- a socket whose attempt timed out or failed is closed, never reused;
- the receive buffer (4096 bytes, above the advertised 1232) belongs to the socket and is reused, so a query allocates no receive buffer.
-
A response is accepted only when the ID, the QR bit, the opcode and the full question section match the query, the name compared case-sensitively (byte for byte, as sent). Mismatching UDP datagrams are ignored until the attempt times out; a mismatch on a stream transport is an error. The QNAME case is not randomised (no 0x20): some upstreams and middleboxes do not preserve case, and a fallback path for them is left for later.
-
The upstream address is cached: an IP literal is parsed once, a host name is resolved again at most every 60 s (a pooled socket connected to an old address is closed).
The trade-off of pooling. A fresh socket per query gives each query a new random source port
(about 15 bits on Linux’s default range) on top of the 16-bit ID. A pooled socket keeps its port for
up to 16 queries or 5 s, so an off-path attacker who learnt the port (for example by observing one
query) has, inside that window, only the 16-bit ID to guess and the exact question to match: a
forged answer that arrives before the real one is accepted with probability about 1/65536 per
forgery, against about 1/2^31 with a fresh port. Rotation keeps the window short, discarding queued
datagrams on reuse prevents pre-loading forgeries into a parked socket, and the defence in depth is
DNSSEC validation in strict mode (the default), which rejects forged answers in signed zones
whatever the transport. Measured with missbench (dnsy pinned to 4 cores, 64 closed-loop clients,
unique names, query log on): a fresh socket per query 113k QPS (31 us CPU per miss); the pool with
128 queries / 60 s / 64 idle 185k QPS (18.5 us); the pool as shipped 172k QPS (20.7 us, p99
1.15 ms).
- Each attempt has a timeout (default 2 s). The TCP retry after TC is a new attempt.
- Queries advertise an EDNS UDP payload of 1232. The caller’s DO bit, CD bit and EDNS options are forwarded unchanged.
- DoT server certificates are always verified with rustls. By default the bundled Mozilla (webpki)
roots are trusted; a configured CA set (
TlsRoots::Custom) replaces them. The certificate must be valid for the host, or for the#sniname when given. - DoT keeps one connection per upstream and reuses it. A connection idle longer than
dot_idle(default 30 s) is closed and replaced on the next query; a connection the peer has closed is replaced transparently.
DNSSEC
DNSY implements DNSSEC itself. The dnsy-dnssec crate holds the cryptography and the
dnsy-proto crate holds the record types and the canonical form. The only third-party
crypto is ring for raw signature checks and digests
(plus zeroize to wipe key material); the DNSSEC logic is written in this repository.
This page covers the cryptographic building blocks, signing keys, zone signing, the chain-of-trust validator and the signed authoritative answers.
Algorithms
| Number | Algorithm | Verify | Sign |
|---|---|---|---|
| 8 | RSASHA256 (RFC 5702) | yes | no |
| 10 | RSASHA512 (RFC 5702) | yes | no |
| 13 | ECDSAP256SHA256 (RFC 6605) | yes | yes |
| 14 | ECDSAP384SHA384 (RFC 6605) | yes | no |
| 15 | Ed25519 (RFC 8080) | yes | yes |
Every other algorithm (RSAMD5, DSA, the RSASHA1 family, GOST, Ed448) is reported as
Unsupported; a validator treats a zone signed only with such algorithms as insecure
(RFC 6840 5.2).
Signing is limited to algorithms 13 and 15 because ring cannot generate RSA keys.
Algorithm 13 is the recommended default; 15 is available for zones whose parent accepts it.
RSA keys
DNSKEY RSA keys follow RFC 3110: an exponent length, the exponent, the modulus, with no
leading zero octets. Moduli of 1024 to 8192 bits are accepted (ring’s legacy RSA variants
cover 1024 to 2047 bits). Smaller keys, including the 512-bit key in the RFC 5702 SHA-256
example, are refused with UnsupportedKeySize. Larger ones are refused too.
Verifying an RRSIG
verify_rrsig(rrset, rrsig, key, now) checks, in this order:
- The RRset is non-empty and every record has the RRSIG’s owner, class and covered type;
the RRSIG
labelsfield does not exceed the owner’s label count; the owner is at or below the signer name. - The DNSKEY has the zone-key flag and protocol 3, the algorithm matches the RRSIG and the key tag matches.
- The validity window, with RFC 1982 serial arithmetic.
verify_rrsig_with_skewwidens the window by a clock skew of up to 86400 seconds. - The signature over the RRSIG RDATA (without signature) followed by the RRset in canonical
form (RFC 4034 3.1.8.1), built by
dnsy_proto::canonical::rrset_signing_input.
ECDSA signatures are the fixed-width r || s form and DNSKEY points are the raw x || y
coordinates (RFC 6605); Ed25519 uses the raw 32-octet key (RFC 8080). Malformed keys are
rejected before any signature is tried.
DS records
ds_for(owner, key, digest_type) hashes the lowercased owner name followed by the DNSKEY
RDATA. SHA-256 (2) and SHA-384 (4) are supported; SHA-1 (1) exists so that existing DS
records can be checked, and should not be used to publish new ones.
NSEC3 hashing
nsec3_hash(name, salt, iterations) is the RFC 5155 hash (SHA-1, salted, iterated) of the
lowercased name. Resolvers must bound the iteration count of records they receive, since each
iteration costs one SHA-1 per name (RFC 9276). checked_nsec3_hash rejects iterations above a
caller-chosen maximum (MAX_NSEC3_ITERATIONS is 100, the same cap the validator applies by default and zone signing enforces) and salts longer than 255 octets.
Signing keys
SigningKey::generate(alg, ksk) creates a key, from_pkcs8 loads one, to_pkcs8 exports
it and dnskey() derives the public record (flags 256, or 257 for a key-signing key). The
Debug output never contains key material and the PKCS#8 bytes are zeroized on drop.
from_pkcs8 is strict: truncated documents, wrong key types and an Ed25519 key whose
embedded public key disagrees with its seed are rejected. sign_rrset returns the RRSIG
record, counting wildcard owners correctly in the labels field.
Test vectors
The tests under crates/dnsy-dnssec/tests/vectors/ are copied from RFC 4034 (DS), RFC 5155
(NSEC3 hashes), RFC 5702 (RSA), RFC 6605 (ECDSA) and RFC 8080 (Ed25519). Two RFC quirks are
documented in the vector files: RFC 8080 prints its RRSIGs without the algorithm and with a
labels value of 3 that exceeds the owner’s label count, so DNSY checks those signatures
directly rather than through the strict verify_rrsig; and RFC 5702’s SHA-256 key is below
the 1024-bit floor, so that algorithm is exercised with a 2048-bit key generated with
openssl.
Signing keys
The KeyStore in dnsy-keys keeps each signing zone’s keys in the dnssec_keys table
(migration 0006). A key has a role (csk, ksk or zsk), an algorithm (13 or 15), its key
tag, a state and a timestamp for every state it has reached. Generation uses ring through
SigningKey::generate; RSA keys cannot be generated.
When a zone’s policy enables DNSSEC, bootstrap creates the first keys as active: one CSK,
or a KSK and a ZSK when key_layout is ksk_zsk.
The crate is split by concern: passphrase (the passphrase and where it comes from), sealed
(argon2id and AES-256-GCM, testable without SQLite), repo (the dnssec_keys rows),
rollover (KeyRecord, the rollover planner and the rollover steps) and bundle (backup
bundles). KeyRole and KeyState live in dnsy-core, because the signer in dnsy-dnssec uses
them too, and dnsy-keys re-exports them. The on-disk format of the rows and the bundles did
not change when the key store moved out of dnsy-zone: crates/dnsy-keys/tests/legacy_keystore.rs
opens a database and a bundle written by the old code and checks every key against the PKCS#8
digests the old code produced.
Encryption at rest
Private keys are PKCS#8 documents sealed with AES-256-GCM (ring). The key comes from the
passphrase through argon2id (19 MiB, 2 iterations, 1 lane by default) with a fresh random salt
and nonce for every key; the KDF cost, the salt and the format version are authenticated and
stored with the ciphertext. A wrong passphrase or damaged data yields one generic decryption
error that never includes key material.
The passphrase is read from DNSY_DNSSEC_PASSPHRASE (an empty value counts as unset), else
from the first line of dnssec.key_passphrase_file. Keep that file at mode 0600; DNSY logs
a warning when other users can read it (the check only warns; there is no setting that makes it fatal,
so enforce the mode with your deployment tooling).
Without a passphrase keys are stored unencrypted, DNSY logs a warning at startup and the API
reports keys_encrypted=false. Protect the database file accordingly (mode 0600, owned by
the service user). Key material is zeroized after use (including the sealed key on its way into
the database, the passphrases of POST /dnssec/keys/export and POST /dnssec/passphrase, and
secrets the CLI reads from stdin) and Debug output of keys and passphrases is redacted; keys
and the passphrase are never logged.
Key states and rollover
generated -> published -> active -> retired -> removed
| State | Meaning |
|---|---|
generated | created, not in the DNSKEY RRset |
published | in the DNSKEY RRset, not signing |
active | signing |
retired | no longer signing, still published for validators holding old signatures |
removed | withdrawn; the private key is wiped from the database |
Rollovers are planned by a pure function of the key set, the zone’s timings and the current
time, and a timer task (KeyStore::spawn_timer) applies them. The clock is injectable, which
is how the tests advance days without sleeping.
- ZSK, pre-publish (RFC 6781).
start_rollovercreates and publishes the new ZSK. After the DNSKEY TTL plus the propagation delay (the zone policy’spropagation_delay, default 3600 s) it becomesactiveand the old ZSK isretired. The old key leaves the DNSKEY RRset once its last signature has expired: the maximum signature validity plus the DNSKEY TTL and propagation delay after retirement. - KSK and CSK, double-DS. The new key is published and its DS goes to the parent. It stays
publishedfor as long as it takes: only when the operator confirms the DS at the parent (confirm_ds, exposed through the API) and the DNSKEY TTL has elapsed does it becomeactiveand the old keyretired. DNSY never contacts the registrar.
A generated key never moves by itself. generate_key refuses to make a second one while a
generated or published key of the role exists (RolloverInProgress), and start_rollover
publishes the waiting generated key rather than creating another.
Only one rollover per role runs at a time, and a role needs an active key to roll. Algorithm rollovers are not coordinated beyond this: start one by passing the new algorithm.
Backup and restore
export_bundle writes the zone’s (or every zone’s) non-removed keys as a single blob encrypted
with AES-256-GCM under a bundle passphrase of the operator’s choosing, independent of the
store passphrase (at least 12 characters in the API and CLI; argon2id with 64 MiB and three
passes, where stored keys use the cheaper per-key cost). The API refuses the export route on a
plain-HTTP listener. import_bundle decrypts it, validates every key (PKCS#8, algorithm, key
tag), re-seals the keys under the current store passphrase and skips keys that are already
present. The file variants create the bundle with mode 0600 and refuse to overwrite.
Zone signing
ZoneSigner::sign(zone, keys, policy, now) in dnsy-dnssec turns a zone’s records into a
SignedZone: every RRset keyed by owner (in canonical order, RFC 4034 6.1) and type, each
with its RRSIGs. dnsy-zone supplies the inputs: Zone::signing_data() for the records,
DnssecPolicy::signing_policy() for the policy and KeyStore::active_keys(zone) for the
keys. export_bind(&zone, Some(&signed)) writes the signed zone as a master file.
Parallel signing and re-signing
RRsets (and the NSEC3 owner hashes) are independent, so ZoneSigner spreads them over
std::thread::scope workers, bounded by available_parallelism and started only for zones
with more than about 500 RRsets. Results are collected in canonical order, so the output equals
a single-threaded run for a fixed clock (Ed25519 signatures are deterministic, ECDSA ones are
valid but not byte-equal between runs). ZoneSigner::resign keeps the RRSIG of every RRset
whose data, key and validity window are unchanged; after one record changes, only that RRset
and, when a name is added or removed, its NSEC3 (or NSEC) record and the chain predecessor are
signed again.
A SignedZone also keeps the NSEC3 hash of every chain name. resign reuses those hashes
while the iterations and salt are unchanged and hashes only new names, which matters with a
non-zero iteration count (RFC 9276 recommends 0, where SHA-1 costs little). The cache adds
about one name and 20 bytes per chain name to a snapshot and is dropped when the parameters
change or the zone is signed with NSEC.
A snapshot is built to be held. Each RRset and its signatures sit in their own exactly sized
shared slices (Arc<[Record]>), names longer than 32 octets (every NSEC3 owner) are shared
rather than copied, and the zone is grouped by sorting instead of through a map per owner, so
signing leaves no freed map nodes behind in the heap. resign hands an unchanged RRset
(and its kept signatures) on to the new snapshot as the same allocations, which means the
snapshot being served and the one replacing it cost little more than one. The zone store’s
signature index shares the snapshot’s signatures too. For a 100,000-name zone signed with NSEC3
and ECDSA P-256 on 24 cores, one snapshot raised the peak resident set by about 157 MiB (it was
about 380 MiB: growth slack in the per-set vectors and copies of every long name) and two held
side by side by about 246 MiB (it was about 670 MiB).
Timing and memory harnesses for a 100,000-name zone are ignored tests in dnsy-dnssec; run
them by hand in release mode on a quiet host:
cargo test --release -p dnsy-dnssec big_zone_timing -- --ignored --nocapture
RSS_SNAPSHOTS=1 cargo test --release -p dnsy-dnssec big_zone_rss -- --ignored --nocapture
What is signed
| RRset | Signed by |
|---|---|
| DNSKEY at the apex | every active, published or retired KSK/CSK |
| CDS, CDNSKEY (during a KSK/CSK rollover) | the KSK/CSKs above and the zone-signing keys |
| every other authoritative RRset, NSEC, NSEC3, NSEC3PARAM | every active ZSK/CSK |
| DS at a delegation | the zone-signing keys |
| NS at a delegation, glue and anything else at or below a zone cut | not signed |
The DNSKEY RRset holds every published, active and retired key. A ZSK being pre-published appears in it without signing anything, and a retired ZSK stays until its signatures are gone. During a KSK or CSK rollover the new key is published and signs the DNSKEY RRset next to the old one, and the retired key keeps signing until it is removed, so the RRset validates whichever DS the parent holds, including an old DS still cached by resolvers after the operator confirmed the new one. While that rollover waits for the DS, the apex also carries CDS (SHA-256) and CDNSKEY records for both keys (RFC 7344).
Each RRSIG has the RRset’s TTL as original TTL, a labels count that leaves out a wildcard
label, inception one hour before signing time (for validators with slow clocks) and
expiration signature_validity_days after it. Expirations are jittered per RRset by up to
half of min(refresh_days, signature_validity_days - refresh_days), so re-signing spreads
out instead of happening all at once. The jitter is derived from the owner, type and signing
time, so it is reproducible. Records of one RRset with different TTLs are given the lowest
one (RFC 2181 5.2).
The signer refuses zones without an apex SOA, records outside the origin, DNSSEC records in
the input (it generates them all), DS at the apex or outside a delegation, and policies whose
validity is not longer than refresh_days.
Denial of existence
The NSEC and NSEC3 records and NSEC3PARAM have the TTL min(SOA minimum, SOA TTL) (RFC 9077).
- NSEC (
denial = "nsec"): one NSEC per authoritative name and delegation point in canonical order, the last pointing back to the apex. Glue names and empty non-terminals get none. A delegation’s bitmap lists only NS, DS (when present), RRSIG and NSEC. - NSEC3 (
denial = "nsec3", the default): SHA-1 hashes of the same names plus every empty non-terminal, sorted by hash and linked in a ring, with NSEC3PARAM at the apex. RFC 9276 defaults apply: 0 extra iterations and an empty salt. A non-zero iteration count needsnsec3.allow_nonzero_iterationsand produces a warning. An insecure delegation’s bitmap is just NS, since nothing is signed there. - Opt-out (
nsec3.opt_out = true): delegations without DS are left out of the chain, along with empty non-terminals that exist only because of them, and every NSEC3 record carries the opt-out flag. NSEC3PARAM flags stay 0.
Re-signing
ZoneSigner::resign(zone, keys, policy, previous, now) builds a new snapshot and keeps a
signature from previous when its RRset is unchanged (same TTL and RDATA in canonical form),
its key still has that signing duty, and it expires more than refresh_days from now.
Anything else is signed again. SignedZone::next_resign is the first time some signature
falls inside the refresh window, and needs_refresh(now) says whether that time has come.
Time is always passed in, so tests drive it with a dnsy_core::ManualClock.
Verification
The tests sign zones with algorithms 13 and 15, for NSEC, NSEC3 and NSEC3 with opt-out, and
check every RRSIG with verify_rrsig against the published DNSKEYs. They also check that the
chains cover names that do not exist, and they re-read the BIND export and verify it again.
When BIND’s dnssec-verify or ldns-verify-zone is installed,
dnssec_verify_accepts_signed_export runs it on the exported zone. Without either tool the
test is skipped.
Chain-of-trust validation
Validator::validate(fetcher, question, response, now) checks one upstream response (fetched
with DO=1 and CD=1) and returns a ValidationOutcome: the RFC 4035 4.3 status, an optional
extended DNS error (RFC 8914) and, for secure answers, min_ttl, the lowest of the validated
records’ TTLs, their RRSIG original TTLs and the seconds left until their RRSIGs expire. The
resolver caps cache lifetimes with it. The clock is a parameter, so callers (and tests)
control it.
| Status | Meaning |
|---|---|
Secure | Every RRset on the answer chain, and the denial proof if any, verified up to a trust anchor. |
Insecure | The data lies below a proven unsigned delegation, an NSEC3 opt-out span, a zone using only unsupported algorithms or digests, or a negative trust anchor. |
Bogus | Something should have verified and did not. |
Indeterminate | No trust anchor covers the name, the response code (SERVFAIL, REFUSED, …) carries nothing to validate, or a DS or DNSKEY fetch failed. |
Trust anchors
TrustAnchors holds DS anchors (add_ds), DNSKEY anchors (add_dnskey) and negative trust
anchors (add_negative, or add_negative_until with an expiry; RFC 7646). An expired anchor
stops applying (is_negative takes the validation time), and an answer accepted under an
expiring anchor carries a TTL cap of the time it has left, so caches drop it by then. The configuration’s dnssec.trust_anchors are the root
KSK DS records (key tags 20326 and 38696) and dnssec.negative_trust_anchors are domain
names. A name at or below a negative trust anchor validates as Insecure without any fetch.
The validator always starts from the closest (deepest) anchor above a name.
Building the chain
Missing DS and DNSKEY RRsets are fetched through the DnssecFetcher trait, so the resolver
can supply them over its upstreams and tests can use in-memory zones. For a signer zone (the
RRSIG signer name), the validator walks up with DS queries until it reaches a zone it has
already validated or the anchor, then validates back down:
- At the anchor, the DNSKEY RRset must be signed by a key matching an anchor DS (or equal to an anchor DNSKEY).
- At each level, the DS RRset is verified with the parent’s keys, and the child’s DNSKEY RRset must be signed by a key matching one of those DS records. SHA-1 DS records are ignored when a SHA-256 one exists (RFC 4509).
- If there is no DS, the parent must prove it with a signed NSEC or NSEC3 record: a
delegation without DS (or an opt-out span) makes the subtree
Insecure; a name that is not a delegation stays in the parent zone. An unsigned DS answer proves nothing. - A DS set using only unsupported algorithms or digest types makes the zone
Insecure.
The RRSIG signer must be the zone apex that the walk found, and must lie at or below the anchor that covers the owner. Validated zones and fetched responses are memoised for the rest of the validation.
Unsigned data is never accepted on its own word: it is Insecure only if the walk from the
anchor proves an insecure delegation above it, and Bogus (EDE 10) otherwise.
Answers, CNAME and DNAME
The answer chain is followed from the question name: each CNAME and DNAME RRset is validated
on its own, so a chain that leaves a signed zone for an unsigned one is Insecure and a
broken link anywhere is Bogus. The CNAME synthesized from a DNAME is unsigned (RFC 6672)
and must equal the substitution. Any other RRset in the answer section is validated too, and
an unsigned one next to a secure chain makes the answer Bogus rather than downgrading it.
A positive answer whose RRSIG labels field is shorter than the owner name is a wildcard
expansion; the authority section must prove that the name itself does not exist (RFC 4035
5.3.4, RFC 5155 8.8), using only denial records signed by the zone that signed the wildcard.
When the NSEC3 covering the next closer name has the opt-out flag, the name may exist as an
unsigned delegation, so the expansion is Insecure, not Secure.
DNSKEYs with the REVOKE flag (0x0080, RFC 5011) are never trusted: they are dropped from the
validated key set and verify_rrsig rejects them with VerifyError::Revoked.
Denial of existence
- NSEC (RFC 4035 5.4): NXDOMAIN needs an NSEC covering the name and one covering the wildcard at the closest encloser. NODATA needs the name’s NSEC without the type or CNAME in its bitmap, an NSEC proving an empty non-terminal, or the wildcard’s NSEC. NSEC records from the parent side of a delegation (NS without SOA) prove nothing about the child, and never deny names below themselves; the child’s apex NSEC cannot prove a missing DS (RFC 6840 4.4).
- NSEC3 (RFC 5155 8): closest-encloser proof (matching NSEC3 for the encloser, covering
NSEC3 for the next closer name), wildcard cover for NXDOMAIN, matching NSEC3 for NODATA,
wildcard NODATA, and opt-out: a DS NODATA or NXDOMAIN proven only through an opt-out span
is
Insecure. NSEC3 records with more than 100 iterations make the answerInsecure(RFC 9276 3.2) without being hashed.
A proof never mixes records of different zones. A negative answer is proven with the records
of the deepest zone that encloses the name; a hidden deeper zone cannot be skipped because the
parent’s records show its delegation. If that proof fails, the name’s own chain is walked: an
insecure delegation above it (for example after a CNAME from a signed zone into an unsigned
one) makes the answer Insecure, anything else leaves it Bogus.
Extended DNS errors
| EDE | When |
|---|---|
| 1 Unsupported DNSKEY algorithm | Insecure: the zone uses only unsupported algorithms. |
| 2 Unsupported DS digest | Insecure: the DS set has only unsupported digest types. |
| 5 DNSSEC indeterminate | No trust anchor, or an error response code. |
| 6 DNSSEC bogus | Bad signature, contradicting denial, work limit exceeded, failed fetch. |
| 7 Signature expired | The only RRSIGs tried have expired. |
| 8 Signature not yet valid | The only RRSIGs tried are not yet valid. |
| 9 DNSKEY missing | No DNSKEY matches the DS, the anchor or the RRSIG key tag, or the DNSKEY fetch failed. |
| 10 RRSIGs missing | Data in a secure zone came without signatures. |
| 12 NSEC missing | A negative answer or wildcard expansion lacks its proof. |
Work limits
NSEC3 records with an unknown hash algorithm, or with any flag bit other than Opt-Out set, are ignored when building a denial proof (RFC 5155 8.1 and 8.2).
Each validation runs under ValidationLimits so that a hostile zone cannot exhaust CPU
(KeyTrap, CVE-2023-50387; NSEC3 floods, CVE-2023-50868). Exceeding any limit makes the answer
Bogus with EDE 6.
| Limit | Default |
|---|---|
max_zone_cuts: names walked between the anchor and a signer | 16 |
max_rrsig_verifications: signature checks per RRset | 8 |
max_verifications: signature checks per validation | 64 |
max_fetches: DS/DNSKEY fetches per validation | 32 |
max_nsec3_iterations: above this, NSEC3 proofs are Insecure | 100 (MAX_NSEC3_ITERATIONS) |
max_nsec3_hashes: distinct NSEC3 hashes per validation | 64 |
clock_skew: seconds the RRSIG validity window is widened (dnssec.clock_skew_s) | 300 |
Only RRSIG/DNSKEY pairs whose key tag and algorithm match are tried, the validity window is
checked before any signature math, and DS digest computations count against
max_verifications. A failed DS or DNSKEY fetch is Bogus, not Indeterminate: an attacker
who can block those fetches must not get unvalidated answers served in strict mode.
Tests
crates/dnsy-dnssec/tests/validate.rs builds zones signed with SigningKey (root, TLD and
child zones, NSEC and NSEC3 chains, opt-out, wildcards) and serves them from an in-memory
DnssecFetcher, covering secure chains, insecure delegations, every bogus case above, the
negative trust anchor short-circuit and the work limits.
Authoritative DNSSEC responses
Zones whose dnssec block has enabled = true are served signed. ZoneSigning (in
dnsy-zone) holds the zone store and the key store; Services::open creates it, signs every
such zone before it returns and exposes it as Services::signing; Services::spawn_background
starts its refresh task (ZoneSigning::spawn(interval)). A zone without keys gets its
first key set (KeyStore::bootstrap) on the first signing pass.
Signed snapshots
A signing pass signs a zone when it was never signed, when its file changed, when its keys
changed state (a rollover step) or when some signature is within refresh_days of expiry
(SignedZone::needs_refresh). Re-signing goes through ZoneSigner::resign, so unchanged
RRsets keep their signatures. The signing itself runs on a blocking thread; the finished
snapshot is installed with the same atomic swap the zone store uses for reloads, so queries
never wait for it and always see one complete version.
A reload that changes a signed zone does not expose unsigned data: the previous version keeps
serving, signed, until the refresh task (woken by the reload) has signed the new one, then
both are swapped in together. A zone that has never been signed (the window between startup
and the first pass, or a zone whose keys cannot be decrypted) is served unsigned and the error
is logged. Disabling dnssec drops the signed snapshot on the next reload.
What a DO query gets
Without the EDNS DO bit nothing changes: no RRSIG, NSEC or NSEC3 is added and an ANY query
leaves the NSEC record out. DNSKEY, NSEC3PARAM, CDS and CDNSKEY are ordinary data at the apex
and are answered when asked for, with or without DO.
With DO (RFC 4035 3.1, RFC 5155 7.2):
| Answer | Added records |
|---|---|
| Data, CNAME chains, additional A/AAAA | the RRSIGs of every RRset (wildcard expansions carry the RRSIG under the query name, its labels field showing the expansion) |
| Wildcard expansion | NSEC covering the query name, or NSEC3 covering the next closer name |
| NXDOMAIN | SOA and RRSIG; NSEC covering the name and NSEC covering the wildcard at the closest encloser, or the NSEC3 closest-encloser proof (matching closest encloser, covering next closer name, covering wildcard) |
| NODATA | SOA and RRSIG; the NSEC or NSEC3 matching the name (for an NSEC empty non-terminal, the NSEC whose span holds it) |
| Wildcard NODATA | the NXDOMAIN-style proof for the name plus the NSEC/NSEC3 matching the wildcard |
| Referral to a signed child | NS, DS and its RRSIG, glue |
| Referral to an unsigned child | NS, the NSEC/NSEC3 matching the cut (bitmap without DS) and its RRSIG; under NSEC3 opt-out, the closest provable encloser proof with the opt-out NSEC3 covering the cut |
Delegation NS records and glue are never signed. DS queries are answered by the parent side of the cut (RFC 4035 3.1.4.1), with a NODATA proof when the delegation has no DS. NSEC3 owner names do not exist as names: a query for one gets NXDOMAIN (RFC 5155 7.2.8).
Authoritative answers set AA and never AD (AD speaks for validated data, RFC 4035 3.1.6);
referrals set neither. When a signed answer does not fit the client’s UDP payload size, the
response is truncated by whole RRsets with TC set so the client retries over TCP; signatures
are never dropped to make an answer fit.
Verification
The tests in crates/dnsy-zone/src/signed_tests.rs sign zones with NSEC, NSEC3 and NSEC3
opt-out, then check every RRSIG of the responses with verify_rrsig against the zone’s DNSKEYs
and check that every proof record matches or covers the names it must (hash order for NSEC3,
canonical order for NSEC). signed_responses_validate_secure feeds the responses to the
chain-of-trust validator, anchored at the zone’s DS: answers, CNAME chains, NODATA, wildcard
answers and wildcard NODATA, NXDOMAIN and DS validate Secure under NSEC, NSEC3 and NSEC3
opt-out (except an NXDOMAIN inside an opt-out span, which is Insecure by RFC 5155 9.2), and
data below the unsigned delegation validates Insecure. The engine test
from_config_serves_signed_zone_with_aa_never_ad runs a DO query through the full pipeline.
Validation in the forwarding path
Forwarded answers are validated according to dnssec.validation:
| Mode | Upstream query | Bogus answer | Status |
|---|---|---|---|
off | the client’s DO and CD bits | served as received | unchecked |
permissive | DO=1, CD=1 | served without AD, with the validator’s EDE, logged as a warning | validated |
strict (default) | DO=1, CD=1 | SERVFAIL with the validator’s EDE | validated |
Validating modes always ask upstream with DO=1 and CD=1, so the upstream returns signatures
and denial records but does not validate (and SERVFAIL) for us. The raw answer is validated,
then scrubbed for the client: RRSIG, NSEC and NSEC3 records are returned only to clients that
set DO. Secure and Insecure answers are served; Indeterminate answers (no trust anchor
covers the name) are served without AD.
AD and CD
- AD is set only when the status is
Secureand the client set DO or AD (RFC 6840 5.7). It is cleared in every other case, including answers served from the cache. - A client that sets CD=1 gets no validation: its query goes upstream with CD=1, the answer is
returned as received (status
unchecked), and it is cached under the CD=1 key. Those entries are never served to CD=0 clients, and a CD=0 client’s Bogus entry is never served to anyone as validated data.
Extended DNS errors on Bogus answers
The EDE is the validator’s reason (see the table above): 6 DNSSEC bogus (bad signature, work limit exceeded), 7 signature expired, 8 signature not yet valid, 9 DNSKEY missing, 10 RRSIGs missing, 12 NSEC missing. It is stored with the cache entry, so cached replies carry the same code.
A DS or DNSKEY fetch that fails or times out is a transport problem, not evidence of an
attack: the status is Indeterminate with EDE 5, never Bogus, so it is not cached for
bogus_ttl. In strict mode the client gets SERVFAIL with EDE 5, cached for servfail_ttl,
so unvalidated data is never handed on and a blip is retried within seconds. In permissive
mode the answer is served without AD, with its TTLs lowered to indeterminate_ttl (5 s).
Cache
- The status is stored with each cache entry. A
Bogusentry lives at mostcache.bogus_ttlseconds, is returned to CD=0 clients only as SERVFAIL (strict) or without AD (permissive), and never replaces a liveSecureentry. - The lifetime of validated data is capped by its signatures: record TTLs are lowered to the
validator’s
min_ttl(the lowest record TTL, RRSIG original TTL and seconds until the RRSIGs expire), so a cachedSecureanswer never outlives its RRSIGs. - DS and DNSKEY answers fetched for validation are memoised in the same cache under the
DO=1/CD=1 key with status
unchecked; the validator checks them on every use. That entry is also what a CD=1 client asking for the same name receives, so the fetcher first strips everything but the asked RRset, its RRSIGs and the denial records of a negative answer. - Changing the validation mode (
Engine::set_dnssec_mode) or the negative trust anchors (Engine::set_negative_trust_anchors, fromdnssec.negative_trust_anchorsat start or the API at run time) flushes the cache.
Amplification bounds
One client query cannot make the validator fetch without limit:
ValidationLimitscaps a validation at 32 DS/DNSKEY fetches and 64 signature checks (see Work limits above);- at most 64 validation fetches are in flight across all clients; a fetch waits for a slot only until its deadline;
- every fetch of one validation shares a deadline of
resolver.timeout_ms * resolver.attempts, so a slow upstream turns the answer Bogus instead of hanging the client; - fetched DS/DNSKEY sets are cached, so popular zones cost one fetch per TTL.
Clock safety
A device without a real-time clock (a Raspberry Pi, for one) may boot in 1970, when every
RRSIG looks “not yet valid”. Validating then would SERVFAIL every name, including the NTP
server that would fix the clock. So while the system clock is earlier than the build time
(passed by the binary through EngineBuilder::build_time; 2026-10-01 by default), DNSY:
- does not validate: answers are served without AD and logged as
indeterminate; - logs a warning once, and
/readyzreturns"warnings": ["dnssec_clock_untrusted"]; - resumes validation as soon as the clock is plausible, flushing the cache of the
indeterminateentries.
Query log and statistics
Every query event carries its validation status (secure, insecure, bogus,
indeterminate, unchecked), whether the answer came from the upstream or the cache. The
query log stores it and the statistics count it per status (by_dnssec, with secure and
bogus totals).
Managing DNSSEC through the API
The REST API (see REST API) drives everything above without editing files.
All changing calls need the admin scope.
Enable signing. PUT /api/v1/zones/example.com./dnssec with {"enabled": true} writes the
dnssec block of the zone file (defaults: algorithm 13, one CSK, NSEC3 with zero iterations, 14 day
signatures refreshed 5 days before expiry), creates the keys and signs the zone, so the next
DO query already gets signed answers. Only algorithms 13 and 15 are accepted. Other policy fields
(key_layout, denial, nsec3, signature_validity_days, refresh_days, dnskey_ttl, propagation_delay) can be
sent in the same or a later call; a field left out keeps its value. The algorithm of existing keys
changes only through a rollover with a new algorithm.
Publish the DS. GET .../dnssec/ds returns the DS records (SHA-256 and SHA-384) of the
published and active KSKs or CSKs, plus the DNSKEY records for registrars that take keys. Send
Accept: text/plain for lines to paste into a registrar form. The digests are computed from the
same DNSKEY the zone serves.
Roll a key. POST .../dnssec/keys/{id}/rollover takes the id of an active key, generates its
successor and publishes it (202). A ZSK rollover finishes by itself once the DNSKEY TTL and
propagation time have passed. A KSK or CSK rollover waits: publish the new key’s DS at the parent
(GET .../dnssec/ds lists it already), then call POST .../dnssec/keys/{new id}/ds-confirmed.
GET .../dnssec reports the state as idle, in_progress or awaiting_ds_confirmation, with
every rollover under way in rollover.rollovers. One rollover per role runs at a time; a second
request is 409. POST .../dnssec/rollover/abort (optionally {role}) withdraws the incoming key
and keeps the active one (KeyStore::abort_rollover); 409 once the new key has become active.
dnsy dnssec rollover abort and dnsy dnssec ds-confirmed do the same from the CLI.
Key backup and passphrase. POST /api/v1/dnssec/keys/export returns the keys as a bundle
encrypted under a given passphrase, and POST /api/v1/dnssec/passphrase re-encrypts all keys
under a new key passphrase atomically (KeyStore::change_passphrase, which blocks key creation
and decryption while it runs). The CLI has dnsy dnssec keys export|import and
dnsy dnssec passphrase change. See the operator guide.
Signed export. GET .../export?signed=true downloads the zone as a BIND file with the DNSKEY,
RRSIG, NSEC or NSEC3 and NSEC3PARAM records of the current signed snapshot.
Validation at run time. GET/PATCH /api/v1/settings/dnssec shows and changes the validation
mode and the negative trust anchors (RFC 7646) without a restart. Names are lowercased and
de-duplicated; an entry may carry an expires_at after which the anchor stops applying; the root
is refused because it would disable validation (use mode off). Any change
flushes the cache, since cached entries carry statuses computed under the old settings. Trust
anchors are read-only: they come from the configuration, and the API lists them with their key
tags. Changes are not saved to the configuration file. GET /api/v1/dnssec/status adds the
answer counts by DNSSEC status for the last 24 hours and whether the clock is trusted.
Secrets. Private keys stay in the key store. No API response, error body or log line contains
them, and the OpenAPI schemas have no field for them. keys_encrypted in the zone’s DNSSEC view
tells whether new keys are encrypted at rest; when it is false, set a key passphrase.
Managing DNSSEC in the web UI
The web UI drives the same API (see Web UI). The zone’s DNSSEC tab enables
or disables signing, shows keys with their timers and starts rollovers, and lists the DS records
to paste at the registrar (SHA-256 first). Enabling and rollovers ask for confirmation and explain
the registrar steps, because a missing or stale DS makes validating resolvers fail the zone. The
DNSSEC page under the main navigation sets the validation mode (strict, permissive, off;
weaker modes ask for confirmation), manages negative trust anchors and lists the configured trust
anchors read-only.
DNSSEC operator guide
This guide is the runbook for running DNSSEC with DNSY: turning on validation, signing a zone,
handing the DS to your registrar, and rolling keys safely. Reference material (record types,
the validator, the API) is on the DNSSEC page. Every command below was run against
a local dnsy build; outputs are copied from those runs (key tags, digests and timestamps
differ on your system).
Commands that talk to the database (dnsy dnssec ..., dnsy zone export --signed) read the
same configuration file and the same passphrase as dnsy serve, so export
DNSY_DNSSEC_PASSPHRASE (or set dnssec.key_passphrase_file) in the shell you run them from.
API examples use $A for Authorization: Bearer <token>; a token for the calls below needs
the scopes api:read, api:write and admin (scopes are not hierarchical).
Concepts in two paragraphs
DNSSEC lets a resolver prove that an answer came from the zone owner. A zone signs each record
set with a zone signing key (ZSK) and publishes the signatures (RRSIG) and public keys
(DNSKEY). A key signing key (KSK) signs the DNSKEY set, and a hash of it, the DS
record, is stored in the parent zone. That makes a chain from the root’s trust anchor
down to your data. Missing names are proven absent with NSEC or NSEC3 records. A validator
ends in one of five states: secure (chain verified), insecure (provably no DNSSEC, for
example no DS at the parent), bogus (the chain exists but fails), indeterminate (no trust
anchor covers the name) or unchecked (not validated).
Clients steer it with three header bits: DO asks for DNSSEC records, CD (checking
disabled) asks the resolver not to validate, and AD in the reply says the data was
validated. DNSY sets AD only when the status is secure and the client set DO or AD.
Remember that a bogus answer is worse than an unsigned one, because the
resolver refuses it. Every DNSSEC change is therefore a timing problem: caches must never see
signatures, keys or DS records that do not match.
Enabling validation
dnssec.validation in dnsy.toml:
| Mode | Behaviour |
|---|---|
strict (default) | bogus answers become SERVFAIL with an extended DNS error |
permissive | bogus answers are served without AD, with the EDE, and logged as a warning |
off | no validation, the client’s DO/CD bits go upstream unchanged |
[dnssec]
validation = "strict"
clock_skew_s = 300
Validation needs at least one trust anchor; dnsy config check refuses the file otherwise.
A running server’s mode can be changed without a restart (not persisted; a restart returns to
the file):
curl -s -X PATCH -H "$A" -H 'Content-Type: application/json' \
-d '{"validation":"permissive"}' http://127.0.0.1:5380/api/v1/settings/dnssec
Trust anchor and its update
The defaults are the DS records of the two root KSKs (key tags 20326 and 38696):
curl -s -H "$A" http://127.0.0.1:5380/api/v1/settings/dnssec | jq -c '.trust_anchors[]'
{"owner":".","key_tag":20326,"algorithm":8,"kind":"ds","digest_type":2,"digest":"E06D44B8...EC8D"}
{"owner":".","key_tag":38696,"algorithm":8,"kind":"ds","digest_type":2,"digest":"683D2D0A...2B16"}
Anchors are read-only through the API. RFC 5011 automatic updates are not implemented:
when the root KSK changes (the next one is announced by IANA months ahead), add the new DS
to dnssec.trust_anchors, or to trust_anchor_file (one DS per line in the form
keytag algorithm digest-type digest-hex; blank lines and lines starting with # or ; are
skipped), then send SIGHUP (kill -HUP $(pidof dnsy)). The reload re-reads [dnssec] from
the file, swaps the anchors, keeps the current mode and negative trust anchors, and flushes the
cache. A malformed anchor keeps the previous set and logs a warning. Keep the old DS until
the old KSK is retired by IANA.
Negative trust anchors
A negative trust anchor (NTA, RFC 7646) makes a name and everything below it validate as
insecure, as an emergency measure when a remote zone is broken and you cannot wait for its
owner. Set it at run time or in dnssec.negative_trust_anchors:
curl -s -X PATCH -H "$A" -H 'Content-Type: application/json' \
-d '{"negative_trust_anchors":["dnssec-failed.org"]}' http://127.0.0.1:5380/api/v1/settings/dnssec
The list replaces the old one and the cache is flushed. An entry is a name, or an object with an expiry (Unix time in the future); the anchor stops applying by itself at that moment:
curl -s -X PATCH -H "$A" -H 'Content-Type: application/json' \
-d '{"negative_trust_anchors":[{"name":"dnssec-failed.org","expires_at":1893456000}]}' \
http://127.0.0.1:5380/api/v1/settings/dnssec
GET returns negative_trust_anchors (names) and negative_trust_anchor_entries (name and
expires_at, null for none); expired anchors are left out. Answers accepted because of an
expiring anchor are cached for no longer than the time it has left, so nothing outlives it.
The web UI takes the expiry as hours from now. Give an NTA an expiry whenever you can: without
one it stays until you remove it. The root cannot be an NTA (422, “use mode off”). NTAs set
through the API are lost on restart; put long-lived ones in the file and remove them as soon
as the zone is fixed. While an NTA is active the reply still carries the EDE explaining why
the chain does not validate.
Clock requirements
Signatures carry absolute validity times, so a wrong clock turns good data bogus. Keep NTP
running. clock_skew_s (default 300) is the tolerance applied to both ends of a signature’s
validity. If the system clock is earlier than the binary’s build time (a device that boots in
1970), DNSY does not validate at all: answers are served without AD and logged as
indeterminate, /readyz warns dnssec_clock_untrusted, and the web UI shows a banner.
GET /api/v1/dnssec/status reports clock_trusted. Validation resumes by itself once the
clock is plausible.
Extended DNS errors
A SERVFAIL (strict) or AD-less answer (permissive) carries an EDE option (RFC 8914):
| EDE | Meaning | Usual cause |
|---|---|---|
| 1 Unsupported DNSKEY algorithm | zone treated as insecure | zone signed only with an algorithm DNSY cannot verify |
| 2 Unsupported DS digest | zone treated as insecure | DS uses only unknown digest types |
| 5 DNSSEC indeterminate | no trust anchor, or an error code | misconfigured anchors |
| 6 DNSSEC bogus | signature does not verify, proof contradicts, or DS/DNSKEY fetch failed | tampering, broken signing, unreachable name servers |
| 7 Signature expired | only expired RRSIGs | zone owner stopped re-signing |
| 8 Signature not yet valid | only RRSIGs from the future | signer clock ahead, or yours behind |
| 9 DNSKEY missing | no DNSKEY matches the DS or RRSIG | DS at the parent does not match the zone’s keys |
| 10 RRSIGs missing | unsigned data in a signed zone | zone unsigned but DS left at the parent |
| 12 NSEC missing | negative answer without proof | broken denial chain |
Reading the query log
Each log row has a dnssec field: secure, insecure, bogus, indeterminate or
unchecked. The Query log page of the web UI has a DNSSEC filter and a lock or warning badge
per row; the API takes dnssec=bogus:
curl -s -H "$A" 'http://127.0.0.1:5380/api/v1/querylog?dnssec=bogus&limit=2' | jq -c '.entries[]'
{"id":6,"qname":"sigfail.verteiltesysteme.net.","qtype":"A","rcode":"SERVFAIL","source":"upstream","source_detail":"1.1.1.1:53","dnssec":"bogus",...}
GET /api/v1/dnssec/status gives the counts for the last 24 hours:
{"validation":"strict","clock_trusted":true,"window_ms":86400000,"counters":{"bogus":2,"secure":4,"unchecked":2},"secure":4,"bogus":2,"signed_zones":0}
The log record does not store the EDE; get it with dig (below).
Troubleshooting a Bogus answer
Ask DNSY with DO and read the EDE:
$ dig @127.0.0.1 -p 5353 dnssec-failed.org A +dnssec
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 35469
; EDNS: version: 0, flags: do; udp: 1232
; EDE: 9 (DNSKEY Missing): (no DNSKEY at dnssec-failed.org. matches the DS or trust anchor)
Then repeat with +cd, which skips validation, to see the data DNSY refuses:
$ dig @127.0.0.1 -p 5353 dnssec-failed.org A +dnssec +cd
;; flags: qr rd ra cd; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1
dnssec-failed.org. 117 IN A 96.99.227.255
dnssec-failed.org. 117 IN RRSIG A 13 2 300 ...
If +cd works and the plain query fails, the problem is in the zone’s chain, not in the
network. If the same name also fails against a public validating resolver
(dig @1.1.1.1 name), it is broken at the source: wait, contact the owner, or add an NTA.
delv shows where the chain breaks:
$ delv @127.0.0.1 -p 5353 cloudflare.com A
; fully validated
cloudflare.com. 51 IN A 104.16.132.229
$ delv @127.0.0.1 -p 5353 dnssec-failed.org A +cd +vtrace
;; validating dnssec-failed.org/A: fetch_callback_dnskey: got failure
;; broken trust chain resolving 'dnssec-failed.org/A/IN': 127.0.0.1#5353
;; resolution failed: broken trust chain
delv uses BIND’s built-in root anchor, so it checks DNSY’s answers independently of DNSY’s
own validator. Also check your own clock first (timedatectl) and the cache: a change of
mode or NTAs flushes it, DELETE /api/v1/cache does too.
Signing a zone
Policy options
Signing is configured per zone in the dnssec block of its JSON file (see
Zones); the API writes the same block.
| Field | Default | Notes |
|---|---|---|
enabled | false | creates keys and signs on the next pass |
algorithm | 13 | 13 ECDSAP256SHA256 or 15 Ed25519 only |
key_layout | csk | csk (one key) or ksk_zsk (two keys) |
denial | nsec3 | nsec or nsec3 |
nsec3 | iterations 0, empty salt, no opt-out | see below |
signature_validity_days | 14 | |
refresh_days | 5 | re-sign this long before expiry; must be below the validity |
dnskey_ttl | 3600 | drives rollover timing |
propagation_delay | 3600 | seconds added to dnskey_ttl before a published key may sign; 0 to 604800 |
Enable by editing the file (hot reload picks it up within a second or so, and the timer signs
within a minute), or through the API. Enabling is idempotent: sending enabled: true again, or
concurrently with a zone reload, never creates a second key set:
curl -s -X PUT -H "$A" -H 'Content-Type: application/json' \
-d '{"enabled":true,"key_layout":"ksk_zsk"}' \
http://127.0.0.1:5380/api/v1/zones/example.com./dnssec
Check the result:
$ dnsy dnssec keys list example.com
1 ksk alg 13 tag 18887 active ds-confirmed: false
2 zsk alg 13 tag 44434 active ds-confirmed: false
Algorithm 8 or 14 is refused (422 algorithm 8 is unsupported (use 13 or 15); the CLI says
algorithm 8 cannot sign (use 13 or 15)). Disabling ("enabled": false) stops signing and
keeps the keys; enabling again reuses them. Never disable signing while a DS is at the
parent: the zone becomes bogus. Remove the DS and wait for its TTL first.
Algorithm choice
Use 13 (the default): universally supported by registries and resolvers, small signatures.
Choose 15 (Ed25519) only if your parent zone and registrar accept it. RSA cannot be generated
(the crypto library lacks key generation), though DNSY validates RSA-signed zones. Changing
algorithm is a rollover with a new --algorithm, see below.
NSEC or NSEC3, and opt-out
nsec3(default) hashes names, so the zone cannot be walked by listing NSEC records. DNSY follows RFC 9276: 0 extra iterations and an empty salt. More iterations are refused (422) unlessallow_nonzero_iterationsis set, which only adds validator cost. Check:dig @127.0.0.1 -p 5353 example.com NSEC3PARAM +noall +answerreturnsexample.com. 300 IN NSEC3PARAM 1 0 0 -.nsecis simpler to debug and cheaper, but lists every name. Use it for public data.nsec3.opt_out: trueleaves insecure delegations out of the chain. It only helps zones with very many unsigned delegations (a TLD); for an ordinary zone leave it off.
Key encryption passphrase
Private keys are sealed with AES-256-GCM under a key derived by argon2id from the passphrase.
Supply it as DNSY_DNSSEC_PASSPHRASE (an empty value counts as unset) or as the first line of
dnssec.key_passphrase_file. Keep the file at mode 0600; with wider permissions DNSY logs
passphrase file is accessible to other users; use mode 0600.
Set it before the first key is created. Without a passphrase the keys are stored
unencrypted, dnsy warns private keys are stored unencrypted at every start and the API
shows keys_encrypted: false. Store the passphrase in your secret manager, apart from the
database backups. A lost or wrong passphrase makes the keys unusable:
$ DNSY_DNSSEC_PASSPHRASE=wrong dnsy zone export example.com --signed
ERROR dnsy_zone::signed: zone signing failed zone=example.com. error=cannot decrypt key material (wrong passphrase or corrupted data)
dnsy: zone `example.com.` could not be signed (see the log)
Changing the passphrase
The change re-encrypts every stored key under the new passphrase in one database transaction: all keys move or none does. It also encrypts keys that were stored unencrypted, so it is the way to add a passphrase to an existing installation. While it runs no key is created or read.
# offline, with the current passphrase in the environment:
DNSY_DNSSEC_PASSPHRASE=old-phrase DNSY_DNSSEC_NEW_PASSPHRASE=new-phrase \
dnsy dnssec passphrase change
# or against the running server (admin token); it switches to the new passphrase at once:
curl -s -X POST -H "$A" -H 'Content-Type: application/json' \
-d '{"new_passphrase":"new-phrase"}' http://127.0.0.1:5380/api/v1/dnssec/passphrase
Passphrases need at least 12 characters. The CLI also reads the new passphrase from stdin
(--new-passphrase-stdin). Afterwards update DNSY_DNSSEC_PASSPHRASE or the passphrase
file before the next start. A server started with the old passphrase cannot read the keys:
it does not serve the zone unsigned (the DS would still be at the parent and validators would
fail it anyway). The zone answers SERVFAIL, the failure is logged once, and /readyz answers
503 with dnssec_signing_failed until the passphrase is right again.
After the offline command, restart a running server with the new passphrase. The passphrase
never appears in logs or responses.
Where the keys live and backups
Keys are rows of the dnssec_keys table in <server.data_dir>/dnsy.db (default
/var/lib/dnsy/dnsy.db); the public part is also in the DNSKEY records. Two backups work:
-
An encrypted key bundle: every non-removed key (or one zone’s), sealed with AES-256-GCM under a bundle passphrase that may differ from the key passphrase:
DNSY_DNSSEC_BACKUP_PASSPHRASE=bundle-phrase dnsy dnssec keys export keys.bundle DNSY_DNSSEC_BACKUP_PASSPHRASE=bundle-phrase dnsy dnssec keys import keys.bundle curl -s -X POST -H "$A" -H 'Content-Type: application/json' \ -d '{"passphrase":"bundle-phrase","zone":"example.com."}' \ http://127.0.0.1:5380/api/v1/dnssec/keys/export # {"bundle":"<base64>"}The CLI creates the file with mode 0600 and refuses to overwrite one;
--zonelimits it and--passphrase-stdinreads the passphrase from stdin. Import re-seals the keys under the store’s passphrase and skips keys that already exist. The API export needs the admin scope (the response is ciphertext only) and is refused with 403 on a plain-HTTP API listener: the request carries the bundle passphrase and the response every private key, so serve the API over TLS ([api] tls) or reach it through a TLS-terminating proxy. The bundle passphrase needs at least 12 characters and is stretched with argon2id at 64 MiB and three passes. Keep it apart from the bundle. -
The SQLite database, taken as described in Operations, plus the passphrase kept separately.
Test a restore: start a copy with the passphrase and run
dnsy dnssec keys ds <zone>; the DS must equal the one at your registrar. The database file is
created with the process umask (0644 in the test above); restrict data_dir to the service
user. Removed keys have their private key wiped from the row.
Giving the DS to the registrar
Print the DS records of the active and published KSKs (or CSK):
$ dnsy dnssec keys ds example.com
example.com. IN DS 18887 13 2 44843D658634C024A33044B2DC4CC81AE9F4A9B52585C59C779D3A3520BC029A
example.com. IN DS 18887 13 4 3538DCDF647691CDFAA57BD3E934B3A456AD085CA7B3B3F40D7CBBC009253BE97E1D10734CD77979E4C131C2393254B1
Or through the API (GET with Accept: text/plain prints the same lines plus the DNSKEY
record; the default JSON has ds and dnskey arrays):
$ curl -s -H "$A" -H 'Accept: text/plain' http://127.0.0.1:5380/api/v1/zones/example.com./dnssec/ds
example.com. IN DS 18887 13 2 44843D65...C029A
example.com. IN DS 18887 13 4 3538DCDF...54B1
example.com. IN DNSKEY 257 3 13 Y4GtDCPqSB/MTPrMTtnKV1N5...
The fields are key tag, algorithm (13), digest type, digest. Digest type 2 is SHA-256 and
4 is SHA-384. Registrars normally take one DS: submit the SHA-256 one (type 2); some
accept several. Registrars that ask for the DNSKEY instead of the DS need the 257 (KSK/CSK)
record from the text output. The steps in the registrar’s panel are theirs (usually
“DNSSEC” or “DS records” on the domain); the web UI’s DNSSEC tab is introduced with DNSY-52 and
is not covered here, use the CLI or API.
Before submitting, make sure DNSY already serves the signed zone from every authoritative server, and nothing else signs the same zone. After submitting, wait for the parent’s DS TTL (often 1 hour to 1 day) and verify:
dig +short DS example.com @a.gtld-servers.net # replace with your TLD's server; DS must appear
dig +dnssec www.example.com A @<your resolver> # flags must include ad
delv www.example.com A # "; fully validated"
Also try an independent validating resolver, such as dig @1.1.1.1 www.example.com +dnssec.
This chain could not be exercised against a real parent in the documentation tests.
Safe key rollover runbook
What DNSY does
Keys move through generated -> published -> active -> retired -> removed. A timer runs every
60 seconds, advances the states and the zone is re-signed on the pass after a change, so
changes show up within about a minute of their due time. Timings used (for dnskey_ttl T and
a signature validity V of 14 days):
- Propagation (P) is the zone policy’s
propagation_delay, default 3600 s, on top of T. Raise it when secondaries or resolvers are slow to see a new DNSKEY. - A published key may become active after
T + Ps (3600 + 3600 with the defaults). - A retired key is removed (and its private key wiped)
T + P + Vafter retirement, so that the last signature it made has expired. - A ZSK rolls by itself (pre-publish). A KSK or CSK also waits for you to confirm that its DS is at the parent (double-DS); DNSY never contacts the registrar.
- A retired KSK or CSK keeps signing the DNSKEY RRset until it is removed, so the RRset stays valid for resolvers that still hold the old DS (a parent’s DS TTL is often 24 to 48 hours). Confirming the DS never takes the old signature away.
- One rollover per role at a time. A second start answers
dnsy: a zsk rollover is already in progress for this zone(API: 409). A role needs an active key to roll. - A key made with
POST /zones/{name}/dnssec/keysis onlygenerated: it stays inert, and the same 409 stops a second one while it exists. Starting a rollover of that role publishes it (it keeps its algorithm) instead of making a new key; abort withdraws it.
ZSK rollover (about 2 hours plus the retire wait, no registrar)
$ dnsy dnssec keys rollover example.com --role zsk
rollover started: new key 3 (tag 31218, published)
$ dnsy dnssec keys list example.com
1 ksk alg 13 tag 18887 active ds-confirmed: false
2 zsk alg 13 tag 44434 active ds-confirmed: false
3 zsk alg 13 tag 31218 published ds-confirmed: false
With T = 3600: after about 2 hours the new ZSK is active and the old one retired; the zone is
re-signed with the new key. Fourteen days and two hours later the old key is removed.
--algorithm 15 starts an algorithm change at the same time (the default is 13, so pass it
explicitly if the zone uses 15). Verified by moving the key timestamps in the database
forward: both transitions happened on the next timer pass and the signatures of www changed
from key tag 44434 to 31218 on the pass after.
Algorithm rollover: double signing
When the new key has a different algorithm than the active key of its role (for example 13 to 15), every algorithm in the DNSKEY RRset must have a complete set of signatures (RFC 4035 2.2, RFC 6781 4.1.4). DNSY therefore double-signs the whole zone for the length of the rollover, instead of letting the new key wait unused as in a same-algorithm pre-publish:
- From the moment the new key is
published, it signs every RRset beside the old key, so validators holding either DNSKEY see a complete chain. - A
retiredkey of the old algorithm keeps signing zone data until it isremoved, for the same reason it keeps signing the DNSKEY RRset. - A published or retired key whose algorithm an active key of the same kind already covers (a same-algorithm rollover) does not sign zone data, so nothing changes there.
- Costs: while double-signing, the zone carries two RRSIGs per RRset, so responses with DNSSEC OK and zone transfers grow, and signing takes about twice the CPU.
For a KSK or CSK algorithm change the DS record for the new algorithm still waits for your confirmation as above; the double signatures keep the zone valid for resolvers on either DS.
KSK or CSK rollover (needs the registrar)
-
Start it:
dnsy dnssec keys rollover example.com --role ksk(API:POST /api/v1/zones/example.com./dnssec/keys/{id}/rolloverwith the active key’s id, answers 202 with the new key). Forkey_layout: cskuse--role csk. -
Print the DS:
dnsy dnssec keys ds example.comnow lists the old and the new DS:example.com. IN DS 18887 13 2 4484... example.com. IN DS 24686 13 2 4AD6ECC42C422994954E9B2A248781E53AC16A978FA7D1C983CCE704524B0844Add the new DS at the registrar and keep the old one.
-
Wait for the parent to publish it, and then for the DS TTL of the old DS records to pass, so every cache holds both. Check with
dig +short DS example.com @<parent server>. -
Confirm to DNSY, with the CLI (it picks the one published KSK or CSK waiting for a DS;
--key <id>names it when there are several), the API or the zone’s DNSSEC tab:$ dnsy dnssec ds-confirmed example.com DS of key 4 confirmed $ curl -s -X POST -H "$A" -w '%{http_code}\n' \ http://127.0.0.1:5380/api/v1/zones/example.com./dnssec/keys/4/ds-confirmed 204 -
DNSY activates the new key once
T + Ps have passed since publication, retires the old one and re-signs the DNSKEY set with the new KSK. WatchGET /api/v1/zones/example.com./dnssec(rollover.stateisin_progressand key 4 showsds_confirmed_at; it returns toidlewhen done) ordnsy dnssec keys list. -
When the new KSK is active and
Tplus the propagation time have elapsed, remove the old DS at the registrar. DNSY does not track this step, and it removes the old DNSKEY afterT + P + V; remove the old DS well before that, never after.
Timeline example (T = 3600, parent DS TTL 24 h)
| When | Action |
|---|---|
| day 0 | start rollover, add the new DS at the registrar |
| day 0 + parent publication + 24 h | confirm the DS (earlier is allowed; this is on you) |
| confirm + about 2 h | new KSK active, old retired, DNSKEY re-signed |
| +2 h and the DS TTL 24 h | remove the old DS at the registrar |
| +14 d 2 h after retirement | old DNSKEY removed, private key wiped |
The rule behind it: never remove something that a cache may still need. A resolver may hold the old DNSKEY set for T, the old DS for the parent’s DS TTL, and signatures for V.
Monitoring
dnsy dnssec keys list <zone>andGET .../dnssec(keys,next_resign,rollover).dig @127.0.0.1 -p 5353 example.com DNSKEY +shortduring a rollover shows the extra keys.- An external validator (
delv,dig @1.1.1.1 ... +dnssec,adflag) at each step. GET /api/v1/dnssec/statusand the query log for a rise inbogus.- The server logs
dnssec key states advanced transitions=Nfor every change.
The rollover view lists every rollover under way in rollover.rollovers (role, state, new
key id), so a KSK and a ZSK rollover running together both show, in the API and in the UI.
(rollover.state, role and new_key_id summarise: awaiting_ds_confirmation wins, and the
last two name the first entry.) Doing ZSK and KSK rollovers one after the other is still the
easier path.
Abort and rollback
A rollover that has not yet switched keys can be aborted: the incoming key is withdrawn (its private key destroyed), the current key stays active, and the DNSKEY set is re-signed without the new key. A new rollover can start right after.
$ dnsy dnssec rollover abort example.com # every rollover of the zone
$ dnsy dnssec rollover abort example.com --role ksk # only the KSK one
aborted ksk rollover: key 4 (tag 51234) withdrawn; its DS was confirmed, remove it at the registrar
$ curl -s -X POST -H "$A" -H 'Content-Type: application/json' -d '{"role":"ksk"}' \
http://127.0.0.1:5380/api/v1/zones/example.com./dnssec/rollover/abort
The API answers with the zone’s DNSSEC state; the zone’s DNSSEC tab has an Abort rollover button that asks for confirmation. It answers 409 when no rollover is in progress. Admin scope is required.
- Remove the new DS from the registrar if you already added it; leaving a DS for a key the zone no longer serves makes validators fail once the old DS is gone. If you confirmed the DS, the CLI reminds you.
- After activation the old key is
retired(the new one is alreadyactive) and the rollover cannot be aborted. Do not remove the old DS yet; fix the problem and roll again. - If the new DS was wrong (typo), the old KSK is still active: replace the DS at the registrar with the correct value. Because the old DS stayed, nothing breaks meanwhile.
Emergency procedures
- Key compromised. Start a rollover of that role now. For a KSK the compromised key still
signs until you confirm the DS, so confirm as soon as the new DS is visible, then remove the
old DS promptly. Note that waiting out
T + P + Vfor removal cannot be shortened with a command. - Zone is bogus and you can’t fix it quickly. Remove the DS at the registrar. After the parent’s DS TTL the zone becomes insecure (resolvers accept it unsigned). Then repair and re-add the DS. Disabling signing in DNSY without removing the DS makes things worse.
- Remote zone bogus, your users affected. Add an NTA for it (above) and remove it when fixed.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
everything bogus or indeterminate after boot | clock wrong | fix NTP; DNSY stops validating while the clock is before the build time, but a clock off by hours gives EDE 7/8 |
| EDE 7, signatures expired | signer stopped (service down, passphrase missing, zone not reloading) | check logs for zone signing failed; restore the passphrase; the next 60 s pass re-signs |
| EDE 9 right after enabling at the registrar | DS does not match the keys served (wrong key tag/digest, wrong zone, old key removed) | compare dnsy dnssec keys ds with the registrar; fix the DS |
| EDE 10 | DS at the parent for an unsigned zone | re-enable signing or remove the DS |
| forgot to update the DS during a KSK rollover | new key never confirmed, or the old DS removed before the new KSK became active | do not remove the old DS; confirm the new one |
| signing fails after a restart | passphrase env or file not set (keys are encrypted but no passphrase is configured) or changed (cannot decrypt key material) | restore the original passphrase |
| lost keys (database lost, no backup) | cannot re-create the same key | remove the DS at the registrar, wait for its TTL, enable signing again to get new keys, then publish the new DS |
Security notes
- Private keys, the passphrase and tokens never appear in API responses, error bodies or logs.
Do not put the passphrase in
dnsy.toml; use the environment or a0600file. - The database holds the keys: keep
data_dirprivate to the service user and encrypt the backups; store the passphrase elsewhere. - Use
strictvalidation;permissiveand NTAs are for incidents. A failed DS or DNSKEY fetch is treated as bogus, deliberately, so an attacker who blocks the fetch cannot downgrade you. - Validation limits (32 fetches, 64 signature checks per query) protect against crafted zones.
- Admin scope is needed for every changing DNSSEC call; give it only to operators who handle keys, and use short-lived tokens.
Filtering
This page covers client ACLs, rate limits and blocklists. The stored rules, list sources and the matcher live in dnsy-filter (fetching list sources over HTTP is its fetch feature, which the binary enables); ACL evaluation and the rate limiter live in dnsy-engine. In the request pipeline the ACL runs first, then the rate limiter, then blocklists and the allowlist.
Client ACL
An ACL is an ordered-by-specificity set of rules plus a configurable default action (default allow for an Acl built directly; the server builds it private-only unless [limits] open_resolver = true: loopback, RFC 1918, 100.64/10, ULA and link-local are allowed and everything else is refused, with stored rules still winning by longest prefix). Each rule maps a match to an action:
| Action | Effect |
|---|---|
allow | process the request |
refuse | answer REFUSED |
drop | send no reply |
Rule matches:
- CIDR, IPv4 or IPv6 (
10.0.0.0/8,2001:db8::/32). Host bits are ignored. IPv4-mapped IPv6 clients (::ffff:a.b.c.d) are matched as IPv4. - Client certificate, by SHA-256 fingerprint, for mTLS-authenticated DoT clients.
Resolution: a client-certificate rule matching the request principal wins; otherwise the longest matching prefix wins; equal prefixes resolve to the stricter action (drop over refuse over allow); with no match the default applies. Rules are stored in the acl_rules table (migration 0004); a duplicate network or fingerprint is rejected.
Rate limiting
Each client gets a token bucket refilling at qps tokens per second with a burst of 2 x qps. Clients are keyed by IPv4 /32 and IPv6 /64 (a subscriber controls a whole /64, so per-address keys would be trivially bypassed). Checking and taking a token is one atomic step, and the number of tracked clients is bounded: when the table is full, fully refilled buckets are discarded first, then the least recently seen one.
Over the limit, UDP queries are dropped silently (a reply to a spoofed source address would amplify an attack); TCP, DoT and DoH queries get REFUSED. A qps of 0 disables limiting. The limit is stored in the rate_limits table.
CNAME targets
A blocklist entry also matches the CNAME targets in an upstream answer. If a query for www.site.test is answered with a chain that reaches cdn.tracker.example and that name (or a parent) is blocked, the whole response is replaced by the configured block response with EDE 15 (Blocked), and the query log records source blocked with the list that matched. This closes CNAME cloaking, where a first-party name hides a tracker. Allow entries on a target win, as for queried names.
Design: the check runs after resolution, next to the response-IP policy, walking the same loop-safe chain as the resolver (at most 16 hops; a loop ends the walk and does not block). It runs on cache hits too, so a list update applies at once, and the blocked reply is not cached (the upstream answer is, once). Local zones are not checked. A CNAME query is answered as asked and not walked.
Response-IP policy
An upstream answer whose A or AAAA record lies inside a listed network is blocked: the client gets the configured block response with EDE 15 (Blocked), and the query log records the source blocked with list response-ip. This stops DNS rebinding to internal ranges and known-bad addresses. IPv4-mapped IPv6 addresses are matched as IPv4. The check also applies to cached answers, so a change takes effect at once.
The list starts from [filter] response_ip_blocklist (CIDRs or single addresses; an invalid entry fails config validation). PUT /api/v1/response-ip replaces the whole list live and stores it in the response_ip_policy table (migration 0016); once stored, the list wins over the file after a restart, like the rate limit. An empty list disables the policy. Reading needs api:read; changing needs api:write (operator or admin).
[filter]
response_ip_blocklist = ["10.0.0.0/8", "192.168.0.0/16", "fc00::/7"]
Blocklists
A list source is parsed in one of three formats (or auto, which detects the format line by line):
| Format | Example | Notes |
|---|---|---|
| hosts | 0.0.0.0 ads.example.com | any IP address, one or more names per line; localhost style entries are ignored |
| domains | ads.example.com, *.ads.example.com | one name per line; a leading *. is the same as the bare name |
| adblock subset | ` |
# and ! start comments, [Adblock ...] headers are skipped, and a UTF-8 BOM and CRLF line endings are tolerated. Internationalised names must already be punycode (xn--...); a name with non-ASCII bytes is invalid. Invalid lines (bad characters, IP-address-only lines, bad UTF-8, over-long lines) are counted in the parse statistics and never abort loading.
Matching
Every entry is a suffix: blocking example.com blocks example.com and everything below it, but not notexample.com. Matching is ASCII case-insensitive. The most specific rule does not matter for blocking; the allowlist always wins: an allow entry (adblock @@|| or a custom allow rule) on the queried name or any parent makes the verdict Allow, even if a longer or shorter block entry exists. Custom block rules are reported as list custom.
Block responses
| Mode | Answer |
|---|---|
NxDomain | NXDOMAIN |
NullIp | 0.0.0.0 for A, :: for AAAA, empty NOERROR for other types |
Refused | REFUSED |
CustomIp | operator addresses; a family without an address answers empty NOERROR |
The blocklist hash tables are keyed per process (SipHash with a random 128-bit seed). A list whose names make a probe run longer than 128 slots makes the table rebuild under a new seed; a table does that at most eight times, so a hostile list cannot slow lookups with colliding names or make loading quadratic.
When the query carried EDNS, the response includes an Extended DNS Error (RFC 8914): code 15 (Blocked) for blocklist matches, 17 (Filtered) for custom rules. Synthesised answers use a TTL of 60 s.
Limits
Memory and input are bounded while building a filter: at most 5,000,000 entries (block plus allow, duplicates not counted) and 64 MiB per list source by default, and lines over 2048 bytes are skipped without being buffered. Exceeding a limit fails the build with an error, so the previous filter stays in use. A filter holds its names in one arena with a compact index, about 30 bytes per entry, and a rebuild reserves room from the entry count each source reported at its last load, so it does not grow tables by doubling. One million entries add about 43 MiB to the process while they are built. There is no regular-expression support.
List sources
A source is a local file path or an http(s) URL, with a name, a format (auto, hosts, domains, adblock), an enabled flag and a refresh interval in seconds. Sources and custom rules are stored in the blocklists and custom_rules tables (migration 0007); on first start, [filter] blocklists seeds the table and refresh_interval becomes each seeded source’s interval. Any other scheme (ftp://, file://) and URLs with embedded credentials are refused.
Refresh and atomic swap
Creating an enabled source through POST /blocklists fetches it immediately (as re-enabling does)
and the response shows the result; a failed first fetch is recorded in last_status and the
scheduler retries.
Each refresh fetches one source, then builds a complete new filter from every loaded list plus the custom rules on a blocking thread, and swaps it in with a single pointer store. Queries read the active filter without locking. If the fetch fails, the list holds no usable entry (for example an HTML error page), or the build exceeds a limit, nothing changes: the previous filter and the previous copy of that list stay active, and the failure is recorded in the source’s last_status (ok, not modified or error: ...).
URL sources use ETag / Last-Modified for conditional requests (If-None-Match, If-Modified-Since); a 304 keeps the held list. Validators are only sent while the process holds the matching copy in memory, so after a restart the first fetch is unconditional. Persisted etag, last_modified, last_fetched and entries are informational.
Schedule
A source is refreshed when it has never been fetched or its due time has passed. After a success the next refresh is due interval plus up to 10% random jitter later, so lists do not refresh in lockstep; after a failure the retry comes after at most five minutes (plus jitter). The scheduler takes an injectable Clock, so tests drive it without sleeping.
URL safety (SSRF)
- Only
httpandhttps. TLS certificates are verified against the built-in web PKI roots; no proxy from the environment is used. - URLs must be ASCII: internationalised host names are not supported (write the punycode
xn--form) and user info is refused. A host ending in a numeric label is an IPv4 address read the way browsers read it (127.1,0x7f.0.0.1and2130706433are all127.0.0.1), so the address checks below see the address actually dialled; a malformed one is refused. - Requests are plain HTTP/1.1, one request per connection, dialled only to the checked addresses.
- The host is resolved once and every address must be public. Loopback, private, link-local (including cloud metadata
169.254.169.254), unique-local, carrier-grade NAT, multicast, reserved and documentation ranges are refused, as are IPv6 forms that embed them (IPv4-mapped, NAT64, 6to4). The connection is pinned to the checked addresses, so a second lookup cannot return a different answer. - Redirects are followed by hand, at most 5, each hop re-checked; a redirect to another scheme or from
httpstohttpis refused. TheLocationmust be an absolutehttp(s)URL or an absolute path (/list.txt); relative and scheme-relative (//host/x) locations are refused. - A source with
allow_privateset skips the address check, for lists served from the local network. It is off by default. - At most 50 MB per list (checked against
Content-Lengthand while streaming; compressed encodings are not requested) and 30 s for the whole fetch, including redirects. - Files are read with the same size cap.
Managing filtering through the API
ACL rules, the rate limit, blocklist sources and custom rules can be changed at runtime with the
REST API and are active
immediately (the ACL is rebuilt and swapped, the blocklist filter is rebuilt off the hot path). The
block mode is a runtime setting (PATCH /settings, filter.block_mode). Creating a source whose
location is a file path or a non-public address needs the admin scope and allow_private; see
URL safety above.
Web UI
The Filtering page (/filtering) manages both halves of the policy without touching the config
file:
- Blocklist sources: add a source by name, URL or file path and format (
auto,hosts,domains,adblock); the table shows the entry count, the last fetch time and status, and lets you enable or disable a source, refresh it now (the outcome and entry count are shown) or remove it after confirming. Admins also see an “Allow private targets and local files” checkbox that sendsallow_private, needed for file paths and private addresses. - Custom rules: add a
blockorallowrule for a domain (it covers the name and everything below it); remove a rule after confirming. Allow rules win over blocklists, as described above.
The API has no endpoint to test a domain against the filters, so the page has no “test a domain”
box; query the server with dig instead.
The Response IP policy page (/response-ip, “Response IPs” in the navigation) edits the
response-IP policy: a textarea with one address or CIDR per line, checked in
the browser and by the server (a 422 message is shown under the field), saved as a whole-list
replace that applies live. Saving an empty list turns the policy off. Operators and admins can edit;
viewers see the list read-only.
DoT and mTLS
DNSY serves DNS over TLS (RFC 7858) on every address in dot.listen (conventionally :853). An
empty listen disables the listener.
[dot]
listen = ["0.0.0.0:853"]
cert = "/etc/dnsy/dot.pem" # certificate chain, PEM, leaf first
key = "/etc/dnsy/dot.key" # private key, PEM (PKCS#8, PKCS#1 or SEC1)
cert and key are required when listen is set; the server refuses to start if they cannot be
read or do not match.
TLS
- TLS 1.2 and 1.3 with rustls’ default cipher suites (ring provider); older versions are not offered.
- ALPN
dotis advertised. Clients that send no ALPN extension are still served. - Framing, pipelining, the idle timeout (
limits.tcp_idle_ms), the connection cap (limits.max_tcp_conns) and shutdown draining are the same as for plain TCP. The TLS handshake also has to finish within the idle timeout. A plain TCP client talking to the DoT port fails the handshake and is closed. - Responses are padded to a multiple of 468 octets (RFC 8467) when the query carried an EDNS Padding option (RFC 7830).
- Queries reach the engine with transport
dot.
Certificate hot reload
The certificate and key are re-read when either file changes (the parent directories are
watched, so atomic renames and Kubernetes secret symlink swaps work) and on SIGHUP. New
handshakes use the new certificate; established connections are not dropped. If the new files are
unreadable or the key does not match the certificate, the previous certificate stays in use and a
warning is logged (without key material).
mTLS
[dot]
mtls = "required" # off | optional | required
client_ca = "/etc/dnsy/clients-ca.pem" # PEM bundle of CAs that issue client certificates
off: no client certificate is requested.optional: clients may connect without a certificate; a presented certificate must be valid.required: a handshake without a valid certificate is refused (with TLS 1.3 the client sees the failure on its first read, not inconnect).
Validation is rustls’ WebPkiClientVerifier: the chain must lead to client_ca, and the
certificate must be within its validity period. client_ca must be set whenever mtls is not
off.
Revocation (CRL)
Set dot.client_crl to a PEM file of CRLs (it needs dot.listen and dot.mtls other than
off). Revoked certificates are refused. The file is read at startup (a missing one is a
startup error) and re-read together with the CA bundle. MtlsVerifier::reload (or spawn_reload(interval)) re-reads the CA bundle and CRL
file; if the new files are unreadable, empty or contain no CRL, the previous state is kept and a
warning is logged. With a CRL configured, a certificate whose issuer is not covered by a CRL is
refused.
Fingerprint allowlist
When an Allowlist is attached, a certificate that passes chain validation must also be on the
list, otherwise the handshake fails. Entries are stored in the client_certs table (migration
0008) and managed through ClientCertService: SHA-256 of the DER certificate, a label, an
enabled flag and an optional expiry (ids are AUTOINCREMENT since migration 0014, so a deleted entry’s id is never reused). Allowlist::refresh / spawn_refresh load the enabled,
unexpired entries; a list with no entries admits no certificate. Disabling, deleting or expiring
an entry takes effect for new handshakes at the next refresh.
Resumption and open connections
TLS session resumption is disabled whenever mTLS is on (no session cache, no tickets), because a
resumed session would skip certificate verification. Every handshake re-verifies the chain, the
CRL and the allowlist. A connection that stays open is re-checked on its next query after the CA,
CRL or allowlist changed, and at least every 30 seconds regardless, so a certificate that
simply expires (leaf notAfter, or the allowlist entry’s expires_at) stops working on a
long-lived connection; if the certificate is no longer permitted the connection is closed.
Enable it with DotConfig::with_mtls.
Client identity
For a connection with a client certificate every request carries the principal
ClientCert { sha256, subject }: the fingerprint and a short CN=... SAN=... string, available
to ACLs and the query log. Raw certificates are never logged.
DoH and tokens
DNS over HTTPS (RFC 8484) is served by the dnsy-server DoH listener on /dns-query. It speaks
HTTP/1.1 and HTTP/2 over TLS (ALPN h2, http/1.1) and uses the same certificate loading and hot
reload as DoT: replacing the PEM files takes effect for new connections, and a bad replacement
keeps the previous certificate.
Requests
| Method | Request |
|---|---|
GET | /dns-query?dns=<base64url>, the DNS message in base64url without padding |
POST | /dns-query, body is the DNS message, Content-Type: application/dns-message |
Responses are 200 with Content-Type: application/dns-message and
Cache-Control: max-age=<n>, where n is the smallest TTL of any record in the response (0 when
there are none). It is Cache-Control: private, max-age=<n> when the request presented a token
or trusted_proxies is set, since the answer may then depend on who asked. Responses are padded (RFC 8467) when the query carries an EDNS Padding option.
| Status | Meaning |
|---|---|
| 400 | bad base64, missing dns, not a DNS message, empty body, or more than 65535 octets (body or decoded parameter) |
| 401 | no token (when required) or a rejected token; WWW-Authenticate: Bearer |
| 403 | valid token without the dns:query scope |
| 404 | any path other than the configured [doh] path (default /dns-query) and {path}/{token} |
| 405 | method other than GET and POST (Allow: GET, POST) |
| 415 | POST with a content type other than application/dns-message |
| 429 | too many rejected tokens from this client address (Retry-After) |
Tokens
Tokens are created through the API (scope dns:query; see Users, tokens and sessions). A token is
required by default. Send it either way:
Authorization: Bearer <token>- in the path,
https://dns.example/dns-query/<token>, for clients that cannot set headers (browsers’ DoH settings, many mobile resolvers)
A presented token is always verified, even when tokens are not required. Verification and secret
comparison are constant time and done by the token verifier in dnsy-auth. The authenticated
principal is attached to the request, so the query log shows which token asked.
A successful verification is cached for 5 s (DOH_TOKEN_CACHE_TTL, at most 4096 tokens,
DOH_TOKEN_CACHE_CAPACITY), so a busy client costs one database read per token every 5 s instead
of one per query. Rejected tokens are never cached. A revoked token, a disabled or deleted owner or
a role change takes effect on the next request when done through the API or the web UI. Changes
made with the offline CLI (dnsy user disable, dnsy token create and friends) while the server
is running apply to DoH within 5 s (verified tokens are cached for 5 s); dnsy user disable says
so when it runs.
Rejected tokens are counted per client (an IPv4 address or an IPv6 /64; default 10 per 60 s). Past that limit the client gets
429 before any token is looked at, so the response never reveals whether a token exists. Malformed,
unknown, revoked, expired and wrong-secret tokens all give the same 401.
The table holds at most 10 000 clients; clients that reached the limit are never evicted to make
room, so flooding it with other addresses cannot unblock them. If a part of the table is full of
blocked clients, failures from further clients share one counter that blocks them all once it
reaches the limit (the table fails closed).
Secrets are not logged
The request URI and the Authorization header are never logged, never copied into error bodies,
and never passed to the engine, so path tokens cannot reach the query log. Access logging records
only the client address and status. Prefer the header where the client allows it: path tokens can
still be recorded by reverse proxies or browser history in front of or around dnsy.
Settings
The listener is configured through DohConfig: listen addresses, certificate and key, the token
authenticator, require_token (default true), max_auth_failures, auth_window and
trusted_proxies ([doh] trusted_proxies, empty by default). A request from a listed proxy is
attributed to the rightmost X-Forwarded-For entry that is not a listed proxy: that address keys
the auth-failure throttle, ACL rules, per-client QPS and the query log (rules and spoofing risks
in Security). It shares
the idle timeout, connection cap and drain time of the TCP and DoT listeners.
Users, tokens and sessions
Roles and scopes
Roles are admin, operator and viewer. Tokens carry scopes (dns:query, api:read,
api:write, admin). A token’s scopes must be a subset of what its owner’s role allows
(viewer: api:read; operator: everything except admin; admin: all). The role is checked
again on every use, so demoting a user narrows their existing tokens.
Scopes are flat: admin is not a superset of api:read or api:write. Each route requires one
scope (viewer-level reads api:read, changes api:write, administration admin), so an
admin-only token that should also read must be minted with all three. GET /api/v1/version,
/healthz, /readyz and /api/openapi.json need no credential by design. The
route-by-role expectations are a committed table in crates/dnsy-api/src/tests_matrix.rs; a new
route without a row fails the test suite.
Limits: a token name is at most 128 characters, a username at most 64 and a user holds at most 100 tokens; the API answers 422 with a problem detail beyond that.
API tokens
Format: dnsy_<base32 id>_<base64url secret> (8-byte id, 32-byte secret). The secret is shown
once, when the token is created. Only sha256(secret) is stored, compared in constant time.
A token can have an expiry and can be revoked at any time. Expired, revoked, unknown and
tampered tokens all fail with the same error. last_used_at is refreshed at most once per
minute. DoH caches verified tokens for 5 s and refreshes last_used_at only when it looks a token
up again, so for DoH traffic the recorded value lags by at most 65 s (see
DoH and tokens).
Sessions
The web UI uses a server-side session with a 256-bit random id (stored hashed). A session ends after 30 minutes without use or 12 hours after login, whichever comes first. Logging in again discards the presented session and issues a new id.
Disabling users
PATCH /users/{id} with {"disabled": true} disables an account: login fails like a wrong
password, the user’s sessions are deleted at once and their API and DoH tokens are refused (401)
until {"disabled": false}; old sessions stay gone. The last active admin cannot be disabled,
demoted or deleted (409), so an installation cannot lock itself out; a disabled admin does not
count as one. dnsy user disable|enable <username> does the same offline; run against a live
server, its DoH tokens stop working within 5 s (the DoH verified-token cache, see
DoH and tokens), API tokens at once.
Login throttling
The API takes the client address from the TCP peer. Behind a reverse proxy every client shares
the proxy’s address, so the limits below apply to all of them together, unless the proxy is
listed in [api] trusted_proxies: then the rightmost X-Forwarded-For entry that is not a listed
proxy is the client (rules and spoofing risks in Security). Addresses are grouped before counting: an IPv6 client counts as its /64, and an
IPv4-mapped IPv6 address (::ffff:a.b.c.d) counts as the plain IPv4 address.
Login attempts are limited in one write transaction, so concurrent requests cannot exceed the limits:
- Per username and client: at most 5 attempts within 15 minutes, and at most 20 per client
across all usernames. Further attempts get HTTP 429 with
Retry-Afteruntil the oldest one leaves the window. A locked-out client cannot lock the account out for anyone else. - Per username, across clients: after 10 attempts on one username within the window from
clients that have not recently logged in as that user, each further attempt from such a client
must wait
2^nseconds after the previous one, capped at 60 seconds. This slows distributed guessing without ever locking the account; the correct password is delayed in the same way (429, never exempt). Clients that completed a login as the user in the last 7 days (the last 8 are remembered in memory and forgotten on restart) are exempt, so an attacker cannot slow the owner’s usual address. Behind a reverse proxy all users share the proxy’s address, so this exemption makes the account-wide delay ineffective there; only the per-client limits protect.
Refused attempts are not counted, so hammering a locked client does not extend the lock. A
successful login clears the username’s counters. Passwords longer than 1 KiB are refused (400)
before hashing. At most as many argon2 hashes as CPUs run at once, and at most 16 requests per client (waiting or hashing; [api] login_hash_per_client), and at most 64
requests wait at once; excess is refused immediately. A login queues up to 5 seconds
([api] login_hash_wait_ms) for a slot, then gets 503 with Retry-After: 1. The slot is taken
before the attempt is recorded, so a busy refusal never counts as a failed password.
Web UI
The users page (admin), the tokens page and the profile page in the web UI wrap this API; see Web UI. The token secret is shown once in a dialog and held only in memory until the dialog closes. Changing a password keeps the current browser signed in (a new session is issued) and ends every other session; changing a user’s role or resetting their password as an admin ends all of that user’s sessions.
Query log and stats
DNSY records every answered query. Events pass through a bounded channel (10,000 events) to a
batched SQLite writer (up to 500 events or 1 second per batch). When the channel is full, events are
dropped and counted instead of slowing DNS. A batch that fails to write (disk full, locked or
read-only database) counts its events as dropped as well and increments a flush-error counter.
GET /api/v1/stats/summary reports both since the server started, as querylog_dropped and
querylog_flush_errors; watch them for gaps in the log.
Sampling, size cap and a full disk
[querylog] sample = Nkeeps one rawquery_logrow in N queries (default 1: all). Only raw rows are sampled: minute rollups and hourly top-N counts include every query, so the stats endpoints stay exact while the log grows N times slower. Searching the log shows the sample; at high QPS use 100 or more.0is treated as1.[querylog] max_size_mb = M(default 0: unlimited) caps the live data in the database (used pages, not the file size; freed pages are reused). After each retention pass, while the data exceeds the cap, the oldest UTC day of raw rows is deleted. Rollups are kept. Today’s rows are never purged; if only today remains, a warning is logged. The cap is read at startup.- When a write fails with
SQLITE_FULL, the events of that batch are dropped and counted, the log warns once, and/readyzreports the warningquerylog_disk_full(the status stays 200). The warning clears after the next successful write; free disk space or lowerretention_daysormax_size_mb.
What is stored
Per query: time, client address, transport, principal, query name (lowercased), type, response code,
answer source (authoritative, cache, cache_stale, upstream, blocked, refused, error;
with the upstream address or blocklist name as detail), DNSSEC status and latency in microseconds.
With client redaction enabled, only the /24 (IPv4) or /48 (IPv6) network address is stored, in the log and in the rollups.
Rollups and stats
Per minute: counts by response code, answer source and DNSSEC status, plus latency sum and maximum.
Per hour: top query names, top blocked names and top clients. The summary reports the cache hit
ratio (cache plus cache_stale answers over those plus upstream answers), stale answers, and
secure and bogus counts. Rollup buckets are whole minutes or hours, so a range includes any bucket
it touches.
Once a UTC day is more than 48 hours old, the hourly retention task folds it into daily rollups:
stats_day (the minute counters summed per day, with latency sum and maximum) and stats_top_day
(the 5000 most frequent names, blocked names and clients of that day). Each day is folded in one
transaction and only once, so a pass interrupted after the fold is finished by the next one without
double counting. The minute and hour rows of folded days are then deleted in batches, so only the
last two to three days exist at minute and hour granularity.
summary,timeserieswith a bucket of one day or more, and the top lists read daily rows for folded days and minute or hour rows for the recent days. A folded day that the range touches counts whole, like any other rollup bucket. A 30-day top-domains query reads about 150 000 rows instead of every hourly row.timeserieswith a bucket under one day only has data for the last two to three days. A day-or-longer bucket holds the days whose first second falls in it.- A top list over folded days is exact for names that rank in the top 5000 of every day it covers; a name that stayed below the 5000th place on a day loses that day’s count. Totals, summaries and timeseries are exact.
- This is a one-way change: the minute and hour detail of folded days is not kept. Restore a backup to get it back.
Searching
Filter by time range, client, query-name substring, type, response code, answer source and DNSSEC status. Results are newest first with keyset pagination: pass the returned cursor to get the next page; pages stay stable while new rows arrive, and every page costs the same however deep it is (the cursor bounds the index range).
Three indexes serve the searches:
| Index | Serves | Size at 10 million rows |
|---|---|---|
query_log_ts (ts_ms, id) | time ranges, paging, unfiltered newest-first | in the base table size |
query_log_client (client, ts_ms, id) (migration 0010) | client = | about 296 MiB, builds in about 7 s |
query_log_rare (ts_ms, id) partial (migration 0011) | source blocked, refused, error and response codes other than NOERROR and NXDOMAIN | about 24 MiB, builds in about 2 s |
On a 10 million row log a middle cursor page went from 125 ms to 0.03 ms, client = from 1.6 s
to 0.01 ms and a deep source = refused page from 274 ms to 24 ms (measurements in the database
analysis). Other filters (query name substring, type, DNSSEC status, common sources) still scan the
time index, which is fast for common values and slow for values that almost never match.
Migration 0015 recreates all three indexes over the compact columns (the partial index tests the source codes 4 to 6).
The first start after an upgrade builds the two indexes while holding the database: about 7 s per
10 million rows for the client index. The server logs applying migration 0010, this can take a while on a large query log before it starts and does not accept connections until it finishes.
The indexes add write work per logged query (two indexes instead of one) and file size
(about 30 percent for the client index).
Row encoding
Rows are stored compactly (migration 0015): transport, source and dnssec are small integers
and client is the 4 (IPv4) or 16 (IPv6) address bytes. The API, the stream and the web UI show
the same text values as before; the mapping lives in dnsy-observe and the codes are fixed.
| Column | Codes |
|---|---|
transport | udp 0, tcp 1, dot 2, doh 3 |
source | authoritative 0, cache 1, cache_stale 2, upstream 3, blocked 4, refused 5, error 6 |
dnssec | unchecked 0, secure 1, insecure 2, bogus 3, indeterminate 4 |
The migration rebuilds the table in one transaction, so the first start after the upgrade holds
the database while it copies the log (the server logs applying migration 0015 first) and an
interrupted or failing run leaves the old table untouched. Expect minutes at tens of millions of
rows. Rows keep their ids. The table is about 24 percent smaller (200 000 rows, three indexes:
26.3 MB to 20.2 MB after VACUUM). The freed pages stay inside the file for reuse until the
database is vacuumed. Back up first, as for any upgrade.
Reading it over the API
GET /api/v1/querylog searches with the filters above and a cursor, and
GET /api/v1/querylog/stream tails new rows as server-sent events (operator or admin, at most 4
streams per user and 16 overall). GET /api/v1/stats/* serves the summary, timeseries and top lists, and
GET /api/v1/cache the live cache counters. See REST API. The API applies client
redaction again on output and replaces anything shaped like a token with [redacted].
In the web UI
The query log page (/query-log, see Web UI) is a front end for the search
and the live tail: filters, a cursor-paged table, a detail drawer and a tail toggle.
Retention
A background task, once an hour, first folds completed days older than 48 hours into the daily rollups (see above), then deletes raw rows older than the retention period, hourly and daily top-N rollups that end before it, and expired sessions. Daily counter rollups are kept 400 days, or the retention period when that is longer (the stats range is capped at 366 days, so nothing reads older ones).
Deletes run in transactions of at most 50 000 rows, so lowering the retention on a large log
(PATCH /settings) never blocks the query-log writer for more than a few milliseconds at a time
and the write-ahead log stays small. The task also runs PRAGMA optimize once a day (and the
server once more on shutdown) so SQLite refreshes planner statistics only where needed.
Web UI
The web interface is an Angular single-page app in web/. It is built to web/dist/dnsy/browser
and embedded into the binary by dnsy-api (feature embed-ui), which serves it from / with a
single-page-app fallback.
This page covers the shell, the dashboard, the query log, the zones pages, the admin pages (filtering, ACL, upstreams, client certificates, cache, settings) and the users, tokens and profile pages.
Screenshots
Taken at 1280 px from a seeded demo with Playwright (scripts/demo/run.sh, scripts/demo/capture.mjs).
Nox in an empty list and while loading:
Stack
- Angular (standalone components, signals, zoneless change detection, esbuild
applicationbuilder), strict TypeScript and strict templates. - Tailwind CSS, configured with the DNSY design tokens (
branding/tokens.css) insrc/styles.css. - angular-eslint and Prettier for linting, Vitest through
ng testfor unit tests, Playwright (web/playwright.config.ts, tests inweb/e2e/) for browser tests. - pnpm, with the lockfile checked in.
Ares theme
Black ground, white 0.5px hairlines and one strong red, with no glow, grid or drop shadow: depth
comes from hairlines and spacing. Red (--dnsy-red) is only the brand: primary buttons, links,
the focus ring, the active navigation item and Nox’s eyes. Danger, destructive actions, field
errors, warnings and DNSSEC bogus or blocked answers are amber (--dnsy-amber), always with a
word, never colour alone; DNSSEC secure and success are green (--dnsy-secure). Corners stay
near-square (2px). Page and section titles, buttons, badges and the wordmark use Michroma in
uppercase with open tracking; body text, tables, forms and navigation use Geist; record data, IPs,
config and tokens use Geist Mono. The palette and the measured contrast ratios are in
Branding.
Colours and fonts come from branding/tokens.css; styles.css maps them to Tailwind colours in
@theme inline (bg, surface, text, muted, line, red, amber, secure) and defines
the shared component classes (page-title, section-title, wordmark, panel, btn,
btn-danger, field-input, field-label, field-error, badge, nav-link) and a hairline
utility for 0.5px borders. Field borders use muted so they keep 3:1 against the surface;
dividers and table rules use line. No hex literal appears outside branding/tokens.css except
in the SVGs. The build bundles the self-hosted woff2 fonts (no CDN). The mark and Nox mascot SVGs
are copied from branding/ into web/public/ by pnpm sync:branding (run by pnpm build and
pnpm start; public/ is generated and not committed).
Strict CSP
The API sends default-src 'self' with no inline styles or scripts, so the build must not emit
any. angular.json sets optimization.styles.inlineCritical: false (no inline critical CSS and no
onload handlers) and fonts are not inlined. Rules for UI code:
- Do not use component
styles/styleUrl; Angular would inject<style>elements at runtime, which the CSP blocks. Style with Tailwind classes or the global stylesheet. - No inline
style="..."attributes, nodata:URIs, no inline event handler attributes.
Routing and auth
All pages are lazy-loaded (loadComponent). /login is public; everything else lives under the
layout shell (sidebar navigation, header with the mascot mark) behind authGuard, which asks
GET /api/v1/auth/me once and redirects to /login?redirect=<url> when unauthenticated. Unknown
paths render the 404 page.
The session is an HttpOnly cookie. apiInterceptor (same-origin /api/ calls only):
- adds
X-CSRF-Tokenfrom thednsy_csrfcookie (__Host-dnsy_csrfwhen the cookies areSecure) on unsafe methods (double submit); - maps failures to a typed
ApiError, carrying the RFC 9457 problem document when the body isapplication/problem+json; - on a 401 clears the session and navigates to
/login.
ApiService is a thin HttpClient wrapper rooted at /api/v1.
API types
web/src/app/api/schema.d.ts is generated from openapi/dnsy.json with openapi-typescript and
checked in. Regenerate after an API change with just openapi followed by just web-types;
just web fails when the file is stale.
Commands
| Command | What it does |
|---|---|
just web | pnpm install --frozen-lockfile, pnpm dedupe --check, lint (ng lint and Prettier), unit tests with coverage (>= 80%), production build, types drift check. Skipped when node or pnpm is missing. |
just web-types | regenerate the API types |
cd web && pnpm start | dev server on http://localhost:4200 (proxy API calls to a running dnsy yourself) |
The production build enforces an initial bundle budget of 320 kB (warning) and 400 kB (error), raw,
and an anyComponentStyle budget of 4 kB (warning) and 8 kB (error); ng build emits no budget
warning. Measured initial total: 304.00 kB raw, 83.14 kB transferred, made of the framework chunk
(151.64 kB), main (101.92 kB), styles (28.46 kB) and one shared chunk (21.36 kB). The login
page draws its backdrop with a plain 2D canvas and no library; three was removed in DNSY-108
(resolving DNSY-91).
Code layout
Feature folders under web/src/app/features/ never import each other: ESLint
no-restricted-imports rejects ../<other-feature>/ imports. Shared code lives in
web/src/app/shared/ (ui/ for Dialog and ConfirmDialog; util/ for copy.ts, util.ts
and password-strength.ts; mascot/), web/src/app/core/ (API client, auth, the DNSSEC service
and logic used by both the zones and DNSSEC pages) and web/src/app/api/. Test helpers are in
web/src/testing/.
Dependencies
typescript-eslint follows the version angular-eslint resolves, @angular/compiler is a dev
dependency (the build compiles ahead of time), pnpm.peerDependencyRules accepts TypeScript 6 and
pnpm.overrides.vite >= 8.3.3 keeps pnpm audit clean.
Mascot components
web/src/app/shared/mascot/ holds six standalone components for the Nox states, each with the
same inputs:
| Component | Selector | Used for |
|---|---|---|
MascotLoadingComponent | dnsy-mascot-loading | route navigation, pending data |
MascotEmptyComponent | dnsy-mascot-empty | empty lists, placeholder pages |
MascotErrorComponent | dnsy-mascot-error | failed requests |
MascotSuccessComponent | dnsy-mascot-success | completed actions |
MascotOfflineComponent | dnsy-mascot-offline | browser offline banner in the shell |
MascotNotFoundComponent | dnsy-mascot-not-found | the 404 page |
Inputs (signals): size (sm, md, lg; default md), message (caption; each state has a
default) and action (label of an optional button; the actionClick output fires on click).
<dnsy-mascot-error
message="Could not load zones."
action="Retry"
(actionClick)="reload()"
/>
The art is drawn inline from branding/mascot/nox-*.svg so the global keyframes in
shared/mascot/mascot.css (imported by styles.css) can animate its parts under the strict CSP:
the head stays the same and the eyes act: a red point of light sweeping across dimmed eyes
(loading), slanted amber eyes that flicker (error), green chevron eyes with a short hop (success),
grey dashed eyes on a half-faded head (offline), and a tilted head with one wide eye (404). Lines
are 0.5px --dnsy-text hairlines (vector-effect: non-scaling-stroke, so they stay hairlines at
every size), eyes and beak take the state colour from --nox-eye. There is no glow and there are
no JavaScript animation libraries.
Accessibility: the SVG has role="img" and a distinct aria-label per state; the caption is a
live region (status or alert) for loading, success, offline and error. With
prefers-reduced-motion: reduce every animation is removed (CSS media query, plus a nox-static
class driven by matchMedia) and Nox keeps its static pose.
The shell shows the loading mascot during route navigation and the offline mascot when the browser
goes offline. A gallery of all states and sizes lives at /dev/mascot (behind login). It is only
registered when Angular runs in dev mode, so production builds answer with the 404 page.
Login page
/login (web/src/app/features/login/) is the one page with its own layout.
- Layout: from
mdup, two columns: the sign-in block in a 380px column on the left (Nox mark and DNSY wordmark, one intro line in muted text, the fields, a red hairline Sign in button and the first-run link), a 0.5px--dnsy-linedivider, and the blob canvas filling the right side. Belowmdthere is one column: the canvas fills the background at half opacity and the form sits on a solid--dnsy-surfacepanel so the fields stay readable. - Blob: a wireframe icosphere (2 subdivisions, unique edges) drawn on a plain 2D canvas in
0.5px white hairlines, radius 0.9 x the canvas height, centred at 95% of the width and 55% of
the height so only its left part shows, simple perspective (camera at 3.2 radii). Five lobes
morph it (
1 + 0.34 x sum(sin(dot(v, d) x f + phase)) / 2.2) with drifting directions and phases, so the shape never repeats; it spins at 0.12 rad/s around y with a slow x tilt. Edges are bucketed by depth into five opacity steps from 0.12 to 0.80. Red pulses start at random, 1.2 per second on average, each sweeping a band of edges along its own random axis at 0.45 to 0.8 units/s, so two or three usually run at once. Geometry is inblob.ts(pure functions, injected randomness); the loop is inlogin-scene.ts. - When it runs: the loop uses
requestAnimationFrameoutside change detection and stops while the tab is hidden, after 30 s without pointer or keyboard activity (it resumes on the next pointer move, tap or key) and while the password field has focus (the DNSY-105 idle pause). The pixel ratio is capped at 2 (cappedPixelRatio) so 0.5px hairlines stay crisp. Withprefers-reduced-motion: reducethe scene draws one still frame without pulses and redraws only on resize; a live change of the preference restarts it in the matching mode. Without a 2D context it draws nothing and the form works the same. On leave every listener is removed and the loop is cancelled. - Accessibility and CSP: the canvas is decorative (
aria-hidden="true"); colours are read from the--dnsy-*custom properties, so there are no inline styles, workers, blobs ordata:URIs. - Form: labelled username and password fields, errors in a
role=alertregion, the Nox loading mascot while the request runs. A 401 reads “Invalid username or password” (the same text for unknown users), a 503 (password checks saturated) reads “The server is busy”, and a 429 locks the form and counts downretry_after_secs. - First admin: the API has no “no users yet” endpoint, so the page cannot detect it. The link
“First run? Set up the admin account” switches the form to setup mode (one-time code printed at
server start, username, password of 8+ characters, enforced with
Validators.minLength(8)), which callsPOST /auth/bootstrapand then signs in. A 409 (admin exists) returns to the sign-in form.
Zones and records
Code lives in web/src/app/features/zones/. Routes: /zones (list) and /zones/:name (detail).
- List: one row per zone with serial and record count, a
signedbadge when DNSSEC signing is enabled and aload errorbadge (with the message) when the zone file on disk is not being served. Loading, empty and error states use the Nox mascot components; the error state has a retry button. “Create zone” opens a dialog (name, apex name servers, SOA, TTLs) and, on success, opens the new zone. - Detail: zone header, record table (50 per page, filter by type), “Download BIND” and, for
signed zones, “Download signed” (
GET /zones/{name}/export?signed=..., saved as<zone>.zone/<zone>.signed.zone). “Delete zone” asks you to type the zone name. - Records: add and edit open an inline form; delete asks for confirmation. Types are A, AAAA,
NS, CNAME, PTR, MX, TXT, SRV and CAA. The form validates the name, the TTL (whole seconds,
empty means the zone default) and the value for the selected type (for example an IPv4 address
for A,
<preference> <host>for MX, four fields for SRV) before anything is sent. Server-side rejections (409, 422) are shown in the form. - Concurrency: the zone
ETagfrom every read and write is sent back asIf-Matchon writes. A 412 means the zone changed elsewhere: nothing was written, and a banner offers “Reload latest” (discard the edit and re-read) or “Overwrite anyway” (repeat the write withIf-Match: *). - Tabs: the detail page has a tab list with the Records and DNSSEC tabs (see DNSSEC tab).
Accessibility: every control has a label, dialogs trap focus, close on Escape and return focus to
their opener, table actions carry descriptive aria-labels, and errors are announced through
role="alert". Forms collapse to one column and tables scroll horizontally below 640 px, so the
pages work at 360 px.
Admin pages
Each page is its own lazy-loaded folder under web/src/app/features/ (filtering, acl,
upstreams, certs, settings, cache) with a typed service over ApiService. All of them use
the Nox mascot components for loading, empty and error states (the error state has a retry button),
confirm every destructive action in a focus-trapping dialog, label every control, announce errors
through role="alert" and collapse to one column with horizontally scrolling tables, so they work
at 360 px. Server rejections (409, 422) are shown next to the form that caused them.
| Route | Folder | What it does |
|---|---|---|
/filtering | filtering | Blocklist sources and custom block/allow rules, see Filtering |
/acl | acl | CIDR allow/refuse/drop rules (IP blocking) and the per-client rate limit |
/response-ip | response-ip | Response-IP policy: networks an upstream answer must not point into, see Filtering |
/upstreams | upstreams | Upstream groups: strategy, members, add/remove, health |
/client-certs | certs | DoT mutual-TLS fingerprint allowlist with labels and expiry |
/cache | cache | Live cache statistics, flush by name (optionally with subdomains) and flush everything |
/settings | settings | Block mode, per-client rate limit, query log retention, cache TTL clamp, serve-stale, prefetch |
- ACL: the address field accepts IPv4 or IPv6, with or without a prefix length, and is
validated in the browser (
shared/util/cidr.ts) before the request is sent; the server validates again. The default action is shown read-only. Rules that match a client certificate (created through the API) are listed and can be removed. - Response IP policy: one address or CIDR per line (commas and spaces also separate entries),
validated in the browser with the ACL rules’ checker before
PUT /api/v1/response-ipreplaces the whole list; a server 422 is shown under the field. Operators and admins edit; viewers see the list read-only. An empty list shows the mascot and turns the policy off. - Upstreams: members show an up/down badge, latency and consecutive failures from
GET /upstream-groups/{name}/health; when that endpoint is unavailable (503) the member shows “unknown”. Changing the strategy sends the group back with its current members, weights and domains. - Client certificates: the fingerprint is normalised (colons, spaces, case) and must be 64 hex digits; the optional expiry is the end of the chosen day in UTC. Entries can be disabled without removal. The certificate itself is never uploaded, so there is no show-once secret.
- Cache: statistics refresh every 5 seconds and stop when the page closes; if a refresh fails the last values stay on screen with a warning. “Flush entire cache” always opens a confirmation dialog first; flushing a single name reports how many entries were removed.
- Settings: one form, one
PATCH /settings. Numbers and the TTL clamp (minimum not above maximum) are checked locally; the server’s validation message is shown in an alert under the form. The API exposes no secrets (tokens, passwords, keys), so the page has no fields for them.
Dashboard
/dashboard (web/src/app/features/dashboard/) summarises the selected range, fetched in one go
from the stats API (one GET /stats/dashboard for the summary, chart and top lists):
- Tiles: queries, blocked share, cache hit ratio (with stale answers), cache entries and
capacity, average latency, DNSSEC secure share and bogus count, and upstream health (available
members over all members of all groups). The cache, DNSSEC and upstream tiles degrade to
-when their endpoint fails (for example no cache is running); the others are required. - Range selector:
1h(1 minute buckets),24h(15 minutes, the default) and7d(2 hours). - Chart: queries per bucket as an area line with the cache-answered share overlaid, drawn as
plain SVG (
chart.ts). Axes scale to the data (rounded up to 1, 2 or 5 times a power of ten) and quiet buckets are zero-filled. No inline styles are used, so the strict CSP holds. The SVG has a text alternative (title with total and peak) and a “Show table” toggle that renders every bucket as a table. - Top lists: top domains, top blocked and top clients (10 each) with proportional bars.
- Auto-refresh: every 30 seconds, with a Pause/Resume button. Switching range reloads at once and
discards responses for a range that is no longer selected (the superseded requests are cancelled).
A tick never starts a second batch while one is still running, and a hidden tab does not poll; it
refreshes once when it becomes visible again. Each refresh is four parallel reads:
/stats/dashboardplus the optional cache, DNSSEC status and upstream group reads. - Dropped events: a badge appears when the dashboard summary reports
querylog_dropped > 0, so a full or failing query log writer is visible. - States: the loading, empty (no queries in the range) and error (with Retry) mascots; an alert
banner when
GET /dnssec/statusreports an untrusted clock (validation suspended).
The layout is a two-column tile grid that grows to four columns, and a single column of lists below
the lg breakpoint, so it works at 360 px.
Query log
Code lives in web/src/app/features/query-log/; route /query-log.
- Filter bar: time range (presets or a custom from/to), domain substring, client, query type,
outcome, response code and DNSSEC status. Outcome is the answer source: blocked, cached, stale,
forwarded, refused, authoritative or error. Filters are sent on Apply and cleared by Reset; empty
filters are left out of the request (
toQueryinquery-log.model.ts). - Table: newest first, 100 rows per request, “Load more” follows the API’s
next_cursor(keyset pagination, so rows arriving meanwhile do not shift pages). Rows are deduplicated by id. Client, response code and latency columns hide on narrow screens; the page works at 360 px. - Badges: DNSSEC status has a lock in
--dnsy-securefor secure, a warning mark in--dnsy-amberfor bogus, amber for indeterminate and muted text for insecure and unchecked. Stale answers carry an amberStaletag; blocked, refused and error answers are amber too, always with their word. - Detail drawer: the domain in each row is a button that opens a side drawer with every field the API returns: time, client, principal, transport, type, response code, source and upstream or blocklist name, latency and DNSSEC status. Escape closes it, focus stays inside and returns to the row. The log record does not carry answer data or extended DNS errors, so the drawer does not show them.
- Live tail: the toggle opens
GET /api/v1/querylog/streamwithEventSource(same-origin, authenticated by the session cookie) using the applied filters, without the time range. New rows are placed on top and the table keeps at most 1000 rows. Pause keeps the connection but holds new rows (up to 500, a counter shows how many) until Resume. Alaggedevent shows a notice that rows were skipped. When the connection drops the page reconnects with exponential backoff (1 s, doubling, capped at 30 s; reset once a connection opens). Viewers cannot tail (the API needs operator or admin), so the toggle is disabled for them. - States: loading, empty and error use the Nox mascot components; a failed “Load more” shows an inline alert and keeps the rows.
- Quick actions: block and allow domain buttons are not present because the API has no such endpoint yet.
Controls are labelled, the table has a caption and column headers, and live status changes are
announced through role="status".
Users, tokens and profile
Users (/users, admins only; other roles are redirected to the dashboard and the nav entry is
hidden) lists accounts and lets an admin create a user, change a role, reset a password and delete
a user after a confirmation. The role and password changes end that user’s sessions. You cannot
delete or disable your own account from here. Disable (after a confirmation) blocks login and
ends the user’s sessions and tokens at once; Enable restores the account (tokens work again,
old sessions do not). Disabled users carry a badge. The last active admin cannot be disabled or
deleted (the API answers 409 and the dialog shows it).
Tokens (/tokens) manages the signed-in user’s own API tokens:
- The scope picker only offers what the role allows (viewer:
api:read; operator: everything butadmin; admin: all). Expiry is optional. - The secret is shown once, in a dialog with a warning and copy buttons (secret, and the full
DoH URL when the token has
dns:query). It lives only in that dialog’s state: closing the dialog (button or Escape) drops it, and it is never written tolocalStorage,sessionStorageor logs. Lose it and you revoke the token and create a new one. - The list shows scopes, last use, expiry and status; revoking asks for confirmation.
- DoH clients use
https://<host>/dns-query/<token>; the page has a copy button for that form. - Creating a token needs a cookie session; the API refuses it with a token.
Profile (/profile) shows the current user and how they signed in, changes the password (the
strength hints are advisory; the server enforces the 8 character minimum and keeps this session,
ending the others) and signs out. A wrong current password shows an error and does not sign you
out.
Responsive layout
Below the md breakpoint (768 px) the sidebar is an off-canvas drawer: a Menu button in the header
opens it (aria-expanded, focus moves into the nav), Escape or the backdrop closes it and Escape
returns focus to the button. From md up the sidebar is always visible. The page content clips
horizontal overflow so wide tables scroll inside their own container; there is no horizontal page
scroll at 360 px.
DNSSEC
The dnssec feature folder (web/src/app/features/dnssec/) has the zone’s DNSSEC tab and the
validation page.
DNSSEC tab
Open a zone and choose the DNSSEC tab. It reads GET /zones/{name}/dnssec.
- Enable signing (zone unsigned): pick the algorithm (ECDSAP256SHA256 or Ed25519, the only ones DNSY signs with), the key layout (one CSK or separate KSK and ZSK) and the denial of existence: NSEC3 (hides names, with optional hex salt, iterations and opt-out) or plain NSEC. The salt and iteration count are checked before anything is sent; non-zero iterations need an explicit tick because RFC 9276 advises 0. A confirmation dialog explains the registrar step: signing alone does not secure the zone until the DS is published at the registrar.
- Disable signing: the dialog says to remove the DS at the registrar first (otherwise validating resolvers return SERVFAIL for the zone) and asks you to type the zone name.
- Keys: role, key tag, state (
generated,published,active,retired,removed) and the timers present (created, published, active, retired, removed, DS confirmed), plus the algorithm, denial settings, the propagation delay and the next re-sign time. - Rollover: “Start rollover” on an active key opens a confirmation, then calls
POST .../keys/{id}/rollover. It is offered for every role that has no rollover under way, so a ZSK can roll while a KSK rollover waits. A KSK or CSK rollover waits for the new DS: publish it at the registrar, then use “DS confirmed at registrar” on the new key. While rollovers run a panel lists every one (role and state), and “Abort rollover” (after a confirmation that reminds you to remove a published new DS) callsPOST .../dnssec/rollover/abort. - Unencrypted keys: when
keys_encryptedis false a warning banner (role="alert") tells you to set a key passphrase. - DS for the registrar: DS records are listed SHA-256 first; “Copy for registrar (SHA-256)” copies that one, every record and DNSKEY has its own copy button. A blocked clipboard shows a message instead of failing silently.
- Signed export: “Download signed export” saves
<zone>.signed.zone.
DNSSEC validation page
The DNSSEC entry in the navigation (/dnssec) shows and changes validation at run time
(GET/PATCH /settings/dnssec, counters from GET /dnssec/status).
- Mode:
strict(recommended; bogus answers become SERVFAIL),permissive(validated and logged, bogus answers still returned) oroff. Choosing a weaker mode asks for confirmation and says what it exposes you to. Changes are not saved to the configuration file. - Negative trust anchors: names at and below which validation is skipped (RFC 7646). Each entry shows its expiry or “no expiry”. The add form takes an optional expiry in whole hours from now (1 to 8760); after it passes the anchor stops applying and disappears from the list. Without an expiry, remove the entry when the zone is fixed. A name is checked and lowercased before it is sent.
- Trust anchors: read-only table (zone, kind, key tag, algorithm, digest); they come from the configuration.
- Counters: secure and bogus answers and signed zones for the status window, and a banner when the clock is untrusted and validation is suspended.
Loading, error (with retry) and empty states use the mascot components; every control has a label,
errors use role="alert" and the pages work at 360 px.
Known limits
- The query log drawer has no answer records or Extended DNS Error codes (the log does not store them) and there are no one-click block or allow buttons on log rows; use the Filtering page.
- Settings changed on the Settings and DNSSEC pages apply at once but are runtime-only (the per-client rate limit is saved); the configuration file wins after a restart.
Branding and the mascot
DNSY has a Tron: Ares look, modernised: black, white hairlines and one strong red, with no neon halo and no grid. Depth comes from 0.5px hairlines and spacing, never from shadows. Its mascot is Nox, a faceted owl head drawn in thin geometric lines that watches the network at night.
Assets
Everything lives in branding/ in the repository; the web UI, the docs and the landing page
consume those files and nothing is duplicated.
| Path | Use |
|---|---|
logo.svg | small mark plus the “DNSY” wordmark in Michroma (text with a system fallback) |
mark.svg | the small Nox mark alone (header) |
favicon.svg | the small mark on a near-black square |
mascot/nox-{idle,loading,empty,error,success,offline,404}.svg | Nox poses for UI states |
tokens.css | the design tokens (--dnsy-*): the palette, font families and @font-face rules |
fonts/ | Michroma, Geist and Geist Mono, self-hosted woff2 Latin subsets (SIL OFL 1.1) |
All SVGs are self-contained (no external references, no embedded fonts) and under 20 KB;
just check-svg enforces that.
Palette
Dark only. Red is the brand and nothing else: danger and DNSSEC bogus are amber, always together with the word, never colour alone.
| Token | Value | Role |
|---|---|---|
--dnsy-bg | #050505 | page ground |
--dnsy-surface | #0d0d0d | panels, sidebar, header |
--dnsy-text | #f2f2f2 | text and hairlines |
--dnsy-muted | #8c8c8c | labels, hints, inactive nav, form field borders, offline Nox |
--dnsy-line | #2a2a2a | dividers and table rules |
--dnsy-red | #ff2e2e | brand: Nox eyes and beak, primary buttons, links, focus ring, active nav, login pulses |
--dnsy-amber | #ffb020 | danger, destructive actions, field errors, warnings, DNSSEC bogus and blocked, error Nox |
--dnsy-secure | #3ddc97 | DNSSEC secure, OK, success Nox |
Corners stay near-square (2 to 4px). The focus ring is a solid red outline.
Contrast
WCAG 2.1 contrast ratios, measured from the token values. Targets: at least 4.5:1 for text, at least 3:1 for the focus ring, interactive borders and icons.
| Foreground | on --dnsy-bg | on --dnsy-surface | Use and target |
|---|---|---|---|
--dnsy-text | 18.21:1 | 17.36:1 | body text (4.5:1) |
--dnsy-muted | 6.06:1 | 5.78:1 | labels and hints (4.5:1); form field borders (3:1) |
--dnsy-red | 5.51:1 | 5.26:1 | small link and button text (4.5:1); focus ring (3:1) |
--dnsy-amber | 11.14:1 | 10.63:1 | errors, warnings, bogus badges (4.5:1) |
--dnsy-secure | 11.53:1 | 11.00:1 | secure and OK badges (4.5:1) |
--dnsy-line | 1.42:1 | 1.35:1 | decorative dividers and table rules only (exempt) |
The active navigation link (red text on a 10% red fill over the surface) measures 4.90:1. Because
--dnsy-line is too faint to identify a control, form fields take their border from
--dnsy-muted (5.78:1 on the surface) and turn red when focused.
Typography
| Family | Weights | Use |
|---|---|---|
| Michroma | 400 | wordmark, page and section titles, buttons and badges |
| Geist | 400, 500, 600 | body text, tables, forms, navigation |
| Geist Mono | 400, 500 | record data, IP addresses, configuration, tokens, the query log |
Michroma is always uppercase with open tracking: about 0.08em for titles, 0.12em for controls and 0.22em for the wordmark. Titles stay modest (about 21px for a page title) so the wide capitals do not turn retro; Michroma is never used for paragraphs or below about 10px.
The fonts ship as woff2 Latin subsets from Fontsource 5.3.0 in branding/fonts/:
michroma-latin-400-normal.woff2, geist-latin-{400,500,600}-normal.woff2 and
geist-mono-latin-{400,500}-normal.woff2, with their licences in OFL-Michroma.txt,
OFL-Geist.txt and OFL-GeistMono.txt (SIL Open Font License 1.1).
Nox
A faceted, helmet-shaped owl head: sharp ear corners, a chevron brow, light-slit eyes, a small
chevron beak and a dashed inner outline, with nothing on the lower face. Lines are 0.5px
--dnsy-text; eyes and beak are --dnsy-red. The geometry is drawn in a 200 by 200 design space;
the files keep viewBox="0 0 64 64" and scale it with one group transform, and every stroke uses
vector-effect: non-scaling-stroke so the hairlines stay 0.5px at any size.
outline M100 34 L146 20 L162 70 L152 140 L100 178 L48 140 L38 70 L54 20 Z (0.5px)
inner M100 46 L138 34 L150 72 L141 133 L100 163 L59 133 L50 72 L62 34 Z (0.5px, dash 2 4, 45%)
brow M44 80 L100 98 L156 80 (0.5px)
eyes M62 101.5 H88 M112 101.5 H138 (1px, red, round caps)
beak M93 108 L100 116 L107 108 (0.5px, red)
The head stays the same in every pose; the eyes act.
| Pose | Look | Motion | Where you see it |
|---|---|---|---|
| idle | as above | none | the book header |
| loading | eyes at 25%, a 9 by 2 red point sweeps across them | 1.6s loop | while a page waits for data |
| empty | relaxed brow, short eyes looking down, beak 6px lower | none | lists with nothing in them |
| error | amber eyes slanted in, lowered brow, amber beak | flicker, 1.4s | failed requests, beside Retry |
| success | green chevron eyes, green beak | a short hop | confirmations |
| offline | grey dashed eyes, head at 50% | none | the browser lost its connection |
| 404 | head tilted -9°, one slit eye and one round eye | none | unknown pages |
The small mark (header 32px, favicon 24px, mark.svg, favicon.svg, logo.svg) keeps only
the outline, brow and eyes: lines 1px, eyes 1.5px, no inner dashes and no beak.
All motion stops under prefers-reduced-motion: reduce, and every pose has a text alternative for
screen readers. The implementation is described under Mascot components in Web UI.
The landing page
dnsy.dev (docs/landing/index.html, copied to the site root by just docs) is one hand-written
page with inline CSS and script and no third-party requests; it takes its palette and fonts from
branding/tokens.css. A full-height hero sets the headline over the same wireframe blob as the
panel’s login page (white hairlines, red pulses, a still frame under reduced motion), followed by
a feature matrix of ten cells and four deep-dives (the request pipeline, DNSSEC, encrypted
transports and the web panel) with inline SVG diagrams. The values in the DNSSEC diagram are
examples. Keep the matrix and the diagrams in step with the features and the
architecture when they change.
Using the tokens
@import "branding/tokens.css";
body { background: var(--dnsy-bg); color: var(--dnsy-text); font-family: var(--dnsy-font-body); }
h1 { font-family: var(--dnsy-font-display); text-transform: uppercase; letter-spacing: 0.08em; }
.card { background: var(--dnsy-surface); border: 0.5px solid var(--dnsy-line); }
This book uses the same tokens through docs/book/theme/dnsy.css.
REST API
The API lives under /api/v1. The rendered reference (Redoc) is on the docs site at
/rest-api/; the machine-readable OpenAPI 3.1 document is served at /api/openapi.json and
checked in as openapi/dnsy.json.
Probes
| Path | Auth | Purpose |
|---|---|---|
GET /healthz | none | the process is up |
GET /readyz | none | the database answers and every DNSSEC zone can be signed (503 otherwise; a signing failure reads dnssec_signing_failed) |
GET /api/v1/version | none | server version |
Authentication
Every other endpoint needs one of:
- Bearer token:
Authorization: Bearer dnsy_.... The token must carry the scope the endpoint needs (api:read,api:write,admin) and its owner’s role must still allow it. - Session cookie: the
dnsy_sessioncookie set by the web UI login. The user’s role must allow the scope (viewer: read; operator: read and write; admin: everything).
Cookie requests with an unsafe method (anything but GET, HEAD, OPTIONS) must also send an
X-CSRF-Token header equal to the dnsy_csrf cookie (double submit); otherwise the answer is 403.
Bearer requests need no CSRF token.
When the cookies are Secure (DNSY serves TLS, or [api] secure_cookies = true), both are named with the
__Host- prefix: __Host-dnsy_session and __Host-dnsy_csrf. Browsers then refuse to store them unless
they are Secure, have Path=/ and no Domain, so a sibling subdomain or a plain-HTTP response cannot
plant them. Over plain HTTP (development, e2e) the unprefixed names are used. In each mode the server only
accepts the cookie names it sets.
Credentials are never logged: request tracing records only method, path, status and latency.
Auth, users and tokens
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/v1/auth/login | none | {username, password}; sets dnsy_session (HttpOnly) and dnsy_csrf cookies, both SameSite=Strict (and Secure over TLS). Answers 401 on bad credentials, 429 when throttled |
POST /api/v1/auth/logout | session | ends the session and clears the cookies (204) |
GET /api/v1/auth/me | read | the caller and how it authenticated (session or token) |
POST /api/v1/auth/bootstrap | none | {code, username, password}; creates the first admin with the one-time code printed at startup. 401 for a wrong or expired code, 409 once any user exists |
PUT /api/v1/auth/me/password | session | {current_password, new_password}; ends every session of the user and returns fresh cookies for this client |
GET/POST /api/v1/users | admin | list, create {username, password, role} |
GET/PATCH/DELETE /api/v1/users/{id} | admin | PATCH takes role, password and/or disabled. disabled: true blocks login, ends the user’s sessions and makes their tokens fail (401) until disabled: false; users show disabled and disabled_at. 409 when it would remove, demote or disable the last active admin |
GET/POST /api/v1/tokens | read / session | list own tokens (admins: all); create {name, scopes, expires_at?} |
DELETE /api/v1/tokens/{id} | read | revoke an own token (admins: any); other users’ tokens answer 404 |
Passwords are at least 8 characters. Changing a user’s password or role, and deleting a user, ends that user’s sessions; a deleted user’s tokens stop working immediately. A token only works while its owner’s current role still allows the scope it is used for, so a demoted user’s old admin token gets 403.
The token secret is returned only in the POST /tokens response (token); listings contain
metadata only. Creating a token and changing a password need a session, so a leaked token cannot
mint further tokens or lock the owner out.
Zones, records and export
Zones are the JSON files of the zones directory; the API reads and writes them through the same validation as hot reload, so a write the API accepts is a file the server loads. Zone routes answer 503 when no zones directory is attached.
| Endpoint | Scope | Purpose |
|---|---|---|
GET /api/v1/zones | read | list zones: name, serial, record count, dnssec_enabled, load_error |
POST /api/v1/zones | write | {name, default_ttl, soa, records}; 201 with ETag. The records must include an apex NS |
GET /api/v1/zones/{name} | read | settings and SOA, record_count, load_error; ETag header |
PUT /api/v1/zones/{name} | write | replace settings and records (default_ttl, soa, records); keeps the DNSSEC policy |
DELETE /api/v1/zones/{name} | write | delete the zone and its file (204) |
GET /api/v1/zones/{name}/records | read | paged: limit (default 100, max 1000), offset, type, name; returns {items, total, limit, offset} |
POST /api/v1/zones/{name}/records | write | {name, type, ttl?, value}; 201 with the record |
PUT /api/v1/zones/{name}/records/{id} | write | replace a record |
DELETE /api/v1/zones/{name}/records/{id} | write | delete a record (204) |
GET /api/v1/zones/{name}/export | read | BIND master file, text/dns; charset=utf-8, Content-Disposition: attachment; filename="<zone>.zone". ?signed=true adds DNSKEY, RRSIG, NSEC or NSEC3 and NSEC3PARAM records (<zone>.signed.zone); 409 when the zone is not signed |
A record id is a hash of its absolute lowercase owner name, type and value, so it is stable
across reloads and changes when any of those change. Record name may be @, relative to the
zone, or absolute (trailing dot).
Rules, all enforced on write:
- Zone names must map to a safe file name: letters, digits,
-and.only, lowercase on disk, no root zone. Anything else (../x,a/b, backslashes,_, IDN in Unicode form) is 400, in the body and in the path. Names are case-insensitive; a second zone with the same name is 409. - Records are validated as in zone files: type (A, AAAA, NS, CNAME, PTR, MX, TXT, SRV, CAA), rdata, owner inside the zone, and CNAME exclusivity (a CNAME cannot share a name with other records and cannot sit at the apex). DNSSEC types and SOA cannot be written by hand. Failures are 422. A TTL above 2147483647 is 422.
- Apex NS. A zone must have at least one NS record at the apex; deleting or changing the last one is 422.
- Duplicates (same name, type, value) are 409.
- SOA serial goes up by one on every successful write (creation uses
soa.serial, default 1). - Limits: request bodies up to 1 MiB (413), 10000 records per zone, 1000 zones, values up to
4096 characters. After answering 413 the listener keeps reading and discarding up to 4 MiB of the
rest of the body (or the declared
Content-Lengthif smaller) for at most 2 seconds, holding the connection slot meanwhile, so a client that is still uploading can read the 413; then it closes.
Concurrency. Zone reads return an ETag. Writes (PUT, DELETE, record POST/PUT/DELETE)
accept If-Match; a stale tag is 412 and nothing is written. If-Match: * always matches. Without
the header the write goes through. Writes are serialised, and write responses carry the new ETag.
Export file names come from the validated zone name, so the Content-Disposition header cannot be
influenced beyond [a-z0-9.-].
DNSSEC
Signing and validation control. Zone routes answer 503 without a zone signer, settings routes
without the engine. Every route that changes something needs the admin scope (the admin role);
operator tokens and viewers get 403. See DNSSEC for the concepts.
| Endpoint | Scope | Purpose |
|---|---|---|
GET /api/v1/zones/{name}/dnssec | read | policy (enabled, algorithm, key_layout, denial, nsec3, validity, refresh, DNSKEY TTL), propagation_delay, keys that are not removed, next_resign, rollover (state plus rollovers, every role under way) and keys_encrypted |
PUT /api/v1/zones/{name}/dnssec | admin | {enabled, ...}: enable, disable or change the policy; fields left out keep their value. Writes the zone file’s dnssec block. Enabling creates the keys and signs the zone before the response, and is idempotent: repeating it never adds keys. propagation_delay (seconds, 0 to 604800, default 3600) is part of the policy. Algorithms other than 13 and 15, bad NSEC3 parameters and bad validity settings are 422 |
GET /api/v1/zones/{name}/dnssec/keys | read | all keys of the zone, removed ones included (public metadata only) |
POST /api/v1/zones/{name}/dnssec/keys | admin | {role, algorithm?} generates a key in state generated; 201. The zone needs signing enabled (409) |
POST /api/v1/zones/{name}/dnssec/keys/{id}/rollover | admin | {algorithm?} (body optional) starts a rollover of an active key’s role; 202 with the incoming key. 409 when that role already has a rollover or the key is not active |
POST /api/v1/zones/{name}/dnssec/keys/{id}/ds-confirmed | admin | records that the key’s DS is at the parent; 204. Lets a KSK or CSK rollover proceed |
POST /api/v1/zones/{name}/dnssec/rollover/abort | admin | {role?} (body optional) withdraws the incoming key of that role, or of every role, and keeps the active key; 200 with the zone’s DNSSEC state. 409 when no rollover is in progress (or the new key already became active) |
POST /api/v1/dnssec/keys/export | admin | {passphrase, zone?} returns {bundle}, the zone keys (all zones when zone is omitted) as base64 of a bundle encrypted under passphrase (at least 12 characters; 422 otherwise). 403 on a plain-HTTP API listener, because the request carries the passphrase and the response every private key. dnsy dnssec keys import restores it |
POST /api/v1/dnssec/passphrase | admin | {new_passphrase} re-encrypts every stored key under the new passphrase in one transaction (all or none) and switches the server to it; 200 {keys_reencrypted}. Update DNSY_DNSSEC_PASSPHRASE or the passphrase file before the next start. The secrets are never logged or returned |
GET /api/v1/zones/{name}/dnssec/ds | read | DS records (SHA-256 then SHA-384) and DNSKEY records of the published and active KSKs and CSKs. JSON, or Accept: text/plain for zone-file lines |
GET /api/v1/settings/dnssec | read | validation (off, permissive, strict), negative_trust_anchors (names) and negative_trust_anchor_entries (name, expires_at; expired anchors are left out), and the read-only trust_anchors (owner, key tag, algorithm, digest type, digest) |
PATCH /api/v1/settings/dnssec | admin | {validation?, negative_trust_anchors?}; applied at once. A change flushes the resolver cache. The list replaces the old one; an entry is a name or {name, expires_at} (Unix time in the future, after which the anchor stops applying; 422 otherwise). The root cannot be an anchor (422) |
GET /api/v1/dnssec/status | read | validation mode, clock_trusted, answer counts by DNSSEC status for the last 24 hours, secure, bogus, signed_zones |
Key responses carry id, role, algorithm, key_tag, state and the state timestamps
(created_at, published_at, active_at, retired_at, removed_at, ds_confirmed_at). No route,
response, error body, log line or OpenAPI schema carries private key material or the key
passphrase; the tests check the responses and the logs against the stored key blobs.
Run-time changes through PATCH /settings/dnssec are not written back to the configuration file;
a restart returns to the [dnssec] section.
Query log, stats and cache
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/v1/querylog | read | search the query log, newest first |
GET /api/v1/querylog/stream | write (operator, admin) | live tail as server-sent events |
GET /api/v1/stats/summary | read | totals, blocked ratio, cache hit ratio, answer sources, DNSSEC status counts, latency |
GET /api/v1/stats/timeseries | read | counts per bucket |
GET /api/v1/stats/top-domains, top-blocked, top-clients | read | ranked lists |
GET /api/v1/stats/dashboard | read | summary, timeseries and the three top lists in one response |
GET /api/v1/cache | read | live cache counters |
DELETE /api/v1/cache | write | flush everything; answers {"removed": n} |
DELETE /api/v1/cache/entries?name=<name>&subtree=<bool> | write | flush one name, optionally with everything below it; answers {"removed": n} |
/querylog filters: from (inclusive) and to (exclusive) in unix milliseconds, client
(exact address), domain (case-insensitive substring), type (A, AAAA, or a number),
outcome (response code such as NXDOMAIN, or a number), source (authoritative, cache,
cache_stale, upstream, blocked, refused, error) and dnssec (unchecked, secure,
insecure, bogus, indeterminate). limit is 1 to 500 (default 100). A page with more rows
behind it carries next_cursor; pass it back as cursor. Pages stay stable while new rows arrive.
Unknown filter values answer 400.
/stats/* take from and to (unix milliseconds; default the last 24 hours, at most 366 days,
from before to, else 400). timeseries takes bucket in seconds (a multiple of 60, default
3600, at most 5000 buckets). The top lists take limit (1 to 100, default 10). Rollups are minute
or hour granular for the last two to three days and day granular before that, so buckets touching
the range count whole; timeseries buckets under one day have no data for older days.
/stats/dashboard answers {summary, series, top_domains, top_blocked, top_clients}, each section
identical to the response of its own route for the same range. It takes from, to, bucket and
limit with the rules above (one limit for all three lists) and answers 400 on the same
violations. The individual routes stay available.
The live tail sends query events (one log record as JSON) and lagged when rows were skipped
because the reader was too slow. The server polls the log at its own pace, so a slow reader never
makes it buffer. At most 16 streams run at once; more answer 429 with Retry-After. It takes the
same filters as the search.
Query-log responses never contain a token: anything shaped like a DNSY token is replaced with
[redacted] (defence in depth; tokens are already redacted before logging). With
querylog.redact_client_ip on, client addresses are shown as their /24 or /48 network, and the
client filter matches the redacted form.
Filtering, upstreams, client certificates and settings
Changes made through these routes are stored and applied to the running server at once. Reads need
api:read; changes need api:write unless noted. See Filtering for what the rules
and lists do.
| Route | Purpose |
|---|---|
GET /acl, POST /acl, DELETE /acl/{id} | CIDR (or client-certificate fingerprint) rules with action allow, refuse or drop; longest prefix wins. A single address blocks one IP |
PUT /acl/rate-limit | Per-client queries per second (0 = unlimited, at most 1,000,000), persisted |
GET /response-ip, PUT /response-ip | Response-IP networks {"cidrs": [...]}; PUT replaces the list live and persists it (invalid network 422) |
GET/POST /blocklists, PATCH/DELETE /blocklists/{id} | List sources and their last fetch status (last_fetched, last_status, entries); PATCH enables or disables |
POST /blocklists/{id}/refresh | Fetch now: {"outcome": "updated" | "not_modified"}; a failed fetch is 422 and the previous list stays active |
GET/POST /rules, DELETE /rules/{id} | Custom block or allow rules for a domain and its subdomains (allow wins) |
GET /upstream-groups, GET /upstream-groups/{name}, GET /upstream-groups/{name}/health | Groups, members and live health (available, consecutive_failures, latency_ms) |
POST /upstream-groups, PUT/DELETE /upstream-groups/{name} | Create, replace, delete a group (admin) |
POST /upstream-groups/{name}/upstreams, DELETE /upstream-groups/{name}/upstreams/{index} | Add or remove one member (admin) |
GET/POST /client-certs, PATCH/DELETE /client-certs/{id} | mTLS fingerprint allowlist with label and optional expiry (admin, reads too) |
GET/PATCH /settings | The runtime-changeable settings (admin) |
A group change is validated as a whole (one default group, no pattern routed twice) before anything is stored. Replacing a group set resets the health counters.
Blocklist sources and SSRF. A source is an http(s) URL or a file path. A URL whose host is an
IP literal in a private, loopback, link-local or otherwise non-public range, localhost, and any
file path are refused unless allow_private is set, and setting it (or using such a location)
needs the admin scope: a write-scope token gets 403. Host names are resolved and pinned at
fetch time, where every non-public address is refused for sources without allow_private.
Client certificates. Adding, disabling, enabling or removing a fingerprint reloads the DoT
allowlist, which bumps its generation so connections that are already open are checked again and
a removed certificate is cut off, not only refused on the next handshake. If the reload fails the
request answers 500; the change is stored and the periodic reload applies it.
Settings. PATCH /settings takes a partial document; GET returns the same shape:
{
"filter": { "block_mode": "null_ip" },
"querylog": { "retention_days": 30 },
"limits": { "per_client_qps": 0 },
"cache": { "min_ttl": 0, "max_ttl": 86400,
"serve_stale": { "enabled": true }, "prefetch": { "enabled": true } }
}
The patch is applied to a copy of the running configuration and checked with the same validation
the config loader uses (for example min_ttl must not exceed max_ttl) before anything changes;
a bad value is 422 and nothing changes. Any other key, such as listeners, TLS paths or the
DNSSEC key passphrase, is not part of the model and is rejected as unknown, so no secret is ever
read or written here. Settings are in effect immediately but are not written to dnsy.toml; after
a restart the file applies again (the rate limit is the exception, it is persisted with the ACL).
Errors
Errors are RFC 9457 application/problem+json:
{ "type": "https://dnsy.dev/problems/forbidden", "title": "Forbidden", "status": 403,
"detail": "missing or invalid CSRF token" }
| Status | Meaning |
|---|---|
| 400 | malformed request |
| 401 | missing, invalid or expired credentials (WWW-Authenticate: Bearer) |
| 403 | credential lacks the scope, or CSRF check failed |
| 404 | no such resource |
| 409 | conflict (duplicate name, record or zone, last admin) |
| 412 | If-Match does not match the current ETag |
| 413 | request body too large |
| 422 | well-formed request with invalid data (record rdata, CNAME conflict, missing apex NS) |
| 429 | too many failed logins or too many live-tail streams; Retry-After header and retry_after_secs field |
| 500 | internal error; details are logged, never returned |
| 503 | not ready, or the service behind the endpoint (the cache) is not running |
Security headers and the web UI
All responses carry Content-Security-Policy: default-src 'self' (plus frame-ancestors 'none'),
X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and, when
the API is served over TLS, Strict-Transport-Security. API responses are Cache-Control: no-store.
With the embed-ui cargo feature the built Angular app (web/dist/dnsy/browser) is embedded in the
binary and served for all non-API paths; unknown client-side routes fall back to index.html.
Build the UI first (just web), then cargo build --features embed-ui.
OpenAPI
The spec is generated from the handler annotations (utoipa). After changing an endpoint run
just openapi and commit openapi/dnsy.json. just check (and so just ci) runs
just openapi-check, and a unit test fails when the checked-in file is stale.
For route authors
Each route group is a file crates/dnsy-api/src/routes/<group>.rs exposing
pub fn router() -> OpenApiRouter<ApiState> with paths relative to /api/v1, registered by one
.merge(<group>::router()) line in routes/mod.rs. Guard handlers by taking
RequireScope<Read>, RequireScope<Write> or RequireScope<AdminScope> as an argument, and return
Result<_, ApiError>.
API reference
The management API is a JSON REST API under /api/v1, described by an OpenAPI 3.1 document.
| Where | What |
|---|---|
openapi/dnsy.json in the repository | the checked-in document; just openapi-check (part of just check) fails when it drifts from the handler annotations |
GET /api/openapi.json on a running server | the same document, served by the binary |
dnsy openapi | prints the document to stdout |
/rest-api/ on the docs site | the OpenAPI document rendered with Redoc |
/api/ on the docs site | rustdoc for every crate |
dnsy openapi | jq '.paths | keys | length' # how many paths the server exposes
curl -s http://127.0.0.1:5380/api/openapi.json | jq '.info'
The REST API page is the narrative reference: authentication (bearer tokens and session cookies with CSRF), error format (RFC 9457 problem documents), pagination and what each endpoint group does. Endpoint groups:
| Group | Paths | Page |
|---|---|---|
| Probes and version | /healthz, /readyz, /api/v1/version | REST API |
| Auth, users, tokens | /api/v1/auth/*, /api/v1/users, /api/v1/tokens | Users, tokens and sessions |
| Zones and records | /api/v1/zones, /api/v1/zones/{name}/records, .../export | Zones |
| DNSSEC | /api/v1/zones/{name}/dnssec*, /api/v1/settings/dnssec, /api/v1/dnssec/status | DNSSEC |
| Filtering and ACL | /api/v1/blocklists, /api/v1/rules, /api/v1/acl, /api/v1/client-certs | Filtering |
| Upstreams, cache, settings | /api/v1/upstream-groups, /api/v1/cache, /api/v1/settings | Resolver, upstreams and cache |
| Query log and stats | /api/v1/querylog, /api/v1/stats/* | Query log and stats |
Generate a client from the document with any OpenAPI tool; the web UI does exactly that for its
TypeScript types (just web-types).
Operations
Signals
| Signal | Effect |
|---|---|
SIGTERM, SIGINT | graceful shutdown: listeners stop accepting, in-flight queries finish (5 s), the API closes, the query log is flushed, then the process exits 0 |
SIGHUP | reload without a restart: zone files, TLS certificates for DoT/DoH, the DoT client CA bundle, blocklists and custom rules, the DNSSEC trust anchors (inline and trust_anchor_file, re-read from dnsy.toml) and a pass of DNSSEC re-signing |
Zone files and certificates are also watched on disk, so SIGHUP is a “do it now”. Other changes to
dnsy.toml (listen addresses, limits, logging, doh.path) need a restart; limits, block mode, cache and retention can also be changed through PATCH /settings without one. A reload that fails keeps
the previous state and logs the reason.
Logging
See also Logging and metrics.
Logs go to stderr through tracing. [logging] level is trace to error; format = "json"
writes one JSON object per line for log shippers. Tokens, Authorization headers, cookies,
DoH path tokens, passwords and the DNSSEC key passphrase are never logged.
Health
GET /healthz answers while the process runs. GET /readyz answers 200 when the database
works and every DNSSEC zone can be signed, and 503 otherwise; point load balancers and orchestrators at it.
Background work
serve runs these tasks for you: blocklist refresh, zone hot reload, re-signing of DNSSEC zones
(every minute it checks for changed zones, expiring signatures and key state changes), the key
rollover timer (advances key states per the zone policy), client-certificate allowlist refresh
and query-log retention ([querylog] retention_days; batched, also removes expired sessions and
folds days older than 48 hours into daily rollups, removes daily counter rollups older than 400 days, and runs PRAGMA optimize daily).
Query log, stats and cache
The query log and stats live in SQLite and are served by the API and UI. Flush the cache from the UI or the command line:
dnsy cache flush --token "$DNSY_TOKEN" # everything
dnsy cache flush example.com --subtree --token "$DNSY_TOKEN"
Dropped queries (ACL drop, UDP rate limiting) get no reply on UDP and TCP; over DoH they
become 403. The exception is the UDP slip: one over-limit UDP query in limits.rate_limit_slip
(default 2) gets an empty reply with TC=1 so that a real client retries over TCP.
Database and file modes
SQLite runs in WAL mode with synchronous = NORMAL (a power loss can lose the last moments of the
query log, never corrupt the database) and journal_size_limit = 64 MiB. Reads use a pool of four
connections, any idle one serves a request, so a slow query-log search does not queue the others
behind it. The first start after an upgrade logs applying migration ... while it builds new
indexes on a large query log.
data_dir is created with mode 0700 and dnsy.db (with -wal and -shm) with 0600. Existing
ones are never changed. At start the server logs a warning for every TLS private key
(api.tls_key, dot.key, doh.key) that group or others can read; fix it with chmod 600.
Backups
See Backup and restore for the procedure; in short:
Run dnsy db backup <file> (SQLite’s online backup; consistent and safe while the server runs),
or stop the server and copy <data_dir>/dnsy.db with its -wal file. It holds users, tokens, rules, upstream groups and DNSSEC private keys; protect the
copy like the keys themselves. Without DNSY_DNSSEC_PASSPHRASE or key_passphrase_file the
keys are stored unencrypted. Zone files are plain JSON in [zones] dir; back them up too.
Database maintenance
dnsy db works on <data_dir>/dnsy.db without creating or migrating it:
| Command | What it does | While the server runs |
|---|---|---|
dnsy db check [--full] | PRAGMA quick_check (--full: integrity_check); prints ok or the problems and exits non-zero | safe, read-only (a few seconds per 10 million query-log rows) |
dnsy db backup <file> | consistent copy via the online backup API, mode 0600, never overwrites | safe |
dnsy db vacuum | rebuilds the file and returns free pages to the disk | refused: it blocks every writer and needs up to twice the database size in free disk, so stop the server first |
vacuum detects a server (or any other process) holding the database and exits with an error
instead of waiting. Retention purges free pages for reuse but do not shrink the file; vacuum only
when disk space matters.
Upgrades and migrations
See Upgrade and migrations. In short:
dnsy serve and dnsy migrate apply pending migrations on start. dnsy migrate --status
lists applied and pending migrations without touching the database; it opens the file with
immutable=1, so it works on a read-only directory, but it ignores the -wal file and therefore
shows the last checkpointed state of a database a server is writing. Migrations are never
edited after release; a database newer than the binary is refused. Back up before upgrading.
Benchmarks
just bench builds the release dnsy, starts it against a fake upstream and prints a table of
throughput and latency: UDP cache hit, UDP cache miss (every name unique), TCP (one persistent
connection, 64 clients, 64 clients with 16 queries pipelined) and DoH (HTTP/1.1 and h2, POST).
All 64 load clients connect from 127.0.0.1, so the runner raises the per-client listener limits
(max_conns_per_client and udp_in_flight_per_client, see below) in its own config; against the
defaults the TCP and DoH rows would be reset and UDP datagrams dropped. Every row must show
0 errors. Pin CPUs for stable numbers, for example taskset -c 0-5 just bench; the server and the load
generator share the machine, so compare runs on the same host only. Reference numbers on a
24-thread workstation, unpinned: UDP hit about 310k QPS, TCP persistent connection p50 18 us,
TCP pipeline 16 at 64 clients about 426k QPS.
TCP and DoT listeners set TCP_NODELAY and write each length-prefixed answer in a single write,
so a client reusing a connection no longer waits for a delayed ACK (about 41 ms per answer
before). just bench-micro runs the criterion micro-benchmarks (codec, cache lookup).
Threads
[server] worker_threads (default: available parallelism, which honours a CPU affinity mask)
sizes the async runtime that runs the listeners and the pipeline. Argon2 and zone signing run on
tokio’s blocking pool on top of that, and every SQLite write runs on one dedicated thread,
dnsy-sqlite-writer, so the query-log writer queues behind nothing but other writes. A UDP
datagram that the pipeline can answer without waiting (cache hit, blocked name, local zone) is
answered in the receive loop of its socket; only one that waits for an upstream becomes a task.
Listener limits and UDP sockets
Admission caps are documented in Security. UDP sockets are created with a 4 MiB
receive buffer (SO_RCVBUF; the kernel may clamp it to net.core.rmem_max, raise that sysctl for
bursty traffic). On Linux [server] udp_sockets_per_address (default: available parallelism)
binds that many sockets per UDP address with SO_REUSEPORT, each with its own receive loop; the
kernel spreads clients across them by source address and port. Other platforms use one socket and
ignore the key. Check for kernel drops with nstat -az UdpRcvbufErrors (or netstat -su,
“receive buffer errors”): it should stay at zero under load. ss -ulnp shows the sockets.
Building with the UI
just build produces the release binary with the web UI embedded (see Install).
Logging and metrics
DNSY has three sources of operational data: process logs, the query log with its statistics, and the health endpoints.
Process logs
Logs go to stderr through tracing. [logging] level selects trace, debug, info, warn or
error; format = "json" writes one JSON object per line for a log shipper, pretty is for
terminals. Tokens, Authorization headers, cookies, DoH path tokens, passwords and the DNSSEC key
passphrase are never logged. A reload that fails (SIGHUP, certificate or zone change) keeps the
previous state and logs the reason at warn.
Under systemd the logs are in the journal (journalctl -u dnsy); in a container they are the
container’s stderr.
Query log and statistics
Every answered query becomes one event: time, client, transport, principal, name, type, response
code, answer source, DNSSEC status and latency. Events are written to SQLite in batches and kept for
[querylog] retention_days; redact_client_ip stores only the client network, sample keeps one raw row in N queries and
max_size_mb caps the database. See
Query log and stats for the fields, rollups and the filter syntax.
The statistics endpoints are the metrics interface:
| Endpoint | What it gives |
|---|---|
GET /api/v1/stats/summary | totals, blocked ratio, cache hit ratio, answers by source and DNSSEC status, latency |
GET /api/v1/stats/timeseries | counts per minute or hour bucket |
GET /api/v1/stats/top-domains, top-blocked, top-clients | ranked lists |
GET /api/v1/cache | live cache counters |
GET /api/v1/dnssec/status | validation mode, trusted clock, secure and bogus counts of the last 24 hours |
GET /api/v1/upstream-groups/{name}/health | per-upstream health |
GET /api/v1/querylog/stream | live tail as server-sent events |
curl -s -H "Authorization: Bearer $DNSY_TOKEN" http://127.0.0.1:5380/api/v1/stats/summary | jq
There is no Prometheus /metrics endpoint in this release. Poll the summary endpoint (it is cheap:
it reads precomputed rollups) or scrape the JSON with an exporter of your own.
Health
GET /healthz is liveness (the process runs). GET /readyz is readiness: 200 when the database
answers and every DNSSEC zone can be signed, 503 otherwise (dnssec_signing_failed when a zone’s keys cannot be opened), and a warnings list such as querylog_disk_full (the query-log writer hit SQLITE_FULL; clears after the next successful write) or dnssec_clock_untrusted while the system
clock is earlier than the build time. Neither needs credentials.
What to alert on
/readyznot 200.- Cache hit ratio dropping, or upstream health below the number of configured upstreams.
bogusanswers rising in/dnssec/status(a broken signed zone or a clock problem).- Query log events dropped (the writer’s counter) under load.
Backup and restore
Everything DNSY persists is in two places.
| What | Where | Notes |
|---|---|---|
| Database | <server.data_dir>/dnsy.db (plus -wal and -shm) | users, password hashes, tokens (hashed), sessions, ACL, rules, blocklist sources, upstream groups, client certificates, DNSSEC keys, query log and rollups |
| Zone files | [zones] dir, *.json | the source of truth for each zone, human readable |
| Configuration | dnsy.toml, TLS certificates and keys, key_passphrase_file | managed by you, not by DNSY |
The cache is memory only and needs no backup.
Back up
Use dnsy db backup, which runs SQLite’s online backup API: the copy is consistent, contains
everything in the -wal file, needs no sqlite3 binary and is safe while the server runs (the
server’s writer is never held for more than a few milliseconds at a time). The file is created
with mode 0600 and the command refuses to overwrite an existing file.
dnsy db backup /backup/dnsy-$(date +%F).db
dnsy db check --full # optional: verify the live database (quick_check without --full)
rsync -a /var/lib/dnsy/zones/ /backup/zones/
Do not copy dnsy.db alone while the server runs: rows still in dnsy.db-wal are silently
missing from such a copy. sqlite3 dnsy.db ".backup '<file>'" is equivalent when the binary is
installed.
Verify a backup with dnsy db check against a configuration whose data_dir holds it, or with
sqlite3 <file> "PRAGMA quick_check".
Copying dnsy.db together with its -wal file while the server is stopped works too. Zone files
can also be exported as BIND text (dnsy zone export example.com, --signed for the signed
form), which is useful as a portable copy but not a replacement for the JSON files.
The database holds DNSSEC private keys. Without a key passphrase (dnssec.key_passphrase_file or
DNSY_DNSSEC_PASSPHRASE) they are stored unencrypted, so protect backups like the keys
themselves, and keep the passphrase separately from the backup: a database copy is useless for
signing without it.
The database file is created with mode 0600 (its -wal and -shm files follow) and a missing
data_dir with 0700. Existing files and directories keep their modes, so check them after
restoring from a backup made by another user: chmod 700 <data_dir>; chmod 600 <data_dir>/dnsy.db*.
The query log can dominate the database size. Lower [querylog] retention_days to shrink the
backup; the rollups behind the dashboard are small: hourly and minute rows are kept for the last two
to three days only, older days are folded into stats_day (about 20 rows per day) and
stats_top_day (at most 15 000 rows per day). The fold is one-way, so a backup is the only way to
get the minute and hour detail of an old day back. The write-ahead log is truncated to 64 MiB
after a checkpoint (journal_size_limit), so a large purge does not leave a large -wal file.
The two query-log indexes add about 30 percent to the file size (see Query log and
stats).
Restore
- Stop
dnsy serve. - Put
dnsy.dbback into<data_dir>(remove stalednsy.db-walanddnsy.db-shmfiles) and the zone files into[zones] dir. - Provide the same key passphrase as when the keys were created.
dnsy config check, thendnsy migrate --statusto see that the database matches the binary.- Start the server. A database newer than the binary is refused; run the binary version that wrote it or a newer one.
After a restore of a signed zone check dnsy dnssec keys list <zone> and compare
dnsy dnssec keys ds <zone> with the DS at the registrar: they must match, otherwise validating
resolvers fail the zone.
Moving to another host
Restore as above on the new host and point clients at it. DoT and DoH certificates are not part of the backup: install them on the new host. Sessions survive a restore only until they expire; tokens keep working because they are stored in the database.
Upgrade and migrations
DNSY is one binary and one SQLite database. Upgrading means replacing the binary and letting it migrate the database.
Procedure
- Read the changelog for the new version.
- Back up the database and the zone files.
- Install the new binary (see Install).
dnsy config check: unknown keys are errors, so a removed or renamed key shows up here.dnsy migrate --status: lists applied and pending migrations without changing anything.- Restart the service.
dnsy serveapplies pending migrations on start;dnsy migrateapplies them without starting the server. - Check
GET /readyzandGET /api/v1/version.
Migration rules
- Migrations are numbered, applied in a set (merge order does not matter) and never edited after release; every change is a new migration.
- A database newer than the binary (a migration the binary does not know) is refused at start. To go back, restore the backup taken before the upgrade and run the older binary.
- There are no down-migrations. The backup is the rollback path.
Rolling back
Stop the service, restore dnsy.db and the zone files from the backup, start the previous binary.
Zone files written by a newer version may use fields an older version rejects (unknown keys are
errors), which is another reason to restore the zones from the same backup.
Troubleshooting
Start with dnsy config check, curl -s http://127.0.0.1:5380/readyz and the server’s stderr
(raise [logging] level to debug temporarily).
The server does not start
| Symptom | Cause and fix |
|---|---|
cannot bind udp listener on 0.0.0.0:53: permission denied | privileged ports need CAP_NET_BIND_SERVICE or root; grant the capability, or listen on a high port and forward. The message names the protocol and address and the key to override (dns.udp, dns.tcp, dot.listen, doh.listen, api.listen, or the matching DNSY_DNS__UDP style variable) |
cannot bind ...: address in use | another resolver (systemd-resolved listens on 127.0.0.53/54, which collides with 0.0.0.0:53; also dnsmasq) holds the port; stop it or change the listen address named in the message |
cannot create the data directory /var/lib/dnsy: permission denied (or the zones directory, or dnsy.db) | running unprivileged against the default paths; set server.data_dir / zones.dir (or DNSY_SERVER__DATA_DIR, DNSY_ZONES__DIR) to a directory you can write |
| a config error naming a key | unknown keys and invalid combinations are errors; run dnsy config check, see Configuration reference |
database disk image is malformed, or sudden query-log or login errors after a crash or disk fault | run dnsy db check --full; if it reports problems, stop the server and restore the last dnsy db backup (see Backup and restore) |
dnsy db vacuum says the database is open in another process | the server holds it; stop dnsy serve first (backup and check do not need that) |
dnsy migrate --status shows older state than the server reports | it reads without the -wal file (so it works on a read-only directory); recent migrations appear after the next checkpoint or a server stop |
| database refused as newer than the binary | run the binary version that wrote it, or restore a backup; see Upgrade |
Clients get errors
| Symptom | Cause and fix |
|---|---|
REFUSED | the client is blocked by an ACL rule, the query has RD=0 (DNSY only offers recursion), or the client is over its rate limit on TCP/DoT |
| no reply at all over UDP | an ACL drop rule, or the rate limit (UDP over the limit is dropped) |
SERVFAIL with EDE 22 or 23 | no upstream reachable (22) or an unusable upstream response (23); check Upstreams and GET /api/v1/upstream-groups/{name}/health |
SERVFAIL with EDE 6, 7, 8, 9, 10 or 12 | DNSSEC validation failed; see below |
a blocked name answers 0.0.0.0 | that is filter.block_mode = "null_ip"; EDE 15 (list) or 17 (custom rule) says why |
| a zone edit does not show | local zones bypass the cache, so check the zone’s load_error in GET /api/v1/zones |
DNSSEC
- Compare with a non-validating query:
dig +cd @127.0.0.1 name. If that works and the plain query fails, the data is bogus (broken signatures, expired RRSIGs, a stale DS at the parent). - EDE 7 and 8 (expired or not yet valid) usually mean a wrong clock, on the server or at the
signer. Until the system clock passes the build time DNSY does not validate at all and
/readyzreportsdnssec_clock_untrusted. - A zone you sign yourself fails everywhere when the DS at the registrar does not match
dnsy dnssec keys ds <zone>. - To keep one broken name working while you fix it, add a negative trust anchor
(
PATCH /api/v1/settings/dnssec, or the DNSSEC page); it is runtime-only.
See the DNSSEC operator guide for key and DS procedures.
DoT, DoH and the API
| Symptom | Cause and fix |
|---|---|
| TLS handshake fails on DoT with mTLS | the client certificate is not signed by dot.client_ca, has expired, or (with mtls = "required") is missing; see DoT and mTLS |
| DoH answers 401 | no token, or a rejected one; 403 means the token lacks dns:query; 429 means too many rejected tokens from that address |
| DoH answers 404 | the path differs from [doh] path; a path token goes after it: /dns-query/<token> |
| the UI paths answer 404 | the binary was built without the embed-ui feature; use just build (Install) |
| API answers 403 on a POST from a script using cookies | cookie sessions need the X-CSRF-Token header; use a bearer token for scripts |
| login answers 429 | five failed logins in 15 minutes for that user and address; wait for Retry-After |
Still stuck
Search the FAQ and the glossary, then open an issue at
https://github.com/repsyio/dnsy/issues with the version (dnsy version), the relevant log lines
(secrets are never logged, but check anyway) and the query you ran. Report security problems
privately, see Security.
FAQ
Is DNSY a recursive resolver? No. It is a forwarding, caching, validating resolver: it asks upstream resolvers (that do the recursion) and validates DNSSEC itself. It also serves local zones authoritatively. See Resolver, upstreams and cache.
Does it depend on a DNS library? No. The wire format, DNSSEC and everything else are written in
the repository; only raw crypto primitives come from ring. See Architecture.
Which database does it use? An embedded SQLite file (bundled, no server to run) for users, tokens, rules, keys and the query log. Zones are plain JSON files. See Backup and restore.
Can I use it as an ad blocker? Yes: add blocklist sources (hosts, domain list or adblock format) and custom block or allow rules. See Filtering.
Does it validate DNSSEC by default? Yes, in strict mode with the root trust anchors built in.
permissive serves bogus answers marked as such, off disables validation. See DNSSEC.
Can it sign my zones? Yes: ECDSA P-256 (13) and Ed25519 (15), NSEC or NSEC3, automatic re-signing and key rollover. You publish the DS record at the registrar yourself. See DNSSEC and the operator guide.
Do I need a certificate for DoT and DoH? Yes, both are off until you set listen together with
a certificate and key. Certificates reload on SIGHUP or when the file changes. See
DoT and mTLS and DoH and tokens.
Why does DoH want a token? doh.require_token defaults to true so a public DoH endpoint does
not become an open resolver. Tokens can travel in a header or in the URL path for clients that
cannot set headers.
Can I change settings in the UI and keep them? Rules, blocklists, ACL, upstream groups, users
and tokens are stored in the database and survive restarts. Some settings changed through
PATCH /api/v1/settings (block mode, cache, retention) and PATCH /api/v1/settings/dnssec apply
at once but are runtime-only: after a restart the dnsy.toml values return. The per-client rate
limit is the exception that is persisted. Put lasting values in the configuration file.
Is there a Prometheus endpoint? Not in this release; see Logging and metrics.
Does the query log show full answers? Not yet. The log stores the response code, the answer source, DNSSEC status and latency per query, but not the answer records or Extended DNS Error codes, and the UI has no one-click block or allow buttons on log rows; add rules on the Filtering page.
Which platforms are supported? Linux is the primary target; the code is portable and release binaries for Linux (x86_64 and aarch64), macOS (Intel and Apple silicon) and Windows (x86_64) are published with every release; see Releases.
Where do I report a security issue? Privately, see Security.
Glossary
| Term | Meaning |
|---|---|
| ACL | access control list: rules deciding per client address or mTLS certificate whether a query is allowed, refused or dropped |
| AD | “authenticated data” header flag, set on answers DNSY validated as secure |
| Authoritative | an answer from a zone DNSY serves itself (AA flag), as opposed to forwarded |
| Bogus | a DNSSEC validation failure: signatures that do not verify, expired signatures, missing proofs |
| CD | “checking disabled” header flag: the client asks for data without validation |
| CSK | combined signing key: one key used as both KSK and ZSK |
| DO | the EDNS “DNSSEC OK” bit: the client wants DNSSEC records |
| DoH | DNS over HTTPS (RFC 8484) |
| DoT | DNS over TLS (RFC 7858) |
| DS | delegation signer record held by the parent zone; links the parent to the child’s KSK |
| EDE | Extended DNS Error (RFC 8914): a reason code attached to an answer, e.g. 6 DNSSEC bogus, 15 blocked |
| EDNS | extension mechanism for DNS (RFC 6891): larger UDP payloads, flags and options |
| KSK / ZSK | key signing key (signs the DNSKEY set, its DS is at the parent) / zone signing key (signs the records) |
| mTLS | mutual TLS: the client also presents a certificate, here for DoT |
| Negative cache | cached NXDOMAIN or NODATA answers (RFC 2308) |
| NSEC / NSEC3 | DNSSEC records proving a name or type does not exist; NSEC3 hashes the names |
| NTA | negative trust anchor (RFC 7646): a name under which validation is switched off temporarily |
| Nox | the DNSY owl mascot, see Branding and the mascot |
| Prefetch | refreshing a popular cache entry shortly before it expires |
| RRSIG | the signature over a record set |
| Serve-stale | answering from an expired cache entry when upstreams fail (RFC 8767) |
| Trust anchor | the starting point of validation, here the root zone’s DS records |
| Upstream group | a set of upstream resolvers with a load-balancing policy and routing |
| Zone | a DNS subtree DNSY is authoritative for, stored as one JSON file |
Architecture
DNSY is a Cargo workspace of small crates behind one binary. Dependencies point one way: the
vocabulary crate dnsy-core is used by everything, dnsy-proto (the wire format, no runtime
dependencies) feeds the DNS crates, and the binary dnsy wires them together.
clients dnsy (one process) upstreams
+---------------------------------------+
UDP/TCP :53 ---> | dnsy-server listeners (UDP TCP DoT DoH)
DoT :853 --> | | |
DoH :443 --> | v |
| dnsy-engine pipeline: ACL, rate limit, sanity, filter,
| | local zones, cache, upstreams, validation,
| | scrubbing, response-IP policy, shaping
| |---> dnsy-zone JSON zones, signing, lookup
| |---> dnsy-filter blocklists, ACL rules
| '---> dnsy-resolver cache, load balancing ----> 1.1.1.1 ...
| '--- dnsy-dnssec validation 9.9.9.9 ...
| dnsy-observe query log + stats dnsy-auth users, tokens
| dnsy-store SQLite dnsy-config TOML schema
browser, curl -> | dnsy-api REST + OpenAPI + embedded web UI :5380
+---------------------------------------+
| Crate | Role |
|---|---|
dnsy-core | shared vocabulary types (transport, principal, role, scope, DNSSEC status, key role and state) and the one Clock trait (SystemClock; ManualClock behind the test-util feature) |
dnsy-proto | wire format, names, records, EDNS, canonical form |
dnsy-dnssec | validation, signing, key handling over ring primitives |
dnsy-keys | the DNSSEC key store: encrypted private keys, rollover, backup bundles |
dnsy-config | TOML schema, defaults, validation |
| (config consumers) | Only dnsy-engine (its builder), dnsy-api and dnsy depend on dnsy-config. The shared enums Validation and BlockMode live in dnsy-core (config re-exports them, serde strings unchanged); the resolver owns CacheOptions and DnssecOptions, which the engine builder fills from [cache] and [dnssec]; dnsy_dnssec::TrustAnchor::parse parses trust anchors (config only checks the shape, EngineBuilder::from_config and dnsy config check parse fully); the key store takes the passphrase environment variable name as a parameter |
dnsy-store | SQLite handle and migrations |
dnsy-auth | users, roles, tokens, sessions |
dnsy-observe | query events, query log, statistics |
dnsy-zone | zone model, JSON files, hot reload, authoritative lookup, signing (ZoneSigning over the dnsy-keys store) |
dnsy-resolver | upstream client, cache, load balancing, validating forwarder |
dnsy-filter | blocklists, allowlist, stored ACL rules and list sources; fetching them is the fetch feature (ACL evaluation and the rate limiter live in dnsy-engine) |
dnsy-engine | the Handler trait and the request pipeline |
dnsy-server | UDP, TCP, DoT and DoH listeners |
dnsy-api | REST API (axum, utoipa) and the embedded UI |
dnsy | the binary: CLI and wiring |
dnsy-api talks to services only, never to the listeners; dnsy-engine is the only place a
query is decided. The sections below describe the listeners and the pipeline.
Handler and plain DNS listeners
dnsy-engine::handler defines the transport-agnostic contract: a Handler takes a RequestContext (client address, transport, optional principal, receive time) and a decoded Message, borrowed (&Message, so the transports keep the query for sizing and encoding without cloning it), and returns the response Message. DoT and DoH reuse it unchanged. Engine (below) is the real handler; RefusedHandler answers REFUSED and remains only for listener tests.
dnsy-server runs the UDP and TCP listeners ([dns] udp/tcp) and only frames bytes:
- UDP: the response limit is
min(client EDNS payload, 1232), or 512 without EDNS. Larger answers are truncated byMessage::encodeand flagged TC, so clients retry over TCP. In-flight requests per socket are bounded and excess datagrams are dropped. - TCP: RFC 1035 two-byte framing with RFC 7766 pipelining; responses are written as they complete, in any order. At most
max_tcp_connsconnections are served (further ones are closed at accept), and a complete frame must arrive withintcp_idle_ms, which also cuts off slowloris-style trickling. Each connection bounds its pipelined requests. - Malformed input: a packet with a readable header gets a minimal FORMERR (id and question only, so no amplification); an unreadable header, a stray response or an oversized datagram is dropped. A malformed frame on TCP gets FORMERR and the connection is closed.
- Shutdown: listeners stop accepting, then wait up to 5 seconds for in-flight requests to be answered.
Request pipeline
dnsy-engine::Engine runs every query through one pipeline, whatever the transport. The first step that produces an answer ends the pipeline; exactly one QueryEvent (source, rcode, DNSSEC status, latency, principal) is emitted per request, including refused and dropped ones.
- ACL: the client address (or mTLS certificate) decides
allow,refuse(REFUSED) ordrop. This runs before any other reply, error replies included. - Rate limit: per client (IPv4 /32, IPv6 /64) token bucket, also before any other reply. Over the limit, UDP is dropped (one in
limits.rate_limit_slipgets a minimal truncated reply instead) and stream transports get REFUSED. - Sanity: no question -> FORMERR; opcode other than QUERY -> NOTIMP; EDNS version other than 0 -> BADVERS.
- Zone transfers: AXFR and IXFR are REFUSED, for local zones and forwarded names alike.
- Blocklists / allowlist: a blocked name is answered per
block_mode(NXDOMAIN,0.0.0.0/::, REFUSED or custom addresses) with EDE 15 (list) or 17 (custom rule). Blocked answers are never cached. - Local zones: an authoritative answer with AA set. ANY gets the RFC 8482 minimal answer. Local zones bypass the cache in both directions: the cache is never consulted for them and their answers are never inserted, so edits take effect immediately. A CNAME chain that ends in a name missing from the zone answers NXDOMAIN, one that ends in a missing type NOERROR (RFC 6604: the RCODE describes the last name of the chain); chains leaving our zones are returned as-is and not chased.
- RD and ANY check: a non-local query with RD=0 is REFUSED (we only offer recursion), and so is a non-local ANY query: forwarding it would turn the server into an amplifier.
- Cache: keyed by (name, type, class, DO, CD), so DNSSEC-requesting and checking-disabled clients never share entries with others. A hit skips the upstreams, validation and scrubbing (they ran when the entry was stored) but still passes the response-IP policy and shaping. Stale answers are served with EDE 3 or 19.
- Upstream groups: on a miss, a clean copy of the question (no client EDNS options, our own id) goes to the routed group, retried up to
resolver.attemptstimes. Identical concurrent misses share one upstream exchange; at mostMAX_MISS_WAITERS(256) requests wait on it, further ones get SERVFAIL (EDE 22) at once. The whole step is bounded by the deadlineresolver.timeout_ms * resolver.attemptsmeasured from receipt: when every upstream is down or slow the client gets SERVFAIL with EDE 22 (no reachable upstream) within it, never a hang; upstream transport errors or unusable responses give SERVFAIL with EDE 23 (network error). Error responses are not cached. - DNSSEC validation: unless
dnssec.validationisoffor the client sent CD=1, the query goes upstream with DO=1 and CD=1 and the raw answer is validated bydnsy-dnssec, fetching DS and DNSKEY records through the cache. Validation runs before scrubbing. Answer TTLs are capped by the remaining validity of the signatures, and the status is stored with the cache entry. Instrictmode a Bogus answer becomes SERVFAIL with the validator’s EDE; inpermissivemode it is served without AD, the EDE attached. A client CD=1 skips validation: the upstream answer is returned as-is from its own (CD) cache entry. See DNSSEC. - Scrubbing (cache poisoning defence): a response whose question differs from ours is discarded (SERVFAIL, EDE 23) and never cached. Otherwise only in-bailiwick data is kept: answer records on the CNAME chain from the question name, the SOA of a zone enclosing the final name for negative answers, DNSSEC records only for DO clients, and no additional section (minimal responses). AA and AD from upstream are cleared.
- Response-IP policy: an answer (fresh or cached) with an A/AAAA address inside a listed network is replaced by a block answer (list name
response-ip, EDE 15), e.g. against DNS rebinding. - Shaping: the client’s id, flags and question spelling are restored, RA is set, AD is set only for Secure answers to clients that sent DO or AD (RFC 6840 5.7), and an OPT record is sent only to clients that sent one (our payload size, their DO bit, EDE options only).
Handler::handle must always return a message, so a dropped request is answered REFUSED there; transports that can stay silent call Engine::process, which returns None for drops.
Assembly
Services::open(&config, &store) builds every store-backed service once: the blocklist ListManager over the persisted list sources (seeded from filter.blocklists on first start, answering in filter.block_mode), the zone store from zones.dir with its ZoneSigning, the cache from [cache], upstream groups from the database (seeded from resolver.upstreams on first start) behind a shared GroupsHandle, and the query-log writer when querylog.enabled. The binary hands the same Services to EngineBuilder::from_services (ACL rules and the rate limit from the database, falling back to limits.per_client_qps; an upstream client using resolver.timeout_ms per attempt; DNSSEC from [dnssec]) and to ApiState::with_services, so the engine, the API and the background tasks hold the same handles; the engine exposes none of them. Services::spawn_background starts the zone watcher, blocklist refresh, zone re-signing and key rollover; Services::reload (SIGHUP) re-reads zones, blocklists and due signatures. EngineBuilder::from_config remains as a shim that does both steps. EngineBuilder::new starts from config defaults only and its setters (and Services::bare plus its public fields) replace single components (tests, embedding). At runtime the ACL, rate limit, response-IP policy and upstream groups can be swapped atomically through Engine::set_*, and the list manager swaps in rebuilt blocklists.
API state and live policy
dnsy-api never names Engine<E>. The running engine is reached through one trait, dnsy_engine::LivePolicy (implemented once for Engine<E> in policy.rs): it applies ACL rules, the rate limit, the block mode and the response-IP policy, validates and swaps upstream groups (GroupSet::from_specs), reads and changes the DNSSEC mode and negative trust anchors, lists the trust anchors and reports the untrusted-clock flag shown by /readyz. ApiState holds it as policy: Option<Arc<dyn LivePolicy>>; without it, mutating routes answer 503 (fail closed). The state’s fields are grouped by concern: AuthServices (users, tokens, sessions, client certificates, bootstrap code, certificate reload, cookie flag), PolicyStores (ACL rules, blocklist sources, upstream groups), Zones (zone store and signer), Observe (store, client-IP redaction, live-tail limits); only policy, cache and lists stay optional at the top level. ApiState::new plus with_services(&Services) and with_policy(engine) is the one wiring path; RuntimeSettings lives in dnsy-api’s own module, not under routes.
Wire protocol
DNSY parses and builds DNS messages with its own codec in dnsy-proto (standard library only).
This page describes how it treats packets on the wire.
Decoding
- Every section count in the header is checked against the data: a count larger than the packet, RDATA whose fields do not fill exactly RDLENGTH octets, or bytes left after the last section are all malformed.
- Compression pointers must point strictly backwards and are followed at most 128 times; labels are at most 63 octets and names at most 255.
- Memory use follows what actually parsed, never the counts claimed in the header.
- A malformed query whose 12-byte header is readable gets a FORMERR reply that echoes its id and,
when it parses, its first question. Packets shorter than a header, and malformed packets that are
themselves responses, are dropped silently. Before a UDP or TCP listener sends that FORMERR it
asks the handler (
Handler::admit): a client the ACL drops, or one over its UDP rate limit, gets no reply, and the FORMERR consumes a rate-limit token like any other reply. - Decoded queries pass the ACL and the rate limit before anything else, so FORMERR (no or several questions), NOTIMP (an opcode other than QUERY, such as NOTIFY or UPDATE) and BADVERS (EDNS version above 0) are only sent to admitted clients and count against their rate limit.
- QTYPE ANY is answered minimally (RFC 8482): for a name in a local zone the answer is one
synthesised
HINFO "RFC8482" ""record with TTL 3789 over UDP and TCP alike (a DO query to a zone served signed gets one real RRset with its RRSIGs instead, so it still validates); ANY for any other name is REFUSED without asking an upstream. AXFR (252) and IXFR (251) are REFUSED for every name, local or not, and are never forwarded.
Encoding
- Owner names in every section are compressed. Inside RDATA only the names of NS, CNAME, PTR, MX and SOA are compressed (RFC 3597 section 4); SRV, RRSIG and NSEC names are always written in full.
- The OPT pseudo-record is always written last in the additional section.
- When a response is larger than the limit (512 octets without EDNS, otherwise the client’s advertised size), whole RRsets are dropped from the end: additional first, then authority, then answers. The OPT record is never dropped. TC is set when an answer or authority RRset had to go; losing only additional data does not set TC (RFC 2181 section 9).
- A message whose rcode does not fit four bits and that has no OPT record fails with
EncodeError::BadRcode; it is never truncated to NOERROR. With an OPT the upper eight bits go into it. - Name compression is case-insensitive: the writer matches suffixes on their lowercased bytes
(RFC 1035 section 4.1.4), so a name written after an equal-but-differently-cased one reuses the
first occurrence and decodes with that case (
MX 10 Mail.Example.com.after a question forwww.example.com.reads back asMail.example.com.). RFC 4343 asks implementations to preserve case; this is a known, accepted deviation. The question name is always written first, so a client’s 0x20 echo is unaffected, and DNSSEC canonical form lowercases these names anyway.
Allocation behaviour
The codec is allocation-lean because it sits on every query. Names up to 32 octets of wire form
are stored inline in Name (longer ones in one boxed slice); decode reserves each section from
its count, capped by what the remaining octets could hold, so a forged count cannot over-allocate;
encode writes straight into the output buffer and the compression table stores only offsets,
verifying a candidate against the bytes already written. Suffix hashes carry a per-process random key and a lookup compares at most 8 candidates, so names crafted to collide cannot make encoding quadratic; past the cap a name is merely written without compression (still valid, never reached by ordinary messages). RRset boundaries for truncation are found
by scanning backwards from the end of the section (RRsets are contiguous in practice); bodies of
more than 64 records use a map. Output is byte-for-byte what the previous implementation produced.
EDNS0
- The OPT pseudo-record is lifted out of the additional section into
Message::edns(UDP payload size, DO bit, version, options). More than one OPT, an OPT not owned by the root name, or an OPT in the answer or authority section makes the packet malformed (FORMERR; RFC 6891 section 6.1.1). Message::header.rcodeis the full 12-bit rcode: the header’s four bits merged with the OPT’s upper eight on decode, split again on encode. A query with an EDNS version other than 0 is answered withMessage::badvers_for(BADVERS, version 0 OPT, no data).- Options: Padding, client subnet, cookie, Extended DNS Error and unknown options (kept raw).
Message::set_ede(code, text)attaches an Extended DNS Error (RFC 8914), creating the OPT with a 1232-octet payload size if needed; the codes live in theedemodule.- For DoT and DoH,
Message::encode_paddedpads the response to a multiple of 468 octets (RFC 8467) when the query carried a Padding option (Edns::has_padding). Padding only uses room that is left: it is shortened or skipped when the message is close to the limit, and it never causes an RRset to be dropped.
DNSSEC records
dnsy-proto has typed codecs and presentation text for DNSKEY, CDNSKEY, RRSIG, DS, CDS, NSEC,
NSEC3 and NSEC3PARAM (RFC 4034, RFC 5155, RFC 8078).
- Presentation: keys and signatures are base64 (may be split over several tokens), digests and
salts are hex (an empty salt is
-), NSEC3 next-hashed-owner names are base32hex (written uppercase, accepted in either case), RRSIG times areYYYYMMDDHHmmSS(a plain count of seconds is accepted too), and algorithms are numbers. - A decoded record always has a presentation form that parses back to the same value, so wire
decoding rejects empty keys, signatures, digests and next-hashed names, and type bitmaps with an
empty, oversized, repeated or out-of-order window or a trailing zero octet.
This strict bitmap acceptance is deliberate and RFC-correct (RFC 4034 4.1.2), but stricter than
BIND and Unbound, which tolerate trailing zero octets: an NSEC or NSEC3 record from a
non-conforming signer then fails to decode, the denial proof is missing, and validation of that
answer ends
Bogusrather than accepting a record whose presentation form would not round-trip. TypeBitmapencodes and decodes the NSEC/NSEC3 window blocks. Types print by mnemonic, or asTYPEnnnwhen unnamed.Dnskey::key_tagfollows RFC 4034 appendix B, including the algorithm 1 special case (a key too short for it gives tag 0).serial_ltandserial_gtimplement RFC 1982 arithmetic for RRSIG validity checks;Rrsig::is_currentuses them. A distance of exactly 2^31 is undefined and compares as neither; RRSIG verification treats it as valid on the expiration side only (an inception exactly 2^31 ahead is not yet valid).canonical::rrset_signing_inputbuilds the octets an RRSIG signs (RFC 4034 3.1.8.1): the RRSIG fields without the signature, then the RRs ordered by canonical RDATA with duplicates removed, the original TTL, lowercased owner names, and wildcard owners rewritten from the RRSIGlabelsfield. Names in the RDATA of NS, CNAME, PTR, MX, SOA, SRV and DNAME, and the replacement name of NAPTR, are lowercased and never compressed (DNAME and NAPTR stay opaque RDATA, the canonical form walks their bytes); NSEC and RRSIG names keep their case (RFC 6840 section 5.1).
Fuzzing
Two fuzz targets cover untrusted input (fuzz_decode for the codec, fuzz_validate for the
chain-of-trust validator). How to run them and the recorded results are in
Contributing.
Contributing
Workflow, conventions and the just ci gate are described in AGENTS.md in the repository.
This page covers the end-to-end tests.
Pull request titles must read DNSY-{n}: {emoji} {Sentence} (several stories: DNSY-12, DNSY-13: ...).
The pr-title workflow (.github/workflows/pr-title.yml) checks this on every open, edit and push, and
prints the expected shape when the title does not match.
Release profile and dependencies
[profile.release] in the root Cargo.toml sets lto = "fat", codegen-units = 1,
strip = true, overflow-checks = true and keeps panic = "unwind" (the server has no
catch_unwind; abort would turn any panic reachable from hostile input into an outage of the
whole resolver). Measured on the release build with the UI embedded:
| before | after | |
|---|---|---|
dnsy binary | 28.99 MB | 17.22 MB |
| clean release build | 80 s | 180 s |
cargo tree --workspace unique crates | 316 | 279 |
cargo tree -p dnsy (normal) | 277 | 240 |
UI dist text assets | 1.11 MB | 0.28 MB brotli (0.32 MB gzip) |
libsqlite3-sys at opt-level = 2 cuts its clean build from about 65 s to 30 s, but writer
throughput on the shared development host could not be shown within 5 % of the default (the
numbers varied by more than 2x between identical runs), so it is not enabled. Revisit with a
quiet machine.
Dependencies are declared once in [workspace.dependencies] with only the features the code uses
(tokio, clap, tracing-subscriber, proptest, insta). Keep it that way: check
unused dependencies with cargo machete before adding a crate, and do not add x509-parser,
time or tower-http back for a convenience.
web/scripts/compress-dist.mjs runs as postbuild after pnpm build and writes .br and .gz
next to each text asset under web/dist/dnsy/browser.
End-to-end tests
The crate tests/e2e (dnsy-e2e) starts the real dnsy binary with a temporary configuration
and talks to it over the wire, the way a client would. It is excluded from just test and
just cov-rust (coverage measures the crates, and these tests need the built binary) and has its own
recipe, which just ci runs last:
just e2e # build target/debug/dnsy, run everything in parallel
cargo nextest run -p dnsy-e2e --test transports mtls # a subset, once the binary is built
The harness looks for the binary next to the test executables (<target>/debug/dnsy); it builds
it with cargo build -p dnsy when it is missing, and DNSY_BIN points it at another binary. After
changing server code rebuild first (cargo build -p dnsy), or use just e2e, which always does.
What Harness::start(opts) does
- Creates a temp directory with the configuration, zone files, a blocklist file and a throwaway
PKI (
rcgen): a server CA and certificate (valid for127.0.0.1), a client CA, and three client certificates: valid, expired and signed by an untrusted CA. - Starts a scriptable fake upstream (
FakeUpstream) whenopts.upstreamis set. - Creates an admin user and two tokens (
dns:queryfor DoH, admin for the API) with the CLI. - Runs
dnsy serve --ready-file ...with every listener on port 0 and waits (polling, with a deadline) for the ready file, which holds the bound addresses. - With mTLS on, registers the valid client certificate’s fingerprint through the API.
Everything is per test: its own temp directory, its own ports and database, so tests run in
parallel (nextest runs each test in its own process). Dropping the Harness kills the server;
terminate() sends SIGTERM and waits for a clean exit. signal("HUP"), logs(),
write_zone(), api_send() and new_token() cover the rest of what a test usually needs.
Clients and fixtures
The clients live in the crate and use dnsy-proto as the codec: udp_exchange / udp_raw,
Framed for TCP and DoT (Framed::connect_dot with or without a client certificate), and
DohClient (HTTP/2 or HTTP/1.1; GET, POST, bearer or path tokens, arbitrary requests).
FakeUpstream::on(name, type, Reply) scripts answers, NXDOMAIN, NODATA, rcodes, drops, delays,
garbage and wrong-id responses and counts what it was asked; serve_signed_root serves a signed
root zone whose DS you pass as a trust anchor. zones::example_zone() is the zone most tests use.
Writing a test
#[tokio::test]
async fn my_case() {
let h = Harness::start(Opts::default().zone("example.test.", example_zone())).await;
let r = udp_exchange(h.udp, &query("www.example.test.", RecordType::A)).await;
assert_eq!(r.header.rcode, Rcode::NoError);
}
- Be deterministic: never sleep for a fixed time to wait for the server. Poll with
eventually(deadline, "what", || async { ... }), or usewait_for_log. The only fixed waits are the short silence windows of negative checks (a dropped UDP query, a log line that must not repeat). - Bind nothing to a fixed port. Use the addresses on the harness.
- The product has no clock hook, so TTL tests use 1 second TTLs and poll for the effect.
- Tools such as
digandkdigare optional: skip with a message when they are missing (tests/interop.rs). - Put secrets in neither assertions’ messages nor logs: tests that look for leaked tokens assert on the server log and on the query log.
Testing the REST API and the cache
h.rest() returns an Api client for the management API. Every request states its credential
explicitly (Auth::None, Bearer, Session, and session variants without or with a wrong CSRF
header), so tests can assert 401/403 mappings; Resp::problem(status) checks the RFC 9457 shape
and Resp::ok(status) returns the JSON body. Api::login, create_user and token_for build
sessions, users and role-limited tokens (the harness admin is e2e / e2e-password-1234, its
token is h.api_token). Req::stream() and Stream::next_event read server-sent events.
Opts::no_users starts a server without users for the bootstrap flow (h.bootstrap_code()), and
Opts::prefetch turns cache prefetch on.
- A change made through the API is asserted on the DNS path too, with
eventually: the zone answer, the REFUSED, the NXDOMAIN with its extended error. - Cache tests use 1 to 3 second TTLs (there is no clock hook) and observe the cache through
GET /cachecounters and the upstream’s query counts; clamps show on cached answers, not on the first (miss) answer. - Query log and stats are written asynchronously: poll them until the expected entries appear.
Suites: protocol (UDP, TCP, EDNS, malformed input, hot reload), transports (DoT, mTLS, DoH),
policy (ACL, rate limit, blocklists), resolver (forwarding, cache, failures, serve-stale,
validation smoke test), lifecycle (SIGHUP, SIGTERM) and interop; the REST suites rest_auth,
rest_errors, rest_zones, rest_policy and rest_observe, and the cache suites cache_api,
cache_ttl and cache_stale.
The DNSSEC suites are dnssec_validation (forwarding path: modes, AD/CD/DO, EDE codes, negative
trust anchors, mode changes), dnssec_signing (signing through the REST API, answers over UDP,
TCP, DoT and DoH, denial, wildcards, DS export, BIND export) and dnssec_lifecycle (a second
instance validating the first, key rollovers, the clock-sanity guard, delv/dig interop and an
ignored live test against the real root). They use dnsy_e2e::dnssec: Tree::build creates a
signed root with signed or unsigned zones below it (NSEC, NSEC3, opt-out, expired or future
signatures, missing, unsupported or mismatching DS, colliding keys) and tree.serve(&upstream)
makes the fake upstream answer from it; Tree::answer returns a response you can forge and script
with upstream.on. validate_answer runs the dnsy-dnssec validator on a server’s own answer.
The clock guard test patches the build timestamp in a copy of the binary (Opts::binary), since a
child process cannot be given another clock.
Benchmarks
The crate tests/bench (dnsy-bench, not published, excluded from just test and the coverage
gate like dnsy-e2e) holds the performance tools:
just bench: the scenario runner (benchbinary). It starts the releasednsy(throughDNSY_BIN) with the e2e harness and a scripted upstream and prints QPS, p50 and p99 for UDP hit and miss, TCP and DoH. It lifts the per-client connection and UDP in-flight limits because all load clients share one source address; every row must report 0 errors.loadgen: a closed-loop load generator for any server over UDP or TCP (--clients,--pipeline,--countnames cycled for cache hits,--uniquefor misses).fakeup: a fast multi-task UDP upstream answering every name (nx*gets NXDOMAIN).just bench-micro: criterion micro-benchmarks (benches/micro.rs): codec, single-thread cache hit andcache/hit_16_threads_320k(16 threads, 320 000 lookups per iteration; ops/s = 320 000 / time).just allocs: allocations per cache hit (allocsbinary).unsafe_codeis forbidden, so the repo has no counting allocator of its own; the binary installs thedhatcrate’s as the global allocator (bench crate only) and reads its cumulative block count around 2 000 runs, counting every thread. It reportsCache::getalone,Handler::handleon a warm cache and a datagram through the UDP listener and back (engine with an event sink that drops events). Targets: cache hit at most 2, UDP hit at most 30; the recipe fails above them. Measured on this tree: cache hit 2.00, engine hit 2.00, UDP hit 9.00. The two cache-hit allocations are the owned copy of the cached message (its question and answer vectors) thatCache::getreturns with TTLs decremented; decrementing at encode time would remove that copy but needs the codec and the engine pipeline to carry the cached message by reference, which the numbers do not call for.
Performance stories paste the just bench table into their PR. Run it on an otherwise idle
machine and pin CPUs with taskset when comparing before and after.
Browser end-to-end tests
The Playwright suite in web/e2e drives the real UI in Chromium against a real dnsy (built with
--features embed-ui, started with a temporary configuration, free ports and a fake UDP upstream
written in the test support code). It covers sign-in and first-admin setup, the throttle, CSRF and
session expiry, the no-WebGL and reduced-motion login, navigation, 404 and the auth guard, the
dashboard after real DNS queries, zones and records (validation, ETag conflict, BIND export),
the query log (filters, live tail), filtering and ACL rules changing DNS answers, upstream groups,
client certificates, settings, cache flush, users and roles, tokens (secret shown once), password
change, the DNSSEC tab, mascot states (simulated with route interception), responsive checks at
360 and 1280 px, keyboard use and an axe scan (no serious or critical violations). Any console
CSP violation fails the test.
just e2e-ui # build UI and binary, install Chromium if missing, run everything
cd web && DNSY_BIN=... pnpm e2e # run again without rebuilding
cd web && pnpm exec playwright test zones --headed # a subset, with a visible browser
cd web && pnpm exec playwright show-report # HTML report of the last run
Chromium is installed once into the Playwright cache (pnpm exec playwright install chromium);
the recipe does it for you. Only Chromium runs: WebKit needs system libraries not present on
every machine and Firefox lacks the clipboard permissions and console messages two tests use.
DNSY_BIN points at another binary (it must include the embedded UI); without it the suite uses
$CARGO_TARGET_DIR/debug/dnsy. A debug build reads web/dist from disk, so rebuild the web app
(pnpm build) after changing it.
How it is organised:
- A global setup starts one shared server with the users
admin,operatorandviewer(passworde2e-password-1234) and stores their signed-in sessions, so most tests start signed in. Pick a role withtest.use({ storageState: roleState('viewer') }). - Tests that change global state (settings, ACL, filtering, cache, counts) start their own server
with the
freshServerfixture and sign in withsessionFor; zones use unique names, so tests run in parallel. Never log an API client in with the page’s cookie: logging in with a session cookie ends that session (Api.loginuses an empty jar for that reason). support/dns.tssends hand-built UDP queries;support/upstream.tsanswers everyAquery with192.0.2.7(names startingnx.get NXDOMAIN) and counts the queries it saw.- No fixed sleeps: locators auto-wait and
expect.poll/toPasswait for server-side effects. - Traces and screenshots are kept only for failing tests (
web/test-results, not committed).
Documentation screenshots
The screenshots in docs/book/src/images/ come from a seeded demo, not from hand-made captures:
just build # or cargo build -p dnsy --features embed-ui
scripts/demo/run.sh /tmp/dnsy-demo target/release/dnsy & # fake upstream, zones, rules, a signed zone, traffic
node scripts/demo/capture.mjs /tmp/dnsy-shots # Playwright at 1280 px (needs `pnpm exec playwright install chromium` in web/)
python3 scripts/demo/optimize.py /tmp/dnsy-shots docs/book/src/images
Keep each image to a few hundred KB at most, write outputs outside the repository, and never commit
logs. just docs checks the book (mdbook test), and the config_reference_lists_all_keys test in
dnsy-config fails when the configuration reference and the schema disagree.
Fuzzing
Two targets cover untrusted input. Neither is part of just ci.
| Target | Crate | What it feeds |
|---|---|---|
fuzz_decode | crates/dnsy-proto/fuzz | Message::formerr_for, decode, encode at five limits, encode_padded, set_ede, canonical RDATA, presentation form and the decode-encode-decode round trip |
fuzz_validate | crates/dnsy-dnssec/fuzz | Validator::validate on a mutated response with mutated DS and DNSKEY answers from the fetcher, against a trust anchor that signs the seeds |
just fuzz # fuzz_decode, one hour, libFuzzer (nightly + cargo-fuzz)
just fuzz fuzz_validate 600 # the validator target for ten minutes
just fuzz-stable fuzz_decode 600 # pinned stable toolchain: std-only mutation fuzzer
just fuzz needs rustup toolchain install nightly and cargo install cargo-fuzz; nothing else
in the repository needs nightly. just fuzz-stable runs the same functions through a small
mutation engine (bit flips, interesting values, splices, truncation, 16-bit field nudges, random
buffers) with overflow-checks on and no coverage feedback; it is what runs where nightly is not
installed. The fuzz crates are not workspace members (their Cargo.lock stays out of the main
one) and dnsy-proto itself keeps zero dependencies. A crashing input is written to the fuzz
directory’s crashes/ (stable) or artifacts/ (libFuzzer); fix it, and add the input as a unit
test next to the code it broke. The committed starting corpora are in fuzz/corpus/<target>/.
Recorded runs
| Date | Toolchain | Target | Iterations | Panics |
|---|---|---|---|---|
| 2026-10-21 | stable 1.99.1, fuzz-stable, 3 processes x 25 min | fuzz_decode | 2 658 869 248 | 0 |
| 2026-10-21 | stable 1.99.1, fuzz-stable, 2 processes x 25 min | fuzz_validate | 315 691 008 | 0 |
The libFuzzer one-hour runs need a nightly toolchain, which the build machines do not have; the
rows above are the stable mutation runs, shorter and without coverage guidance. Add a row here
after each just fuzz run.
Code coverage
Coverage is a merge gate. just ci runs just cov-rust and the web gate (inside just web).
just cov # Rust gate, then web gate
just cov-rust # cargo llvm-cov via nextest, per-crate table, HTML report
just cov-web # ng test --coverage (Vitest, v8)
What is measured
- Rust: line coverage from
cargo llvm-cov nextest --workspace --exclude dnsy-e2e --exclude dnsy-bench, that is unit and integration tests. The end-to-end suites (just e2e,just e2e-ui) are not counted: they spawn the real binary, so the instrumentation would have to follow child processes, and a gate that depends on timing-sensitive wire tests would be flaky. The e2e suites are additional safety, not a way to reach the numbers. - Web:
ng test --coverageoverweb/src. The thresholds live inweb/angular.json. - Exclusions: none. No
#[cfg(coverage)], no ignore regions and no ignore-filename regex;main.rsglue and thegen-openapibinary are measured like everything else.
Gates
| Scope | Metric | Floor |
|---|---|---|
| Rust workspace | lines | 80% |
| every crate | lines | 80% |
dnsy-proto, dnsy-dnssec | lines | 90% |
| web | lines, statements, functions | 80% |
| web | branches | 70% |
The floors are set in the justfile (cov-rust) and enforced by scripts/cov-crate-gates.sh,
which also prints the table. Percentages are exact, so 89.99% fails a 90% gate. Raise a floor in
the same change that earns it; never lower one to merge.
Reading the report
just cov-rust ends with one row per crate (lines, covered, percent, min, ok/FAIL) and
a workspace total. For the per-file view open target/llvm-cov/html/index.html, or list the
uncovered lines of a file from the last run:
cargo llvm-cov report --show-missing-lines | grep dnsy-server
The web table is printed by Vitest (% Stmts, % Branch, % Funcs, % Lines, uncovered line
numbers per file).
Filling gaps
Look at uncovered lines first, then ask what behaviour they encode: error paths, limits, parsers
fed malformed input, auth, throttles, ACLs, redaction, the DNSSEC validator. Write a test that
asserts that behaviour (the right error, nothing added, memory bounded), not one that merely calls
the function. Put new tests in their own *_tests.rs module or tests/ file. Untestable code
(process glue) stays measured and is justified in the PR, not hidden.
Continuous integration
Two workflows run on every pull request; both call the same just
recipes as the local gate, so a green just ci predicts a green pipeline.
| Workflow / job | What it runs |
|---|---|
ci / rust (linux) | just check (fmt, clippy, cargo-deny, advisory cargo-audit, OpenAPI drift), just cov-rust (full nextest suite plus the coverage gates) |
ci / rust (embed-ui) | builds web/dist, then clippy and the dnsy-api tests with --features embed-ui, which the default build compiles out (ui_serves_brotli_when_accepted). just ci does not run these |
ci / web | just web (frozen install, dedupe check, ng lint, ng test --coverage, ng build, API types drift) plus an advisory pnpm audit |
e2e / e2e (rust) | just e2e |
e2e / e2e (browser) | the just e2e-ui steps, with playwright install --with-deps chromium; the report is uploaded on failure |
Slower, platform-specific checks do not run on every pull request. The release-pre-check workflow
holds them: build-and-test (macos-latest, windows-latest) (cargo build --workspace --all-targets
and the nextest suite, without coverage, deny or e2e) and the pkg (macos arm64) and
msi (windows x64) jobs, which build the cargo-dist installers and install them on a real runner.
It never runs on pull requests: start it by hand (Actions, release-pre-check, Run workflow, on main
or on a branch that changes packaging, dist-workspace.toml, the build scripts or platform-specific
code) and it runs weekly on main. It is not a required check.
The jobs named ci and e2e only aggregate the others, so branch protection needs just those two
(plus pr-title) as required checks. Every third-party action is pinned by commit SHA with its
version in a comment; Dependabot (.github/dependabot.yml) bumps them weekly together with the
cargo and web/ npm dependencies (minor and patch grouped, majors individual). Workflows use
permissions: contents: read, never pull_request_target, and pass no untrusted input to run:.
Dependabot commit and PR titles use the prefix DNSY-115: ⬆️ (cargo, npm) or DNSY-115: 👷
(github-actions), where DNSY-115 is the tracking story for dependency updates, so they satisfy the
pr-title check, e.g. DNSY-115: ⬆️ Bump tokio from 1.40.0 to 1.41.0 in the tokio-stack group.
No include: scope is set, which would insert (deps) after the prefix.
Security
This page summarises the threat model and the controls that address it. The detailed behaviour is on the pages linked in each row.
Reporting a vulnerability
Report vulnerabilities privately to security@repsy.io. Do not open a public issue. Reports are
acknowledged within a few working days. The policy is in SECURITY.md in the repository.
Threat model
| Threat | Controls |
|---|---|
| Open resolver and amplification | the private-only default ACL (open_resolver), per-client rate limiting, both applied before any reply (FORMERR, NOTIMP and BADVERS included, so a dropped client gets nothing and error replies count against the limit), REFUSED for RD=0 queries, RFC 8482 minimal ANY for local zones and REFUSED ANY for forwarded names, REFUSED AXFR/IXFR, minimal FORMERR responses, UDP responses capped at the client’s EDNS size (max 1232), DoH tokens (Filtering, Architecture) |
| Cache poisoning | random CSPRNG query IDs and random source ports on connected sockets; pooled upstream sockets rotate after 16 queries, 5 s or 2 s idle and drop queued datagrams before reuse, so a learnt port helps an attacker only inside that window (Resolver); responses are scrubbed to in-bailiwick data, a question that does not match byte for byte is discarded, cache keys include DO and CD, DNSSEC validation in strict mode by default (Architecture, DNSSEC) |
| Upstream exhaustion by slow names | identical concurrent cache misses share one upstream exchange, and at most 256 requests wait on one; further identical misses get SERVFAIL (EDE 22) at once instead of holding a listener slot (Resolver) |
| DNSSEC attacks | work limits against KeyTrap-style and NSEC3 cost attacks, algorithm checks, signature validity windows, a clock-safety guard (DNSSEC) |
| DNS rebinding | response-IP policy replaces answers pointing into listed networks |
| Credential theft | passwords hashed, tokens stored as SHA-256 of the secret and compared in constant time, sessions stored hashed, login throttling, HttpOnly SameSite=Strict cookies (__Host- prefixed when Secure) with double-submit CSRF (Users, tokens and sessions) |
| Secret leakage | tokens, Authorization, cookies, DoH path tokens, passwords and the DNSSEC passphrase never reach logs, the query log, API responses or error bodies (Logging and metrics) |
| Key theft at rest | DNSSEC private keys encrypted with a passphrase when one is configured (DNSSEC) |
| SSRF through blocklist fetch | blocklist URLs to private or loopback addresses are refused unless an admin allows them (Filtering) |
| Path traversal | zone file names are derived from validated zone names only (Zones) |
| XSS in the UI | strict CSP (default-src 'self', no inline script or style) (Web UI) |
| Unauthorised client on DoT | optional or required mTLS with a client CA and a certificate allowlist (DoT and mTLS) |
Hardening checklist
- Run as a dedicated non-root user with
CAP_NET_BIND_SERVICE. - Keep
[api] listenon loopback or put it behind a TLS terminator; setapi.tls_certandapi.tls_keyto serve TLS directly. - Behind a TLS-terminating reverse proxy set
[api] secure_cookies = true: the session and CSRF cookies then carrySecureand responses sendStrict-Transport-Security, even though DNSY itself speaks plain HTTP to the proxy.auto(the default) followsapi.tls_cert;falseturns both off. - Behind a reverse proxy list the proxy’s own addresses in
[api] trusted_proxiesand[doh] trusted_proxies(CIDRs, empty by default). Only a connection from a listed peer has itsX-Forwarded-Forread: the header instances are walked right to left, listed proxies are skipped and the first other address is the client; entries left of it are never looked at. A request from a listed peer that names no usable client (no header, only listed proxies, more than 16 listed hops, or a reached entry that is not a bare IPv4/IPv6 address or a[v6]literal, such as a port suffix, a hostname or an empty entry) is keyed to0.0.0.0: one shared bucket with no ACLallow, no private-network trust and no known-client standing, never the proxy’s own address. Withlimits.open_resolver = falseforwarded public clients are refused like direct ones; add ACL rules for the networks the proxy serves. On the API the forwarded address keys the login throttle and the known-client exemption; on DoH it keys the auth-failure throttle, ACL rules, per-client QPS and the query log, and everyDoHanswer carriesCache-Control: private(as do answers to requests with a token on any listener), so a shared cache never hands one client’s answer to another. Connections from a listed proxy count only againstmax_conns, notmax_conns_per_client.Forwarded(RFC 7239),X-Real-IPand the PROXY protocol are not supported. Spoofing risk: a listed peer chooses the client address of every request it sends. A list wider than the proxies themselves lets any direct client pick its key: bypass the login and DoH auth throttles, claim the owner’s usual address for the known-client lockout exemption, match an ACLallowrule by address, dodge per-client QPS and pollute the query log. So the list is explicit,0.0.0.0/0and::/0are refused at load, and loopback is not trusted implicitly. The proxy must append its hop toX-Forwarded-Forrather than pass a client-supplied header through unchanged; client-supplied entries to the left of the proxy’s hop are then ignored. - Set a DNSSEC key passphrase before enabling signing.
- Leave
doh.require_token = trueon public listeners. - DNSY is private-only by default:
limits.open_resolver = falseserves only loopback, RFC 1918, 100.64.0.0/10, ULA and link-local clients, andlimits.per_client_qpsdefaults to 50. Settingopen_resolver = truemakes the server answer the whole internet; do that only with ACL rules and a non-zeroper_client_qps.dnsy serveanddnsy config checkwarn about an open, unlimited non-loopback listener. This was an open, unlimited forwarder before; see the changelog. - Connection and in-flight caps are on by default: each of TCP, DoT and DoH has one pool of
limits.max_tcp_conns(1024) shared by all its listen addresses, and one client (IPv4 address or IPv6/64) may holdlimits.max_conns_per_client(16) of them, so a single source cannot take every slot. When a pool is full, the connection that has been idle longest (no request in flight) is closed to make room for the new one, as BIND does withtcp-clients. Only connections that finished their TLS handshake (DoT, DoH) or sent a complete query (plain TCP) can be chosen, and never one that proved an identity (an mTLS client certificate, a DoH token), so a flood of connections that never get that far cannot push out working or authenticated ones. A connection that is still not established 5 seconds after accept is recycled ahead of established ones (younger ones may be mid-handshake and are left alone), so silent connections cannot pin slots; they are also closed by the idle timeout. A client that already holds its whole share never evicts anyone, one client (IPv4 address or IPv6/56) recycles at most one connection per second, all clients together at most 64 per second, and at most 32 newcomers wait for a recycled slot at once. The recycled connection is dropped immediately, abandoning any unflushed output, so a peer that stopped reading cannot hold the slot. If nothing can be recycled the new connection is closed at accept. The accept loop never waits for any of this. UDP is bounded bylimits.udp_in_flight(4096) requests in flight withlimits.udp_in_flight_per_client(64) per client; excess datagrams are dropped. The API listener closes connections that send nothing or an incomplete request head within 30 s, closes connections (HTTP/1.1 or HTTP/2, pings notwithstanding) that have had no request in progress for 30 s, serves at mostapi.max_conns(256) connections (andapi.max_conns_per_client(64) per client address, so one address cannot lock out the admin API) and answers 408 to a request whose handler takes longer than 30 s (the live-tail stream is exempt). UDP slots are keyed on the unauthenticated source address, so a spoofed source can occupy that address’s share for the milliseconds a request is in flight; spoofed floods stay bounded by the global pool, the rate limiter and the private-only default ACL. The rate limiter drops over-limit UDP queries, but a spoofed flood from a victim’s address would then silence the victim too. So one over-limit UDP query inlimits.rate_limit_slip(2; 0 disables) is answered with a minimal reply (the question, TC=1, no records, no larger than the query), and a real client falls back to TCP, where it is limited per connection rather than starved. Per-client buckets live in sharded tables of bounded size; when a shard is full the least recently used of 8 randomly sampled buckets is evicted, so a flood of new spoofed addresses costs constant work per packet instead of a scan of the shard. Behind a NAT or proxy many users share one address: raisemax_conns_per_clientfor DoT/DoH front-ends that multiplex many users. - Protect backups: they contain password hashes and DNSSEC keys (Backup and restore).
- Keep the binary current (Upgrade).
Connection recycling model
When the TCP, DoT or DoH pool is full, a newcomer may ask one existing connection to close.
| Connection state | Can be chosen? |
|---|---|
| handshaking, younger than 5 s | no (it may be mid-handshake) |
| handshaking or silent, older than 5 s | yes, first |
| established (handshake done, or first query sent on plain TCP) and idle | yes, longest idle first |
| authenticated (mTLS certificate, DoH token) | never |
| busy (a request in flight) | never |
| already asked to close | no |
Choosing a victim and the moves to established, authenticated or busy happen under one lock, and each of those moves withdraws a pending request, so a stale request never closes a connection that has since proved itself or started work. The handshake itself listens for the request, and a recycled connection is dropped at once (unflushed output is abandoned).
Budgets: a client that already holds its whole share never recycles; one client (IPv4 address or
IPv6 /56) recycles at most once per second; all clients together at most 64 per second, of which
the last 32 are reserved for clients that have not recycled in the last 10 seconds. The order is fixed:
candidates are ranked without waking anyone, the budget is reserved, and only then is a victim asked
to close, so a refused reservation never disturbs a connection. The budget is given back only when
the recycle certainly freed nothing: no candidate was left after reserving, or the 500 ms wait for
the slot ended and the victim never saw the request (which is then withdrawn). Once the victim saw
the request, or its slot went to anyone, the budget stays spent. At most 32 newcomers wait at once and the accept loop never
waits. The invariants (protected connections are never asked to close, each recycle frees exactly
one slot, nothing leaks) are tested in crates/dnsy-server/src/admission.rs.
Third-party crates
Everything on the network path is Rust. Crates worth knowing about when you audit:
ringis the only source of cryptographic primitives (TLS throughrustls, DNSSEC signatures and digests, tokens). It is maintained by a small team and releases are infrequent; if that stops being acceptable the exit isaws-lc-rs, which is a dependency decision for a later release.rust-embedembeds the web UI and has a single maintainer. It does one thing (include files at compile time) and cannot be replaced cheaply without a build script, so it is accepted and watched throughcargo denyadvisories.rusqlitebundles SQLite, compiled from source with the system C compiler.- A small in-house HTTP/1.1 client on
hyperandtokio-rustls(dnsy-filter::http) fetches blocklists and sendsdnsy cache flush; HTTPS uses the bundledwebpki-rootstrust anchors and certificate verification is always on. URLs are split by hand (ASCII only, no IDNA), so theurl/idna/ICU crates are not in the binary. - mTLS subjects in logs come from a small DER walk in
dnsy-server, not an X.509 library; it only reads the common name and text subject alternative names, bounds the result and replaces control characters. Chain and validity checks stay inrustls.
just check runs cargo deny (advisories, licenses, bans) and cargo audit (advisory only: a
finding is printed but does not fail the recipe).
Known limits
-
Upstream UDP sockets are pooled for speed. Inside one socket’s rotation window (at most 16 queries or 5 s) its source port is fixed, so an off-path attacker who learnt it guesses only the 16-bit ID (about 1 in 65536 per forged answer instead of about 1 in 2^31). DNSSEC
strictvalidation is the second line of defence for signed zones; see Resolver for the numbers. -
Source ports come from the operating system. Linux and macOS draw them at random; Windows hands them out one after another, so on Windows an off-path attacker guesses the port far more easily and the 16-bit ID plus DNSSEC are the defences left. Run the resolver on Linux for the full protection.
-
Live tail (
/querylog/stream) allows 4 streams per user and 16 overall, with 2 of the 16 reserved for admins. -
Settings changed through
PATCH /settings*are runtime-only (the rate limit excepted).