DNS

Authoritative DNS (PowerDNS) with a validated record editor, templates, BIND import/export, history and DNSSEC.

In preview dns version 0.1.0 Part of DNS

Actions

40 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
dns.server.install Install the DNS server high No dry run
dns.server.status DNS server status (read) low No dry run
dns.config.status Hand-edited DNS settings (read) low No dry run
dns.config.adopt Keep a hand edit on top of the panel settings medium
dns.config.reapply Render the panel settings again medium
dns.config.reset Reset the settings to defaults medium
dns.config.unmanage Hand the settings file off high
dns.zone.reconcile Re-apply zones to the server medium No dry run
dns.zone.create Create a zone low
dns.zone.list List zones (read) low No dry run
dns.zone.get Get a zone (read) low No dry run
dns.zone.delete Delete a zone high
dns.zone.suspend Suspend a zone medium
dns.zone.unsuspend Resume a zone low
dns.zone.import Import a BIND zone file medium
dns.zone.export Export a BIND zone file (read) low No dry run
dns.record.list List records (read) low No dry run
dns.record.create Add a record low
dns.record.update Change a record low
dns.record.delete Delete a record medium
dns.record.bulk Change many records at once medium
dns.record.set Set the records of a name and type low
dns.record.unset Remove the records of a name and type low
dns.history.list Zone change history (read) low No dry run
dns.history.restore Restore an earlier zone state medium
dns.template.list List zone templates (read) low No dry run
dns.template.save Create or change a zone template low No dry run
dns.template.delete Delete a zone template low No dry run
dns.template.apply Apply a template to a zone medium
dns.dnssec.enable Enable DNSSEC medium
dns.dnssec.disable Disable DNSSEC high
dns.dnssec.ds Get the DS record (read) low No dry run
dns.nameservers.get Get the nameservers for new zones (read) low No dry run
dns.nameservers.set Set the nameservers for new zones medium No dry run
dns.config.reset_all Reset the whole service to defaults high
dns.config.snapshot.list Snapshots before resets (read) low No dry run
dns.config.snapshot.restore Undo a reset (restore a snapshot) high
dns.config.history.list Earlier versions of a file (read) low No dry run
dns.config.history.diff Compare an earlier version (read) low No dry run
dns.config.history.restore Restore an earlier version of a file high

Permissions and limits

Permissions

  • dns.zone.view View zones and records
  • dns.zone.manage Create, suspend and delete zones
  • dns.record.manage Edit records and import zone files
  • dns.template.manage Manage zone templates
  • dns.dnssec.manage Manage DNSSEC
  • dns.nameserver.manage Set nameservers for new zones
  • dns.server.manage Install and reconcile the DNS server

Plan limits

  • dns.zones DNS zones
  • dns.records_per_zone Records per zone

Engineering notes

Generated from modules/dns/docs.md at build d90e9e2. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

Desired state (zones, records, templates, history, nameserver sets) lives in schema m_dns; PowerDNS on the DNS node is only ever told the complete zone (dns.zone.apply, agent op). Apply == reconcile: the agent diffs against the server and PATCHes the changed record sets in one API call; the SOA serial (YYYYMMDDnn) moves only when something changed. Refusal by the node undoes the change; an unreachable node keeps it and marks the zone error until dns.zone.reconcile (accounts pattern).

Backend choice

Default pgsql: own Postgres database pdns + role pdns created on the node by the agent (postgres local cluster, password generated on the node, kept only in /etc/powerdns/pdns.d/rc.conf; never the panel DB, ADR 0012 §5). sqlite3 (backend: sqlite3) for the Micro profile. The pdns API listens on 127.0.0.1:8081 only; its key is generated by the control plane, stored in the vault (pdns_api_key:<node>) and written to the node config. pdns answers on every non-loopback address of the node (plus 127.0.0.1) so systemd-resolved's stub on 127.0.0.53 is not disturbed. Packages are installed under a policy-rc.d hold so pdns never starts unconfigured.

Contracts other modules may rely on (stable)

