A small, self-contained authoritative DNS server for UDP, configured in
Dhall and implemented in
Zig as a small set of binaries built by
dhake (Dhakefile.dhall).
It links the dhall-c interpreter core
(compiled to zig-out/libdhall.so) to parse, typecheck and normalize a Dhall
config file describing zones and records,
then serves those records authoritatively over UDP (RFC 1035).
▶ Try it live in your browser — https://fixpointlinux.org/compendium/ (edit the Dhall config and query a frozen prebuilt wasm build of the server, entirely client-side; the wasm build path itself has been removed).
This project started as a simple question: can a real, useful network server be written in C, configured in a typed language, and shipped as one self-contained binary?
Three decisions shaped the answer:
1. The config is code — so it's typechecked. dnsd doesn't parse a config file; it interprets one. Your zones are a Dhall program, evaluated at startup by the same interpreter core this project shares with dhall-c. A typo in a record isn't a silent runtime surprise — it's a type error before the server ever binds a port. Configuration as code means configuration that's verified.
2. One engine, many targets. The same interpreter core and wire codec compile to:
- a native server binary (
zig-out/dnsd, built byzig/build.sh) that runs on Linux — no runtime, no interpreter, just the file (+ thelibdhall.soseated next to it); - WebAssembly (
dnsd.wasm) that runs the actual server, client-side, in a browser tab (a frozen prebuilt demo artifact; the wasm build path was removed and is no longer regenerable from source).
You don't write DNS twice. You write it once and decide how to ship it.
3. Public servers demand conservative engineering.
An authoritative nameserver on the open internet is a reflection/amplification
target. So dnsd is deliberately boring: per-source and global rate limits,
answers capped with proper TC truncation, no recursion, no EDNS0 large-response
amplification, full bounds-checking on the wire path — and it runs unprivileged
under MemoryDenyWriteExecute + a seccomp allowlist, with exactly one
capability (cap_net_bind_service).
Boring is the feature. A DNS server that never needs a CVE is one you can forget about.
- Records:
A,AAAA,CNAME,TXT,MX,NS,SOA,CAA(RFC 8659),PTR(reverse DNS) - Semantics: authoritative answer / NODATA / NXDOMAIN,
ANY, name-compression - Config: Dhall, typechecked against its schema, evaluated at startup
- Build: Zig 0.16 via dhake →
zig-out/dnsd(+ check binaries +libdhall.so) - Hardened for public exposure:
- per-source + global token-bucket rate limiting (DoS)
- bounded answer count (
MAX_ANSWERS) with truncation (TC) bit - bounded name-compression probe depth, cached A/AAAA rdata, capped lookup
- full bounds-checking on the remote wire path
Requires zig (0.16.x). The dhall-c interpreter core is a git submodule
at ./dhall-c. The build is driven by dhake (Dhakefile.dhall), which
replaces the former Makefile and uses verified builds — each target pins
the expected sha256 of its output and every source dependency, so a build
fails loudly if any input or the deterministic output hashes to something
unexpected.
git submodule update --init # fetch the dhall-c core (once after clone)
./vendor/dhake/dhake.com # builds all Zig binaries into zig-out/ (default: all)
./vendor/dhake/dhake.com all # same, explicit
./vendor/dhake/dhake.com test # runs the golden regression harness (zig/dnsd_diff.sh)
./vendor/dhake/dhake.com dist/index.html # build the docs site (fixpointlinux.org/compendium)
./vendor/dhake/dhake.com clean # remove built binariesThe engine sources live in zig/src/ (one module per former C file); see
zig/README.md for the layout, the golden-test harness and the re-pinning
workflow after a toolchain bump.
./zig-out/dnsd --config config.example.dhall --port 5353 --address 127.0.0.1Options:
-c, --config <file>— Dhall config (required)-p, --port <n>— listen port (default 5353)-a, --address <ip>— listen address (default 127.0.0.1)
let Record = < A : { name : Text, ttl : Natural, value : Text }
| AAAA : { name : Text, ttl : Natural, value : Text }
| CNAME : { name : Text, ttl : Natural, value : Text }
| TXT : { name : Text, ttl : Natural, value : Text }
| MX : { name : Text, ttl : Natural, priority : Natural, exchange : Text }
| NS : { name : Text, ttl : Natural, value : Text }
| SOA : { name : Text, ttl : Natural, mname : Text, rname : Text
, serial : Natural, refresh : Natural, retry : Natural
, expire : Natural, minimum : Natural }
| CAA : { name : Text, ttl : Natural, flags : Natural, tag : Text, value : Text }
| PTR : { name : Text, ttl : Natural, value : Text } >
in let Zone = { name : Text, records : List Record }
in let Config = { zones : List Zone }
in { zones = [ { name = "example.com.", records = [ < A = { name = "@", ttl = 3600, value = "192.0.2.1" } > ] } ] } : ConfigOwner names are relative to the zone (@ / "" = apex); rdata target names are
absolute FQDNs with a trailing dot. See config.example.dhall for a full example.
- Records-only authoritative: no recursion, no forwarding, no AXFR.
- EDNS0 is ignored (no large-response amplification); responses capped at 512 bytes.
- Rate limits (per-source burst 100 / 20 q/s; global burst 500 / 100 q/s) are
tunable in
zig/src/dnsd.zig.
MIT