Imported from NothingDNS/NothingDNS (
AGENTS.md). Install upstream withnpx skills add NothingDNS/NothingDNS. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents and contributors working with this repository.
Build, Lint & Test
go build -o nothingdns ./cmd/nothingdns # Server binary
go build -o dnsctl ./cmd/dnsctl # CLI binary
go vet ./... # Lint
go test ./... -count=1 -short # All tests (short mode)
go test ./internal/protocol/ -run TestName # Single test
go test ./internal/e2e/... -v # End-to-end tests
Static analysis (CI Go workflow / security job) — must pass before merge:
staticcheck ./...
errcheck -ignoretests -exclude .errcheck-excludes.txt ./... # see note below
go-errorlint -test=false ./... # production code only
govulncheck ./... # Go stdlib + deps CVEs
.errcheck-excludes.txt is errcheck's exclude list (one fully-qualified
function/method signature per line, no comments — errcheck reads every
line as a symbol). It suppresses conventionally-safe, deliberately-ignored
calls (fmt.Fprint* to writers, io/net Close, os.Remove, deadline/
buffer setters, (net/http.ResponseWriter).Write, …) so errcheck flags only
genuine unhandled errors. Both errcheck and go-errorlint are scoped to
production code (-ignoretests / -test=false); test files are not linted.
When a new deliberate-ignore is genuinely safe and broadly applicable, add its
signature here rather than scattering _ = — otherwise annotate the call site
with _ = / _, _ =.
Go version: 1.26.6+ (root go.mod). CGO_ENABLED=0 for static builds.
Docker: Multi-stage Dockerfile builds both binaries from scratch — golang:1.26.6-alpine compiles with CGO_ENABLED=0, -trimpath, and stripped/static link flags, then copies to FROM scratch.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ UDP Server │ TCP Server │ DoH Server │ DoT Server │ DoQ Server │
├─────────────────────────────────────────────────────────────────────┤
│ Request Handler │
│ Cache → Auth Zones → Upstream/Resolver → DNSSEC Validator │
├─────────────────────────────────────────────────────────────────────┤
│ Cluster Manager (Gossip + Raft) │ Storage (KV + WAL) │
├─────────────────────────────────────────────────────────────────────┤
│ API Layer (HTTP + WebSocket) │ Config (Hot Reload) │
└─────────────────────────────────────────────────────────────────────┘
Request flow: Transport → Protocol parser → Cache → Zone lookup → Upstream/Resolver → DNSSEC → Response
Request Pipeline (integratedHandler.ServeDNS)
integratedHandler.ServeDNS delegates to the stage pipeline built in cmd/nothingdns/pipeline.go (NewPipeline); stage functions live in pipeline_stages.go. Every response passes through headerPolicyResponseWriter (response_header_policy.go), which echoes OPCODE/RD/CD and clears RA when recursion is unavailable or not allowed for the client. Stage order:
- setup / queryDirection / validation — request IDs, drop responses, FORMERR on bad questions, IDNA (RFC 5891)
- metrics
- acl — general ACL for every query (first match wins; unmatched clients are refused once any rule exists)
- recursionPolicy — marks whether the client may recurse (
allow_recursion) - rpzClient — RPZ client-IP policy
- rateLimit — per-client token bucket
- requestPolicy / cookie — EDNS/opcode policy, DNS Cookies (RFC 7873)
- any / transfer — ANY handling, AXFR/IXFR/NOTIFY/UPDATE
- blocklist / rpzQname — filtering before the cache
- doBit / splitHorizon / authoritative / cname — local zones and in-zone CNAME chasing, before the caches so cached upstream data (an NXDOMAIN or aggressive NSEC proof for a parent name) never shadows a local zone
- cache / nsecCache — cache lookups (skipped for clients without recursion)
- authoritativeOnly — REFUSED outside zones when
resolution.authoritative_only - recursionRefused — REFUSED (EDE 18) outside zones for clients without recursion
- resolver / upstream / noUpstream — iterative resolution or forwarding, DNSSEC validation, RPZ response checks, DNS64, caching, stale serving (RFC 8767)
Manager Pattern
cmd/nothingdns/ uses manager constructors to encapsulate subsystem initialization:
cache_manager.go— cache with persistence and prefetchupstream_manager.go— upstream pool with health checkszone_manager.go— zone file loading and radix treesecurity_manager.go— blocklist, RPZ, geo, ACL + recursion allow list (and the persistedaccess_policy.json), rate limiterdnssec_manager.go— validator and key rollovercluster_manager.go— gossip membership + Raft consensustransfer_manager.go— AXFR/IXFR/NOTIFY/DDNS
All are wired into a single integratedHandler (handler.go, handler_deps.go).
Hot Config Reload
SIGHUP triggers config reload without downtime: zones, blocklists, RPZ rules, split-horizon views, and TLS certs are reloaded in-place. Validate config beforehand with -validate-config flag.
Key packages
internal/protocol/— DNS wire protocol (RFC 1035), no external dependenciesinternal/server/— UDP, TCP, TLS, DoH transportsinternal/cache/— Thread-safe LRU with TTL, negative caching, stale serving, NSEC aggressive cachinginternal/cluster/— Gossip-based membership (SWIM-like) with AES-256-GCM encryption; Raft consensus incluster/raft/with optional TLS RPCinternal/config/— Custom YAML parser (tokenizer → parser → node tree, no gopkg.in/yaml)internal/resolver/— Iterative recursive resolver with CNAME chasinginternal/dnssec/— Validation, signing, key rollover (RFC 7583), Ed25519/ECDSA/RSAinternal/storage/— KV store with WAL, ACID transactions, TLV serializationinternal/zone/— BIND-format zone file parser with$GENERATE, radix tree, WAL journal, ZONEMDinternal/transfer/— AXFR/IXFR zone transfers, NOTIFY, Dynamic DNS (RFC 2136), XoT (RFC 9103)internal/dashboard/— Embedded React 19 SPA served fromstatic/dist/internal/dso/— DNS Stateful Operations (RFC 8490): TCP/TLS keepalive sessions, max-payload negotiation, TLV stream parser. Body lives inprotocol.Message.RawBody(opcode 6).internal/odoh/— Oblivious DNS over HTTPS (RFC 9230) with RFC 9180 base-mode HPKE (DHKEM-X25519 / HKDF-SHA256 / AES-GCM), stdlib-only. HPKE math validated against RFC 9180 §A.1 vectors inhpke_vectors_test.go. Legacy non-RFC-9230 helpers inodoh.goare retained for test compatibility only — do not extend them; build onhpke.go/rfc9230.go.
Project Structure
cmd/
├── nothingdns/ # Main DNS server (main.go wiring, pipeline, managers, transports)
└── dnsctl/ # CLI management tool (zone, record, cache, cluster, blocklist, config, dig, dnssec, server)
internal/
├── api/ # HTTP REST API + OpenAPI/Swagger
├── audit/ # Structured query audit logging
├── auth/ # JWT-based multi-user authentication with RBAC
├── blocklist/ # Domain blocklist engine (hosts-file + URL-based)
├── cache/ # LRU cache with TTL, prefetch, negative caching, stale serving
├── catalog/ # Zone catalog for managing zone metadata (RFC 9432)
├── cluster/ # Gossip-based HA clustering with raft consensus
├── config/ # Custom YAML parser (handles most YAML, not anchors/multiline)
├── dashboard/ # Embedded React 19 SPA (served from internal/dashboard/static/)
├── dns64/ # DNS64/NAT64 synthesis (RFC 6147)
├── dnscookie/ # DNS Cookies (RFC 7873)
├── dnssec/ # DNSSEC validation/signing, Ed25519/ECDSA/RSA
├── doh/ # DNS over HTTPS (RFC 8484)
├── dso/ # DNS Stateful Operations (RFC 8490)
├── e2e/ # End-to-end tests
├── filter/ # Split-horizon views, rate limiting, ACL
├── geodns/ # GeoIP DNS with MMDB support
├── idna/ # Internationalized domain name validation
├── load/ # Load balancing and anycast
├── memory/ # Runtime memory monitoring and OOM protection
├── metrics/ # Prometheus metrics export
├── odoh/ # Oblivious DNS over HTTPS (RFC 9230)
├── otel/ # OpenTelemetry tracing
├── protocol/ # DNS wire protocol parser (RFC 1035)
├── quic/ # DNS over QUIC transport
├── resolver/ # Iterative recursive resolver with CNAME chasing
├── rpz/ # Response Policy Zones for DNS filtering
├── server/ # UDP/TCP/TLS transport handlers
├── storage/ # KV store with WAL and TLV serialization
├── transfer/ # AXFR/IXFR zone transfers, NOTIFY, DDNS, XoT
├── upstream/ # Upstream forwarding with health checks and load balancing
├── websocket/ # WebSocket server for live query streaming
└── zone/ # BIND format zone file parser with $GENERATE support
Dependency Policy
Minimal external dependencies — direct deps are github.com/quic-go/quic-go (DoQ transport), golang.org/x/sys, and the official OpenTelemetry SDK (go.opentelemetry.io/otel, otel/sdk, otel/trace, and the otlptracehttp exporter; added 2026-08-19 when the hand-rolled tracer/OTLP/Jaeger exporters were replaced by the SDK — see internal/otel/). golang.org/x/{net,crypto,text} and go.uber.org/mock are indirect (see go.mod). Everything else is hand-rolled on stdlib (including the YAML parser — no gopkg.in/yaml). Adding any new third-party import requires explicit discussion and justification.
Note: there is no PostgreSQL/
pgxbackend. Zone/KV storage is the embedded WAL-backed KV store ininternal/storage/. (Earlier revisions of this file documented apgx/v5postgres_zonestore.gobackend that was never committed; that text has been removed to match the code.)
Known Gotchas
- Config struct tags are documentation only —
yaml:"..."tags do not drive parsing. Every new key must also be read in the matchingunmarshal*function ininternal/config(and listed inknownTopLevelKeysfor top-level keys), otherwise it is silently ignored. Add a parse test (UnmarshalYAML) for each new key. - Dashboard-managed state overrides the config file: users created at runtime live in
server.http.users_file(default<storage.data_dir>/users.json); ACL andallow_recursionchanges made via API/dashboard live in<storage.data_dir>/access_policy.json, which replaces the config'sacl/allow_recursionat start and on reload. The no-restart tunables changed viaPUT /api/v1/config/*(logging level, RRL, cache, resolution, DNS64/cookie toggles, upstream server list) live in<storage.data_dir>/runtime_overrides.json(internal/config/runtime_overrides.go), whichloadConfigre-applies over the YAML section by section at start and on every reload — an invalid section is warned about and skipped, so a new override key must also be handled inApplyRuntimeOverrides/MergeRuntimeOverridePatchor it is silently dropped. - ODoH suite ids are RFC 9180 values (KEM 0x0020, KDF 0x0001, AEAD 0x0001/0x0002) in both config and
internal/odoh. - Shipped configs are validated in CI by
scripts/validate-shipped-configs.sh(example, deploy, Docker, k8s and installer-generated configs) — run it after touching any of them. - Port 53 requires root on Unix; use 5354+ for testing
- YAML parser is custom — does not support anchors/aliases or multiline strings. Block-sequence indent handling in
parseBlockSequenceuses column-based peek: the inline-mapping continuation loops break onTokenDedentwhen the post-dedent token doesn't share the item's first-key column, and the sequence main loop absorbs dedents only when a Dash at the sequence's own column waits behind them. Regression test:TestParser_BlockSeqOfInlineMaps_ThenSiblingKeyinparser_test.go; smoke-test vianothingdns -config config.example.yaml -validate-config. protocol.CanonicalWireName()is the shared canonical name encoder — do not create new onesadvance()andpeek()skipTokenCommentautomatically — never handle comments in parse logic- Health check goroutines use per-round
sync.WaitGroup— do not reuse the main WG sync.Poolbuffers: copy before passing todefer pool.Put()— the reference may be reclaimed- Upstream TCP messages must check
len(packed) > 65535before sending - UDP truncation must be record-boundary-aware (remove answers from end, not byte-level cut)
- Default config path:
/etc/nothingdns/nothingdns.yaml; override with--configflag