{
  "markdown": "# rdns\n\nSmall DNS server. Answers configured local names for your LAN, forwards\neverything else upstream (e.g. Cloudflare) or resolves it recursively itself.\n\n## Install\n\nInstalls the latest release binary to `/usr/local/bin/rdns` and, if you opt\nin, sets up a systemd service running as a recursive resolver. **Linux\nx86_64 only** — no macOS or ARM builds are published yet, so the script\nexits with an error on other platforms. The release binary is built on\n`ubuntu-latest` and is **glibc-linked**; it will not run on musl-based\ndistros (e.g. Alpine) even though they report `x86_64` — the installer\ndetects musl-only systems (no glibc present) and exits with a clear error\nrather than installing a binary that can't execute.\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/rpmoore/rdns/main/scripts/install.sh | sudo bash\n```\n\nPass `--yes` to install and start the service without prompting, `--no-service`\nto install only the binary, or `--version <tag>` to pin a specific release —\nincluding an older one, to downgrade (the installer prompts for confirmation\nbefore downgrading unless `--yes` is given). See `./scripts/install.sh --help`\nfor details (from a repo checkout).\n\nTo install (or downgrade to) a specific version, e.g. `v0.1.4`:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/rpmoore/rdns/main/scripts/install.sh | sudo bash -s -- --version v0.1.4\n```\n\n## Run it\n\n```bash\ncargo run\n```\n\nStartup config resolution order:\n\n1. `--config <path>` CLI flag, if given — takes precedence over everything\n   else. This is how the installer's systemd unit starts rdns\n   (`--config /etc/rdns/config.toml`), so on an installed/service setup\n   this is the config in effect regardless of `RDNS_CONFIG`.\n2. `RDNS_CONFIG` env var, if set — path to a TOML config file.\n3. `./config.toml`, if present in the working directory.\n4. Otherwise, built-in loopback dev defaults (listens on `127.0.0.1:5300`,\n   forwards to `1.1.1.1:53`, no local entries).\n\nA sample `config.toml` ships at the repo root and loads automatically. Its\n`[[local_dns_entries]]` sample (`nas.lan`) is `enabled = false` out of the\nbox — enable it and set your own address, or add your own entries — so a\nplain checkout never answers a fake record. Point at a different file:\n\n```bash\nRDNS_CONFIG=/etc/rdns/config.toml cargo run\n```\n\nTest it:\n\n```bash\ndig @127.0.0.1 -p 5300 nas.lan A        # local entry, once enabled in config.toml\ndig @127.0.0.1 -p 5300 example.com A    # forwarded upstream\n```\n\n## Config file format\n\nTOML. Top-level:\n\n```toml\ndns_listen = [\"127.0.0.1:5300\"]   # one or more \"host:port\" UDP+TCP listeners\nper_query_deadline_ms = 2000       # per-query timeout budget\nmax_udp_payload_size = 1232        # EDNS UDP payload size advertised/accepted\nmax_tcp_connections = 128          # concurrent TCP connections per listener\n```\n\n`dns_listen` binding to port 53 (or any port `<= 1024`) is allowed by config\nvalidation, but the OS still requires privilege to bind it — run as root or\ngrant the built binary the capability:\n\n```bash\nsudo setcap cap_net_bind_service=+ep target/release/rdns\n```\n\n(This only matters if you build/run the binary manually. `scripts/install.sh`'s\nsystemd unit grants the capability via `AmbientCapabilities` instead, so its\nservice doesn't need `setcap` and won't lose the capability across binary\nupgrades.)\n\nUnknown fields in the file are rejected at load time (fails closed rather\nthan silently ignoring a typo'd or unsupported setting).\n\n### Upstream resolvers (forwarding)\n\nEverything not answered by a local entry is forwarded to `[[upstreams]]`,\ntried in ascending `priority` order:\n\n```toml\n[[upstreams]]\nname = \"cloudflare\"\nendpoint = \"1.1.1.1:53\"\nprotocol = \"udp\"\nenabled = true\npriority = 10\ntimeout_ms = 750\n\n[[upstreams]]\nname = \"quad9\"\nendpoint = \"9.9.9.9:53\"\nprotocol = \"udp\"\nenabled = true\npriority = 20\ntimeout_ms = 750\n```\n\nMultiple entries give failover, not fan-out: rdns tries them in priority\norder and falls through on failure/timeout. `protocol` parses `\"tcp\"` but\nforwarding only considers `enabled` upstreams with `protocol = \"udp\"` —\n`\"tcp\"`-configured upstreams are skipped entirely (an all-`\"tcp\"` upstream\nlist leaves no backend to forward to). Initial forwarding queries always go\nout over UDP; rdns retries the same upstream over TCP if the UDP response\ncomes back truncated (`TC` bit set).\n\n### Recursive resolution (acting as your own root-to-leaf resolver)\n\nInstead of forwarding, rdns can walk the DNS hierarchy itself starting from\nthe root servers. Add a `[resolution]` section — if omitted, rdns defaults\nto forward mode using `[[upstreams]]` as above.\n\nSimplest form, using the bundled root hints:\n\n```toml\n[resolution]\nmode = \"recursive\"\n\n[resolution.recursive]\nroot_hints = \"bundled\"\nroot_hints_version = \"bundled:v1\"\n```\n\n`[[upstreams]]` is ignored in recursive mode — you don't need any.\n\nFull set of tunables (all but `root_hints`/`root_hints_version` are\noptional and default as shown):\n\n```toml\n[resolution]\nmode = \"recursive\"\ngeneration = 1              # bump to force cache namespace invalidation\n\n[resolution.recursive]\nroot_hints = \"bundled\"              # \"bundled\" or \"custom\"\nroot_hints_version = \"bundled:v1\"   # required; any label, used for cache namespacing\nper_authority_timeout_ms = 750      # timeout per upstream authority query\nmax_recursion_depth = 16            # referral chain depth limit\nmax_cname_restarts = 8              # CNAME-chase limit\nallowed_transports = [\"udp\", \"tcp\"] # transports used to query authorities\ndnssec_validation = \"disabled\"      # only \"disabled\" is currently supported\ndname_handling = \"defer\"            # only \"defer\" is currently supported\n```\n\nTo use your own root server list instead of the bundled one:\n\n```toml\n[resolution.recursive]\nroot_hints = \"custom\"\nroot_hints_version = \"custom:v1\"\n\n[[resolution.recursive.root_hints_entries]]\nname = \"a.root-servers.net\"\nendpoints = [\"198.41.0.4:53\"]\n\n[[resolution.recursive.root_hints_entries]]\nname = \"b.root-servers.net\"\nendpoints = [\"199.9.14.201:53\"]\n```\n\n`[resolution]`/`[[upstreams]]` reload on SIGHUP too — you can flip between\n`forward` and `recursive`, or change recursive settings, without a restart.\nOnly `dns_listen` is restart-only, see below.\n\n#### Updating the bundled (`root_hints = \"bundled\"`) root server list\n\nThe bundled list (currently all 13 root servers, IPv4 + IPv6) isn't\nhand-maintained Rust — it's parsed at runtime from a committed copy of\nIANA/InterNIC's root hints zone file at `src/config/named.root` (embedded\ninto the binary at compile time via `include_str!`), via `parse_named_root()`\nin `src/config/mod.rs`. To refresh it when a root server's address changes:\n\n```bash\ncurl -sS https://www.internic.net/domain/named.root -o src/config/named.root\n```\n\nOr `just update-iana-data`, which refreshes both this file and the TLD\nlist below in one step.\n\nThen rebuild — `parse_named_root` re-derives `bundled_root_hints()` from\nthe new file automatically, no other code changes needed. It reads the\nstandard BIND zone-file shape IANA publishes (`;`-prefixed comments,\n`<name> <ttl> <type> <rdata>` data lines) and keeps only the `A`/`AAAA`\nglue records, grouped by root server name in first-seen order; `NS`\nrecords are ignored (redundant with the address records' owner names).\nBump `root_hints_version` in your config after a refresh if you want the\nnew file to invalidate the resolver's recursive-mode cache namespace on\nnext load/reload.\n\n### Local DNS entries\n\nAnswers exact names for devices on your network, skipping upstream entirely\nfor those names:\n\n```toml\n[[local_dns_entries]]\nname = \"nas.lan\"\nipv4 = [\"192.168.1.10\"]\nttl = 300\nenabled = true\npublic_address_acknowledged = false\n\n[[local_dns_entries]]\nname = \"printer.lan\"\nipv6 = [\"fd00::1\"]\nttl = 300\nenabled = true\npublic_address_acknowledged = false\n```\n\nRules enforced at load time:\n- At least one of `ipv4`/`ipv6` must be set.\n- `ttl` must be between 1 and 86400 seconds (24h).\n- Names must be unique (case/trailing-dot normalized).\n- If an address is public/routable (not private-use, loopback, or\n  link-local), you must set `public_address_acknowledged = true` or the\n  config is rejected — this stops an accidental public IP in a local entry\n  from being silently exposed as the \"local\" answer.\n- `enabled = false` keeps the entry in the file but disabled — no lookup\n  match, useful for keeping a device's known address around without serving\n  it.\n- **The entry's name cannot use a real, currently-delegated top-level\n  domain as its suffix** (checked against a bundled copy of IANA's TLD\n  registry — see \"Local zones\" below for the full explanation). `nas.lan`\n  is fine; `nas.dev`/`nas.app`/`nas.io` are rejected, because those are\n  real, currently-registered gTLDs — a local override should never be able\n  to shadow a real public domain. If you were relying on a real TLD suffix\n  for a local name, rename it to something like `.lab`, `.home`, or\n  `.internal` instead.\n\n### Local zones (BIND-style zone files)\n\nFor a larger local-network record set, or to migrate an existing BIND\nsetup, point rdns at a real zone file instead of (or alongside)\n`[[local_dns_entries]]`:\n\n```toml\n[[local_zones]]\npath = \"zones/mynetwork.zone\"\nroot_domain = \"mynetwork\"\npublic_address_acknowledged = false\nenabled = true\n```\n\n- `path` is resolved relative to the directory containing the config file\n  (`RDNS_CONFIG`/`./config.toml`) when relative; absolute paths are used\n  as-is. With no config file loaded, a relative path resolves against the\n  current working directory.\n- The zone file uses standard BIND zone-file syntax (`$ORIGIN`, `$TTL`,\n  `SOA`, `NS`, multi-line parenthesized records, comments, owner-name\n  inheritance, etc.) — parsed with [the `domain` crate's zone-file\n  scanner](https://docs.rs/domain), the same crate other Rust DNS tooling\n  uses. Zone files are expected to declare their own `$ORIGIN`; rdns never\n  programmatically overrides it.\n- Only `A`, `AAAA`, `SOA`, and `NS` records are supported. `SOA`/`NS` are\n  recognized and ignored (they're zone-management boilerplate, not\n  answerable local records). Any other record type (`CNAME`, `MX`, `TXT`,\n  `SRV`, DNSSEC records, etc.) or a `$INCLUDE` directive causes the whole\n  config to be rejected rather than silently dropping data — this is a\n  deliberate limitation, not an oversight.\n- Multiple `A`/`AAAA` records under the same owner name are grouped into\n  one local entry, same as listing multiple addresses in one\n  `[[local_dns_entries]]` block's `ipv4`/`ipv6` arrays. If the same owner\n  name has records with different TTLs, the first one seen wins for the\n  whole entry (rdns has one TTL per entry, not one per address family).\n- A zone file is capped at 10 MiB and 10,000 `A`/`AAAA` records; an\n  oversized file is rejected rather than parsed, to bound worst-case parse\n  time/memory from a misconfigured or unexpected file.\n- `root_domain` is mandatory and enforced: every record's owner name in\n  the zone file must be at or below it, and `root_domain` itself is\n  checked with the same **not-a-registered-TLD** rule described above for\n  `[[local_dns_entries]]` — a zone can never claim authority over a real\n  public domain. Most of IANA's Special-Use Domain Names (RFC 6761:\n  `local`, `test`, `invalid`, `example`, `onion`, `localhost`) remain legal\n  choices simply because they're **not** delegated TLDs at all — `.local`\n  specifically still carries the existing mDNS conflict warning.\n  `home.arpa` (RFC 8375) is a narrow, explicit exception rather than an\n  instance of that same rule: `arpa` itself *is* a real, delegated\n  infrastructure TLD, so `home.arpa`/`*.home.arpa` are allowed only because\n  they're special-cased, not because `arpa` is absent from the checked\n  list — nothing else under `.arpa` is exempted.\n- `public_address_acknowledged` here is **zone-wide** (BIND zone files\n  have no per-record acknowledgement syntax) — set it only if you\n  intentionally have a public/routable address somewhere in that zone\n  file; otherwise any public/routable `A`/`AAAA` record in the file is\n  rejected, same fail-closed default as inline entries.\n- `enabled = false` (default `true`) skips the zone entirely, including\n  never reading the file from disk — useful for keeping a zone file\n  configured but temporarily out of service without needing the file to\n  even exist.\n- `local_zones` entries merge with `[[local_dns_entries]]`: both are\n  checked for duplicate names against each other (across every zone file\n  and the inline list together), and the not-a-registered-TLD rule\n  applies to **both** kinds of entries, not just zone-file ones.\n- Reloadable via `SIGHUP` exactly like `[[local_dns_entries]]` — see below.\n\n#### Updating the bundled IANA TLD list\n\nThe list of real, currently-delegated top-level domains checked against\n(for both `[[local_dns_entries]]` names and `[[local_zones]]`\n`root_domain`s) is a committed, verbatim copy of IANA's published TLD\nregistry at `src/config/tlds-alpha-by-domain.txt`, embedded into the\nbinary at compile time. Refresh it the same way as the root hints list:\n\n```bash\ncurl -sS https://data.iana.org/TLD/tlds-alpha-by-domain.txt -o src/config/tlds-alpha-by-domain.txt\n```\n\nOr `just update-iana-data`, which refreshes both this file and the root\nhints file above in one step.\n\nThen rebuild — `parse_iana_tlds`/`bundled_iana_tlds()` re-derive the\nchecked set from the new file automatically, no other code changes\nneeded.\n\n## Reloading config without a restart\n\nSend `SIGHUP` to the running process to reload resolution mode, upstreams,\nand local DNS entries (inline and zone-file-sourced) from the same config\nfile:\n\n```bash\nkill -HUP <pid>\n```\n\n- The file is re-read, re-parsed, and fully re-validated before anything is\n  applied. A broken edit (bad TOML, invalid address, duplicate name, etc.)\n  is logged and rejected in full — the server keeps serving the last-good\n  config, with no fields changed from the rejected reload.\n- On a successful reload, the new backend (upstreams/recursive settings)\n  and new local DNS entries are published together as one atomic step: a\n  query in flight during the reload sees either the fully old pair or the\n  fully new pair, never one field from each.\n- `[resolution]`, `[[upstreams]]`, `[[local_dns_entries]]`, and\n  `[[local_zones]]` are all reloadable this way — including switching\n  between `forward` and `recursive` mode. A `[[local_zones]]` file is\n  re-read from disk on every reload, so editing the zone file itself and\n  sending `SIGHUP` picks up the change too, without touching `config.toml`.\n- `dns_listen` and `[metrics]` changes are **not** picked up on SIGHUP —\n  changing DNS listen addresses/ports, or the metrics endpoint's\n  address/enabled state, requires a restart.\n- No effect if rdns started with no config file (built-in dev defaults) —\n  there's nothing to re-read.\n\n## Metrics (Prometheus)\n\n> **Breaking change:** rdns used to push metrics via OpenTelemetry OTLP/gRPC\n> (`OTEL_EXPORTER_OTLP_ENDPOINT`). That exporter has been removed entirely and\n> replaced with a Prometheus pull endpoint. If you were scraping metrics via\n> an OTLP collector, that pipeline stops receiving data after upgrading —\n> point your Prometheus server at the new `/metrics` endpoint instead (there\n> is no OTLP compatibility mode).\n\nrdns exposes `GET /metrics` in Prometheus text exposition format — no TLS,\nno auth. The endpoint's own reachability doubles as a liveness check: if\n`/metrics` responds, the process is up.\n\n```toml\n[metrics]\nenabled = true              # set false to disable the endpoint entirely\nlisten = \"127.0.0.1:9090\"   # loopback by default since there's no TLS/auth;\n                             # override to e.g. \"0.0.0.0:9090\" for an\n                             # external Prometheus server to reach it\nmax_connections = 32        # concurrent HTTP connection cap\n```\n\nIf `[metrics]` is omitted, these are the defaults — the endpoint is on by\ndefault. A bind failure (e.g. the port is already in use by something else\non the host) is logged and does **not** prevent rdns from starting or\nserving DNS; it just runs without a metrics endpoint for that run.\n\n`[metrics]` is **restart-only** — unlike `[resolution]`/`[[upstreams]]`/\n`[[local_dns_entries]]`, changes here (including `enabled = false`) are not\npicked up on `SIGHUP`. A reload log line reporting success does not mean the\nmetrics listener's address or enabled state changed; it keeps running with\nwhatever `[metrics]` config was in effect at startup until the process is\nrestarted.\n\nIncludes request/cache counters (`query_received_total`, `cache_hit_total`,\n`cache_miss_total`, ...), latency histograms split by cache-hit vs.\ncache-miss/backend path (`cache_hit_query_duration_seconds`,\n`cache_miss_query_duration_seconds`), and cache size/capacity gauges\n(`cache_size`, `cache_capacity`).\n",
  "bytes": 16850,
  "sha": "8a9801da1e5018184928a96e8f1f1a33b9f343cf9178483b782ca6b6d559399e",
  "repo_slug": "rpmoore/rdns",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_rpmoore_rdns_docs_knowledge_index_md_914c2b41/readme"
}