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

dns-rs

dns-rs is a network-wide DNS sinkhole written in Rust, a modern alternative to Pi-hole built to run in a container and as stateless replicas on Kubernetes. It forwards queries to an upstream resolver with a cache, blocks the names on your blocklists, answers local records, and forwards chosen domains to their own server. It exports a query log, Prometheus metrics and Kubernetes probes.

All configuration is a TOML file that dns-rs reloads without a restart. The project is young: see the README for its current status.

Where to start

Pages

PageContent
Getting startedRun the image or the Helm chart, check it, point clients at it
Migrating from Pi-holeHow Pi-hole concepts map to dns-rs, and what is missing
ConfigurationEvery setting, default, environment override and reload behavior
BlocklistsSupported formats, rule syntax, blocking modes, download limits
Metrics and probesThe HTTP endpoint: /healthz, /readyz, /metrics, /api/v1/check, /api/v1/status and the metric series
LoggingThe query log and the operational log messages
Container imagePublished tags, running the image, ports, signals
ReleasingCutting a release: the tag checks and what gets published
KubernetesThe Helm chart: objects, values, configuration changes, pod security
Architecture decisionsWhy dns-rs works the way it does

Source code and issues: github.com/EvannDev/dns-rs.

Getting started

This page takes you from nothing to a dns-rs that answers queries and blocks a domain, first with the container image, then with Helm.

Run the container

Write a minimal configuration, dns-rs.toml. Every key is optional; this one blocks one name and loads one public blocklist:

[blocking]
lists = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"]
deny = ["ads.example.com"]

Start the image, publishing the DNS port on a private address of the host (replace 192.168.1.10 with yours):

docker run -d --name dns-rs \
  -p 192.168.1.10:53:5353/udp -p 192.168.1.10:53:5353/tcp -p 127.0.0.1:8080:8080 \
  -v "$PWD/dns-rs.toml:/etc/dns-rs/dns-rs.toml:ro" -e DNS_RS_CONFIG=/etc/dns-rs/dns-rs.toml \
  ghcr.io/evanndev/dns-rs:edge

edge follows the main branch. Released versions are tagged X.Y.Z and X.Y; see Container image. Inside the container dns-rs listens on 5353, which is why the host port 53 maps to it.

Do not publish the DNS port to the Internet: an open resolver is abused for denial-of-service amplification.

Check that it answers

dig @192.168.1.10 example.com
dig @192.168.1.10 ads.example.com

The first query is forwarded to the upstream (1.1.1.1:53 by default). The second is blocked and gets 0.0.0.0, the default blocking mode. Watch the log with docker logs dns-rs: each query is one line with its source (blocked, cache, upstream…); see Logging. The probe curl http://127.0.0.1:8080/readyz answers ready once the sockets are bound and the first blocking policy is installed; see Metrics and probes.

Point your clients at it

dns-rs has no DHCP server (ADR 0004). Use one of these:

  • In your router’s DHCP settings, set the DNS server announced to clients to the address of the host running dns-rs. This covers the whole network.
  • Or set the DNS server on a single device to try it first.

Then query from a client and confirm the entries appear in the log.

Run it on Kubernetes

The Helm chart is published as an OCI artifact on a v* tag (see Releasing). Replace the version with the one you want:

helm install dns-rs oci://ghcr.io/evanndev/charts/dns-rs --version 0.1.0 \
  --namespace dns --create-namespace

The configuration goes in the config value as TOML text, for example in a values.yaml:

config: |
  [blocking]
  lists = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"]

Apply it with -f values.yaml. The chart runs two replicas behind a Service on port 53, with probes, a PodDisruptionBudget and helm test. See Kubernetes for the values, the client-IP setup and the API token.

Next steps

Migrating from Pi-hole

dns-rs covers the DNS side of Pi-hole: blocking, local records and conditional forwarding. There is no import tool: you translate your settings into dns-rs.toml by hand. This page maps the concepts and lists what is missing.

Concept map

Pi-holedns-rsNotes
Adlistsblocking.listsHosts files, plain domain lists, *.domain and Adblock ||domain^ rules from https://, http:// or file://. See Blocklists
Gravity updateblocking.refreshDownloads again every 24h by default, minimum 1m; no command to run
Allowlist (exact)blocking.allowexample.com is that name only, *.example.com includes subdomains. Allow always wins
Denylist (exact)blocking.denySame syntax
Regex allowlist and denylistblocking.allow_regex, blocking.deny_regexMatched against the lowercase name without the trailing dot
Blocking modeblocking.modenull (default), nxdomain, nodata, refused
Local DNS records[local] recordsA and AAAA only; see Configuration
Conditional forwarding[[forward]]One table per domain; see Configuration
Upstream DNS serversupstream.serverA single plain DNS server, UDP with TCP fallback
Cache sizecache.sizeRestart needed to change
Query logOne log line per queryWritten to the process log, target dns_rs::query; see Logging
StatisticsPrometheus metrics, /api/v1/statusSee Metrics and probes
Testing a domain/api/v1/check/{domain}Needs the API token

Differences to know about

  • Bare domains block that name only, as in Pi-hole. Lists written for wildcard blocking (OISD domainswild2, HaGeZi “wildcard domains”) need { url = "...", wildcard = true }. See ADR 0006.
  • Regexes use the Rust regex syntax. Look through your Pi-hole regexes and test them: a pattern that does not compile is a configuration error that names the key. dns-rs does not check compatibility with the regex flavor Pi-hole uses.
  • Local names are never blocked. Local records are answered before blocking, and subdomains of a local name are not local.
  • Forwarded domains do not fall back to the upstream. If the server of a [[forward]] fails, the client gets SERVFAIL, so private names do not leak (ADR 0017).
  • Configuration is a file, not a database. Edit dns-rs.toml: [blocking], [local] and [[forward]] apply within 10 seconds or on SIGHUP. There is no admin UI that writes it (ADR 0003).
  • Replicas are stateless. There is no query database, so history and long-term statistics come from your log and metrics pipeline (ADR 0002).

A translation example

[blocking]
mode = "null"
lists = [
  "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
]
allow = ["analytics.example.com"]
deny = ["*.tracker.example"]
deny_regex = ['^ad[0-9]+\.']

[local]
records = [{ name = "nas.home.arpa", ip = "192.168.1.10" }]

[[forward]]
domain = "home.arpa"
server = "192.168.1.1"

Then follow Getting started to run it, and check queries with dig before switching your DHCP server over.

What dns-rs does not do

Checked against the code and the decision records:

  • DHCP server. Not implemented, by decision (ADR 0004). Announce dns-rs as DNS server from your router or a separate DHCP server.
  • Web admin interface. There is none. The HTTP port serves probes, metrics and a small read-only API (/api/v1/check, /api/v1/status).
  • Groups and per-client rules. All clients get the same policy.
  • Encrypted upstreams. upstream.server is plain DNS over UDP and TCP. DNS over HTTPS and DNS over TLS are not supported.
  • Several upstream servers. upstream.server takes one address.
  • DNSSEC. Not validated by dns-rs.
  • Reverse lookups and CNAMEs for local records. Only A and AAAA records are supported in [local].
  • Query history in the application. Queries go to the log; there is no stored history or per-domain statistics beyond the metrics.

Several of these are on the roadmap in CLAUDE.md, but planned work is not available.

Configuration

dns-rs reads one TOML file, named by the DNS_RS_CONFIG environment variable. Without it, the defaults below apply. Every section and key is optional, and an unknown key is an error. The commented reference is dns-rs.example.toml.

Durations use the humantime format (2s, 500ms, 24h).

Settings

