Skip to content

shadowdns.yaml

shadowdns.yaml is ShadowDNS's own unified configuration file (specified with --config): a single YAML document containing three optional top-level sections, aliases (backup domain → root mapping), ephemeral_api (an HTTP API for short-lived TXT records), and doh (DNS-over-HTTPS, RFC 8484). Any other top-level key is rejected at startup (strict decoding).

# shadowdns.yaml

aliases:
  example.com:
    members:
      - backup.example.com
      - mirror.example.com
  example.org:
    members:
      - backup.example.org
    rewrite_rdata_labels: true
    collapse_cname_chain: true

ephemeral_api:
  listen: "127.0.0.1:8053"
  allow:
    - "127.0.0.1"
    - "10.0.0.0/8"
  # token: "optional-bearer-token"

doh:
  listen: "203.0.113.10:443"
  acme:
    directory_url: "https://acme-v02.api.letsencrypt.org/directory"
    ip: "203.0.113.10"
    http01_listen: "203.0.113.10:80"
    account_key_file: "/var/lib/shadowdns/acme/account.key"
    # initial_delay: "30s"

Environment variable expressions

ShadowDNS expands environment expressions in YAML string values every time it loads shadowdns.yaml, including startup, --dry-run, prune-backup, and each SIGHUP reload. Expansion supports this deliberately limited grammar:

Form Behavior
${NAME} Required. Loading fails when NAME is unset or set to an empty string.
${NAME:-literal default} Uses the environment value when it is non-empty; otherwise uses the literal default, which may be empty.
$$ Produces one literal $. For example, $${NAME} produces the literal text ${NAME}.

Variable names must match [A-Za-z_][A-Za-z0-9_]*. Shell forms such as $NAME, ${NAME-default}, and ${NAME:?message} are not expansion syntax: $NAME remains literal, while unsupported unescaped braced operators are rejected. Use $$ when literal text could otherwise be parsed as an expression.

Each scalar is scanned exactly once from left to right. Environment values and defaults are inserted literally and are not recursively expanded. For example, if OUTER=${INNER}, ${OUTER} results in the literal text ${INNER}.

ephemeral_api:
  listen: "${API_LISTEN:-127.0.0.1:8053}"
  allow:
    - "${API_ALLOW:-127.0.0.1}"
  token: "${API_TOKEN}"

Expansion is isolated from YAML structure:

  • Only value-side YAML string scalars are expanded. Mapping keys, sequence structure, booleans, numbers, nulls, and other non-string scalars are unchanged.
  • An anchored string value is expanded at its anchor definition; YAML aliases continue to refer to that same expanded value. Alias nodes are not expanded a second time.
  • The result remains scalar data even when an environment value contains :, #, newlines, ---, tags, anchors, or alias-like text. It cannot inject YAML keys or nodes.
  • As with normal loading, only the first YAML document is used.

Loading is fail-closed. A missing or empty required variable, malformed expression, strict-decoding error, or semantic validation error rejects the entire load. On SIGHUP, the running configuration remains unchanged and ephemeral records are not cleared. Diagnostics for expression errors identify the variable and YAML location. If an environment-derived value later fails decoding or validation, the error identifies only the involved variable names and YAML paths; it does not print the environment value or a transformed derivative of it.

Use environment expansion only for fields that are safe for ShadowDNS to consume from process environment. For deployment patterns and secret-handling guidance, see Environment Variables. For reload scope and process-environment limitations, see Configuration Reloading.

aliases fields

Each key under aliases is a root domain; the value is an object:

Field Required Description
members Yes (must be non-empty) List of backup domains served by rewriting queries to this root
rewrite_rdata_labels No (default false) When true, RDATA name fields (CNAME/SRV targets, NS, MX, PTR, SOA names) get a label-anywhere rewrite — every root-label sequence inside the value is replaced with the backup origin, not just the in-bailiwick suffix. For zones using templated CDN-style targets that embed the root origin as a middle label
collapse_cname_chain No (default false) When true, in-zone CNAME chains are collapsed in responses for this root and all of its members — see CNAME Chain Collapsing

rewrite_rdata_labels: false vs true in practice

CDN-fronted zones often return a templated CNAME whose target embeds the zone's own origin as a middle label before the CDN provider's suffix. Suppose example.com is the root and example.net is one of its backup members, and the root's authoritative record for assets.example.com is:

assets.example.com.  300  IN  CNAME  assets.example.com.c.cdn.example.org.

A query then arrives for the backup name — assets.example.net A. ShadowDNS rewrites the owner name back into the backup namespace either way; the two settings differ only in how the CNAME target is rewritten:

