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
- New here: Getting started.
- Coming from Pi-hole: Migrating from Pi-hole.
- Deploying: Container image or Kubernetes.
Pages
| Page | Content |
|---|---|
| Getting started | Run the image or the Helm chart, check it, point clients at it |
| Migrating from Pi-hole | How Pi-hole concepts map to dns-rs, and what is missing |
| Configuration | Every setting, default, environment override and reload behavior |
| Blocklists | Supported formats, rule syntax, blocking modes, download limits |
| Metrics and probes | The HTTP endpoint: /healthz, /readyz, /metrics, /api/v1/check, /api/v1/status and the metric series |
| Logging | The query log and the operational log messages |
| Container image | Published tags, running the image, ports, signals |
| Releasing | Cutting a release: the tag checks and what gets published |
| Kubernetes | The Helm chart: objects, values, configuration changes, pod security |
| Architecture decisions | Why 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
- Configuration: every setting, and what reloads without a restart.
- Blocklists: formats, your own rules, blocking modes.
- Coming from Pi-hole? Read Migrating from Pi-hole.
- The commented
dns-rs.example.tomllists every key with its default.
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-hole | dns-rs | Notes |
|---|---|---|
| Adlists | blocking.lists | Hosts files, plain domain lists, *.domain and Adblock ||domain^ rules from https://, http:// or file://. See Blocklists |
| Gravity update | blocking.refresh | Downloads again every 24h by default, minimum 1m; no command to run |
| Allowlist (exact) | blocking.allow | example.com is that name only, *.example.com includes subdomains. Allow always wins |
| Denylist (exact) | blocking.deny | Same syntax |
| Regex allowlist and denylist | blocking.allow_regex, blocking.deny_regex | Matched against the lowercase name without the trailing dot |
| Blocking mode | blocking.mode | null (default), nxdomain, nodata, refused |
| Local DNS records | [local] records | A and AAAA only; see Configuration |
| Conditional forwarding | [[forward]] | One table per domain; see Configuration |
| Upstream DNS servers | upstream.server | A single plain DNS server, UDP with TCP fallback |
| Cache size | cache.size | Restart needed to change |
| Query log | One log line per query | Written to the process log, target dns_rs::query; see Logging |
| Statistics | Prometheus metrics, /api/v1/status | See 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
regexsyntax. 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 onSIGHUP. 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.serveris plain DNS over UDP and TCP. DNS over HTTPS and DNS over TLS are not supported. - Several upstream servers.
upstream.servertakes 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
| Key | Default | Meaning |
|---|---|---|
server.listen | 0.0.0.0:53 | Address of the UDP and TCP listeners |
http.listen | 0.0.0.0:8080 | Address of the HTTP endpoint for probes and metrics (see Metrics and probes) |
http.api_token_file | none | File 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.server | 1.1.1.1:53 | Resolver queries are forwarded to; the port defaults to 53 |
upstream.timeout | 2s | Upstream timeout; must be greater than zero |
cache.size | 10000 | Maximum number of cached responses |
blocking.mode | null | null, nxdomain, nodata or refused (see Blocklists) |
blocking.refresh | 24h | How often blocklists are downloaded again; at least 1m |
blocking.lists | none | Blocklist URLs, or { url = "...", wildcard = true } tables |
blocking.deny | none | Domains to block: example.com or *.example.com |
blocking.allow | none | Same syntax; always wins over deny, regexes and lists |
blocking.deny_regex | none | Regexes of names to block |
blocking.allow_regex | none | Regexes of names to allow |
local.ttl | 300 | TTL of local answers, in seconds |
local.records | none | { name = "nas.home.arpa", ip = "192.168.1.10" } tables answered by dns-rs itself (see Local records) |
forward | none | [[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
localin 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.arpais forwarded,myhome.arpais not. When domains nest, the longest one wins. - A domain may have a single label (
lan). The root is rejected: useupstream.serverfor 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
forwardedin the query log and the metrics. A failing server is reported asfailed.
Environment variables
| Variable | Overrides |
|---|---|
DNS_RS_CONFIG | Path of the TOML file |
DNS_RS_LISTEN | server.listen |
DNS_RS_HTTP_LISTEN | http.listen |
DNS_RS_API_TOKEN | API 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_FILE | http.api_token_file. The Helm chart uses it because it refuses [http] in the reloaded configuration |
DNS_RS_UPSTREAM | upstream.server |
DNS_RS_UPSTREAM_TIMEOUT_MS | upstream.timeout, in milliseconds |
DNS_RS_CACHE_SIZE | cache.size |
RUST_LOG | Log 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.timeoutof zero.blocking.refreshunder one minute.- A
local.recordsname that is not a domain of at least two labels (the error nameslocal.records), or an address that does not parse. - A
denyorallowentry 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://orhttp://, or anhttp(s)://URL that does not parse (for examplehttps://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.
| Line | Blocks |
|---|---|
0.0.0.0 ads.example.com (hosts) | ads.example.com only |
ads.example.com | ads.example.com only |
*.example.com | example.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.
| Mode | Answer |
|---|---|
null (default) | 0.0.0.0 for A, :: for AAAA, empty NOERROR for other types |
nxdomain | NXDOMAIN |
nodata | Empty NOERROR for every type |
refused | REFUSED |
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.
| Tag | Published from |
|---|---|
edge | Every push to main |
sha-<commit> | Every push to main |
X.Y.Z and X.Y | A 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:
| Variable | Value in the image | Serves |
|---|---|---|
DNS_RS_LISTEN | 0.0.0.0:5353 | DNS, UDP and TCP |
DNS_RS_HTTP_LISTEN | 0.0.0.0:8080 | Probes, 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:
| Object | Name | Content |
|---|---|---|
| Deployment | <release>-dns-rs | replicaCount replicas (default 2); each loads the configuration and the blocklists itself |
| ConfigMap | <release>-dns-rs | dns-rs.toml, from config, mounted at /etc/dns-rs |
| Service | <release>-dns-rs-dns | DNS on service.dns.port (default 53), UDP and TCP, to container port 5353 |
| Service | <release>-dns-rs-http | Probes, metrics and API on service.http.port (default 8080) |
| Secret | <release>-dns-rs-api | The API token, only when api.token is set |
| PodDisruptionBudget | <release>-dns-rs | maxUnavailable pods, only with podDisruptionBudget.enabled and more than one replica |
| ServiceMonitor | <release>-dns-rs | Scrapes /metrics of the http Service, only with serviceMonitor.enabled |
| NetworkPolicy | <release>-dns-rs | Ingress 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
| Value | Default | Meaning |
|---|---|---|
replicaCount | 2 | Number of replicas, at least 1 |
image.repository | ghcr.io/evanndev/dns-rs | Image repository, see Container image |
image.tag | "" | Image tag; empty means the chart’s appVersion |
image.pullPolicy | IfNotPresent | Always, IfNotPresent or Never |
imagePullSecrets | [] | Pull secrets |
upstream.server | 1.1.1.1:53 | Upstream resolver (DNS_RS_UPSTREAM); the port defaults to 53 |
upstream.timeoutMs | 2000 | Upstream timeout in milliseconds (DNS_RS_UPSTREAM_TIMEOUT_MS) |
cache.size | 10000 | Maximum 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.type | ClusterIP | ClusterIP, NodePort or LoadBalancer |
service.dns.port | 53 | Service 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.type | ClusterIP | ClusterIP, NodePort or LoadBalancer |
service.http.port | 8080 | Service 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.existingSecretKey | token | Key of the token in api.existingSecret |
podDisruptionBudget.enabled | true | Renders a PodDisruptionBudget, only with more than one replica |
podDisruptionBudget.maxUnavailable | 1 | Pods that may be unavailable during voluntary disruptions |
topologySpread.enabled | true | Spreads replicas across nodes (kubernetes.io/hostname, maxSkew: 1, ScheduleAnyway) |
serviceMonitor.enabled | false | Renders a Prometheus Operator ServiceMonitor for /metrics |
serviceMonitor.interval | 30s | Scrape interval |
serviceMonitor.labels | {} | Extra labels, for example to match a Prometheus serviceMonitorSelector |
networkPolicy.enabled | false | Renders 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.enabled | true | Renders the helm test pod, see Testing a release |
tests.image | registry.k8s.io/e2e-test-images/jessie-dnsutils:1.7 | Image with dig for the test pod |
tests.query | example.com | Name the test pod asks for |
resources | requests 50m CPU and 128Mi memory, limit 256Mi memory | Container resources |
podAnnotations, podLabels, nodeSelector, tolerations, affinity | empty | Passed 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
maxUnavailablepods up during node drains. With one replica it is not rendered, so it never blocks a drain. - Replicas are spread across nodes when possible;
ScheduleAnywaymeans 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. WithnetworkPolicy.enabled, the chart fails to render unlessnetworkPolicy.http.fromlets 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.fromlists peers; port 8080 is closed unlessnetworkPolicy.http.fromlists 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).
| Path | Response |
|---|---|
/healthz | 200 with ok while the process runs (liveness) |
/readyz | 200 with ready once the DNS sockets are bound and the first blocking policy is installed; 503 with starting before that (readiness) |
/metrics | Metrics 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/status | JSON 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
| Metric | Type | Labels |
|---|---|---|
dns_rs_queries_total | counter | source, rcode, type |
dns_rs_query_duration_seconds | histogram | source |
dns_rs_build_info | info | version |
dns_rs_blocking_rules | gauge | none |
dns_rs_blocklist_rules | gauge | list |
dns_rs_blocklist_last_success_timestamp_seconds | gauge | list |
dns_rs_blocklist_failures_total | counter | list, reason |
dns_rs_config_reloads_total | counter | result |
dns_rs_config_valid | gauge | none |
dns_rs_cache_entries | gauge | none |
The histogram has 16 buckets from 50 µs, doubling up to about 1.6 s.
Label values:
-
source:cache,upstream,blocked,local,forwarded,rejectedorfailed(the same values as in the query log). -
rcode:NOERROR,FORMERR,SERVFAIL,NXDOMAIN,NOTIMP,REFUSED, orother. -
type:A,AAAA,ANY,CAA,CNAME,DNSKEY,DS,HTTPS,MX,NS,PTR,SOA,SRV,SVCB,TXT, orother. A request without a question hastype="none". -
list: the blocklist as configured, as afile://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 examplehttps://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,statusorfailed. -
result(config reloads):appliedorrejected.
The label sets are fixed or come from the configuration, so a client cannot create new series. See ADR 0011.
State metrics
| Metric | Meaning |
|---|---|
dns_rs_blocking_rules | Deny rules in the blocking policy in force |
dns_rs_blocklist_rules | Deny rules from each list, as last loaded. A list that fails to refresh keeps its last valid rules |
dns_rs_blocklist_last_success_timestamp_seconds | Unix time of the last successful load of each list; alert when it gets old |
dns_rs_blocklist_failures_total | Failed list loads, by reason |
dns_rs_config_reloads_total | Configuration changes applied or rejected. Unchanged files are not counted, and the same rejection is counted once |
dns_rs_config_valid | 1 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_entries | Responses 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"}}
| Field | Meaning |
|---|---|
domain | The name as sent in the path |
blocked | true when a deny rule matches and no allow rule does (allow wins) |
mode | The configured blocking.mode: null, nxdomain, nodata or refused |
denied_by | The deny rule that matched, or null |
allowed_by | The allow rule that matched, or null |
A rule is an object with three fields:
kind:exact,subtreeorregex.rule: the rule as written, for exampleads.example.com,*.example.comor 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 thelistmetric 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}}]}
| Field | Meaning |
|---|---|
version | The dns-rs version |
blocking.mode | The configured blocking.mode: null, nxdomain, nodata or refused |
blocking.rules | Deny rules in the blocking policy in force |
config.valid | true while the configuration file, as last read, is valid (same meaning as dns_rs_config_valid) |
cache.entries | Responses held in the cache, read at each request |
lists | The configured blocklists, in configuration order |
Each entry of lists has:
list: the list label, the same as thelistmetric 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, ornullif the list never loaded.last_success: Unix time of the last successful load, ornull.last_failure: the last failure if it happened after the last success, otherwisenull. It hasreason(timeout,too_large,statusorfailed),at(Unix time) and, forstatusfailures,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:
| Field | Value |
|---|---|
client | Client IP address |
name | Queried name, with the trailing dot |
type | Record type, for example A |
rcode | NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED, or RCODE<n> |
source | cache, 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_us | Time to answer, in microseconds |
Turn it off without silencing the rest: RUST_LOG=info,dns_rs::query=off.
Operational messages
| Level | Message | Meaning |
|---|---|---|
| info | dns-rs started | Listening; shows listen, http_listen and upstream |
| info | blocking policy loaded | A policy is in force; shows the rule count and mode |
| info | local records loaded | At startup and when [local] changed; names is the number of local names |
| info | forwarded domains loaded | At startup and when [[forward]] changed; domains is the number of forwarded domains |
| info | blocklist loaded | Per list: deny, allow and skipped counts |
| warn | cannot load blocklist, skipped | A list failed; the cause is in error |
| warn | keeping the previous version of the blocklist | A refresh failed, the old content stays |
| warn | blocklist fetched over plain HTTP, prefer HTTPS | An http:// list |
| info | reload requested | SIGHUP received |
| info | refreshing blocklists | The blocking.refresh schedule fired |
| info | configuration reloaded | A changed configuration was applied |
| warn | configuration changes need a restart to apply | Lists the restart-only settings that changed |
| error | configuration rejected, keeping the previous one | Invalid or unreadable file |
| warn | upstream query failed | The client receives SERVFAIL |
| info | shutting down | Ctrl-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:
| Job | What it does |
|---|---|
verify | Checks 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 |
image | Runs 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) |
chart | Packages 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 |
release | Creates 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
-
In one pull request, set the same version in
crates/dns-rs/Cargo.tomland in theversionandappVersionofcharts/dns-rs/Chart.yaml, and turn## [Unreleased]inCHANGELOG.mdinto## [X.Y.Z]. -
After it is merged, tag the merge commit on
mainand 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.
| # | Decision | Status |
|---|---|---|
| 0001 | Rust, with hickory-proto and our own server loop | Accepted |
| 0002 | Replicas are stateless | Accepted |
| 0003 | Declarative TOML configuration | Accepted |
| 0004 | No DHCP server | Accepted |
| 0005 | Blocking runs before the cache, on an atomically swapped policy | Accepted |
| 0006 | Blocklist matching semantics | Accepted |
| 0007 | Bounded, fail-soft blocklist downloads | Accepted |
| 0008 | Test-driven development, enforced by CI and agents | Accepted |
| 0009 | Agent permissions are enforced outside prompts | Accepted |
| 0010 | Hot reload by polling and SIGHUP, blocking settings only | Accepted, amended by 0016 |
| 0011 | Per-replica metrics with bounded labels | Accepted |
| 0012 | The API requires a bearer token | Accepted, amended by 0015 |
| 0013 | The container listens on an unprivileged port | Accepted |
| 0014 | The Helm chart takes the configuration as TOML text | Accepted |
| 0015 | The API token file is reloaded | Accepted |
| 0016 | Local records | Accepted |
| 0017 | Conditional forwarding | Accepted |
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
unsafecode forbidden. - Use
hickory-protoonly 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-resolveror our own client, behind the sameUpstreamtrait.
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: Localon 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.tomldocuments 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, answers0.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 = trueto 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 tofile://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
reqwestwithrustls(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
tracingoutput live alone in their own test binary, because tracing caches callsite interest globally. mainis 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 inrust-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.
mainis 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
PreToolUsehooks with an allowlist that fails closed:rust-refactor-guard.shallows 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 testexecutes that code, from any directory it cancdto. 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.
SIGHUPtriggers an immediate reload; signals arriving while a reload is pending merge into it. - Only
[blocking]changes apply live.server.listen,upstream.server,upstream.timeoutandcache.sizeare 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(default24h, minimum1m, 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
SIGHUPis 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
/metricson an HTTP endpoint (http.listen, default0.0.0.0:8080) that also serves/healthzand/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 hastype="none". A client cannot create series. /readyzreturns503until 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.listenneeds 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 carryAuthorization: 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 byhttp.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,/readyzand/metricsstay open for probes and scrapes.
Consequences
- Using the API requires setting a token first.
- Rotating the token needs a restart, like every
[http]setting. /metricsremains 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:5353andDNS_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 withport: 53andtargetPort: 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.listenandhttp.listenin 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.configis 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
configholds[server],[http],[upstream]or[cache]: a ConfigMap change would only reach pods created later and replicas would run different values. values.schema.jsondeclaresconfigas a string, so a YAML map fails athelm install.- The chart does not add a
checksum/configannotation: 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 templateand restarts pods at every GitOps sync.
Decision
- dns-rs reads the token file (
http.api_token_file, orDNS_RS_API_TOKEN_FILE) again at each configuration poll (10 s) and onSIGHUP, 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
localin 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, sonasreaches dns-rs asnas.home.arpawhen 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.arpaoverhome.arpa). A domain may have one label (lan); the root may not (that isupstream.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
forwardedin 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).