KeyDefaultMeaning
server.listen0.0.0.0:53Address of the UDP and TCP listeners
http.listen0.0.0.0:8080Address of the HTTP endpoint for probes and metrics (see Metrics and probes)
http.api_token_filenoneFile holding the API token, for example a mounted Secret; DNS_RS_API_TOKEN wins over it. Without a token the API is disabled (see Authentication)
upstream.server1.1.1.1:53Resolver queries are forwarded to; the port defaults to 53
upstream.timeout2sUpstream timeout; must be greater than zero
cache.size10000Maximum number of cached responses
blocking.modenullnull, nxdomain, nodata or refused (see Blocklists)
blocking.refresh24hHow often blocklists are downloaded again; at least 1m
blocking.listsnoneBlocklist URLs, or { url = "...", wildcard = true } tables
blocking.denynoneDomains to block: example.com or *.example.com
blocking.allownoneSame syntax; always wins over deny, regexes and lists
blocking.deny_regexnoneRegexes of names to block
blocking.allow_regexnoneRegexes of names to allow
local.ttl300TTL of local answers, in seconds
local.recordsnone{ name = "nas.home.arpa", ip = "192.168.1.10" } tables answered by dns-rs itself (see Local records)
forwardnone[[forward]] tables with domain and server (see Conditional forwarding)

Local records

local.records lists names dns-rs answers itself, as Pi-hole’s local DNS does (ADR 0016):

[local]
ttl = 300
records = [
  { name = "nas.home.arpa", ip = "192.168.1.10" },
  { name = "nas.home.arpa", ip = "fd00::10" },
]
  • The address family gives the type: A for IPv4, AAAA for IPv6. A name may have several records; ANY returns all of them.
  • Names match exactly and without regard to case, and the answer keeps the name as the client wrote it. Subdomains of a local name are not local.
  • Local records are answered before blocking, so a list never blocks them. The answer is authoritative (AA) and uses neither the upstream nor the cache.
  • For a local name, other types (MX, TXT…) and a family with no record get an empty NOERROR, never NXDOMAIN.
  • These queries have the source local in the query log and the metrics.
  • Not supported yet: PTR (reverse lookups) and CNAME.

Conditional forwarding

A [[forward]] table sends a domain, and every name under it, to its own server instead of upstream.server, as Pi-hole’s conditional forwarding does (ADR 0017):

[[forward]]
domain = "home.arpa"
server = "192.168.1.1"   # port 53 unless given
  • Matching is label by label and case-insensitive, and a trailing dot is ignored: nas.home.arpa is forwarded, myhome.arpa is not. When domains nest, the longest one wins.
  • A domain may have a single label (lan). The root is rejected: use upstream.server for that.
  • Order: local records, then blocking, then the cache, then the forward server or the upstream. Blocking still applies to forwarded names.
  • The server is queried like the upstream (UDP with TCP fallback, same upstream.timeout). Answers are cached.
  • If the server fails, the client gets SERVFAIL. The query is not retried on the upstream, so private names do not leak.
  • A domain that is not a valid name, the root, or a domain given twice (ignoring case and trailing dot) is a configuration error.
  • Changing the rules empties the cache, so an NXDOMAIN cached before a rule existed does not hide it.
  • These queries have the source forwarded in the query log and the metrics. A failing server is reported as failed.

Environment variables

VariableOverrides
DNS_RS_CONFIGPath of the TOML file
DNS_RS_LISTENserver.listen
DNS_RS_HTTP_LISTENhttp.listen
DNS_RS_API_TOKENAPI token, at least 16 characters; wins over http.api_token_file. Not a TOML key: keep the token out of the file
DNS_RS_API_TOKEN_FILEhttp.api_token_file. The Helm chart uses it because it refuses [http] in the reloaded configuration
DNS_RS_UPSTREAMupstream.server
DNS_RS_UPSTREAM_TIMEOUT_MSupstream.timeout, in milliseconds
DNS_RS_CACHE_SIZEcache.size
RUST_LOGLog filter, default info (see Logging)

Overrides are applied after the file is read, at startup and on every reload.

Validation

dns-rs refuses to start on an invalid configuration. The error is one line naming the problem, with its position for TOML syntax errors, for example invalid config file: line 4, column 1: unknown field ....

Beyond syntax, these are rejected:

  • upstream.timeout of zero.
  • blocking.refresh under one minute.
  • A local.records name that is not a domain of at least two labels (the error names local.records), or an address that does not parse.
  • A deny or allow entry that is not a domain or *.domain.
  • A regex that does not compile, or a regex list whose patterns are too large together (each list is compiled as one set). The error names the key.
  • A list source that is not file:///absolute/path, https:// or http://, or an http(s):// URL that does not parse (for example https://exa mple.org).

Reload without restart

The file is read every 10 seconds, compared by content, and immediately on SIGHUP. Changes to [blocking], [local] and [[forward]] apply at once. Changes to server.listen, http.listen, http.api_token_file, upstream.server, upstream.timeout and cache.size need a restart: dns-rs logs configuration changes need a restart to apply once, listing the settings, and keeps the running values. An invalid or missing file is rejected (logged once as configuration rejected, keeping the previous one) and the previous configuration keeps working.

The API token file is read again at the same moments, so a rotated token applies without a restart (see Authentication).

Blocklists are downloaded again every blocking.refresh. On a configuration reload only lists that are new are downloaded; lists that were removed stop blocking. An invalid [local] rejects the whole file, so the previous records stay in force. See ADR 0010.

Blocklists

Sources

blocking.lists takes entries of two forms:

[blocking]
lists = [
  "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
  { url = "https://big.oisd.nl/domainswild2", wildcard = true },
  "file:///etc/dns-rs/lists/mine.txt",
]

Accepted schemes are https://, http:// and file:// with an absolute path. Plain http:// works but logs a warning: a tampered list could add exceptions and unblock domains. A redirect from HTTPS to HTTP is refused.

Lists are loaded at startup and again every blocking.refresh, four at a time. Each download has a 30 second timeout and a 64 MiB size limit. A list that fails is skipped and logged, and DNS keeps working. A list that loaded before and fails on refresh keeps blocking with its last valid version. See ADR 0007.

Formats

Each line may be a hosts entry, a plain domain, a *.domain wildcard or an Adblock Plus rule. Comments and lines dns-rs does not understand are ignored; the number of ignored lines is logged per list.

LineBlocks
0.0.0.0 ads.example.com (hosts)ads.example.com only
ads.example.comads.example.com only
*.example.comexample.com and its subdomains
||example.com^example.com and its subdomains
@@||example.com^Allows example.com and its subdomains, overriding blocks

Hosts boilerplate such as localhost is not treated as an entry.

Bare domains block that name only, as in Pi-hole. Lists meant to cover subdomains too (OISD domainswild2, HaGeZi “wildcard domains”) need wildcard = true in the table form, which makes bare domains match their subdomains as well. See ADR 0006.

Your own rules

blocking.deny and blocking.allow use the same example.com and *.example.com syntax. blocking.deny_regex and blocking.allow_regex are matched against the lowercase name without the trailing dot. Allow rules always win over deny rules and lists.

Blocking modes

blocking.mode sets what a blocked name receives. Synthesized answers have a 2 second TTL, so unblocking takes effect quickly.

ModeAnswer
null (default)0.0.0.0 for A, :: for AAAA, empty NOERROR for other types
nxdomainNXDOMAIN
nodataEmpty NOERROR for every type
refusedREFUSED

Container image

The image is built from the Dockerfile at the repository root. It builds with the toolchain pinned in rust-toolchain.toml and ships the binary on gcr.io/distroless/cc-debian13:nonroot: no shell, no package manager, CA certificates for HTTPS blocklists, and a non-root user (USER 65532:65532, numeric so that Kubernetes can verify runAsNonRoot). The base images are pinned by digest and Dependabot keeps the digests up to date.

