Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

PathPurpose
/etc/dnsy/dnsy.tomlconfiguration (override with --config or DNSY_CONFIG)
<server.data_dir>/dnsy.dbSQLite database: users, tokens, rules, keys, query log
<zones.dir>/*.jsonzone 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

TargetArchiveRunner
x86_64-unknown-linux-gnudnsy-x86_64-unknown-linux-gnu.tar.xzubuntu-22.04, manylinux_2_28 container
x86_64-unknown-linux-musldnsy-x86_64-unknown-linux-musl.tar.xzubuntu-22.04
aarch64-unknown-linux-gnudnsy-aarch64-unknown-linux-gnu.tar.xzubuntu-24.04-arm, manylinux_2_28 container
aarch64-unknown-linux-musldnsy-aarch64-unknown-linux-musl.tar.xzubuntu-24.04-arm
x86_64-apple-darwindnsy-x86_64-apple-darwin.tar.xzmacos-15-intel
aarch64-apple-darwindnsy-aarch64-apple-darwin.tar.xzmacos-14
x86_64-pc-windows-msvcdnsy-x86_64-pc-windows-msvc.zipwindows-2022

Native installers sit beside the archives:

TargetInstallerRunner
x86_64-apple-darwindnsy-x86_64-apple-darwin.pkgmacos-15-intel
aarch64-apple-darwindnsy-aarch64-apple-darwin.pkgmacos-14
x86_64-pc-windows-msvcdnsy-x86_64-pc-windows-msvc.msiwindows-2022

Linux distribution packages are built from the two gnu archives after the release exists:

PackageFileBuilt by
Debian and Ubuntu, x86_64dnsy_<ver>_amd64.debpackage-linux.yml on ubuntu-24.04
Debian and Ubuntu, arm64dnsy_<ver>_arm64.debpackage-linux.yml on ubuntu-24.04
RHEL, Rocky, Alma, Fedora, x86_64dnsy-<ver>-1.x86_64.rpmpackage-linux.yml on ubuntu-24.04
RHEL, Rocky, Alma, Fedora, arm64dnsy-<ver>-1.aarch64.rpmpackage-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):

  1. Where to install. install.sh offers system-wide (/usr/local/bin, uses sudo unless 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.
  2. A starter config. If you say yes it runs dnsy config init: the annotated system config at /etc/dnsy/dnsy.toml for a system-wide install, and a --dev config (port 5353 on loopback, no root needed) at ~/.config/dnsy/dnsy.toml or next to a local install. A release older than the one that added config init skips 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:

PathWhat
/usr/bin/dnsythe binary (the same one as in the gnu archive)
/etc/dnsy/dnsy.tomlthe annotated sample config, mode 0640; a conffile (deb) or %config(noreplace) (rpm), so your edits survive upgrades
/usr/lib/systemd/system/dnsy.servicethe hardened unit
/usr/lib/sysusers.d/dnsy.confcreates 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; keeps dnsy.toml (dpkg asks if you and the package both changed it, rpm writes dnsy.toml.rpmnew).
  • Remove (sudo apt remove dnsy, sudo dnf remove dnsy): stops and disables the service and deletes the binary and unit. /etc/dnsy, the dnsy user and /var/lib/dnsy stay (rpm saves an edited config as dnsy.toml.rpmsave).
  • Purge (sudo apt purge dnsy, Debian only): additionally deletes the config, the dnsy user 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 .pkg opened from the Finder or installed from a browser download. Open System Settings > Privacy & Security, scroll to the message about dnsy-...pkg and choose Open Anyway. Running sudo installer in a terminal on a file fetched with curl (which sets no quarantine flag) is not blocked; for a browser download you can instead run xattr -d com.apple.quarantine <file>.pkg.
  • Windows: SmartScreen shows “Windows protected your PC” for the .msi; choose More info then Run anyway. msiexec /qn does 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.

  1. plan runs dist host --steps=create and computes the build matrix from the tag. It fails if the tag does not match the version in Cargo.toml.
  2. custom-release-verify (release-verify.yml) builds the web UI and runs the embedded-UI test ui_serves_brotli_when_accepted with --features embed-ui in release mode; its glibc-floor job builds dnsy in the two manylinux_2_28 images and checks the glibc floor. No artifact is built when either fails.
  3. build-local-artifacts runs once per target. Its prerequisite steps (.github/build-setup.yml) install pnpm and Node 24, run pnpm install --frozen-lockfile && pnpm build in web/ (producing web/dist/dnsy/browser) and, outside the manylinux containers (no apt-get there), install musl-tools; then dist build compiles dnsy --features embed-ui with the dist profile, which inherits [profile.release] unchanged (fat LTO, one codegen unit, stripped, overflow checks, panic = "unwind"). On the macOS and Windows runners the same dist build also produces the .pkg (pkgbuild and productbuild, identifier from [dist.mac-pkg-config]) and the .msi (WiX 3 via cargo-wix, from crates/dnsy/wix/main.wxs; the WiX upgrade and PATH GUIDs are in [package.metadata.wix]).
  4. build-global-artifacts creates the installers, source.tar.gz and sha256.sum.
  5. host uploads everything and creates the GitHub Release (it has contents: write). Tags with a suffix such as v0.2.0-rc.1 are marked as pre-releases.
  6. custom-package-linux (package-linux.yml, a dist publish-jobs entry, the only other job with contents: write) runs after host and only for a stable tag (dist skips publish jobs for pre-releases). It downloads dnsy-x86_64-unknown-linux-gnu.tar.xz and the aarch64 one from the release, checks them against their .sha256, runs scripts/package-linux.sh (nfpm 2.48.0, downloaded by scripts/install-nfpm.sh and verified against the sha256 pinned in that script; configuration in packaging/nfpm.yaml, maintainer scripts in packaging/scripts/) and uploads the four packages and their .sha256 files with gh release upload --clobber, then downloads the release’s sha256.sum, adds the package lines with scripts/update-sha256sum.sh, checks every line of the result with sha256sum -c against the release’s files, and uploads it back with --clobber. It fails if the release has no sha256.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.

  1. On main with a green CI, move the [Unreleased] section of CHANGELOG.md under ## [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.

  2. Check that version in the root Cargo.toml ([workspace.package]) is 0.1.0. Bumping it is a normal PR that also updates Cargo.lock.

  3. Run the release-pre-check workflow on main (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 .pkg and .msi; none of that runs on pull requests, so this is where a platform break shows up before a tag. It also runs weekly on main. The release workflow does not wait for it, so a hotfix tag is never blocked by it.

  4. Optional dry run on your machine (no tag, nothing published): with dist 0.33.1 installed run dist plan and dist generate --check. Add dist build --artifacts=local --target=<triple> after cd web && pnpm install --frozen-lockfile && pnpm build to build one archive.

  5. 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
    
  6. Watch the Release workflow. When it is green the release v0.1.0 has the 7 archives, 2 script installers, 2 .pkg files, the .msi, the 2 .deb and 2 .rpm packages, source.tar.gz, per-file .sha256 files and sha256.sum.

  7. Verify the packages by hand (release-pre-check does the same on a runner, but a release is the real test):

    • macOS (a Mac with no dnsy installed): download dnsy-<arch>.pkg in a browser, expect the Gatekeeper block, Open Anyway, finish the installer, then dnsy version prints 0.1.0 and which dnsy is /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 run dnsy version; check C:\Program Files\dnsy\bin\dnsy.exe and the system PATH; install it again over itself and over an older build (no second Apps entry); uninstall and check both are gone.
  8. Verify the Linux packages: apt install ./dnsy_0.1.0_amd64.deb on a clean Ubuntu or Debian host and dnf install ./dnsy-0.1.0-1.x86_64.rpm on a clean Rocky or Fedora host; dnsy version prints 0.1.0, systemctl status dnsy shows the unit loaded but inactive and disabled, and apt purge dnsy / dnf remove dnsy behave as described above. The packages workflow runs these steps in containers on pull requests, but only a tag exercises the download from the release and the upload.

  9. Verify: download sha256.sum and the archives and run sha256sum -c sha256.sum --ignore-missing; run the installer on a clean Linux and a clean Windows machine; dnsy version must print 0.1.0 and / 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

  1. 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 check
    

    config check validates the file (unknown keys are errors), the zone files, and that the referenced certificate, key and passphrase files exist.

    Set [resolver] upstreams to the resolvers DNSY forwards to, for example upstreams = ["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] udp and tcp to 127.0.0.1:5353 and [server] data_dir to 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"
    
  2. Create the first user (a random password is printed once, or use --password-stdin):

    dnsy user create admin --role admin
    

    Starting the server with no users also prints a one-time bootstrap code for the first-run screen of the web UI.

  3. Run it:

    dnsy serve
    

    The DNS listeners default to 0.0.0.0:53, which needs root or CAP_NET_BIND_SERVICE; with the unprivileged override above they listen on 127.0.0.1:5353. If the server exits with address already in use on port 53, see Port 53 and systemd-resolved.

    The API and UI listen on [api] listen (default 127.0.0.1:5380). GET /healthz is liveness, GET /readyz checks that the database answers.

  4. Query it:

    dig @127.0.0.1 -p 5353 example.com
    

    By 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-zero per_client_qps, 50 by default) to answer everyone.

  5. Add a zone by dropping a JSON file into [zones] dir (see Zones); it is picked up without a restart. Export it with dnsy zone export example.com.

  6. Create a token for scripts or DNS-over-HTTPS:

    dnsy token create --user admin --name laptop --scope dns:query --scope api:write
    

    The secret is printed once. dns:query tokens 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-resolved
    

    The host’s /etc/resolv.conf still points at 127.0.0.53 afterwards, so point it at DNSY (for example nameserver 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

CommandWhat 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 checkvalidate the configuration
dnsy migrate [--status]apply database migrations, or only report them
dnsy user create|reset-password|disable|enable|listmanage 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 changeencrypted 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 openapiprint the OpenAPI document
dnsy versionprint 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.

SectionKeys (defaults)
serverdata_dir (/var/lib/dnsy), udp_sockets_per_address (available parallelism on Linux, 1 elsewhere; see Operations), worker_threads (available parallelism; at least 1)
dnsudp, tcp listen lists (0.0.0.0:53; empty disables)
dotlisten (off), cert, key, client_ca, client_crl, mtls = off/optional/required
dohlisten (off), cert, key, path (/dns-query), require_token (true), trusted_proxies (empty; CIDRs whose X-Forwarded-For is honoured, see Security)
apilisten (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)
zonesdir (/var/lib/dnsy/zones)
resolvertimeout_ms (2000), attempts (2), upstreams (seed upstreams), case_randomization (false; 0x20, see Resolver)
cacheenabled (true), max_entries (100000), min_ttl (0), max_ttl (86400), negative_max_ttl (3600), servfail_ttl (5), bogus_ttl (60)
cache.serve_staleenabled (true), max_stale (86400), answer_ttl (30), client_timeout_ms (1800)
cache.prefetchenabled (true), min_hits (5), remaining_ttl_pct (10)
dnssecvalidation = 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
filterblock_mode = null_ip/nxdomain/refused, blocklists, refresh_interval (seconds, 86400)
limitsper_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.listen needs dot.cert and dot.key; dot.mtls other than off needs dot.client_ca.
  • doh.listen needs doh.cert and doh.key; doh.path starts with /.
  • api.tls_cert and api.tls_key are set together.
  • cache.min_ttl must not exceed cache.max_ttl; cache.max_entries is at least 1; cache.prefetch.remaining_ttl_pct is 1 to 100.
  • Trust anchors are DS records: keytag algorithm digest-type digest-hex. With validation on, at least one trust anchor (or a trust_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}
}
  • name is relative to the origin, @ is the origin, and a trailing dot makes it absolute. It must lie inside the zone.
  • value is RFC 1035 presentation format. Names inside values are absolute.
  • ttl is optional and defaults to default_ttl. The SOA takes an optional ttl too.
  • Supported types: A, AAAA, NS, CNAME, PTR, MX, TXT, SRV, CAA and DS. The SOA lives in the soa block 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. and dev.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.

StrategyOrder
round_robinrotating start, one step per query
weightedrandom, each member chosen with probability proportional to its weight (>= 1)
lowest_latencyascending EWMA latency (alpha 0.3); members not yet measured go first
failoverconfigured 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:

FormTransport
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’s SubjectPublicKeyInfo (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 to udp:// and tcp:// 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.tcp listen 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_LIFETIME and UDP_POOL_IDLE in upstream/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 #sni name 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

NumberAlgorithmVerifySign
8RSASHA256 (RFC 5702)yesno
10RSASHA512 (RFC 5702)yesno
13ECDSAP256SHA256 (RFC 6605)yesyes
14ECDSAP384SHA384 (RFC 6605)yesno
15Ed25519 (RFC 8080)yesyes

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:

  1. The RRset is non-empty and every record has the RRSIG’s owner, class and covered type; the RRSIG labels field does not exceed the owner’s label count; the owner is at or below the signer name.
  2. The DNSKEY has the zone-key flag and protocol 3, the algorithm matches the RRSIG and the key tag matches.
  3. The validity window, with RFC 1982 serial arithmetic. verify_rrsig_with_skew widens the window by a clock skew of up to 86400 seconds.
  4. 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
StateMeaning
generatedcreated, not in the DNSKEY RRset
publishedin the DNSKEY RRset, not signing
activesigning
retiredno longer signing, still published for validators holding old signatures
removedwithdrawn; 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_rollover creates and publishes the new ZSK. After the DNSKEY TTL plus the propagation delay (the zone policy’s propagation_delay, default 3600 s) it becomes active and the old ZSK is retired. 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 published for 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 become active and the old key retired. 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

RRsetSigned by
DNSKEY at the apexevery 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, NSEC3PARAMevery active ZSK/CSK
DS at a delegationthe zone-signing keys
NS at a delegation, glue and anything else at or below a zone cutnot 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 needs nsec3.allow_nonzero_iterations and 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.

StatusMeaning
SecureEvery RRset on the answer chain, and the denial proof if any, verified up to a trust anchor.
InsecureThe 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.
BogusSomething should have verified and did not.
IndeterminateNo 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:

  1. At the anchor, the DNSKEY RRset must be signed by a key matching an anchor DS (or equal to an anchor DNSKEY).
  2. 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).
  3. 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.
  4. 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 answer Insecure (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

EDEWhen
1 Unsupported DNSKEY algorithmInsecure: the zone uses only unsupported algorithms.
2 Unsupported DS digestInsecure: the DS set has only unsupported digest types.
5 DNSSEC indeterminateNo trust anchor, or an error response code.
6 DNSSEC bogusBad signature, contradicting denial, work limit exceeded, failed fetch.
7 Signature expiredThe only RRSIGs tried have expired.
8 Signature not yet validThe only RRSIGs tried are not yet valid.
9 DNSKEY missingNo DNSKEY matches the DS, the anchor or the RRSIG key tag, or the DNSKEY fetch failed.
10 RRSIGs missingData in a secure zone came without signatures.
12 NSEC missingA 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.

LimitDefault
max_zone_cuts: names walked between the anchor and a signer16
max_rrsig_verifications: signature checks per RRset8
max_verifications: signature checks per validation64
max_fetches: DS/DNSKEY fetches per validation32
max_nsec3_iterations: above this, NSEC3 proofs are Insecure100 (MAX_NSEC3_ITERATIONS)
max_nsec3_hashes: distinct NSEC3 hashes per validation64
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):

AnswerAdded records
Data, CNAME chains, additional A/AAAAthe RRSIGs of every RRset (wildcard expansions carry the RRSIG under the query name, its labels field showing the expansion)
Wildcard expansionNSEC covering the query name, or NSEC3 covering the next closer name
NXDOMAINSOA 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)
NODATASOA and RRSIG; the NSEC or NSEC3 matching the name (for an NSEC empty non-terminal, the NSEC whose span holds it)
Wildcard NODATAthe NXDOMAIN-style proof for the name plus the NSEC/NSEC3 matching the wildcard
Referral to a signed childNS, DS and its RRSIG, glue
Referral to an unsigned childNS, 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:

ModeUpstream queryBogus answerStatus
offthe client’s DO and CD bitsserved as receivedunchecked
permissiveDO=1, CD=1served without AD, with the validator’s EDE, logged as a warningvalidated
strict (default)DO=1, CD=1SERVFAIL with the validator’s EDEvalidated

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 Secure and 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 Bogus entry lives at most cache.bogus_ttl seconds, is returned to CD=0 clients only as SERVFAIL (strict) or without AD (permissive), and never replaces a live Secure entry.
  • 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 cached Secure answer 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, from dnssec.negative_trust_anchors at start or the API at run time) flushes the cache.

Amplification bounds

One client query cannot make the validator fetch without limit:

  • ValidationLimits caps 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 /readyz returns "warnings": ["dnssec_clock_untrusted"];
  • resumes validation as soon as the clock is plausible, flushing the cache of the indeterminate entries.

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:

ModeBehaviour
strict (default)bogus answers become SERVFAIL with an extended DNS error
permissivebogus answers are served without AD, with the EDE, and logged as a warning
offno 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):

EDEMeaningUsual cause
1 Unsupported DNSKEY algorithmzone treated as insecurezone signed only with an algorithm DNSY cannot verify
2 Unsupported DS digestzone treated as insecureDS uses only unknown digest types
5 DNSSEC indeterminateno trust anchor, or an error codemisconfigured anchors
6 DNSSEC bogussignature does not verify, proof contradicts, or DS/DNSKEY fetch failedtampering, broken signing, unreachable name servers
7 Signature expiredonly expired RRSIGszone owner stopped re-signing
8 Signature not yet validonly RRSIGs from the futuresigner clock ahead, or yours behind
9 DNSKEY missingno DNSKEY matches the DS or RRSIGDS at the parent does not match the zone’s keys
10 RRSIGs missingunsigned data in a signed zonezone unsigned but DS left at the parent
12 NSEC missingnegative answer without proofbroken 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.

FieldDefaultNotes
enabledfalsecreates keys and signs on the next pass
algorithm1313 ECDSAP256SHA256 or 15 Ed25519 only
key_layoutcskcsk (one key) or ksk_zsk (two keys)
denialnsec3nsec or nsec3
nsec3iterations 0, empty salt, no opt-outsee below
signature_validity_days14
refresh_days5re-sign this long before expiry; must be below the validity
dnskey_ttl3600drives rollover timing
propagation_delay3600seconds 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) unless allow_nonzero_iterations is set, which only adds validator cost. Check: dig @127.0.0.1 -p 5353 example.com NSEC3PARAM +noall +answer returns example.com. 300 IN NSEC3PARAM 1 0 0 -.
  • nsec is simpler to debug and cheaper, but lists every name. Use it for public data.
  • nsec3.opt_out: true leaves 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; --zone limits it and --passphrase-stdin reads 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 + P s (3600 + 3600 with the defaults).
  • A retired key is removed (and its private key wiped) T + P + V after 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/keys is only generated: 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 retired key of the old algorithm keeps signing zone data until it is removed, 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)

  1. Start it: dnsy dnssec keys rollover example.com --role ksk (API: POST /api/v1/zones/example.com./dnssec/keys/{id}/rollover with the active key’s id, answers 202 with the new key). For key_layout: csk use --role csk.

  2. Print the DS: dnsy dnssec keys ds example.com now lists the old and the new DS:

    example.com. IN DS 18887 13 2 4484...
    example.com. IN DS 24686 13 2 4AD6ECC42C422994954E9B2A248781E53AC16A978FA7D1C983CCE704524B0844
    

    Add the new DS at the registrar and keep the old one.

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

  4. 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
    
  5. DNSY activates the new key once T + P s have passed since publication, retires the old one and re-signs the DNSKEY set with the new KSK. Watch GET /api/v1/zones/example.com./dnssec (rollover.state is in_progress and key 4 shows ds_confirmed_at; it returns to idle when done) or dnsy dnssec keys list.

  6. When the new KSK is active and T plus the propagation time have elapsed, remove the old DS at the registrar. DNSY does not track this step, and it removes the old DNSKEY after T + P + V; remove the old DS well before that, never after.

Timeline example (T = 3600, parent DS TTL 24 h)

WhenAction
day 0start rollover, add the new DS at the registrar
day 0 + parent publication + 24 hconfirm the DS (earlier is allowed; this is on you)
confirm + about 2 hnew KSK active, old retired, DNSKEY re-signed
+2 h and the DS TTL 24 hremove the old DS at the registrar
+14 d 2 h after retirementold 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> and GET .../dnssec (keys, next_resign, rollover).
  • dig @127.0.0.1 -p 5353 example.com DNSKEY +short during a rollover shows the extra keys.
  • An external validator (delv, dig @1.1.1.1 ... +dnssec, ad flag) at each step.
  • GET /api/v1/dnssec/status and the query log for a rise in bogus.
  • The server logs dnssec key states advanced transitions=N for 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 already active) 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 + V for 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

SymptomCauseFix
everything bogus or indeterminate after bootclock wrongfix 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 expiredsigner 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 registrarDS 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 10DS at the parent for an unsigned zonere-enable signing or remove the DS
forgot to update the DS during a KSK rollovernew key never confirmed, or the old DS removed before the new KSK became activedo not remove the old DS; confirm the new one
signing fails after a restartpassphrase 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 keyremove 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 a 0600 file.
  • The database holds the keys: keep data_dir private to the service user and encrypt the backups; store the passphrase elsewhere.
  • Use strict validation; permissive and 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:

ActionEffect
allowprocess the request
refuseanswer REFUSED
dropsend 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):

FormatExampleNotes
hosts0.0.0.0 ads.example.comany IP address, one or more names per line; localhost style entries are ignored
domainsads.example.com, *.ads.example.comone 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

ModeAnswer
NxDomainNXDOMAIN
NullIp0.0.0.0 for A, :: for AAAA, empty NOERROR for other types
RefusedREFUSED
CustomIpoperator 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 http and https. 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.1 and 2130706433 are all 127.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 https to http is refused. The Location must be an absolute http(s) URL or an absolute path (/list.txt); relative and scheme-relative (//host/x) locations are refused.
  • A source with allow_private set 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-Length and 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 sends allow_private, needed for file paths and private addresses.
  • Custom rules: add a block or allow rule 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 dot is 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 in connect).

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

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

StatusMeaning
400bad base64, missing dns, not a DNS message, empty body, or more than 65535 octets (body or decoded parameter)
401no token (when required) or a rejected token; WWW-Authenticate: Bearer
403valid token without the dns:query scope
404any path other than the configured [doh] path (default /dns-query) and {path}/{token}
405method other than GET and POST (Allow: GET, POST)
415POST with a content type other than application/dns-message
429too 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-After until 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^n seconds 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 = N keeps one raw query_log row 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. 0 is treated as 1.
  • [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 /readyz reports the warning querylog_disk_full (the status stays 200). The warning clears after the next successful write; free disk space or lower retention_days or max_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, timeseries with 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.
  • timeseries with 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:

IndexServesSize at 10 million rows
query_log_ts (ts_ms, id)time ranges, paging, unfiltered newest-firstin 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 NXDOMAINabout 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.

ColumnCodes
transportudp 0, tcp 1, dot 2, doh 3
sourceauthoritative 0, cache 1, cache_stale 2, upstream 3, blocked 4, refused 5, error 6
dnssecunchecked 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 application builder), strict TypeScript and strict templates.
  • Tailwind CSS, configured with the DNSY design tokens (branding/tokens.css) in src/styles.css.
  • angular-eslint and Prettier for linting, Vitest through ng test for unit tests, Playwright (web/playwright.config.ts, tests in web/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, no data: 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-Token from the dnsy_csrf cookie (__Host-dnsy_csrf when the cookies are Secure) on unsafe methods (double submit);
  • maps failures to a typed ApiError, carrying the RFC 9457 problem document when the body is application/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

CommandWhat it does
just webpnpm 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-typesregenerate the API types
cd web && pnpm startdev 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:

ComponentSelectorUsed for
MascotLoadingComponentdnsy-mascot-loadingroute navigation, pending data
MascotEmptyComponentdnsy-mascot-emptyempty lists, placeholder pages
MascotErrorComponentdnsy-mascot-errorfailed requests
MascotSuccessComponentdnsy-mascot-successcompleted actions
MascotOfflineComponentdnsy-mascot-offlinebrowser offline banner in the shell
MascotNotFoundComponentdnsy-mascot-not-foundthe 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 md up, 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-line divider, and the blob canvas filling the right side. Below md there is one column: the canvas fills the background at half opacity and the form sits on a solid --dnsy-surface panel 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 in blob.ts (pure functions, injected randomness); the loop is in login-scene.ts.
  • When it runs: the loop uses requestAnimationFrame outside 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. With prefers-reduced-motion: reduce the 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 or data: URIs.
  • Form: labelled username and password fields, errors in a role=alert region, 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 down retry_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 calls POST /auth/bootstrap and 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 signed badge when DNSSEC signing is enabled and a load error badge (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 ETag from every read and write is sent back as If-Match on 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 with If-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.

RouteFolderWhat it does
/filteringfilteringBlocklist sources and custom block/allow rules, see Filtering
/aclaclCIDR allow/refuse/drop rules (IP blocking) and the per-client rate limit
/response-ipresponse-ipResponse-IP policy: networks an upstream answer must not point into, see Filtering
/upstreamsupstreamsUpstream groups: strategy, members, add/remove, health
/client-certscertsDoT mutual-TLS fingerprint allowlist with labels and expiry
/cachecacheLive cache statistics, flush by name (optionally with subdomains) and flush everything
/settingssettingsBlock 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-ip replaces 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) and 7d (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/dashboard plus 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/status reports 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 (toQuery in query-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-secure for secure, a warning mark in --dnsy-amber for bogus, amber for indeterminate and muted text for insecure and unchecked. Stale answers carry an amber Stale tag; 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/stream with EventSource (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. A lagged event 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 but admin; 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 to localStorage, sessionStorage or 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) calls POST .../dnssec/rollover/abort.
  • Unencrypted keys: when keys_encrypted is 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) or off. 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.

PathUse
logo.svgsmall mark plus the “DNSY” wordmark in Michroma (text with a system fallback)
mark.svgthe small Nox mark alone (header)
favicon.svgthe small mark on a near-black square
mascot/nox-{idle,loading,empty,error,success,offline,404}.svgNox poses for UI states
tokens.cssthe 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.

TokenValueRole
--dnsy-bg#050505page ground
--dnsy-surface#0d0d0dpanels, sidebar, header
--dnsy-text#f2f2f2text and hairlines
--dnsy-muted#8c8c8clabels, hints, inactive nav, form field borders, offline Nox
--dnsy-line#2a2a2adividers and table rules
--dnsy-red#ff2e2ebrand: Nox eyes and beak, primary buttons, links, focus ring, active nav, login pulses
--dnsy-amber#ffb020danger, destructive actions, field errors, warnings, DNSSEC bogus and blocked, error Nox
--dnsy-secure#3ddc97DNSSEC 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.

Foregroundon --dnsy-bgon --dnsy-surfaceUse and target
--dnsy-text18.21:117.36:1body text (4.5:1)
--dnsy-muted6.06:15.78:1labels and hints (4.5:1); form field borders (3:1)
--dnsy-red5.51:15.26:1small link and button text (4.5:1); focus ring (3:1)
--dnsy-amber11.14:110.63:1errors, warnings, bogus badges (4.5:1)
--dnsy-secure11.53:111.00:1secure and OK badges (4.5:1)
--dnsy-line1.42:11.35:1decorative 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

FamilyWeightsUse
Michroma400wordmark, page and section titles, buttons and badges
Geist400, 500, 600body text, tables, forms, navigation
Geist Mono400, 500record 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.

PoseLookMotionWhere you see it
idleas abovenonethe book header
loadingeyes at 25%, a 9 by 2 red point sweeps across them1.6s loopwhile a page waits for data
emptyrelaxed brow, short eyes looking down, beak 6px lowernonelists with nothing in them
erroramber eyes slanted in, lowered brow, amber beakflicker, 1.4sfailed requests, beside Retry
successgreen chevron eyes, green beaka short hopconfirmations
offlinegrey dashed eyes, head at 50%nonethe browser lost its connection
404head tilted -9°, one slit eye and one round eyenoneunknown 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

PathAuthPurpose
GET /healthznonethe process is up
GET /readyznonethe database answers and every DNSSEC zone can be signed (503 otherwise; a signing failure reads dnssec_signing_failed)
GET /api/v1/versionnoneserver 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_session cookie 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

EndpointAuthPurpose
POST /api/v1/auth/loginnone{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/logoutsessionends the session and clears the cookies (204)
GET /api/v1/auth/mereadthe caller and how it authenticated (session or token)
POST /api/v1/auth/bootstrapnone{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/passwordsession{current_password, new_password}; ends every session of the user and returns fresh cookies for this client
GET/POST /api/v1/usersadminlist, create {username, password, role}
GET/PATCH/DELETE /api/v1/users/{id}adminPATCH 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/tokensread / sessionlist own tokens (admins: all); create {name, scopes, expires_at?}
DELETE /api/v1/tokens/{id}readrevoke 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.

EndpointScopePurpose
GET /api/v1/zonesreadlist zones: name, serial, record count, dnssec_enabled, load_error
POST /api/v1/zoneswrite{name, default_ttl, soa, records}; 201 with ETag. The records must include an apex NS
GET /api/v1/zones/{name}readsettings and SOA, record_count, load_error; ETag header
PUT /api/v1/zones/{name}writereplace settings and records (default_ttl, soa, records); keeps the DNSSEC policy
DELETE /api/v1/zones/{name}writedelete the zone and its file (204)
GET /api/v1/zones/{name}/recordsreadpaged: limit (default 100, max 1000), offset, type, name; returns {items, total, limit, offset}
POST /api/v1/zones/{name}/recordswrite{name, type, ttl?, value}; 201 with the record
PUT /api/v1/zones/{name}/records/{id}writereplace a record
DELETE /api/v1/zones/{name}/records/{id}writedelete a record (204)
GET /api/v1/zones/{name}/exportreadBIND 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-Length if 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.

EndpointScopePurpose
GET /api/v1/zones/{name}/dnssecreadpolicy (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}/dnssecadmin{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/keysreadall keys of the zone, removed ones included (public metadata only)
POST /api/v1/zones/{name}/dnssec/keysadmin{role, algorithm?} generates a key in state generated; 201. The zone needs signing enabled (409)
POST /api/v1/zones/{name}/dnssec/keys/{id}/rolloveradmin{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-confirmedadminrecords that the key’s DS is at the parent; 204. Lets a KSK or CSK rollover proceed
POST /api/v1/zones/{name}/dnssec/rollover/abortadmin{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/exportadmin{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/passphraseadmin{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/dsreadDS 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/dnssecreadvalidation (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/dnssecadmin{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/statusreadvalidation 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

EndpointAuthPurpose
GET /api/v1/querylogreadsearch the query log, newest first
GET /api/v1/querylog/streamwrite (operator, admin)live tail as server-sent events
GET /api/v1/stats/summaryreadtotals, blocked ratio, cache hit ratio, answer sources, DNSSEC status counts, latency
GET /api/v1/stats/timeseriesreadcounts per bucket
GET /api/v1/stats/top-domains, top-blocked, top-clientsreadranked lists
GET /api/v1/stats/dashboardreadsummary, timeseries and the three top lists in one response
GET /api/v1/cachereadlive cache counters
DELETE /api/v1/cachewriteflush everything; answers {"removed": n}
DELETE /api/v1/cache/entries?name=<name>&subtree=<bool>writeflush 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.

RoutePurpose
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-limitPer-client queries per second (0 = unlimited, at most 1,000,000), persisted
GET /response-ip, PUT /response-ipResponse-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}/refreshFetch 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}/healthGroups, 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 /settingsThe 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" }
StatusMeaning
400malformed request
401missing, invalid or expired credentials (WWW-Authenticate: Bearer)
403credential lacks the scope, or CSRF check failed
404no such resource
409conflict (duplicate name, record or zone, last admin)
412If-Match does not match the current ETag
413request body too large
422well-formed request with invalid data (record rdata, CNAME conflict, missing apex NS)
429too many failed logins or too many live-tail streams; Retry-After header and retry_after_secs field
500internal error; details are logged, never returned
503not 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.

WhereWhat
openapi/dnsy.json in the repositorythe 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 serverthe same document, served by the binary
dnsy openapiprints the document to stdout
/rest-api/ on the docs sitethe OpenAPI document rendered with Redoc
/api/ on the docs siterustdoc 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:

GroupPathsPage
Probes and version/healthz, /readyz, /api/v1/versionREST API
Auth, users, tokens/api/v1/auth/*, /api/v1/users, /api/v1/tokensUsers, tokens and sessions
Zones and records/api/v1/zones, /api/v1/zones/{name}/records, .../exportZones
DNSSEC/api/v1/zones/{name}/dnssec*, /api/v1/settings/dnssec, /api/v1/dnssec/statusDNSSEC
Filtering and ACL/api/v1/blocklists, /api/v1/rules, /api/v1/acl, /api/v1/client-certsFiltering
Upstreams, cache, settings/api/v1/upstream-groups, /api/v1/cache, /api/v1/settingsResolver, 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

SignalEffect
SIGTERM, SIGINTgraceful shutdown: listeners stop accepting, in-flight queries finish (5 s), the API closes, the query log is flushed, then the process exits 0
SIGHUPreload 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:

CommandWhat it doesWhile the server runs
dnsy db check [--full]PRAGMA quick_check (--full: integrity_check); prints ok or the problems and exits non-zerosafe, 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 overwritessafe
dnsy db vacuumrebuilds the file and returns free pages to the diskrefused: 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:

EndpointWhat it gives
GET /api/v1/stats/summarytotals, blocked ratio, cache hit ratio, answers by source and DNSSEC status, latency
GET /api/v1/stats/timeseriescounts per minute or hour bucket
GET /api/v1/stats/top-domains, top-blocked, top-clientsranked lists
GET /api/v1/cachelive cache counters
GET /api/v1/dnssec/statusvalidation mode, trusted clock, secure and bogus counts of the last 24 hours
GET /api/v1/upstream-groups/{name}/healthper-upstream health
GET /api/v1/querylog/streamlive 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

  • /readyz not 200.
  • Cache hit ratio dropping, or upstream health below the number of configured upstreams.
  • bogus answers 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.

WhatWhereNotes
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, *.jsonthe source of truth for each zone, human readable
Configurationdnsy.toml, TLS certificates and keys, key_passphrase_filemanaged 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

  1. Stop dnsy serve.
  2. Put dnsy.db back into <data_dir> (remove stale dnsy.db-wal and dnsy.db-shm files) and the zone files into [zones] dir.
  3. Provide the same key passphrase as when the keys were created.
  4. dnsy config check, then dnsy migrate --status to see that the database matches the binary.
  5. 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

  1. Read the changelog for the new version.
  2. Back up the database and the zone files.
  3. Install the new binary (see Install).
  4. dnsy config check: unknown keys are errors, so a removed or renamed key shows up here.
  5. dnsy migrate --status: lists applied and pending migrations without changing anything.
  6. Restart the service. dnsy serve applies pending migrations on start; dnsy migrate applies them without starting the server.
  7. Check GET /readyz and GET /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

SymptomCause and fix
cannot bind udp listener on 0.0.0.0:53: permission deniedprivileged 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 useanother 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 keyunknown 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 faultrun 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 processthe server holds it; stop dnsy serve first (backup and check do not need that)
dnsy migrate --status shows older state than the server reportsit 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 binaryrun the binary version that wrote it, or restore a backup; see Upgrade

Clients get errors

SymptomCause and fix
REFUSEDthe 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 UDPan ACL drop rule, or the rate limit (UDP over the limit is dropped)
SERVFAIL with EDE 22 or 23no 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 12DNSSEC validation failed; see below
a blocked name answers 0.0.0.0that is filter.block_mode = "null_ip"; EDE 15 (list) or 17 (custom rule) says why
a zone edit does not showlocal 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 /readyz reports dnssec_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

SymptomCause and fix
TLS handshake fails on DoT with mTLSthe client certificate is not signed by dot.client_ca, has expired, or (with mtls = "required") is missing; see DoT and mTLS
DoH answers 401no token, or a rejected one; 403 means the token lacks dns:query; 429 means too many rejected tokens from that address
DoH answers 404the path differs from [doh] path; a path token goes after it: /dns-query/<token>
the UI paths answer 404the binary was built without the embed-ui feature; use just build (Install)
API answers 403 on a POST from a script using cookiescookie sessions need the X-CSRF-Token header; use a bearer token for scripts
login answers 429five 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

TermMeaning
ACLaccess 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
Authoritativean answer from a zone DNSY serves itself (AA flag), as opposed to forwarded
Bogusa DNSSEC validation failure: signatures that do not verify, expired signatures, missing proofs
CD“checking disabled” header flag: the client asks for data without validation
CSKcombined signing key: one key used as both KSK and ZSK
DOthe EDNS “DNSSEC OK” bit: the client wants DNSSEC records
DoHDNS over HTTPS (RFC 8484)
DoTDNS over TLS (RFC 7858)
DSdelegation signer record held by the parent zone; links the parent to the child’s KSK
EDEExtended DNS Error (RFC 8914): a reason code attached to an answer, e.g. 6 DNSSEC bogus, 15 blocked
EDNSextension mechanism for DNS (RFC 6891): larger UDP payloads, flags and options
KSK / ZSKkey signing key (signs the DNSKEY set, its DS is at the parent) / zone signing key (signs the records)
mTLSmutual TLS: the client also presents a certificate, here for DoT
Negative cachecached NXDOMAIN or NODATA answers (RFC 2308)
NSEC / NSEC3DNSSEC records proving a name or type does not exist; NSEC3 hashes the names
NTAnegative trust anchor (RFC 7646): a name under which validation is switched off temporarily
Noxthe DNSY owl mascot, see Branding and the mascot
Prefetchrefreshing a popular cache entry shortly before it expires
RRSIGthe signature over a record set
Serve-staleanswering from an expired cache entry when upstreams fail (RFC 8767)
Trust anchorthe starting point of validation, here the root zone’s DS records
Upstream groupa set of upstream resolvers with a load-balancing policy and routing
Zonea 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
                  +---------------------------------------+
CrateRole
dnsy-coreshared 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-protowire format, names, records, EDNS, canonical form
dnsy-dnssecvalidation, signing, key handling over ring primitives
dnsy-keysthe DNSSEC key store: encrypted private keys, rollover, backup bundles
dnsy-configTOML 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-storeSQLite handle and migrations
dnsy-authusers, roles, tokens, sessions
dnsy-observequery events, query log, statistics
dnsy-zonezone model, JSON files, hot reload, authoritative lookup, signing (ZoneSigning over the dnsy-keys store)
dnsy-resolverupstream client, cache, load balancing, validating forwarder
dnsy-filterblocklists, allowlist, stored ACL rules and list sources; fetching them is the fetch feature (ACL evaluation and the rate limiter live in dnsy-engine)
dnsy-enginethe Handler trait and the request pipeline
dnsy-serverUDP, TCP, DoT and DoH listeners
dnsy-apiREST API (axum, utoipa) and the embedded UI
dnsythe 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 by Message::encode and 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_conns connections are served (further ones are closed at accept), and a complete frame must arrive within tcp_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.

  1. ACL: the client address (or mTLS certificate) decides allow, refuse (REFUSED) or drop. This runs before any other reply, error replies included.
  2. 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_slip gets a minimal truncated reply instead) and stream transports get REFUSED.
  3. Sanity: no question -> FORMERR; opcode other than QUERY -> NOTIMP; EDNS version other than 0 -> BADVERS.
  4. Zone transfers: AXFR and IXFR are REFUSED, for local zones and forwarded names alike.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.attempts times. Identical concurrent misses share one upstream exchange; at most MAX_MISS_WAITERS (256) requests wait on it, further ones get SERVFAIL (EDE 22) at once. The whole step is bounded by the deadline resolver.timeout_ms * resolver.attempts measured 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.
  10. DNSSEC validation: unless dnssec.validation is off or the client sent CD=1, the query goes upstream with DO=1 and CD=1 and the raw answer is validated by dnsy-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. In strict mode a Bogus answer becomes SERVFAIL with the validator’s EDE; in permissive mode 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.
  11. 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.
  12. 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.
  13. 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 for www.example.com. reads back as Mail.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.rcode is 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 with Message::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 the ede module.
  • For DoT and DoH, Message::encode_padded pads 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 are YYYYMMDDHHmmSS (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 Bogus rather than accepting a record whose presentation form would not round-trip.
  • TypeBitmap encodes and decodes the NSEC/NSEC3 window blocks. Types print by mnemonic, or as TYPEnnn when unnamed.
  • Dnskey::key_tag follows RFC 4034 appendix B, including the algorithm 1 special case (a key too short for it gives tag 0). serial_lt and serial_gt implement RFC 1982 arithmetic for RRSIG validity checks; Rrsig::is_current uses 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_input builds 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 RRSIG labels field. 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:

beforeafter
dnsy binary28.99 MB17.22 MB
clean release build80 s180 s
cargo tree --workspace unique crates316279
cargo tree -p dnsy (normal)277240
UI dist text assets1.11 MB0.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

  1. Creates a temp directory with the configuration, zone files, a blocklist file and a throwaway PKI (rcgen): a server CA and certificate (valid for 127.0.0.1), a client CA, and three client certificates: valid, expired and signed by an untrusted CA.
  2. Starts a scriptable fake upstream (FakeUpstream) when opts.upstream is set.
  3. Creates an admin user and two tokens (dns:query for DoH, admin for the API) with the CLI.
  4. 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.
  5. 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 use wait_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 dig and kdig are 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 /cache counters 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 (bench binary). It starts the release dnsy (through DNSY_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, --count names cycled for cache hits, --unique for 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 and cache/hit_16_threads_320k (16 threads, 320 000 lookups per iteration; ops/s = 320 000 / time).
  • just allocs: allocations per cache hit (allocs binary). unsafe_code is forbidden, so the repo has no counting allocator of its own; the binary installs the dhat crate’s as the global allocator (bench crate only) and reads its cumulative block count around 2 000 runs, counting every thread. It reports Cache::get alone, Handler::handle on 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) that Cache::get returns 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, operator and viewer (password e2e-password-1234) and stores their signed-in sessions, so most tests start signed in. Pick a role with test.use({ storageState: roleState('viewer') }).
  • Tests that change global state (settings, ACL, filtering, cache, counts) start their own server with the freshServer fixture and sign in with sessionFor; 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.login uses an empty jar for that reason).
  • support/dns.ts sends hand-built UDP queries; support/upstream.ts answers every A query with 192.0.2.7 (names starting nx. get NXDOMAIN) and counts the queries it saw.
  • No fixed sleeps: locators auto-wait and expect.poll / toPass wait 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.

TargetCrateWhat it feeds
fuzz_decodecrates/dnsy-proto/fuzzMessage::formerr_for, decode, encode at five limits, encode_padded, set_ede, canonical RDATA, presentation form and the decode-encode-decode round trip
fuzz_validatecrates/dnsy-dnssec/fuzzValidator::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

DateToolchainTargetIterationsPanics
2026-10-21stable 1.99.1, fuzz-stable, 3 processes x 25 minfuzz_decode2 658 869 2480
2026-10-21stable 1.99.1, fuzz-stable, 2 processes x 25 minfuzz_validate315 691 0080

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 --coverage over web/src. The thresholds live in web/angular.json.
  • Exclusions: none. No #[cfg(coverage)], no ignore regions and no ignore-filename regex; main.rs glue and the gen-openapi binary are measured like everything else.

Gates

ScopeMetricFloor
Rust workspacelines80%
every cratelines80%
dnsy-proto, dnsy-dnsseclines90%
weblines, statements, functions80%
webbranches70%

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 / jobWhat 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 / webjust 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

ThreatControls
Open resolver and amplificationthe 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 poisoningrandom 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 namesidentical 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 attackswork limits against KeyTrap-style and NSEC3 cost attacks, algorithm checks, signature validity windows, a clock-safety guard (DNSSEC)
DNS rebindingresponse-IP policy replaces answers pointing into listed networks
Credential theftpasswords 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 leakagetokens, 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 restDNSSEC private keys encrypted with a passphrase when one is configured (DNSSEC)
SSRF through blocklist fetchblocklist URLs to private or loopback addresses are refused unless an admin allows them (Filtering)
Path traversalzone file names are derived from validated zone names only (Zones)
XSS in the UIstrict CSP (default-src 'self', no inline script or style) (Web UI)
Unauthorised client on DoToptional 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] listen on loopback or put it behind a TLS terminator; set api.tls_cert and api.tls_key to serve TLS directly.
  • Behind a TLS-terminating reverse proxy set [api] secure_cookies = true: the session and CSRF cookies then carry Secure and responses send Strict-Transport-Security, even though DNSY itself speaks plain HTTP to the proxy. auto (the default) follows api.tls_cert; false turns both off.
  • Behind a reverse proxy list the proxy’s own addresses in [api] trusted_proxies and [doh] trusted_proxies (CIDRs, empty by default). Only a connection from a listed peer has its X-Forwarded-For read: 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 to 0.0.0.0: one shared bucket with no ACL allow, no private-network trust and no known-client standing, never the proxy’s own address. With limits.open_resolver = false forwarded 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 every DoH answer carries Cache-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 against max_conns, not max_conns_per_client. Forwarded (RFC 7239), X-Real-IP and 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 ACL allow rule by address, dodge per-client QPS and pollute the query log. So the list is explicit, 0.0.0.0/0 and ::/0 are refused at load, and loopback is not trusted implicitly. The proxy must append its hop to X-Forwarded-For rather 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 = true on public listeners.
  • DNSY is private-only by default: limits.open_resolver = false serves only loopback, RFC 1918, 100.64.0.0/10, ULA and link-local clients, and limits.per_client_qps defaults to 50. Setting open_resolver = true makes the server answer the whole internet; do that only with ACL rules and a non-zero per_client_qps. dnsy serve and dnsy config check warn 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 hold limits.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 with tcp-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 by limits.udp_in_flight (4096) requests in flight with limits.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 most api.max_conns (256) connections (and api.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 in limits.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: raise max_conns_per_client for 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 stateCan be chosen?
handshaking, younger than 5 sno (it may be mid-handshake)
handshaking or silent, older than 5 syes, first
established (handshake done, or first query sent on plain TCP) and idleyes, longest idle first
authenticated (mTLS certificate, DoH token)never
busy (a request in flight)never
already asked to closeno

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:

  • ring is the only source of cryptographic primitives (TLS through rustls, DNSSEC signatures and digests, tokens). It is maintained by a small team and releases are infrequent; if that stops being acceptable the exit is aws-lc-rs, which is a dependency decision for a later release.
  • rust-embed embeds 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 through cargo deny advisories.
  • rusqlite bundles SQLite, compiled from source with the system C compiler.
  • A small in-house HTTP/1.1 client on hyper and tokio-rustls (dnsy-filter::http) fetches blocklists and sends dnsy cache flush; HTTPS uses the bundled webpki-roots trust anchors and certificate verification is always on. URLs are split by hand (ASCII only, no IDNA), so the url/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 in rustls.

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 strict validation 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).