rewrite_rdata_labels CNAME target returned for assets.example.net Correct?
false (default) assets.example.com.c.cdn.example.org. ✗ — the target ends in .example.org, so the root origin example.com sits as a middle label, not an in-bailiwick suffix. The conservative rule leaves it untouched, and the backup leaks the root's name.
true assets.example.net.c.cdn.example.org. ✓ — the label-anywhere rule replaces the embedded example.com with the backup origin example.net, exactly what a natively-served example.net zone would return.

Set rewrite_rdata_labels: true whenever a root's records point at templated CDN targets that carry the root origin as an embedded label. Leave it false (the default) for ordinary zones whose RDATA names are either in-bailiwick or genuinely external, where a label that coincidentally equals the root origin should not be rewritten.

aliases rules

  • A given backup domain may appear at most once across all roots (after normalization).
  • A backup domain must not equal its root (self-aliases are rejected).
  • Domains not listed here are treated as independent root zones, fully loaded into memory.
  • A backup zone may optionally provide its own zone file containing TXT, MX, and SRV override records. A, AAAA, CNAME, NS, and SOA records in a backup zone file are discarded with a WARN — these types are always inherited from the root.

For the query-handling details of zone aliasing, see Zone Aliasing Internals.

ephemeral_api fields

Field Required Description
listen Yes The host:port the API server binds to
allow Yes (must be non-empty) List of source IPs or CIDRs allowed to access the API; an empty list is rejected
token No Pre-shared bearer token. When set, every request must carry Authorization: Bearer <token>; when omitted, token verification is skipped (the IP ACL still applies)

When the ephemeral_api section is absent, no HTTP API server is started. For endpoint details, request/response schemas, and curl examples, see Ephemeral TXT API.

doh fields

Every field except acme.initial_delay is required; loading fails naming the first missing field. acme.initial_delay is optional and takes its documented default when absent.

Field Required Description
listen Yes The host:port the DoH HTTPS service binds to, e.g. 203.0.113.10:443
acme.directory_url Yes ACME directory URL of the issuing CA (must be an absolute https:// URL)
acme.ip Yes The IP address the certificate is issued for (RFC 8738 IP-identifier certificate)
acme.http01_listen Yes The host:port the ACME HTTP-01 challenge responder binds to; MUST be reachable from the public Internet as port 80
acme.account_key_file Yes Absolute path to the persisted ACME account private key (PKCS#8 PEM, mode 0600). Generated on first use when absent and reused across restarts so re-registration is idempotent and does not exhaust the ACME new-account rate limit. Use a path under the systemd StateDirectory (/var/lib/shadowdns). This file is a secret — keep it 0600 and owned by the service user. Changing it requires a restart to take effect
acme.initial_delay No A Go duration string (e.g. 30s) that delays only the first certificate obtain attempt of this process. Default 0s (absent or empty string), which issues immediately — the behavior when this field did not exist. Changing it requires a restart to take effect

The delay exists for orchestrated deployments whose routing data plane has not yet converged on a freshly started instance; see DNS-over-HTTPS for when to set it. A negative value or a string that is not a Go duration (30 without a unit, soon) fails the load with an error naming initial_delay and quoting the rejected value. No upper bound is enforced — an over-long window is visible in the log and self-corrects on restart.

The ACME account is registered without a contact email, so doh.acme accepts no email field; including one fails the load as an unknown field. (Contact email is optional under RFC 8555, and the short-lived auto-renewed certificate makes expiry notifications moot.)

When the doh section is absent, no DoH server, ACME client, or HTTP-01 listener is started.

DoH reuses the authoritative query path and is non-recursive: it only answers zones ShadowDNS hosts, and out-of-zone queries get REFUSED. It is not a general recursive DoH resolver.

The TLS certificate is obtained for the IP via ACME HTTP-01 using the Let's Encrypt short-lived profile (~6 days) and is auto-renewed and hot-swapped without a restart.

For deployment walkthrough and operational details, see DNS-over-HTTPS.

SIGHUP hot reload

SIGHUP re-reads shadowdns.yaml and atomically replaces the in-memory alias map:

  • If validation of either section fails, the running server keeps its previous state and ephemeral records are unaffected.
  • On a successful reload, the ephemeral record store is cleared.
  • The doh section is re-validated on reload (validation errors keep the running server). However, changes to doh.listen or any doh.acme.* field are not applied live — they require a process restart and are logged as an advisory. Certificate rotation is independent and automatic.
  • Every reload attempt is observable via Prometheus:
    • shadowdns_reload_total{result="success"|"failure"} counts reload outcomes
    • shadowdns_config_last_reload_success_timestamp_seconds records the Unix time of the last successful configuration load (initialized at startup); use time() - <gauge> for configuration-staleness alerting

Breaking change as of v0.x

The legacy --aliases CLI flag and the aliases.yaml file have been removed. Migration is mechanical: move the entries from the old aliases.yaml (root → [backups] format) under the aliases: section of the new shadowdns.yaml.