Add DNSSEC signing, key backups and Porkbun support

Zones get a DNSSEC setting: named signs them with BIND's default (or a
custom) dnssec-policy and inline signing, with keys in a per-view key
directory. Jarl shows the keys, the DS record and when it is safe to
publish it, checks the parent's DS records (check-ds, which also tells
named), and refuses steps that would break a signed domain: switching
straight to unsigned, finishing a turn-off too early, deleting or moving
a signed zone, TTLs above max-zone-ttl. Turning off goes through the
insecure policy; wipe starts over once the parent has no DS.

Backups store the DNSSEC keys and restore puts them back for named; the
backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's
serial above named's whenever it writes an older one (after a restore).
The serials check compares the signed serial.

With a Porkbun API key in jarl.yaml, the registrar's name servers, glue
records and DS records can be shown and changed, each change checked
first (dry run, force to override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 21:19:04 +00:00
1 parent 293e6ddd33
commit 5630e7a848
72 files changed
+5760 -153

No files matched your search

+99
View File
@@ -144,6 +144,14 @@ may be the name.
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
| GET | `/zones/{zone}/serials` | asks the zone's other name servers for their serial (below) |
| GET | `/zones/{zone}/delegation` | checks the parent zone's delegation of a primary zone (below); `?resolver=IP` |
| GET | `/zones/{zone}/dnssec` | the zone's DNSSEC keys, DS record and phase ([DNSSEC](#dnssec)) |
| POST | `/zones/{zone}/dnssec/check-ds` | asks the parent for the zone's DS records and tells named; `?resolver=IP` |
| POST | `/zones/{zone}/dnssec/wipe` | `{"force": false, "dry_run": false}`: signing off at once, keys deleted (administrators) |
| GET | `/zones/{zone}/registrar` | name servers, glue and DS records at the registrar ([Registrar](#registrar-porkbun)) |
| POST | `/zones/{zone}/registrar/glue` | create or update glue records |
| PUT | `/zones/{zone}/registrar/ns` | set the domain's name servers |
| POST | `/zones/{zone}/registrar/ds` | publish the zone's DS record |
| DELETE | `/zones/{zone}/registrar/ds/{keytag}` | remove a DS record |
| POST | `/zones/{zone}/import` | `{"text": "<zone file>", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` |
ZoneInput:
@@ -159,6 +167,7 @@ ZoneInput:
"refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300},
"default_ttl": 3600,
"serial_policy": "date", // date, increment, unixtime
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
"comment": "",
// create only:
"nameservers": ["ns1.adbraun.lol."], // default: settings
@@ -197,6 +206,96 @@ when called ([operations.md](operations.md#checking-name-servers-and-the-delegat
(`name`, `parent`, `jarl`, `match`), `servers` (as above, for the delegated
name servers) and `findings`: `{"level": "ok"|"info"|"warning"|"error",
"message": "..."}`. A zone that isn't delegated gets one `info` finding.
`parent_ds` lists the zone's DS records at the parent, and the findings
compare them with the zone's keys (a DS that matches no key, or a DS for a
zone that isn't signed, is an `error`).
Zones in responses also carry `registrar` (`"porkbun"`) when Jarl can
manage the domain at a registrar.
#### DNSSEC
Signing is the zone's `dnssec_policy` field, changed with `PATCH
/zones/{zone}` like any other field, so it goes through validation,
changesets and history. Jarl refuses unsafe steps there: `""` straight from
a signed policy (use `"insecure"` first), `""` before named has finished
removing the signatures, deleting a signed zone, or moving it to another
view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
`GET /zones/{zone}/dnssec`:
```json
{
"zone": "adbraun.lol.", "view": "external", "type": "primary",
"policy": "default",
"key_dir": "/var/lib/bind/jarl/dnssec-keys/view-3",
"keys": [{
"tag": 51126, "algorithm": 13, "algorithm_name": "ECDSAP256SHA256", "role": "CSK",
"dnskey": "adbraun.lol.\t3600\tIN\tDNSKEY\t257 3 13 ...",
"ds": {"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "record": "adbraun.lol.\t3600\tIN\tDS\t51126 13 2 FFBE..."},
"goal": "omnipresent", "dnskey_state": "rumoured", "ds_state": "hidden",
"zone_rrsig_state": "rumoured", "key_rrsig_state": "rumoured",
"created": "...", "published": "...", "active": "...", "publish_cds": "...",
"files": ["Kadbraun.lol.+013+51126.key", "Kadbraun.lol.+013+51126.private", "Kadbraun.lol.+013+51126.state"]
}],
"phase": {
"code": "signing", "summary": "named is signing the zone. ...", "next": "Wait until ...",
"ds_ready_at": "...", "can_publish_ds": false, "can_finish": false
},
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
"policies": ["default"],
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
"registrar": "porkbun" // when the DS can be published through Jarl
}
```
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
`POST /dnssec/check-ds` (zones write) returns `parent` (the parent's
servers with their `ds`, the union `ds`, `delegated`, `reached`,
`consistent`, or `error`), `marked` (what named was told, `rndc dnssec
-checkds`), `notes` and the updated `status`.
`POST /dnssec/wipe` (administrators) asks the parent first and answers
`409` while it has a DS record for the zone, or while named still counts a
removed one as cached; when the parent can't be asked, it needs `"force":
true`. It returns `removed` (the files deleted) and `changeset_id`.
#### Registrar (Porkbun)
Administrators only, and only with a Porkbun key in jarl.yaml
([dnssec.md](dnssec.md#porkbun)). `GET /zones/{zone}/registrar`:
```json
{
"registrar": "porkbun", "domain": "adbraun.lol",
"ns": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"], "jarl_ns": ["ns1.adbraun.lol", "ns2.adbraun.lol"],
"ns_match": false,
"glue": [{"host": "ns1.adbraun.lol", "registry": [], "jarl": ["203.0.113.1", "2001:db8::1"],
"needed": true, "in_use": false, "action": "create"}],
"ds": [{"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "matches": true}],
"findings": [{"level": "warning", "message": "..."}],
"error": "" // e.g. "Porkbun: ... (API_ACCESS_DISABLED)"; the rest is then empty
}
```
The changes take `{"force": false, "dry_run": false}` (`PUT /ns` also
`"ns": [...]`, default the zone's NS records; `POST /ds` also `"key_tag"`)
and answer:
```json
{"done": ["create glue for ns1.adbraun.lol: 203.0.113.1, 2001:db8::1"], "dry_run": false,
"checks": [{"level": "ok"|"info"|"warning"|"error", "message": "..."}], "warnings": []}
```
Each change runs its checks first: the name servers answer for the zone,
glue matches, the DS points at a key the zone has, and the domain is
delegated to servers that serve the signed zone before a DS is published. An
`error` check refuses the change with `409` unless `force` is set; a dry run
returns the checks without changing anything. Errors from Porkbun are `502
registrar_error`.
### Records