Operations
Signals
| Signal | Effect |
|---|---|
SIGTERM, SIGINT | graceful shutdown: listeners stop accepting, in-flight queries finish (5 s), the API closes, the query log is flushed, then the process exits 0 |
SIGHUP | reload without a restart: zone files, TLS certificates for DoT/DoH, the DoT client CA bundle, blocklists and custom rules, the DNSSEC trust anchors (inline and trust_anchor_file, re-read from dnsy.toml) and a pass of DNSSEC re-signing |
Zone files and certificates are also watched on disk, so SIGHUP is a “do it now”. Other changes to
dnsy.toml (listen addresses, limits, logging, doh.path) need a restart; limits, block mode, cache and retention can also be changed through PATCH /settings without one. A reload that fails keeps
the previous state and logs the reason.
Logging
See also Logging and metrics.
Logs go to stderr through tracing. [logging] level is trace to error; format = "json"
writes one JSON object per line for log shippers. Tokens, Authorization headers, cookies,
DoH path tokens, passwords and the DNSSEC key passphrase are never logged.
Health
GET /healthz answers while the process runs. GET /readyz answers 200 when the database
works and every DNSSEC zone can be signed, and 503 otherwise; point load balancers and orchestrators at it.
Background work
serve runs these tasks for you: blocklist refresh, zone hot reload, re-signing of DNSSEC zones
(every minute it checks for changed zones, expiring signatures and key state changes), the key
rollover timer (advances key states per the zone policy), client-certificate allowlist refresh
and query-log retention ([querylog] retention_days; batched, also removes expired sessions and
folds days older than 48 hours into daily rollups, removes daily counter rollups older than 400 days, and runs PRAGMA optimize daily).
Query log, stats and cache
The query log and stats live in SQLite and are served by the API and UI. Flush the cache from the UI or the command line:
dnsy cache flush --token "$DNSY_TOKEN" # everything
dnsy cache flush example.com --subtree --token "$DNSY_TOKEN"
Dropped queries (ACL drop, UDP rate limiting) get no reply on UDP and TCP; over DoH they
become 403. The exception is the UDP slip: one over-limit UDP query in limits.rate_limit_slip
(default 2) gets an empty reply with TC=1 so that a real client retries over TCP.
Database and file modes
SQLite runs in WAL mode with synchronous = NORMAL (a power loss can lose the last moments of the
query log, never corrupt the database) and journal_size_limit = 64 MiB. Reads use a pool of four
connections, any idle one serves a request, so a slow query-log search does not queue the others
behind it. The first start after an upgrade logs applying migration ... while it builds new
indexes on a large query log.
data_dir is created with mode 0700 and dnsy.db (with -wal and -shm) with 0600. Existing
ones are never changed. At start the server logs a warning for every TLS private key
(api.tls_key, dot.key, doh.key) that group or others can read; fix it with chmod 600.
Backups
See Backup and restore for the procedure; in short:
Run dnsy db backup <file> (SQLite’s online backup; consistent and safe while the server runs),
or stop the server and copy <data_dir>/dnsy.db with its -wal file. It holds users, tokens, rules, upstream groups and DNSSEC private keys; protect the
copy like the keys themselves. Without DNSY_DNSSEC_PASSPHRASE or key_passphrase_file the
keys are stored unencrypted. Zone files are plain JSON in [zones] dir; back them up too.
Database maintenance
dnsy db works on <data_dir>/dnsy.db without creating or migrating it:
| Command | What it does | While the server runs |
|---|---|---|
dnsy db check [--full] | PRAGMA quick_check (--full: integrity_check); prints ok or the problems and exits non-zero | safe, read-only (a few seconds per 10 million query-log rows) |
dnsy db backup <file> | consistent copy via the online backup API, mode 0600, never overwrites | safe |
dnsy db vacuum | rebuilds the file and returns free pages to the disk | refused: it blocks every writer and needs up to twice the database size in free disk, so stop the server first |
vacuum detects a server (or any other process) holding the database and exits with an error
instead of waiting. Retention purges free pages for reuse but do not shrink the file; vacuum only
when disk space matters.
Upgrades and migrations
See Upgrade and migrations. In short:
dnsy serve and dnsy migrate apply pending migrations on start. dnsy migrate --status
lists applied and pending migrations without touching the database; it opens the file with
immutable=1, so it works on a read-only directory, but it ignores the -wal file and therefore
shows the last checkpointed state of a database a server is writing. Migrations are never
edited after release; a database newer than the binary is refused. Back up before upgrading.
Benchmarks
just bench builds the release dnsy, starts it against a fake upstream and prints a table of
throughput and latency: UDP cache hit, UDP cache miss (every name unique), TCP (one persistent
connection, 64 clients, 64 clients with 16 queries pipelined) and DoH (HTTP/1.1 and h2, POST).
All 64 load clients connect from 127.0.0.1, so the runner raises the per-client listener limits
(max_conns_per_client and udp_in_flight_per_client, see below) in its own config; against the
defaults the TCP and DoH rows would be reset and UDP datagrams dropped. Every row must show
0 errors. Pin CPUs for stable numbers, for example taskset -c 0-5 just bench; the server and the load
generator share the machine, so compare runs on the same host only. Reference numbers on a
24-thread workstation, unpinned: UDP hit about 310k QPS, TCP persistent connection p50 18 us,
TCP pipeline 16 at 64 clients about 426k QPS.
TCP and DoT listeners set TCP_NODELAY and write each length-prefixed answer in a single write,
so a client reusing a connection no longer waits for a delayed ACK (about 41 ms per answer
before). just bench-micro runs the criterion micro-benchmarks (codec, cache lookup).
Threads
[server] worker_threads (default: available parallelism, which honours a CPU affinity mask)
sizes the async runtime that runs the listeners and the pipeline. Argon2 and zone signing run on
tokio’s blocking pool on top of that, and every SQLite write runs on one dedicated thread,
dnsy-sqlite-writer, so the query-log writer queues behind nothing but other writes. A UDP
datagram that the pipeline can answer without waiting (cache hit, blocked name, local zone) is
answered in the receive loop of its socket; only one that waits for an upstream becomes a task.
Listener limits and UDP sockets
Admission caps are documented in Security. UDP sockets are created with a 4 MiB
receive buffer (SO_RCVBUF; the kernel may clamp it to net.core.rmem_max, raise that sysctl for
bursty traffic). On Linux [server] udp_sockets_per_address (default: available parallelism)
binds that many sockets per UDP address with SO_REUSEPORT, each with its own receive loop; the
kernel spreads clients across them by source address and port. Other platforms use one socket and
ignore the key. Check for kernel drops with nstat -az UdpRcvbufErrors (or netstat -su,
“receive buffer errors”): it should stay at zero under load. ss -ulnp shows the sockets.
Building with the UI
just build produces the release binary with the web UI embedded (see Install).