Actions
54 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.
| Action | What it does | Risk | Preview |
|---|---|---|---|
dns.server.install | Install the DNS server | high | |
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.secondary.add | Add a secondary name server | medium | |
dns.secondary.list | List secondary name servers (read) | low | No dry run |
dns.secondary.remove | Remove a secondary name server | medium | |
dns.secondary.resync | Resync a secondary | low | |
dns.secondary.rotate_key | Replace a secondary's transfer key | medium | |
dns.secondary.promote | Promote a secondary to primary | high | |
dns.secondary.set_address | Move a secondary to a new address | medium | |
dns.firewall.requirements | Ports the zone transfers need (read) | low | No dry run |
dns.secondary.zone_set | Choose the secondaries of a zone | medium | |
dns.secondary.status | Secondary sync state (read) | low | No dry run |
dns.zone.secondary_add | Hold a zone as a secondary | medium | |
dns.zone.secondary_update | Correct a secondary copy | medium | |
dns.zone.secondary_status | Secondary copy state (read) | low | 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.domain.points_here | Does this address point here? (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.viewView zones and recordsdns.zone.manageCreate, suspend and delete zonesdns.record.manageEdit records and import zone filesdns.template.manageManage zone templatesdns.dnssec.manageManage DNSSECdns.nameserver.manageSet nameservers for new zonesdns.server.manageInstall and reconcile the DNS server
Plan limits
dns.zonesDNS zonesdns.records_per_zoneRecords per zone
Engineering notes
Generated from modules/dns/docs.md at build f3a3c074. 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 withreplace_prefix(mail: SPFv=spf1, DKIM TXT atdefault._domainkey, DMARC, MX with10 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|bulkby 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 thesystemactor (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 templatedefault(account's, else global, else builtin: apex A/AAAA, www CNAME, mail A/AAAA, MX 10 mail.., SPF v=spf1 a mx ~allas 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
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).
Secondaries, transfers and promotion (w11-59, w11-66, w11-75)
Actions: dns.secondary.{add,list,remove,resync,rotate_key,zone_set,status,set_address,promote}, dns.zone.secondary_{add,update,status}
(a zone held from an outside primary), dns.firewall.requirements. Transfers are answered on port 5300 only (the process on 53 answers none):
a request needs the key and, where the panel manages the node's firewall, a listed address (rules tagged rc-dns:). dns.secondary.list
returns transfer (port, this server's address, enforced, the plain reason; enforced = the panel manages the firewall of the DNS node,
read with security.firewall.get) and fenced (zones whose old primary has not yet been told to follow a promotion). dns.zone.list|get rows carry
held_from {primary_address, state, error} for a zone held from outside, and fence_node for administrators only. A zone held from outside is never
pushed to our own secondaries. Promotion reports its steps with c.Step (named steps in the job dialog) and returns {promoted, node, zones, failed, fenced, waiting, old_primaries}.
UI: ui/src/pages/secondaries.tsx (Name servers page, zone Secondaries tab), held.tsx (zones held from outside), transfer.tsx (settings blocks, enforcement note).
Tests: cp/w11_75_test.go (set_address, promote and fence, transferNeeds, held zone list, enforcement note), web/apps/panel/src/dns-secondaries.test.tsx.
"Does this address point here?" (w11-124, the guide)
dns.domain.points_here {domain} -> {domain, points_here, found, expected, checked_at, message, nameservers?, cached}, a read
(dns.zone.view, so customers and team members may run it) on the person's own addresses: the domain itself or a name under it
(www.yourshop.com). Whose it is (domains.list) and which website answers for it (web.sites.list) are asked as the person;
anything they cannot see is "not one of yours". expected = the addresses of the server the website is placed on (else the account's
server, else this DNS server's configured ip4/ip6), through the system calls already declared (accounts.get,
cluster.node.address). found = the panel server's own resolver (the system one, which follows the public delegation), reused
for a minute per name. points_here = it has an address and every address it has, of a kind this server has (IPv4/IPv6), is this
server's; an old host's address next to the new one is "not yet" (some visitors still go there). message says it in plain words
with what to do (the name servers to set when this panel runs the zone, else the address to point to). Not covered: a name behind a
CDN or a load-balancer address answers "not this server" (that journey step then runs out after two days with a plain notice; the
person can skip it, and the padlock step after it still proves the website works).