Small DNS server. Answers configured local names for your LAN, forwards everything else upstream (e.g. Cloudflare) or resolves it recursively itself.
cargo runStartup config resolution order:
RDNS_CONFIGenv var, if set — path to a TOML config file../config.toml, if present in the working directory.- Otherwise, built-in loopback dev defaults (listens on
127.0.0.1:5300, forwards to1.1.1.1:53, no local entries).
A sample config.toml ships at the repo root and loads automatically. Its
[[local_dns_entries]] sample (nas.lan) is enabled = false out of the
box — enable it and set your own address, or add your own entries — so a
plain checkout never answers a fake record. Point at a different file:
RDNS_CONFIG=/etc/rdns/config.toml cargo runTest it:
dig @127.0.0.1 -p 5300 nas.lan A # local entry, once enabled in config.toml
dig @127.0.0.1 -p 5300 example.com A # forwarded upstreamTOML. Top-level:
dns_listen = ["127.0.0.1:5300"] # one or more "host:port" UDP listeners
per_query_deadline_ms = 2000 # per-query timeout budget
max_udp_payload_size = 1232 # EDNS UDP payload size advertised/accepteddns_listen binding to port 53 (or any port <= 1024) is allowed by config
validation, but the OS still requires privilege to bind it — run as root or
grant the built binary the capability:
sudo setcap cap_net_bind_service=+ep target/release/rdnsUnknown fields in the file are rejected at load time (fails closed rather than silently ignoring a typo'd or unsupported setting).
Everything not answered by a local entry is forwarded to [[upstreams]],
tried in ascending priority order:
[[upstreams]]
name = "cloudflare"
endpoint = "1.1.1.1:53"
protocol = "udp"
enabled = true
priority = 10
timeout_ms = 750
[[upstreams]]
name = "quad9"
endpoint = "9.9.9.9:53"
protocol = "udp"
enabled = true
priority = 20
timeout_ms = 750Multiple entries give failover, not fan-out: rdns tries them in priority
order and falls through on failure/timeout. protocol parses "tcp" but
forwarding only considers enabled upstreams with protocol = "udp" —
"tcp"-configured upstreams are skipped entirely (an all-"tcp" upstream
list leaves no backend to forward to). Initial forwarding queries always go
out over UDP; rdns retries the same upstream over TCP if the UDP response
comes back truncated (TC bit set).
Instead of forwarding, rdns can walk the DNS hierarchy itself starting from
the root servers. Add a [resolution] section — if omitted, rdns defaults
to forward mode using [[upstreams]] as above.
Simplest form, using the bundled root hints:
[resolution]
mode = "recursive"
[resolution.recursive]
root_hints = "bundled"
root_hints_version = "bundled:v1"[[upstreams]] is ignored in recursive mode — you don't need any.
Full set of tunables (all but root_hints/root_hints_version are
optional and default as shown):
[resolution]
mode = "recursive"
generation = 1 # bump to force cache namespace invalidation
[resolution.recursive]
root_hints = "bundled" # "bundled" or "custom"
root_hints_version = "bundled:v1" # required; any label, used for cache namespacing
per_authority_timeout_ms = 750 # timeout per upstream authority query
max_recursion_depth = 16 # referral chain depth limit
max_cname_restarts = 8 # CNAME-chase limit
allowed_transports = ["udp", "tcp"] # transports used to query authorities
dnssec_validation = "disabled" # only "disabled" is currently supported
dname_handling = "defer" # only "defer" is currently supportedTo use your own root server list instead of the bundled one:
[resolution.recursive]
root_hints = "custom"
root_hints_version = "custom:v1"
[[resolution.recursive.root_hints_entries]]
name = "a.root-servers.net"
endpoints = ["198.41.0.4:53"]
[[resolution.recursive.root_hints_entries]]
name = "b.root-servers.net"
endpoints = ["199.9.14.201:53"][resolution]/[[upstreams]] reload on SIGHUP too — you can flip between
forward and recursive, or change recursive settings, without a restart.
Only dns_listen is restart-only, see below.
The bundled list (currently all 13 root servers, IPv4 + IPv6) isn't
hand-maintained Rust — it's parsed at runtime from a committed copy of
IANA/InterNIC's root hints zone file at src/config/named.root (embedded
into the binary at compile time via include_str!), via parse_named_root()
in src/config/mod.rs. To refresh it when a root server's address changes:
curl -sS https://www.internic.net/domain/named.root -o src/config/named.rootThen rebuild — parse_named_root re-derives bundled_root_hints() from
the new file automatically, no other code changes needed. It reads the
standard BIND zone-file shape IANA publishes (;-prefixed comments,
<name> <ttl> <type> <rdata> data lines) and keeps only the A/AAAA
glue records, grouped by root server name in first-seen order; NS
records are ignored (redundant with the address records' owner names).
Bump root_hints_version in your config after a refresh if you want the
new file to invalidate the resolver's recursive-mode cache namespace on
next load/reload.
Answers exact names for devices on your network, skipping upstream entirely for those names:
[[local_dns_entries]]
name = "nas.lan"
ipv4 = ["192.168.1.10"]
ttl = 300
enabled = true
public_address_acknowledged = false
[[local_dns_entries]]
name = "printer.lan"
ipv6 = ["fd00::1"]
ttl = 300
enabled = true
public_address_acknowledged = falseRules enforced at load time:
- At least one of
ipv4/ipv6must be set. ttlmust be between 1 and 86400 seconds (24h).- Names must be unique (case/trailing-dot normalized).
- If an address is public/routable (not private-use, loopback, or
link-local), you must set
public_address_acknowledged = trueor the config is rejected — this stops an accidental public IP in a local entry from being silently exposed as the "local" answer. enabled = falsekeeps the entry in the file but disabled — no lookup match, useful for keeping a device's known address around without serving it.- The entry's name cannot use a real, currently-delegated top-level
domain as its suffix (checked against a bundled copy of IANA's TLD
registry — see "Local zones" below for the full explanation).
nas.lanis fine;nas.dev/nas.app/nas.ioare rejected, because those are real, currently-registered gTLDs — a local override should never be able to shadow a real public domain. If you were relying on a real TLD suffix for a local name, rename it to something like.lab,.home, or.internalinstead.
For a larger local-network record set, or to migrate an existing BIND
setup, point rdns at a real zone file instead of (or alongside)
[[local_dns_entries]]:
[[local_zones]]
path = "zones/mynetwork.zone"
root_domain = "mynetwork"
public_address_acknowledged = false
enabled = truepathis resolved relative to the directory containing the config file (RDNS_CONFIG/./config.toml) when relative; absolute paths are used as-is. With no config file loaded, a relative path resolves against the current working directory.- The zone file uses standard BIND zone-file syntax (
$ORIGIN,$TTL,SOA,NS, multi-line parenthesized records, comments, owner-name inheritance, etc.) — parsed with thedomaincrate's zone-file scanner, the same crate other Rust DNS tooling uses. Zone files are expected to declare their own$ORIGIN; rdns never programmatically overrides it. - Only
A,AAAA,SOA, andNSrecords are supported.SOA/NSare recognized and ignored (they're zone-management boilerplate, not answerable local records). Any other record type (CNAME,MX,TXT,SRV, DNSSEC records, etc.) or a$INCLUDEdirective causes the whole config to be rejected rather than silently dropping data — this is a deliberate limitation, not an oversight. - Multiple
A/AAAArecords under the same owner name are grouped into one local entry, same as listing multiple addresses in one[[local_dns_entries]]block'sipv4/ipv6arrays. If the same owner name has records with different TTLs, the first one seen wins for the whole entry (rdns has one TTL per entry, not one per address family). - A zone file is capped at 10 MiB and 10,000
A/AAAArecords; an oversized file is rejected rather than parsed, to bound worst-case parse time/memory from a misconfigured or unexpected file. root_domainis mandatory and enforced: every record's owner name in the zone file must be at or below it, androot_domainitself is checked with the same not-a-registered-TLD rule described above for[[local_dns_entries]]— a zone can never claim authority over a real public domain. Most of IANA's Special-Use Domain Names (RFC 6761:local,test,invalid,example,onion,localhost) remain legal choices simply because they're not delegated TLDs at all —.localspecifically still carries the existing mDNS conflict warning.home.arpa(RFC 8375) is a narrow, explicit exception rather than an instance of that same rule:arpaitself is a real, delegated infrastructure TLD, sohome.arpa/*.home.arpaare allowed only because they're special-cased, not becausearpais absent from the checked list — nothing else under.arpais exempted.public_address_acknowledgedhere is zone-wide (BIND zone files have no per-record acknowledgement syntax) — set it only if you intentionally have a public/routable address somewhere in that zone file; otherwise any public/routableA/AAAArecord in the file is rejected, same fail-closed default as inline entries.enabled = false(defaulttrue) skips the zone entirely, including never reading the file from disk — useful for keeping a zone file configured but temporarily out of service without needing the file to even exist.local_zonesentries merge with[[local_dns_entries]]: both are checked for duplicate names against each other (across every zone file and the inline list together), and the not-a-registered-TLD rule applies to both kinds of entries, not just zone-file ones.- Reloadable via
SIGHUPexactly like[[local_dns_entries]]— see below.
The list of real, currently-delegated top-level domains checked against
(for both [[local_dns_entries]] names and [[local_zones]]
root_domains) is a committed, verbatim copy of IANA's published TLD
registry at src/config/tlds-alpha-by-domain.txt, embedded into the
binary at compile time. Refresh it the same way as the root hints list:
curl -sS https://data.iana.org/TLD/tlds-alpha-by-domain.txt -o src/config/tlds-alpha-by-domain.txtThen rebuild — parse_iana_tlds/bundled_iana_tlds() re-derive the
checked set from the new file automatically, no other code changes
needed.
Send SIGHUP to the running process to reload resolution mode, upstreams,
and local DNS entries (inline and zone-file-sourced) from the same config
file:
kill -HUP <pid>- The file is re-read, re-parsed, and fully re-validated before anything is applied. A broken edit (bad TOML, invalid address, duplicate name, etc.) is logged and rejected in full — the server keeps serving the last-good config, with no fields changed from the rejected reload.
- On a successful reload, the new backend (upstreams/recursive settings) and new local DNS entries are published together as one atomic step: a query in flight during the reload sees either the fully old pair or the fully new pair, never one field from each.
[resolution],[[upstreams]],[[local_dns_entries]], and[[local_zones]]are all reloadable this way — including switching betweenforwardandrecursivemode. A[[local_zones]]file is re-read from disk on every reload, so editing the zone file itself and sendingSIGHUPpicks up the change too, without touchingconfig.toml.dns_listenchanges are not picked up on SIGHUP — changing listen addresses/ports requires a restart.- No effect if rdns started with no config file (built-in dev defaults) — there's nothing to re-read.
The current OpenTelemetry OTLP metrics exporter configuration supports plaintext gRPC endpoints.
Use an http:// endpoint in OTEL_EXPORTER_OTLP_ENDPOINT.
HTTPS OTLP endpoints (https://) are not currently supported in this configuration.