Published images

The Image workflow publishes ghcr.io/evanndev/dns-rs for linux/amd64 and linux/arm64 as one multi-platform manifest.

TagPublished from
edgeEvery push to main
sha-<commit>Every push to main
X.Y.Z and X.YA v* tag, through the Release workflow (see Releasing); a pre-release (vX.Y.Z-rc.1) gets only X.Y.Z-rc.1

The workflow can also be started manually. Pull requests build the image and smoke-test it in CI but do not publish it.

Run

docker run -d -p 53:5353/udp -p 53:5353/tcp -p 8080:8080 \
  -v "$PWD/dns-rs.toml:/etc/dns-rs/dns-rs.toml:ro" -e DNS_RS_CONFIG=/etc/dns-rs/dns-rs.toml \
  ghcr.io/evanndev/dns-rs:edge

Do not publish the DNS port to the Internet. dns-rs answers any client, so an exposed port makes it an open resolver: attackers use those to amplify denial-of-service attacks. Keep it on your LAN (publish on a private address, -p 192.168.1.10:53:5353/udp), or restrict it with a firewall or a Kubernetes NetworkPolicy.

Without DNS_RS_CONFIG the defaults apply (see Configuration).

Ports

The image sets two environment variables so that it runs without capabilities or sysctls:

VariableValue in the imageServes
DNS_RS_LISTEN0.0.0.0:5353DNS, UDP and TCP
DNS_RS_HTTP_LISTEN0.0.0.0:8080Probes, metrics and API

Publish the DNS port as 53 on the host (-p 53:5353/udp -p 53:5353/tcp). In Kubernetes, use a Service with port: 53 and targetPort: 5353, or the Helm chart.

Environment variables override the configuration file, so server.listen and http.listen in a mounted file have no effect unless you also override these variables (for example -e DNS_RS_LISTEN=0.0.0.0:53).

Signals

SIGTERM (sent by docker stop) and Ctrl-C stop dns-rs. SIGHUP reloads the configuration, as described in Configuration.

Kubernetes

The Helm chart in charts/dns-rs runs dns-rs as stateless replicas. A v* tag publishes it as an OCI artifact (see Releasing):

helm install dns-rs oci://ghcr.io/evanndev/charts/dns-rs --version 0.1.0 \
  --namespace dns --create-namespace

Replace 0.1.0 with the version you want. To install the development version, use a checkout of the repository instead:

helm install dns-rs charts/dns-rs --namespace dns --create-namespace

The chart creates:

ObjectNameContent
Deployment<release>-dns-rsreplicaCount replicas (default 2); each loads the configuration and the blocklists itself
ConfigMap<release>-dns-rsdns-rs.toml, from config, mounted at /etc/dns-rs
Service<release>-dns-rs-dnsDNS on service.dns.port (default 53), UDP and TCP, to container port 5353
Service<release>-dns-rs-httpProbes, metrics and API on service.http.port (default 8080)
Secret<release>-dns-rs-apiThe API token, only when api.token is set
PodDisruptionBudget<release>-dns-rsmaxUnavailable pods, only with podDisruptionBudget.enabled and more than one replica
ServiceMonitor<release>-dns-rsScrapes /metrics of the http Service, only with serviceMonitor.enabled
NetworkPolicy<release>-dns-rsIngress rules, only with networkPolicy.enabled

If the release name already contains dns-rs, the prefix is not repeated (release dns-rs gives dns-rs, dns-rs-dns and dns-rs-http). Run helm template to see the exact names.

Values

ValueDefaultMeaning
replicaCount2Number of replicas, at least 1
image.repositoryghcr.io/evanndev/dns-rsImage repository, see Container image
image.tag""Image tag; empty means the chart’s appVersion
image.pullPolicyIfNotPresentAlways, IfNotPresent or Never
imagePullSecrets[]Pull secrets
upstream.server1.1.1.1:53Upstream resolver (DNS_RS_UPSTREAM); the port defaults to 53
upstream.timeoutMs2000Upstream timeout in milliseconds (DNS_RS_UPSTREAM_TIMEOUT_MS)
cache.size10000Maximum number of cached responses (DNS_RS_CACHE_SIZE)
config[blocking] with mode = "null"The reloaded part of the configuration file, as TOML text
service.dns.typeClusterIPClusterIP, NodePort or LoadBalancer
service.dns.port53Service port for DNS (UDP and TCP)
service.dns.externalTrafficPolicy""Cluster or Local; empty leaves the Kubernetes default; the schema rejects it on a ClusterIP Service
service.dns.annotations{}Service annotations
service.http.typeClusterIPClusterIP, NodePort or LoadBalancer
service.http.port8080Service port for HTTP
service.http.annotations{}Service annotations
api.token""API token, at least 16 characters (the schema rejects shorter ones); the chart stores it in a Secret. Prefer api.existingSecret: a token in values ends up in Helm’s release history
api.existingSecret""Name of a Secret you manage; set it or api.token, not both (the chart fails to render otherwise)
api.existingSecretKeytokenKey of the token in api.existingSecret
podDisruptionBudget.enabledtrueRenders a PodDisruptionBudget, only with more than one replica
podDisruptionBudget.maxUnavailable1Pods that may be unavailable during voluntary disruptions
topologySpread.enabledtrueSpreads replicas across nodes (kubernetes.io/hostname, maxSkew: 1, ScheduleAnyway)
serviceMonitor.enabledfalseRenders a Prometheus Operator ServiceMonitor for /metrics
serviceMonitor.interval30sScrape interval
serviceMonitor.labels{}Extra labels, for example to match a Prometheus serviceMonitorSelector
networkPolicy.enabledfalseRenders a NetworkPolicy (ingress only)
networkPolicy.dns.from[]NetworkPolicyPeers allowed to query DNS (port 5353); empty means everyone
networkPolicy.http.from[]NetworkPolicyPeers allowed on port 8080 (metrics, API); empty means nobody
tests.enabledtrueRenders the helm test pod, see Testing a release
tests.imageregistry.k8s.io/e2e-test-images/jessie-dnsutils:1.7Image with dig for the test pod
tests.queryexample.comName the test pod asks for
resourcesrequests 50m CPU and 128Mi memory, limit 256Mi memoryContainer resources
podAnnotations, podLabels, nodeSelector, tolerations, affinityemptyPassed to the pod template

values.schema.json rejects unknown keys and invalid values at helm install and helm lint.

Configuration

config holds the [blocking], [local] and [[forward]] parts of dns-rs.toml (see Configuration and dns-rs.example.toml):

config: |
  [blocking]
  mode = "nxdomain"
  lists = ["https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"]
  deny = ["ads.example.com"]
  [local]
  records = [{ name = "nas.home.arpa", ip = "192.168.1.10" }]
  [[forward]]
  domain = "home.arpa"
  server = "192.168.1.1"

It is TOML text, not a YAML map: Helm’s toToml writes integers as floats (cache.size = 20000.0), which dns-rs rejects. See ADR 0014.

Startup settings do not go in config. The chart fails to render if config holds a [server], [http], [upstream] or [cache] section (or a dotted key or assignment such as cache.size = 1 or cache = { size = 1 }). Set upstream.server, upstream.timeoutMs and cache.size in the values instead: the chart passes them to the pods as environment variables (DNS_RS_UPSTREAM, DNS_RS_UPSTREAM_TIMEOUT_MS, DNS_RS_CACHE_SIZE). The chart also sets DNS_RS_LISTEN=0.0.0.0:5353 and DNS_RS_HTTP_LISTEN=0.0.0.0:8080 to match the container ports; see Container image.