Callers pass zone as a name (example.com) or id. Names are relative (www, @) or absolute with a trailing dot. All are idempotent and return {zone, changed, applied, changes, warning?}.

  • dns.record.set {zone, name, type, ttl?, values[], replace_prefix?} - replace the record set, or only records of that name+type whose content starts with replace_prefix (mail: SPF v=spf1, DKIM TXT at default._domainkey, DMARC, MX with 10 mail.example.com.; MX content needs the priority).
  • dns.record.unset {zone, name, type, value?, prefix?} - removes; nothing to remove is success.
  • dns.record.create|update|delete|bulk by record id for editors; dns.record.list, dns.zone.get.
  • dns.acme_challenge.set {domain, value} / dns.acme_challenge.clear {domain, value?} - internal: callable only as the system actor (ssl runs DNS-01 from its own internal action/scheduler, not as the end user). Adds/removes a TXT at _acme-challenge.<name> (TTL 60) in the longest-suffix zone; several values coexist (wildcard + apex); *. is stripped. The call returns after the node answers with the value.
  • Events dns.zone.created, dns.zone.deleted, dns.record.changed, dns.dnssec.enabled.
  • Subscriptions: domains.created -> zone from template default (account's, else global, else builtin: apex A/AAAA, www CNAME, mail A/AAAA, MX 10 mail.., SPF v=spf1 a mx ~all as placeholders for mail); subdomains add A/AAAA to the parent zone; domains.deleted -> zone removed (limit released); accounts.deleted -> every zone of the account removed.

Rules

Types A AAAA CNAME MX TXT SRV CAA NS PTR TLSA SSHFP DS HTTPS SVCB NAPTR URI LOC (SOA is zone-level). Content is validated and canonicalised with miekg/dns; errors are field-level (content: ...). CNAME at apex, CNAME beside other data, duplicates, out-of-zone absolute names, TTL differences inside one set (adding a record re-aligns its set), no apex NS, TTL < min_ttl (setting, default 30) and > max_records_per_zone (setting, default 5000) are rejected. TXT longer than 255 bytes is chunked. IDN names are punycode. Targets: @ = zone, a single label is relative, a dotted name without trailing dot is taken as fully qualified. Ownership: a name inside another account's zone is refused (admin override_ownership). Limit dns.zones is reserved per account. dns.records_per_zone is declared but not enforceable per zone with the host's counter API; the cap above is used instead (follow-up for the CTO: a host "read limit value" call). Settings read from core.settings scope module:dns: node_id, ip4, ip6, nameservers, nameserver_ips, hostmaster, default_ttl, min_ttl, max_records_per_zone, max_zonefile_bytes. Nameservers for new zones: account set -> server default set (dns.nameservers.set) -> settings -> ns1/ns2.<zone> with glue. Every mutating action except the template/nameserver ones supports ?dry_run=1 and returns a Plan with per-record diffs. History keeps 100 snapshots per zone; dns.history.restore writes a new entry. DNSSEC: dns.dnssec.enable (one CSK, ECDSAP256SHA256) returns DS; reconcile re-enables it on a rebuilt node.

Verified in the lab (2026-10-09, instance w2-01, Ubuntu 24.04, pdns 4.8.3 + Postgres 16 on cp1)

dns.server.install (20 s, idempotent re-run); zone from domains.created event answered by dig @cp1 (SOA/NS/MX/TXT/ www CNAME/glue); same answers through the lab CoreDNS resolver; create/update/delete/set/unset/bulk (atomic), record validation errors, import (bad lines reported, rest imported)/export round trip (re-import = no change), history + restore, template save/apply, vanity nameservers + glue, wildcard, IDN (Bücher.lab.test -> xn--bcher-kva), DNSSEC enable (RRSIG served, DS returned) and disable, suspend (REFUSED) / resume, ownership conflicts, dry-run plans, agent outage (saved, zone error, dns.zone.reconcile fixes), drift (zone deleted behind our back, reconcile restores), pdns restart persistence, account deletion removing zones. Not exercisable through the REST API (internal, system actor only): dns.acme_challenge.* and the event actions' failure branches; the ACME path shares mutate with dns.record.set (tested). sqlite3 backend: schema/config rendering only, not run in the lab. Pdns caches negative answers for 5 s (negquery-cache-ttl=5). The lab CoreDNS swap to Corefile.pdns happens in lab/up.sh only when pdns is already active at lab-up; in this run it was swapped by hand after dns.server.install (sed 's/10.77.0./10.77.N./g' lab/fakenet/Corefile.pdns > lab/.run/<id>/dns/Corefile).

Not done / next

UI; secondaries + TSIG (cluster card); DDNS tokens; external providers (Cloudflare/Hostinger) and registrar DS push; health/propagation checks; applying a changed nameserver set to existing zones; ALIAS type.

External zones (w7-03)

When a domain's DNS lives at a provider (providers module link mode external): dns.on_domain_created creates no node zone; dns.record.set|unset|list for such a name (no local zone) delegate to providers.dns.record.* as the same actor; dns.acme_challenge.set|clear delegate to providers.acme_challenge.* when no local zone holds the name. dns has no depends_on for providers (providers depends on dns); if providers is absent the calls fail soft (zone created / not found as before). Dry runs of an external set/unset return a one-line plan (SDK Host.Call carries no dry-run flag). dns.zone.import in replace mode now removes what the file drops in a first step and adds in a second (PowerDNS refuses a CNAME<->A swap in one patch).