Changing the configuration

A change to config does not restart pods. dns-rs reads the mounted file every 10 seconds and applies [blocking], [local] and [[forward]] changes on the running pods; see Reload without restart. The kubelet needs some time to refresh a mounted ConfigMap, so a change can take longer than 10 seconds to reach a pod.

A change to upstream.* or cache.size edits the pod template, so Kubernetes rolls every replica and they never run different values.

Clients and the DNS Service

Use service.dns.type: LoadBalancer with externalTrafficPolicy: Local to keep client IP addresses, which the query log shows (ADR 0002). Do not expose the DNS Service to the Internet: an open resolver is abused for amplification attacks. See the warning in Container image.

API token

Without api.token or api.existingSecret, /api/* answers 403 (see Authentication). /healthz, /readyz and /metrics work without it.

The chart mounts the Secret as a file at /run/secrets/dns-rs/api-token and sets DNS_RS_API_TOKEN_FILE to that path. The token is not in the pod spec. dns-rs reads the file again every 10 seconds, so a rotated Secret reaches every replica after the kubelet refreshes the mounted file (about a minute), without restarting pods; this applies to chart-managed and existing Secrets alike. See ADR 0015. Removing the token does not disable the API until pods restart.

Availability and network

  • With more than one replica, the PodDisruptionBudget keeps all but maxUnavailable pods up during node drains. With one replica it is not rendered, so it never blocks a drain.
  • Replicas are spread across nodes when possible; ScheduleAnyway means the scheduler still places them together if it has to.
  • The ServiceMonitor selects the http Service through the label app.kubernetes.io/component: http, which both Services now carry. It needs the Prometheus Operator CRDs. With networkPolicy.enabled, the chart fails to render unless networkPolicy.http.from lets Prometheus reach port 8080.
  • The NetworkPolicy restricts ingress only; egress stays open because dns-rs queries its upstream and downloads blocklists. DNS is open to everyone unless networkPolicy.dns.from lists peers; port 8080 is closed unless networkPolicy.http.from lists peers. Kubelet probes come from the node and are usually allowed by the CNI regardless.

Pod security

The pods run as the numeric user and group 65532 with runAsNonRoot, a read-only root filesystem, no privilege escalation, all capabilities dropped and the RuntimeDefault seccomp profile. The user is numeric because the kubelet cannot check runAsNonRoot against a user name such as nonroot. The liveness probe uses /healthz and the readiness probe /readyz, both on the http port. dns-rs starts its HTTP server before it loads the blocklists (up to 30 seconds per list), so liveness answers while lists load and readiness stays 503 until the first policy is installed.

Testing a release

helm test <release> --logs runs a pod that queries the DNS Service with dig, first over UDP, then over TCP. It passes when dns-rs answers, whatever the answer: dig exits 0 on any response (including NXDOMAIN and SERVFAIL) and 9 when there is none, so the result does not depend on the upstream. Set tests.enabled=false to not render the pod.

With networkPolicy.dns.from set, allow pods labelled app.kubernetes.io/component=test, or the test cannot reach the Service.

Checks in CI

The chart job runs helm lint --strict (warnings fail) on the default values and on each file in charts/dns-rs/ci/, the helm-unittest suites in charts/dns-rs/tests and kubeconform -strict against Kubernetes 1.34 on the rendered manifests, with the default values and with every feature on (charts/dns-rs/ci/all-features-values.yaml).

The e2e job installs the chart in a kind cluster with the image built by CI (.github/scripts/e2e-kind.sh). The upstream is the cluster’s own DNS, so no query leaves the cluster. It checks helm test, resolution through the Service, blocking of a denied name, and hot reload: after a helm upgrade that changes config, every replica, queried on its pod IP, applies the new rule with the same pods and no restart. The script refuses any context that is not kind-*. The job is not a required check yet.

Metrics and probes

dns-rs serves a small HTTP endpoint on http.listen (default 0.0.0.0:8080, override DNS_RS_HTTP_LISTEN). Changing the address needs a restart. If the address cannot be bound, dns-rs exits at startup.

/healthz, /readyz and /metrics have no authentication. Metrics reveal traffic volumes and the version, so on a shared network bind the endpoint to loopback (127.0.0.1:8080) or, on Kubernetes, allow only the kubelet and Prometheus to reach it with a NetworkPolicy. The API under /api/ reveals your blocking rules and requires a token (see Authentication).

PathResponse
/healthz200 with ok while the process runs (liveness)
/readyz200 with ready once the DNS sockets are bound and the first blocking policy is installed; 503 with starting before that (readiness)
/metricsMetrics in the OpenMetrics text format (application/openmetrics-text; version=1.0.0; charset=utf-8)
/api/v1/check/{domain}JSON explanation of the blocking decision for a domain (see Check API)
/api/v1/statusJSON state of the replica: policy, configuration, cache and blocklists (see Status API)

Authentication

Requests under /api/ must carry Authorization: Bearer <token>. The scheme is case-insensitive. A missing or wrong token gets 401 with WWW-Authenticate: Bearer and {"error":"missing or invalid API token"}.

The token comes from the DNS_RS_API_TOKEN environment variable or from the file named by http.api_token_file (surrounding whitespace is trimmed, so a mounted Secret works); the variable wins. It must have at least 16 characters, otherwise dns-rs exits at startup without printing the token. It is not a TOML key, because the configuration file often ends up in a ConfigMap. Changing http.api_token_file needs a restart.

dns-rs reads the token file again at each configuration poll (10 seconds) and on SIGHUP, so a rotated token applies without a restart and logs API token rotated (never the token). If the file is missing or the new token is invalid (for example shorter than 16 characters), dns-rs keeps the previous token and logs API token rejected, keeping the previous one once. Removing the token does not disable the API until a restart. A token set with DNS_RS_API_TOKEN stays fixed for the life of the process. See ADR 0015.

Without a token the API is disabled: every request under /api/ gets 403, and dns-rs logs API disabled: set DNS_RS_API_TOKEN or http.api_token_file to enable it at startup. See ADR 0012.

curl -H "Authorization: Bearer $DNS_RS_API_TOKEN" http://localhost:8080/api/v1/check/example.com

Metrics

MetricTypeLabels
dns_rs_queries_totalcountersource, rcode, type
dns_rs_query_duration_secondshistogramsource
dns_rs_build_infoinfoversion
dns_rs_blocking_rulesgaugenone
dns_rs_blocklist_rulesgaugelist
dns_rs_blocklist_last_success_timestamp_secondsgaugelist
dns_rs_blocklist_failures_totalcounterlist, reason
dns_rs_config_reloads_totalcounterresult
dns_rs_config_validgaugenone
dns_rs_cache_entriesgaugenone

The histogram has 16 buckets from 50 µs, doubling up to about 1.6 s.

Label values:

  • source: cache, upstream, blocked, local, forwarded, rejected or failed (the same values as in the query log).

  • rcode: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED, or other.

  • type: A, AAAA, ANY, CAA, CNAME, DNSKEY, DS, HTTPS, MX, NS, PTR, SOA, SRV, SVCB, TXT, or other. A request without a question has type="none".

  • list: the blocklist as configured, as a file:// path or a URL. For URLs, credentials, query string and fragment are removed, because the endpoint has no authentication and private list URLs may carry a token. When something was removed, the label ends with # and an 8-digit hexadecimal fingerprint of the full URL (for example https://lists.example/hosts#1a2b3c4d), so lists that differ only by their query string keep separate series. The fingerprint is the low 32 bits of FNV-1a 64 of the full URL, so it is the same on every replica and build, and it does not reveal the removed part. Series of a list removed from the configuration are dropped on reload.

  • reason (blocklist failures): timeout, too_large, status or failed.

  • result (config reloads): applied or rejected.

The label sets are fixed or come from the configuration, so a client cannot create new series. See ADR 0011.

State metrics

MetricMeaning
dns_rs_blocking_rulesDeny rules in the blocking policy in force
dns_rs_blocklist_rulesDeny rules from each list, as last loaded. A list that fails to refresh keeps its last valid rules
dns_rs_blocklist_last_success_timestamp_secondsUnix time of the last successful load of each list; alert when it gets old
dns_rs_blocklist_failures_totalFailed list loads, by reason
dns_rs_config_reloads_totalConfiguration changes applied or rejected. Unchanged files are not counted, and the same rejection is counted once
dns_rs_config_valid1 while the configuration file, as last read, is valid; 0 after an invalid or missing file. It returns to 1 as soon as a valid file is read, even one identical to the applied configuration
dns_rs_cache_entriesResponses held in the cache, read at each scrape

Check API

GET /api/v1/check/{domain} (token required) answers “why is this domain blocked, or not?” using the blocking policy in force, including after a reload. Nothing is queried upstream.

GET /api/v1/check/ad.doubleclick.net
{"domain":"ad.doubleclick.net","blocked":false,"mode":"null",
 "denied_by":{"kind":"exact","rule":"ad.doubleclick.net","origin":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts#f2b086a8"},
 "allowed_by":{"kind":"exact","rule":"ad.doubleclick.net","origin":"config:allow"}}
FieldMeaning
domainThe name as sent in the path
blockedtrue when a deny rule matches and no allow rule does (allow wins)
modeThe configured blocking.mode: null, nxdomain, nodata or refused
denied_byThe deny rule that matched, or null
allowed_byThe allow rule that matched, or null

A rule is an object with three fields:

  • kind: exact, subtree or regex.
  • rule: the rule as written, for example ads.example.com, *.example.com or a regular expression.
  • origin: where the rule comes from: config:deny, config:allow, config:deny_regex, config:allow_regex, or the label of the blocklist (the same label as the list metric label, without credentials, query string or fragment).

When several rules match, the most specific one is reported: an exact rule, then the closest subtree rule, then the first regex. Configuration rules are loaded before lists, so a rule found both in the configuration and in a list is attributed to the configuration; a rule present in several lists keeps its first origin.

The name is lowercased and a trailing dot is ignored. A name that a client could not query (characters other than letters, digits, - and _, an empty label, a label over 63 bytes, or a name over 253 bytes) gets 400 with {"error":"..."}.

Status API

GET /api/v1/status (token required) reports the state of this replica: the blocking policy in force, whether the configuration file is valid, the cache size and the configured blocklists. It reads state the reloader already keeps; nothing is fetched.

GET /api/v1/status
{"version":"0.1.0","blocking":{"mode":"null","rules":3},"config":{"valid":true},"cache":{"entries":1},
 "lists":[{"list":"file:///etc/dns-rs/mine.txt","wildcard":false,"rules":2,"last_success":1792195792,"last_failure":null},
          {"list":"https://example.invalid/private.txt#3a1c6aac","wildcard":false,"rules":null,"last_success":null,
           "last_failure":{"reason":"failed","at":1792195792}}]}
FieldMeaning
versionThe dns-rs version
blocking.modeThe configured blocking.mode: null, nxdomain, nodata or refused
blocking.rulesDeny rules in the blocking policy in force
config.validtrue while the configuration file, as last read, is valid (same meaning as dns_rs_config_valid)
cache.entriesResponses held in the cache, read at each request
listsThe configured blocklists, in configuration order

Each entry of lists has:

  • list: the list label, the same as the list metric label (credentials, query string and fragment removed, with a fingerprint when something was removed).
  • wildcard: whether the bare domains of the list also block their subdomains.
  • rules: deny rules from the last successful load, or null if the list never loaded.
  • last_success: Unix time of the last successful load, or null.
  • last_failure: the last failure if it happened after the last success, otherwise null. It has reason (timeout, too_large, status or failed), at (Unix time) and, for status failures, http_status.

A failure reports its reason only, never the error message, because HTTP errors embed the full list URL, which may hold a token. A list keeps its history across reloads while it stays in the configuration; a list removed from the configuration disappears from the report.

Prometheus and Kubernetes

Each replica exposes its own counters; scrape every replica and let Prometheus aggregate them (for example with sum by (source)). dns-rs keeps no statistics such as top domains.

For Kubernetes, point the liveness probe at /healthz and the readiness probe at /readyz, both on the http.listen port.

Logging

dns-rs logs through tracing to standard output. RUST_LOG sets the filter and defaults to info.

Query log

Each answered query produces one INFO line under the target dns_rs::query, with these fields:

FieldValue
clientClient IP address
nameQueried name, with the trailing dot
typeRecord type, for example A
rcodeNOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED, or RCODE<n>
sourcecache, upstream, blocked, local (a local record), forwarded (answered by a forward server), rejected (FORMERR or NOTIMP) or failed (upstream or forward server could not answer, SERVFAIL)
duration_usTime to answer, in microseconds

Turn it off without silencing the rest: RUST_LOG=info,dns_rs::query=off.

Operational messages

LevelMessageMeaning
infodns-rs startedListening; shows listen, http_listen and upstream
infoblocking policy loadedA policy is in force; shows the rule count and mode
infolocal records loadedAt startup and when [local] changed; names is the number of local names
infoforwarded domains loadedAt startup and when [[forward]] changed; domains is the number of forwarded domains
infoblocklist loadedPer list: deny, allow and skipped counts
warncannot load blocklist, skippedA list failed; the cause is in error
warnkeeping the previous version of the blocklistA refresh failed, the old content stays
warnblocklist fetched over plain HTTP, prefer HTTPSAn http:// list
inforeload requestedSIGHUP received
inforefreshing blocklistsThe blocking.refresh schedule fired
infoconfiguration reloadedA changed configuration was applied
warnconfiguration changes need a restart to applyLists the restart-only settings that changed
errorconfiguration rejected, keeping the previous oneInvalid or unreadable file
warnupstream query failedThe client receives SERVFAIL
infoshutting downCtrl-C or SIGTERM received

Malformed queries and connection errors are logged at debug.

Releasing

A tag named v* pushed to the repository starts the Release workflow (.github/workflows/release.yml). Jobs run in this order, and a failure stops the following ones:

JobWhat it does
verifyChecks that the tagged commit is on main, that the tag is vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-pre, that the tag, the crate version (crates/dns-rs/Cargo.toml) and the chart version and appVersion (charts/dns-rs/Chart.yaml) name the same version, and that CHANGELOG.md has a ## [X.Y.Z] section
imageRuns the Image workflow: publishes ghcr.io/evanndev/dns-rs with the tags X.Y.Z and X.Y; a pre-release gets only X.Y.Z-pre, so X.Y keeps pointing to the last stable release (see Container image)
chartPackages the chart, pushes it to oci://ghcr.io/evanndev/charts, then renders what a user would pull. It waits for image, so a published chart never points to a missing image
releaseCreates the GitHub release with the changelog section, the install commands and the chart .tgz. A version with a -suffix is marked as a pre-release

Cutting a release

  1. In one pull request, set the same version in crates/dns-rs/Cargo.toml and in the version and appVersion of charts/dns-rs/Chart.yaml, and turn ## [Unreleased] in CHANGELOG.md into ## [X.Y.Z].

  2. After it is merged, tag the merge commit on main and push the tag:

    git tag vX.Y.Z
    git push origin vX.Y.Z
    

If a release fails partway

Jobs that succeeded stay published. Fix the cause and use Re-run failed jobs on the same run: pushing the image and the chart again under the same version replaces them with the same content. Never move or reuse a tag that was published, even partly: users may already have pulled it. If the fix needs a code change, release it as the next patch version.

Version checks

CI runs .github/scripts/check-release.sh on every pull request, so the three versions cannot drift apart before the tag. The first time a package is published, GHCR may create it as private; make it public once in the package settings.

Architecture decision records

Decisions that shape dns-rs and would be hard to reverse, with the reasons behind them. Accepted records are never rewritten; a new record supersedes an old one.

#DecisionStatus
0001Rust, with hickory-proto and our own server loopAccepted
0002Replicas are statelessAccepted
0003Declarative TOML configurationAccepted
0004No DHCP serverAccepted
0005Blocking runs before the cache, on an atomically swapped policyAccepted
0006Blocklist matching semanticsAccepted
0007Bounded, fail-soft blocklist downloadsAccepted
0008Test-driven development, enforced by CI and agentsAccepted
0009Agent permissions are enforced outside promptsAccepted
0010Hot reload by polling and SIGHUP, blocking settings onlyAccepted, amended by 0016
0011Per-replica metrics with bounded labelsAccepted
0012The API requires a bearer tokenAccepted, amended by 0015
0013The container listens on an unprivileged portAccepted
0014The Helm chart takes the configuration as TOML textAccepted
0015The API token file is reloadedAccepted
0016Local recordsAccepted
0017Conditional forwardingAccepted

0001. Rust, with hickory-proto and our own server loop

  • Status: Accepted
  • Date: 2026-10-15

Context

dns-rs replaces Pi-hole, whose core is a fork of dnsmasq written in C, surrounded by Bash scripts. That stack is hard to evolve, to test and to secure. The replacement must be memory-safe, fast enough to answer from cache in microseconds, and easy to ship as a small static binary.

Decision

  • Write dns-rs in Rust on the tokio runtime, with unsafe code forbidden.
  • Use hickory-proto only to parse and serialize DNS messages, and write our own UDP/TCP server loop, request handler and upstream client, so the pipeline (validation, blocking, cache, forwarding, logging) stays fully under our control and testable.

Consequences

  • Protocol details are our responsibility and are covered by tests that cite the RFCs: truncation (RFC 1035), EDNS (RFC 6891), TCP framing and pipelining (RFC 7766), negative caching (RFC 2308).
  • The upstream client checks message IDs and questions itself, to resist spoofed responses.
  • Encrypted upstreams (DoH, DoT) can come later from hickory-resolver or our own client, behind the same Upstream trait.

0002. Replicas are stateless

  • Status: Accepted
  • Date: 2026-10-15

Context

Pi-hole keeps everything in each instance: configuration, compiled lists, query history. Running several instances means running several unrelated Pi-holes. dns-rs targets containers and Kubernetes, where a DNS service should run as several identical replicas behind one service.

Decision

A replica keeps no state another replica would need:

  • configuration and rules come from a shared, declarative source (0003);
  • the DNS cache is local to each replica and only an optimization;
  • query logs and, later, metrics are exported (stdout, Prometheus) rather than stored in the replica.

The planned evolution is a split between a data plane (stateless resolvers) and a control plane (API, list downloads, aggregated statistics), both from the same binary.

Consequences

  • Every feature is reviewed against this constraint.
  • Features that need per-node state, such as DHCP, are out of scope (0004).
  • Per-client rules depend on the client IP: behind a load balancer it must be preserved (for example externalTrafficPolicy: Local on Kubernetes).
  • Rate limiting, when added, will be approximate per replica.

0003. Declarative TOML configuration

  • Status: Accepted
  • Date: 2026-10-16

Context

Pi-hole is configured through its web UI and stores the result in a local database, which fits one instance but not several replicas, nor GitOps. dns-rs needs one configuration that every replica reads, which can live in version control and later in a Kubernetes ConfigMap or custom resources.

Decision

  • The configuration is one TOML file, named by DNS_RS_CONFIG. Without it, defaults apply.
  • A few DNS_RS_* environment variables override single values, which is convenient in containers.
  • Validation is strict: unknown keys, invalid regexes, malformed domain patterns and unsupported list sources stop startup with a message naming the field. A typo must never silently fall back to a default.
  • dns-rs.example.toml documents every option, and a test checks that it stays valid.

TOML was preferred to YAML for the quality of its Rust tooling.

Consequences

  • There is no write API for the configuration yet; changes go through the file. A write API may come later on top of the same model.
  • Hot reload must keep the last valid configuration when the file becomes invalid (planned).

0004. No DHCP server

  • Status: Accepted
  • Date: 2026-10-15

Context

Pi-hole ships a DHCP server, inherited from dnsmasq, mostly for routers that cannot announce another DNS server.

Decision

dns-rs does not implement DHCP.

Consequences

  • DHCP leases are per-node state and need broadcast access to the local network, which conflicts with stateless replicas (0002) and with running in Kubernetes.
  • Users whose router cannot announce dns-rs as DNS server need a separate DHCP server.

0005. Blocking runs before the cache, on an atomically swapped policy

  • Status: Accepted
  • Date: 2026-10-16

Context

The blocking rules change at runtime: list refreshes, configuration reloads. A change must apply at once, without restarting and without slowing down queries.

Decision

  • The handler checks the blocking policy before the cache, so a new rule applies to the very next query, including names cached moments ago.
  • The policy (compiled filter and blocking mode) is held in an ArcSwap: readers never lock, and a new policy replaces the old one atomically.
  • Blocked answers are synthesized locally with a 2-second TTL, so that unblocking takes effect quickly on clients. The default mode, null, answers 0.0.0.0 / :: to A / AAAA queries and an empty NOERROR to other types, so HTTPS/SVCB records cannot leak address hints. Other modes: nxdomain, nodata, refused.

Consequences

  • Each query pays one filter lookup before the cache: a hash lookup per label of the name, plus the regex set.
  • Building a new policy happens off the query path and may take time on large lists; only the swap is instantaneous.

0006. Blocklist matching semantics

  • Status: Accepted
  • Date: 2026-10-16

Context

Public blocklists come in several formats with different meanings: hosts files list exact names, Adblock-style lists use ||domain^ for a domain and its subdomains, and “wildcard” domain lists such as OISD domainswild2 mean “this domain and its subdomains” without saying so in each line.

Decision

  • A bare domain or hosts entry matches that exact name, as in Pi-hole.
  • *.example.com (in lists and in the configuration) and ||example.com^ match the domain and all its subdomains.
  • A list entry in the configuration can be a table with wildcard = true to make its bare and hosts domains cover their subdomains.
  • Allow rules always win over deny rules. Lists may ship @@||domain^ exceptions, which become allow rules.
  • Adblock rules with options ($third-party…) are skipped: applying them without their options would block more than the author meant. Cosmetic filters are skipped too.
  • Names with a single label (com, *.com, ||net^) are rejected, so a typo can never block a whole top-level domain.
  • Matching is case-insensitive and ignores the trailing dot; regexes are matched against the lowercase name without trailing dot.

Consequences

  • Operators must mark wildcard lists explicitly; otherwise such lists block less than intended. The example configuration shows how.
  • Skipped lines are counted and logged per list, to help diagnose formats.

0007. Bounded, fail-soft blocklist downloads

  • Status: Accepted
  • Date: 2026-10-16

Context

Blocklists are downloaded from third-party servers. DNS resolution must not depend on their availability, and their content is untrusted: a list can be huge, slow, or tampered with in transit.

Decision

  • Lists are loaded concurrently, at most 4 at a time, each within 30 seconds and 64 MiB. The size limit applies to the bytes received, not only to Content-Length, and to file:// lists too.
  • Any failure (HTTP status, timeout, network error, size) skips that list only and is logged with its cause; DNS starts regardless.
  • Redirects are followed at most 5 times and never from HTTPS down to plain HTTP. Plain http:// lists log a warning: a tampered list could inject @@ exceptions and unblock domains.
  • HTTP uses reqwest with rustls (no OpenSSL). A TLS stack that cannot start is a fatal startup error, since it means a broken environment.

Consequences

  • Peak memory while loading stays bounded by the concurrency and size limits.
  • On a list failure, the last valid version of that list should be kept across refreshes (planned with periodic refresh).

0008. Test-driven development, enforced by CI and agents

  • Status: Accepted
  • Date: 2026-10-15

Context

dns-rs is developed with AI agents writing, reviewing and documenting code. Without a strict discipline, behaviour drifts and tests stop proving anything.

Decision

  • Every behaviour change starts with a failing test. A test written after the code must be proven to bite by breaking the code on purpose.
  • Tests assert the reason of a failure, not just that it fails; time is injected; tests use no network except loopback.
  • Tests that capture tracing output live alone in their own test binary, because tracing caches callsite interest globally.
  • main is protected: changes go through pull requests that must pass formatting, clippy (pedantic, warnings as errors) and tests on Linux and macOS. The Rust toolchain is pinned in rust-toolchain.toml.
  • A Claude agent reviews every pull request against CLAUDE.md, and a documentation agent (.claude/agents/docs.md) keeps the documentation true to the code.

Consequences

  • The rules live in CLAUDE.md, read by both humans and agents.
  • Agent reviews are advisory; the required checks are the CI jobs.

0009. Agent permissions are enforced outside prompts

  • Status: Accepted
  • Date: 2026-10-17

Context

Agents work on this repository with real permissions: the review and docs agents run in GitHub Actions with a token that can push, and the rust-refactor agent runs locally with a shell. A prompt rule such as “never push” is an instruction, not a control: a model can misread it, and untrusted input (a PR body, a downloaded file) can try to override it. The first version of the refactor agent’s guard used a deny list, which review showed could be bypassed in 19 ways (git -C . commit, cargo +nightly add, subshells…).

Decision

Every limit that matters is enforced by something the agent cannot talk its way around; prompts only explain the limits.

  • main is protected by a ruleset with no bypass actor: no agent or person can push to it, only merge a pull request whose required checks pass.
  • Agents that act in CI run only for people with write access; forks, drafts and Dependabot are skipped, and the docs agent ignores its own pushes.
  • Local agents restricted beyond tool names use PreToolUse hooks with an allowlist that fails closed: rust-refactor-guard.sh allows cargo build, test and lint commands, read-only git and text tools, and blocks everything else, including when the hook itself fails. Its cases are tested in CI.
  • Third-party agent prompts are read in full and adapted before adoption, with their tools narrowed, and credited in THIRD_PARTY_NOTICES.md.

Consequences

  • A hook is a guardrail, not a sandbox: an agent allowed to edit code and run cargo test executes that code, from any directory it can cd to. Containment against a hostile agent would need an isolated environment.
  • The guard controls which programs run, not which files are written: shell redirections (>), like the agent’s own Write and Edit tools, can write anywhere in the working tree, including the guard itself. Changes to .claude/ still go through a reviewed pull request.
  • Allowlists block some harmless commands (a ; inside quotes splits the command); agents then ask, or use their file tools instead.

0010. Hot reload by polling and SIGHUP, blocking settings only

  • Status: Accepted; amended by 0016 ([local] also applies live)
  • Date: 2026-10-16

Context

Rules must change without restarting dns-rs, including in containers where the configuration is a mounted Kubernetes ConfigMap. Sockets, the upstream client and the cache are created at startup. See also 0007, which planned keeping the last valid version of a list across refreshes.

Decision

  • The configuration file is read every 10 seconds and compared by content, so a ConfigMap swap counts as an edit. SIGHUP triggers an immediate reload; signals arriving while a reload is pending merge into it.
  • Only [blocking] changes apply live. server.listen, upstream.server, upstream.timeout and cache.size are reported once as needing a restart and are not applied.
  • An invalid or missing file is rejected, logged once, and the previous configuration stays in force.
  • All lists are downloaded again every blocking.refresh (default 24h, minimum 1m, so providers do not see every replica download every few seconds). A list that fails keeps its last valid version. A configuration reload downloads only lists that are new; removed lists stop blocking.
  • Lists are parsed and the policy is compiled on a blocking thread, and lists are shared through Arc, so building a policy copies no rules and does not delay queries.

Consequences

  • A change takes up to 10 seconds to apply unless SIGHUP is sent.
  • Changing the listen address, upstream or cache size still requires a restart.
  • Each replica downloads lists independently; a shared source is not implemented.

0011. Per-replica metrics with bounded labels

  • Status: Accepted
  • Date: 2026-10-16

Context

Replicas are stateless (0002), so no replica can hold the statistics of the others. Operators need query counts and latency, and Kubernetes needs liveness and readiness probes. Record types and response codes come from the network, so labels built from them could grow without bound.

Decision

  • No in-process statistics such as top domains. Each replica exposes counters and Prometheus aggregates them.
  • Metrics are served in the OpenMetrics text format at /metrics on an HTTP endpoint (http.listen, default 0.0.0.0:8080) that also serves /healthz and /readyz.
  • Label values come from fixed lists. Record types outside a list of common ones and unusual response codes share the value other; a request without a question has type="none". A client cannot create series.
  • /readyz returns 503 until the DNS sockets are bound and the first policy is installed, so Kubernetes routes no traffic too early.

Consequences

  • Per-domain or per-client views are not available from metrics; the query log carries them.
  • Record types outside the list cannot be told apart in metrics.
  • Changing http.listen needs a restart.
  • The endpoint listens on all interfaces by default and has no authentication.

0012. The API requires a bearer token

  • Status: Accepted; token rotation amended by 0015
  • Date: 2026-10-16

Context

The HTTP endpoint serves Kubernetes probes, Prometheus metrics and, since /api/v1/check, an API that reveals the blocking policy: rules, regexes, list labels and file paths. It listens on 0.0.0.0:8080 by default, which the probes need (the kubelet connects to the pod IP), so the API was open to anyone who could reach the port. /api/v1/status will reveal more.

Three options were weighed: keep the API open and document how to restrict the port; make the API opt-in; protect it with a token.

Decision

  • Requests under /api/ must carry Authorization: Bearer <token>. The scheme is case-insensitive; the token is compared in constant time for a given length.
  • The token comes from DNS_RS_API_TOKEN, or from the file named by http.api_token_file (a mounted Kubernetes Secret); the variable wins. It never goes in the TOML file, which often ends up in a widely readable ConfigMap. It is at least 16 characters, read at startup, and never logged or printed.
  • Without a token, the API is disabled (403): secure by default.
  • /healthz, /readyz and /metrics stay open for probes and scrapes.

Consequences

  • Using the API requires setting a token first.
  • Rotating the token needs a restart, like every [http] setting.
  • /metrics remains readable without a token: it reveals traffic volumes and list labels, not the rules themselves.
  • The whole /api/ prefix is behind the token, unknown paths included, so an endpoint added later cannot be left open by mistake.
  • Known limits, accepted for now:
    • the comparison takes the same time for tokens of the same length, but reveals whether a guess has the right length;
    • the endpoint speaks plain HTTP: on an untrusted network the token travels in clear, so put TLS in front (ingress, service mesh) or keep the port private (NetworkPolicy, loopback);
    • failed attempts are not rate limited, which a long random token makes harmless (for example openssl rand -hex 32).

0013. The container listens on an unprivileged port

  • Status: Accepted
  • Date: 2026-10-17

Context

dns-rs defaults to port 53, which a non-root process cannot bind without CAP_NET_BIND_SERVICE or a sysctl. The image runs as the nonroot user on a distroless base (no shell, no package manager).

Decision

  • The image sets DNS_RS_LISTEN=0.0.0.0:5353 and DNS_RS_HTTP_LISTEN=0.0.0.0:8080, so it runs without capabilities or sysctls.
  • The port is published as 53: docker run -p 53:5353/udp -p 53:5353/tcp, or a Kubernetes Service with port: 53 and targetPort: 5353.
  • The binary’s own default stays 0.0.0.0:53; only the image changes it.

Consequences

  • Operators get port 53 from the runtime, not from extra privileges in the container.
  • Environment variables override the configuration file, so server.listen and http.listen in a file mounted into the image are ignored unless the variables are overridden too.

0014. The Helm chart takes the configuration as TOML text

  • Status: Accepted
  • Date: 2026-10-17

Context

The chart needs to put dns-rs.toml in a ConfigMap. The natural Helm way is a YAML map in values.yaml rendered with toToml. Helm parses numbers in values as floats, so toToml writes cache.size = 20000.0, which dns-rs rejects (checked with the binary, PR #22).

Decision

  • values.config is a string holding the reloaded part of the TOML file ([blocking]), copied into the ConfigMap as is.
  • Startup settings (upstream server and timeout, cache size) are structured values passed to the pods as environment variables, so a change edits the pod template and rolls every replica. The chart fails if config holds [server], [http], [upstream] or [cache]: a ConfigMap change would only reach pods created later and replicas would run different values.
  • values.schema.json declares config as a string, so a YAML map fails at helm install.
  • The chart does not add a checksum/config annotation: dns-rs reloads [blocking] changes by itself (ADR 0010), so a configuration change does not restart pods.

Consequences

  • Operators write the same TOML as in Configuration; the example file can be pasted.
  • Helm cannot override single keys with --set; override the whole text.
  • Restart-only settings cannot be changed through the ConfigMap; they live in values and a change rolls every replica.

0015. The API token file is reloaded

  • Status: Accepted (amends 0012: token rotation no longer needs a restart)
  • Date: 2026-10-17

Context

ADR 0012 read the API token once at startup, so rotating it needed a restart. With several replicas, the Helm chart then had two bad options:

  • let a rotated Secret reach only pods created later, so replicas would accept different tokens;
  • roll the pods through an annotation holding a hash of the token. An unsalted SHA-256 in the pod spec is readable by anyone allowed to get pods and open to offline guessing of weak tokens; a random salt changes at every helm template and restarts pods at every GitOps sync.

Decision

  • dns-rs reads the token file (http.api_token_file, or DNS_RS_API_TOKEN_FILE) again at each configuration poll (10 s) and on SIGHUP, and swaps it into the token the API checks per request.
  • An invalid or missing file keeps the previous token, logged once and never echoing the token.
  • DNS_RS_API_TOKEN, set in the environment, stays fixed for the process lifetime.
  • The Helm chart mounts the token Secret (chart-managed or existing) as a file; the kubelet updates it when the Secret changes. Nothing derived from the token appears in the pod spec.

Consequences

  • A rotation reaches every replica within the kubelet’s Secret sync delay (about a minute) plus one poll, without restarting pods, for Secrets the chart manages and Secrets managed elsewhere alike.
  • Disabling the API (removing the token) still needs a restart: a missing file keeps the previous token, so a Secret being recreated does not open or close the API by accident.

0016. Local records

  • Status: Accepted (amends 0010: [local] also applies live)
  • Date: 2026-10-17

Context

A home network has machines with fixed addresses (a NAS, a printer) that no public resolver knows. Pi-hole answers such names from a “local DNS” list. dns-rs replicas share no state (ADR 0002), so these records must come from the same configuration as the blocking rules.

Decision

  • A [local] section lists records as name and address pairs:

    [local]
    ttl = 300 # seconds, the default
    records = [
      { name = "nas.home.arpa", ip = "192.168.1.10" },
      { name = "nas.home.arpa", ip = "fd00::10" },
    ]
    

    The address family gives the type: A for IPv4, AAAA for IPv6. Several records may share a name. Names are matched exactly and without regard to case; subdomains of a local name are not local. A name must have at least two labels, as blocklist domains do; home.arpa (RFC 8375) is the suffix meant for this.

  • Local records are answered before blocking: a name the operator gave an address on purpose is not blocked by a list. The answer is authoritative (AA set), comes from dns-rs alone and is not cached.

  • For a local name, A and AAAA queries get the addresses of that family, ANY gets all of them, and any other type gets an empty NOERROR (the name exists). A family with no record also gets an empty NOERROR, never NXDOMAIN, so clients do not conclude the name is gone.

  • Queries answered locally are reported with the source local in the query log and metrics.

  • [local] applies live, like [blocking] (ADR 0010): a reload replaces the records atomically; an invalid name or address rejects the whole configuration and the previous records stay in force.

Consequences

  • Reverse lookups (PTR for the addresses) and aliases (CNAME) are not answered yet; they can be built on the same records later.
  • Single-label names (nas) are refused; clients usually append their search domain, so nas reaches dns-rs as nas.home.arpa when the DHCP server announces that domain.

0017. Conditional forwarding

  • Status: Accepted
  • Date: 2026-10-17

Context

Home and office networks have names that only a local server knows: the router’s DHCP leases under home.arpa or lan, an Active Directory domain, the reverse zone of the LAN (1.168.192.in-addr.arpa). Public resolvers answer NXDOMAIN for them. Pi-hole calls the fix “conditional forwarding”: queries for a domain go to a given server instead of the upstream.

Local records (ADR 0016) cover a few fixed names; they cannot follow DHCP leases, which only the router knows.

Decision

A [[forward]] table sends a domain to its own server:

[[forward]]
domain = "home.arpa"
server = "192.168.1.1"   # port 53 unless given
  • Matching. A rule covers its domain and every name under it, label by label and without case. When rules nest, the longest domain wins (lab.home.arpa over home.arpa). A domain may have one label (lan); the root may not (that is upstream.server).
  • Order. Local records, then blocking, then the cache, then the forward server or the upstream. Forwarding chooses where a query goes; it does not exempt a name from blocking.
  • Transport. The same client as the upstream: UDP with TCP fallback, the same anti-spoofing checks and upstream.timeout.
  • Cache. Forwarded answers are cached like others. When the rules change, the cache is emptied: an NXDOMAIN cached from the public upstream before a rule was added would otherwise hide the new rule for as long as its negative TTL.
  • Hot reload. [[forward]] applies live, like [blocking] and [local] (ADR 0010); an invalid table rejects the whole configuration and the previous rules stay in force.
  • Validation. A domain given twice (case-insensitive) is an error, so which server wins is never a matter of order.
  • Observability. Answers from a forward server are reported with the source forwarded in the query log and in metrics.

Consequences

  • One server per domain, no failover between several. A list of servers can be added later without changing the matching rules.
  • Emptying the cache on a rule change costs a burst of upstream queries on that replica; rules change rarely, and only on reload. A query in flight during the change may still cache an answer from the previous server, for its TTL at most.
  • A forward server that fails gives SERVFAIL; the query is not retried on the upstream, which must not learn private names.
  • Replicas stay stateless (ADR 0002): rules come from the shared configuration, and each replica’s cache is its own.
  • The forward server must be reachable from every replica. The chart’s NetworkPolicy filters ingress only, so it needs no change.
  • Chart users set [[forward]] in the TOML configuration, as they do [blocking] and [local] (ADR 0014).