Merge branch 'dnssec'

The DNSSEC notes in CHANGELOG.md belong under Unreleased; git had put
them into the 0.1.0 section, which didn't exist when the branch was made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-09 15:16:33 +00:00
commit 24a2c875fa
72 files changed
+5759 -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
+32 -1
View File
@@ -150,7 +150,7 @@ to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|------|---------|
| 0 | Success |
| 1 | Other error (bad arguments, connection failure, ...) |
| 2 | A check found problems (`zone serials`, `zone delegation`); its report was printed |
| 2 | A check found problems (`zone serials`, `zone delegation`, `zone dnssec check-ds`, `zone registrar status`), or a step can't be taken yet (`zone dnssec disable` before turning off has finished); the report was printed |
| 3 | Not authenticated, or not allowed (token lacks the permission) |
| 4 | Not found |
| 5 | Conflict: concurrent change, object in use, drift |
@@ -211,6 +211,37 @@ zone's delegation and glue against the zone, and calls a zone that isn't
delegated (an internal-only zone) a note, not a problem. Both only run when
you run them; see [operations.md](operations.md#checking-name-servers-and-the-delegation).
### DNSSEC and the registrar
```sh
jarl zone dnssec status adbraun.lol --view external # keys, DS record, what's next
jarl zone dnssec enable adbraun.lol --view external [--policy NAME]
jarl zone dnssec check-ds adbraun.lol --view external # has the parent got the DS? tells named
jarl zone dnssec disable adbraun.lol --view external # step 1: insecure; run again to finish
jarl zone dnssec wipe adbraun.lol --view external [--force] # off at once, keys deleted
jarl zone registrar status adbraun.lol --view external # Porkbun: NS, glue, DS vs the zone
jarl zone registrar sync-glue adbraun.lol --view external [--dry-run]
jarl zone registrar set-ns adbraun.lol --view external [NS...] [--dry-run]
jarl zone registrar publish-ds adbraun.lol --view external [--key TAG]
jarl zone registrar remove-ds adbraun.lol 51126 --view external
```
[dnssec.md](dnssec.md) explains the steps. `enable` is safe at any time:
validation only starts once the zone's DS record is at the parent, and
`status` says when to publish it. `disable` never makes a zone unsigned
while the parent may still have its DS: its first run switches to BIND's
insecure policy, and it finishes (exit 0) only when named is done (exit 2
before). `wipe` switches off at once and deletes the keys, and refuses
while the parent zone has a DS record for the zone; `--force` wipes when the
parent can't be checked.
The `registrar` commands need a Porkbun API key in jarl.yaml and an
administrator. Each change checks first that it won't break the domain and
is refused otherwise: `--dry-run` shows the checks, `--force` skips them.
(Here `--force` means that; elsewhere it overwrites files changed outside
Jarl.)
## Records
```sh
+2 -4
View File
@@ -40,8 +40,8 @@ renders the complete configuration from the database:
`jarld adopt` turns `/etc/bind/named.conf` into a single
`include "/etc/bind/jarl/named.conf";`, so named loads only what Jarl
generates. Zone files go under `/var/lib/bind` because Debian's AppArmor
profile lets named write there. That keeps journals and future DNSSEC
signing working.
profile lets named write there: its journals, the signed copies of zones
it signs, and their keys (under `dnssec-keys/`, see [dnssec.md](dnssec.md)).
Because files are always regenerated, anything you edit by hand is
overwritten. Jarl records a checksum of every file it writes and reports
@@ -290,8 +290,6 @@ journal through jarld's log.
* **Dynamic updates** (`allow-update`, `update-policy`): named would write
to zone files that Jarl regenerates, so the validator rejects them on Jarl
zones. Use the API (for example `jarl acme`) instead of nsupdate.
* **DNSSEC signing**: not managed yet. The zone directory is
named-writable, so `dnssec-policy` can be added later.
* **Several servers from one jarld**: each jarld manages the named on its
own host. A second server runs its own jarld with secondary zones, which
copy the primary's zones by normal AXFR/IXFR (see "A secondary server" in
+24 -3
View File
@@ -39,8 +39,8 @@ Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
### Backups
`jarld backup` without a file name, which the `jarl-backup.timer` systemd
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` and then
deletes old backups there. It reads these settings from this file each
timer runs daily, writes `jarl-<UTC time>.db` into `backup.dir` (with the
DNSSEC keys of signed zones inside it) and then deletes old backups there. It reads these settings from this file each
time it runs; when it runs is the timer's (`systemctl edit
jarl-backup.timer`). See [operations.md](operations.md#backups), also for
restoring.
@@ -91,9 +91,10 @@ jarld restarts.
|-----|---------|---------|
| `bind.main_config` | `/etc/bind/named.conf` | The file named loads. `jarld import` reads it, and `jarld adopt` replaces it with an include of Jarl's configuration. |
| `bind.config_dir` | `/etc/bind/jarl` | Where Jarl writes `named.conf` and `keys.conf`. Must be readable by named. |
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals here. |
| `bind.zone_dir` | `/var/lib/bind/jarl` | Primary zone files (`<view>/<zone>.zone`). named may write journals, signed copies of zones and, under `dnssec-keys/`, DNSSEC keys here. |
| `bind.cache_dir` | `/var/cache/bind/jarl` | Where named stores secondary, stub and mirror zones. |
| `bind.file_group` | `bind` | Group given to generated files and directories so named can read them. Empty leaves the group alone. |
| `bind.named_user` | `bind` | The user named runs as. `jarld restore` gives it the DNSSEC keys it puts back. |
| `bind.named_checkconf` | `/usr/bin/named-checkconf` | Used to check every candidate configuration (`-z` also loads the zones). |
| `bind.named_checkzone` | `/usr/bin/named-checkzone` | Used to check zone files. |
| `bind.rndc` | `/usr/sbin/rndc` | Used to reconfigure and reload named and to read zone serials. |
@@ -111,6 +112,26 @@ there (`ReadWritePaths`). If you change them, update both
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
```
### Porkbun (registrar)
With an API key, Jarl can show and set a domain's name servers, glue
records and DNSSEC DS records at Porkbun, after checking that the change
won't break the domain ([dnssec.md](dnssec.md#porkbun)). Without a key
(the default) nothing about Porkbun shows up.
| Key | Default | Meaning |
|-----|---------|---------|
| `porkbun.api_key` | `""` | The API key (`pk1_...`). Set both or neither. |
| `porkbun.secret_api_key` | `""` | Its secret (`sk1_...`). |
| `porkbun.domains` | `[]` | Only these domains (default: every primary zone the key can reach). |
The key can move your domain anywhere, so it is only ever read from this
file: it isn't stored in the database or backups, and there is no API to
set it. Keep the file readable only by root and jarld (`chown root:jarl
jarl.yaml; chmod 640 jarl.yaml`, as the Debian package installs it); jarld
warns at start when everyone can read it. At porkbun.com, switch on "API
Access" for the domain, and limit the key to the domain if you can.
## Example: management network with TLS
```yaml
+8 -3
View File
@@ -24,8 +24,10 @@ internal/
web/static/ the web UI: plain ES modules, no build step, embedded with go:embed
api/, client/ JSON types and the Go client shared by server and CLI
acme/ ACME DNS-01 challenge records, shared by jarl acme and the httpreq endpoint
backup/ database backups: write (VACUUM INTO) and check, list, prune by the time in the name
nscheck/ DNS queries to other name servers: zone serials and the parent's delegation
backup/ database backups: write (VACUUM INTO, plus the DNSSEC key files) and check, list, prune by the time in the name
nscheck/ DNS queries to other name servers: zone serials, the parent's delegation and DS records
dnssec/ reads named's DNSSEC key and state files; DS records; the signing phases (docs/dnssec.md)
porkbun/ Porkbun API client (name servers, glue, DS records) and an in-memory fake for tests
config/ jarld's YAML configuration
deploy/ systemd units (jarld, the daily backup service and timer), sysusers.d and tmpfiles.d files
deploy/debian/ the Debian package's control template and postinst/prerm/postrm
@@ -110,7 +112,10 @@ checker tests work with the installed `bind9-utils` too.
`scripts/e2e.sh` builds a legacy BIND setup, starts named, runs
`jarld init`, `import`, `adopt` and `serve`, then drives every CLI command
(records, batches, changesets, views, ACLs, TSIG, options, users, tokens,
ACME hooks, audit) and checks the answers with `dig`. `KEEP=1` leaves named
ACME hooks, DNSSEC signing, backup and restore with keys, audit) and checks
the answers with `dig`. The Porkbun code is tested against
`porkbun.Fake`, an in-memory stand-in for its API; nothing calls the real
one. `KEEP=1` leaves named
and jarld running afterwards. The output says where; set `JARL_CLI_CONFIG`
to that directory's `cli.yaml` to keep using the CLI against it.
+334
View File
@@ -0,0 +1,334 @@
# DNSSEC signing
DNSSEC lets resolvers that validate (Cloudflare 1.1.1.1, Google 8.8.8.8,
Quad9, many ISPs) detect forged answers for your domain. Jarl signs zones
with BIND's own key and signing policy (`dnssec-policy`), and guides you
through the steps that involve the parent zone, where getting the order
wrong takes a domain offline.
- [Should you sign?](#should-you-sign)
- [How it works](#how-it-works)
- [Turning signing on](#turning-signing-on)
- [Putting your own name servers on the internet](#putting-your-own-name-servers-on-the-internet)
- [Porkbun](#porkbun)
- [Turning signing off](#turning-signing-off)
- [Wipe: a clean slate](#wipe-a-clean-slate)
- [Keys, backups and moving servers](#keys-backups-and-moving-servers)
- [What Jarl refuses, and why](#what-jarl-refuses-and-why)
- [Phases](#phases)
- [Custom policies](#custom-policies)
- [Adopting a signed zone](#adopting-a-signed-zone)
## Should you sign?
Sign a zone that the internet sees through a delegation from its parent:
your registered domain (`adbraun.lol`), as served to the outside world.
Validation only happens once the parent zone (`lol`) publishes a DS
record for the zone's key, which you add at your registrar.
Signing an internal-only zone (one no registrar delegates, or a split-DNS
view only your own network reaches) adds nothing. There is no parent to
publish a DS, and BIND doesn't validate the zones it serves itself.
**Split DNS.** In Jarl a zone belongs to a view, so you sign the external
view's `adbraun.lol` and leave the internal view's copy unsigned. That is
the usual setup, with one catch. Once the DS is at the parent, the world
expects `adbraun.lol` to be signed. A device on your network that validates
DNSSEC *itself* and gets the internal, unsigned copy rejects it. Examples
are a Pi-hole or Unbound with DNSSEC on, systemd-resolved with
`DNSSEC=yes`, and some routers. Devices that just ask this server are fine.
If you run such a validator, sign the internal copy too and publish both
DS records.
**What it costs.** It is one more way for the domain to break: if the DS at
the parent stops matching the zone's keys (keys lost, wiped, or a server
moved without them), every validating resolver rejects the whole domain.
Most of Jarl's DNSSEC features exist to stop that from happening.
## How it works
named signs the zone itself, with *inline signing*:
- Jarl writes the zone file as before (`/var/lib/bind/jarl/<view>/<zone>.zone`).
named keeps a signed copy next to it (`.signed`, `.signed.jnl`), signs
every change Jarl makes, and re-signs before signatures expire.
- The keys live in Jarl's key directory,
`/var/lib/bind/jarl/dnssec-keys/<view>/`. The subdirectory is named after
the view's ID, so renaming a view doesn't move the keys. Each key is
three files, `K<zone>.+013+<tag>.key`, `.private` (readable only by
named's user) and `.state`. Zones of the same name in two views have
separate directories, because named requires that.
- With BIND's `default` policy there is one key, a CSK (combined signing
key) using ECDSA P-256 (algorithm 13). It is never replaced, so the DS
record at the registrar never has to change.
- The signed zone has its own serial, usually a little ahead of Jarl's.
Jarl's reload check compares the serial of the file it wrote. The serials
check (`jarl zone serials`) compares the signed serial, which is what
secondaries serve.
- Secondaries transfer the signed zone and need nothing else.
- The server's clock must be right (NTP): signatures are valid for a fixed
time window.
## Turning signing on
```sh
jarl zone dnssec enable adbraun.lol --view external
jarl zone dnssec status adbraun.lol --view external
```
or, in the web UI, the zone's **DNSSEC** tab → *Turn signing on*. This is
safe at any time. Resolvers keep treating the zone as unsigned until its
DS record is at the parent.
`status` shows the key, the DS record, and when it is safe to publish:
```
Status: named is signing the zone. Resolvers still treat it as unsigned, ...
Next: Wait until Fri 9 Oct 21:13 UTC before adding the DS at the parent: ...
KEY ROLE ALGORITHM GOAL DNSKEY DS AT PARENT CREATED
51126 CSK ECDSAP256SHA256 omnipresent rumoured hidden 2026-10-08 21:08
DS record for the parent zone (at your registrar):
(not yet: wait until Fri, 09 Oct 2026 21:13:51 UTC)
adbraun.lol. 3600 IN DS 51126 13 2 FFBECE3D...
key tag 51126, algorithm 13 (ECDSAP256SHA256), digest type 2 (SHA-256), digest FFBECE3D...
```
named first publishes the key for long enough that every resolver's cache
has caught up: about a day with the default policy. Adding the DS earlier
can make validating resolvers reject cached answers. Then:
1. **Make sure the domain is delegated to servers that serve the signed
zone.** Your own servers, or secondaries that transfer from them.
Publishing a DS while the registrar still points at other name servers
(Porkbun's own, say) breaks the domain. `jarl zone delegation ZONE`
shows where the parent points.
2. **Publish the DS record at the registrar**, by hand (the DNSSEC tab has
copy buttons for the whole record and for its four fields), or with
Jarl's [Porkbun](#porkbun) support, which checks step 1 first.
3. **Tell named.** Once the parent zone has the DS (minutes to hours after
the registrar accepts it), run the DS check:
```sh
jarl zone dnssec check-ds adbraun.lol --view external
```
It asks every server of the parent zone for the zone's DS records, and
tells named (`rndc dnssec -checkds`) when all of them have it. named
waits for the parent's DS TTL before it counts the DS as in place.
4. **Check.** `jarl zone delegation ZONE` now also compares the parent's
DS with the zone's keys. A DS that matches no key is an error.
[DNSViz](https://dnsviz.net/) gives an independent picture.
If the parent zone is itself a primary zone in Jarl (`home.adbraun.lol`
inside `adbraun.lol`), the DNSSEC tab says so: add the DS as a record in
that zone.
## Putting your own name servers on the internet
To serve your domain from your own server, the parent zone must delegate
it to your server's *name* (NS records can't hold addresses). If that name
is inside the domain itself (`ns1.adbraun.lol` for `adbraun.lol`),
resolvers couldn't look it up without already reaching your server. **Glue
records** solve this: the registry publishes `ns1.adbraun.lol`'s address
together with the delegation. Each name server inside the domain needs its
own glue record, with all of its addresses (IPv4 and IPv6).
The order matters:
1. In the zone (external view): NS records for your name servers, and A/AAAA
records for the ones inside the domain.
2. At the registrar: a glue record for each of those (Porkbun calls them
"glue records", others "host records" or "child name servers").
3. Check that every name server answers for the zone from the internet.
4. At the registrar: set the domain's name servers.
5. `jarl zone delegation ZONE` until it is all green. Resolvers may use the
old name servers for a day or two after the change, so keep those
serving the zone until then.
Jarl's [Porkbun](#porkbun) support does steps 2 to 4 with checks.
Before you go public:
- **Two name servers.** Registries and RFC 1034 expect at least two, on
different networks: with one server at home, a power or internet outage
takes the domain off the internet. A free secondary service (Hurricane
Electric's dns.he.net, for example) or a small VPS transfers the zone
from Jarl, signed zone included. A *hidden primary* goes one step
further: your server feeds the secondaries but isn't in the NS records,
so your home address isn't advertised.
- **A dynamic home address** means the glue must follow it.
- **Inbound port 53** (UDP and TCP) must reach the server: some ISPs block
it, and CGNAT makes it impossible.
## Porkbun
With a Porkbun API key, Jarl manages a domain's name servers, glue records
and DS records at Porkbun, and checks each change before making it.
**Setting it up**
1. At porkbun.com, *Account → API Access*: create a key. If you can,
limit it to the domain and the permissions it needs (domains, read),
and to your server's address.
2. On the domain's page, switch **API Access** on (Porkbun refuses API
calls for a domain until you do).
3. Put the key in `/etc/jarl/jarl.yaml` and restart jarld:
```yaml
porkbun:
api_key: pk1_...
secret_api_key: sk1_...
domains: [adbraun.lol] # optional: only these
```
The key can move your domain anywhere, so it lives only in that file:
never in Jarl's database or its backups, and there is no API or CLI command
to upload it. Keep the file readable by root and jarld only (`chown
root:jarl`, `chmod 640`, as the package installs it); jarld warns at start
when everyone can read it. Only administrators can use the registrar
features.
**Using it**: the zone's **Registrar** tab, or `jarl zone registrar`:
| | |
|---|---|
| `status ZONE` | Name servers, glue and DS records at Porkbun next to the zone's, with what doesn't match. |
| `sync-glue ZONE` | Gives every name server inside the domain a glue record with the zone's A and AAAA records for it. Changing the glue of a name server the domain already uses sends resolvers to the new addresses, so Jarl checks first that they answer for the zone. |
| `set-ns ZONE [NS...]` | Sets the domain's name servers (default: the zone's NS records). Checked first: each answers for the zone with the current serial, the ones inside the domain have matching glue, and DS records at Porkbun match the zone's keys (otherwise the domain goes dark for validating resolvers). |
| `publish-ds ZONE` | Publishes the zone's DS record. Refused until named says the zone is ready, and unless the domain is delegated to the zone's name servers and they serve the signed zone. |
| `remove-ds ZONE KEYTAG` | Removes a DS record (the step when turning signing off; also the fix for a DS that matches no key). |
Every change can be tried first with `--dry-run`, which shows the checks.
Errors refuse the change; `--force` (in the web UI, a checkbox) overrides
them, for example when this server can't reach its own public address to
check it.
## Turning signing off
Never switch a signed zone straight to unsigned while its DS might still
be at the parent: validating resolvers would reject the whole domain until
the DS is gone *and* its TTL has passed (often a day). Jarl refuses that
step and goes through BIND's `insecure` policy instead:
```sh
jarl zone dnssec disable adbraun.lol --view external
```
1. The zone switches to the `insecure` policy. named keeps it signed and
publishes a CDS "delete" record. Nothing breaks.
2. Remove the DS record at your registrar (`jarl zone registrar remove-ds`
with Porkbun). Once the parent zone no longer has it, run `jarl zone
dnssec check-ds` so named knows.
3. named waits until resolvers have forgotten the DS (the parent's DS TTL
plus a margin, about a day), then removes the keys and signatures.
`status` shows the progress.
4. Run `jarl zone dnssec disable` again (or *Finish turning off* in the web
UI) when `status` says so: DNSSEC is then off for the zone.
If the DS never reached the parent, named skips the wait and you can
finish at once. The old keys stay in the key directory (named keeps them
retired; signing again makes a new key) until you wipe them.
## Wipe: a clean slate
```sh
jarl zone dnssec wipe adbraun.lol --view external
```
or *Wipe (clean slate)* on the DNSSEC tab. It switches signing off at once,
without the insecure policy, and deletes the zone's keys and named's signed
copy, so signing again starts from scratch. It also cleans up a zone that
is off but still has old keys.
That is only safe when the parent zone has no DS record for the zone, so
Jarl asks the parent's servers first and **refuses while they have one**.
It also refuses while named still counts a recently removed DS as cached.
When the parent can't be asked (no internet access, or a zone that isn't on
the internet), `--force` wipes anyway. Wiping needs an administrator, and
the web UI asks you to type the zone's name.
Deleting a zone that is signed is refused: turn signing off first.
## Keys, backups and moving servers
The zone's keys exist only in the key directory: Jarl's database doesn't
hold them. Losing them while the parent has a DS for them breaks the
domain.
- **Backups.** Every backup (`jarld backup`, and the daily
`jarl-backup.timer`) stores the key directory's files inside the backup
file, private keys included. Keep `/var/backups/jarl` private (it is
`0700`). named's private keys are readable only by named's user, so
`jarl-backup.service` runs with the one capability it needs to read them
(`CAP_DAC_READ_SEARCH`); a manual backup must run as root. `--without-keys`
backs up only the database.
- **Restoring.** `jarld restore` puts the keys back, owned by named's user
(`bind.named_user`), which needs root. Key files that exist but aren't in
the backup are left alone.
- **Serials after a restore.** A restored database may have older zone
serials than named has. named won't load a signed zone's file unless its
serial went up, and secondaries ignore a serial that went down. Jarl
checks this whenever it writes a zone file, and raises the serial above
named's when needed (it shows in the result, and in the audit log).
- **Moving to a new server.** Restore the backup *before* named loads
Jarl's configuration there. Otherwise named finds no keys and makes new
ones, which the parent's DS doesn't match.
## What Jarl refuses, and why
| Change | Why |
|---|---|
| Switching a signed zone straight to unsigned | The parent may still have the DS: use `disable` (insecure first), or `wipe` when the parent has none. |
| Finishing `disable` before named is done | Resolvers may still have the DS cached. |
| Deleting a signed zone | Turn signing off first. |
| Moving a signed zone to another view | It would get new keys (another key directory) that the DS doesn't match. |
| A TTL above the policy's `max-zone-ttl` (a day by default) | named refuses to load such a signed zone and keeps serving the old one. |
| `dnssec-policy` as a zone, view or server option | Jarl sets it per zone, so it can guard the steps. Top-level `dnssec-policy NAME { ... }` statements (custom policies) are fine. |
| A custom policy with an empty `keys { }` list | It crashes named 9.20 when the configuration loads (named-checkconf accepts it). Use the built-in `insecure` policy. |
| `inline-signing no` on a signed zone | Jarl rewrites zone files; named must keep its signatures in a separate copy. |
| Signing a secondary zone | A secondary gets its signatures from its primary. |
## Phases
`status`, the DNSSEC tab and the API report a phase:
| Phase | Meaning | Next |
|---|---|---|
| `off` | Not signed. | |
| `leftover_keys` | Not signed, but old keys are in the key directory. | Wipe them, or sign again. |
| `starting` | Signing is on; named hasn't made the key yet. | Wait a moment. |
| `signing` | Signed, but caches may not have the key yet. | Wait until the time shown, then publish the DS. |
| `ready` | Safe to add the DS at the parent. | Publish it, then run the DS check. |
| `ds_pending` | named knows the DS is at the parent and waits for its TTL. | |
| `secure` | Signed, and the parent's DS points at the key. | |
| `removing_ds` | Turning off; the DS must go from the parent first. | Remove it, then run the DS check. |
| `ds_expiring` | The DS is gone; caches may still have it. | Wait. |
| `removing_keys` | named removes the keys and signatures. | Finish when you like (it's safe now). |
| `unsigned` | Turning off has finished. | Switch DNSSEC off for the zone. |
The key states come from named (`rndc dnssec -status ZONE` shows the same):
*rumoured* means published, but caches may not have it yet; *omnipresent*
means everyone has it; *unretentive* means removed, but caches may still
have it; *hidden* means gone.
## Custom policies
`default` suits most domains. For anything else (scheduled key rollovers,
NSEC3, other algorithms), define a policy as a top-level statement
(`jarl option set dnssec-policy 'NAME { ... }' --toplevel`, or *Server
options* in the web UI) and enable it with `jarl zone dnssec enable ZONE
--policy NAME`. With a policy that rolls the key-signing key,
the DS at the parent must change at every rollover: `status` and the DS
check tell you when, and named waits for the DS check before it retires
the old key.
## Adopting a signed zone
When `jarld import` finds a zone that named already signs, it imports its
policy and warns. Before `jarld adopt`, copy the zone's key files
(`K<zone>.+*`) from its old key directory into the one Jarl uses (`jarl
zone dnssec status ZONE` shows it), owned by named's user. Otherwise named
makes new keys, and if the parent has a DS for the old ones, the zone stops
validating.
+2 -2
View File
@@ -167,7 +167,7 @@ This installs:
| `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI |
| `/usr/local/share/bash-completion/completions/jarl`, `.../jarld` | bash completion (loaded automatically by the `bash-completion` package) |
| `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) |
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup ([operations.md](operations.md#backups)) |
| `/etc/systemd/system/jarl-backup.service`, `jarl-backup.timer` | the daily database backup, DNSSEC keys included ([operations.md](operations.md#backups)) |
| `/usr/lib/sysusers.d/jarl.conf` | creates the `jarl` system user and adds it to group `bind` |
| `/usr/lib/tmpfiles.d/jarl.conf` | creates `/etc/jarl`, `/var/lib/jarl`, `/etc/bind/jarl`, `/var/lib/bind/jarl`, `/var/cache/bind/jarl`, `/var/backups/jarl` |
| `/etc/jarl/jarl.yaml` | example configuration (only if none exists) |
@@ -390,7 +390,7 @@ a backup (it is safe while jarld runs); `jarld restore` puts it back if
needed:
```sh
sudo -u jarl jarld backup
sudo jarld backup
```
## Uninstalling
+37 -18
View File
@@ -6,7 +6,8 @@
|------|-----|
| Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems, the newest backup, tokens about to expire |
| Are the secondaries up to date? | `jarl zone serials ZONE`, or the zone's Name servers tab ([below](#checking-name-servers-and-the-delegation)) |
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo -u jarl jarld backup`; restore with `jarld restore` ([below](#backups)) |
| Back up / restore | Daily by `jarl-backup.timer`; by hand `sudo jarld backup`; restore with `jarld restore` ([below](#backups)) |
| DNSSEC | `jarl zone dnssec status ZONE`, or the zone's DNSSEC tab ([dnssec.md](dnssec.md)) |
| What changed and who did it? | `jarl history`, `jarl changeset show ID` (with diff), `jarl audit` |
| Undo a change | `jarl changeset revert ID`, review it, then `jarl changeset apply NEW_ID` (or History → Revert in the UI) |
| Check the configuration | `jarl check`: full validation plus `named-checkconf -z` |
@@ -23,8 +24,10 @@ loaded. jarld is only needed to make changes.
Everything Jarl knows is in its database (`/var/lib/jarl/jarl.db`): zones,
records, views, ACLs, TSIG keys, options, users, tokens, history and the
audit log. named's files are generated from it, so the database is the one
thing to back up.
audit log. named's files are generated from it. The one exception is the
DNSSEC keys of signed zones, which named keeps in Jarl's key directory
(`/var/lib/bind/jarl/dnssec-keys`): every backup stores those too, inside the
backup file ([dnssec.md](dnssec.md#keys-backups-and-moving-servers)).
### The daily backup
@@ -41,17 +44,19 @@ Each run:
1. writes a consistent copy of the database with SQLite's `VACUUM INTO`
(safe while jarld runs and changes things) to a temporary file in the
backup directory;
2. checks the copy with SQLite's `integrity_check`, and only then gives it
2. stores the DNSSEC keys in it (every file of the key directory, private
keys included);
3. checks the copy with SQLite's `integrity_check`, and only then gives it
its name, `jarl-<UTC time>.db`, e.g. `jarl-20261008T031512Z.db`. A file
with that name is always a complete, intact backup;
3. deletes backups older than `retention`, but always keeps the newest
4. deletes backups older than `retention`, but always keeps the newest
`keep_at_least`, however old they are. If backups stop for a while (the
machine was off, the disk was full), the old ones aren't all deleted by
the next run. Ages come from the time in the name, not the file's
modification time, which copying the directory would change. Only files
named `jarl-<time>.db` (and `stats-<time>.db`) are ever deleted, so
anything else you put in the directory is safe;
4. records the result in the database, so the dashboard and `jarl status`
5. records the result in the database, so the dashboard and `jarl status`
show when the newest backup was made, and whether the last attempt
failed.
@@ -87,7 +92,9 @@ systemctl edit jarl-backup.timer
The backup service runs as the `jarl` user in a sandbox that may only
write to `/var/lib/jarl` and `/var/backups/jarl` (created by
`tmpfiles.d/jarl.conf`, mode 0700). To keep backups elsewhere, set
`tmpfiles.d/jarl.conf`, mode 0700). It has one capability,
`CAP_DAC_READ_SEARCH`, to read named's DNSSEC private keys, which only
named's user may read. To keep backups elsewhere, set
`backup.dir`, create the directory (owned by `jarl`, mode 0700) and allow
it:
@@ -104,18 +111,23 @@ well.
### By hand
```sh
sudo -u jarl jarld backup # into backup.dir, as the timer does
sudo -u jarl jarld backup /root/before-upgrade.db # one copy, never deleted automatically
sudo jarld restore # list the backups in backup.dir
sudo jarld backup # into backup.dir, as the timer does
sudo jarld backup /root/before-upgrade.db # one copy, never deleted automatically
sudo jarld restore # list the backups in backup.dir
sudo systemctl start jarl-backup # or exactly what the timer runs
```
Query statistics live in a separate database (`/var/lib/jarl/stats.db`).
DNS doesn't need it, so it is backed up only with `backup.stats: true`, as
`stats-<UTC time>.db`.
Run it as root (files it creates are given to `jarl`): named's DNSSEC
private keys are readable only by named's user. As another user it fails
when there are keys; `--without-keys` backs up only the database.
The database holds password and token *hashes*, and TSIG secrets in the
clear. Backups are written with mode 0600 into a 0700 directory; keep any
copies just as private.
clear, and backups hold the DNSSEC private keys too. Backups are written
with mode 0600 into a 0700 directory; keep any copies just as private.
### Restoring
@@ -138,7 +150,11 @@ changing anything (`--yes` skips the question). Then it:
`jarl.db.before-restore-<UTC time>` (with its `-wal` and `-shm` files),
so a restore can be undone the same way;
* copies the backup in place, upgrades it to the current schema if it is
older, and records the restore in its audit log.
older, and records the restore in its audit log;
* puts the DNSSEC keys stored in the backup back into the key directory,
owned by named's user (`bind.named_user`) with the modes they had. Key
files that are there but not in the backup are left alone.
`--without-keys` skips this; without it, a backup with keys needs root.
Then start jarld and bring named in line with the restored database:
@@ -152,11 +168,14 @@ jarl resync --force # write everything and reload named
Things to know:
* **Zone serials.** An older backup can carry lower zone serials than named
last served. Your own named doesn't mind, but secondaries and caches
ignore a serial that went backwards, so they keep the newer data. If you
have secondaries, raise the serials after a restore:
`jarl zone update ZONE --serial <higher than before>`, then check with
`jarl zone serials ZONE`.
last served. Secondaries ignore a serial that went backwards, and named
refuses one for a signed zone. Jarl handles it: whenever it writes a zone
file whose serial isn't above the one named has loaded, it raises the
serial past named's (the resync result and the audit log show it). Check
the secondaries afterwards with `jarl zone serials ZONE`.
* **DNSSEC keys** must be back before named loads a signed zone, or named
makes new keys that the parent's DS record doesn't match. On a new
machine, restore before adopting.
* **Tokens and passwords** go back to what they were when the backup was
made: tokens created since are gone, and revoked ones work again. Rotate
or revoke as needed.
+4 -3
View File
@@ -27,8 +27,9 @@ certbot, CI jobs and backups.
## Grants
A user's permissions are a list of grants. Administrators (`admin: true`)
may do everything, and only they can manage users, see everyone's tokens
and run `resync`.
may do everything, and only they can manage users, see everyone's tokens,
run `resync`, wipe a zone's DNSSEC keys, and use the registrar (Porkbun,
whose key can move the domain).
A grant is written as:
@@ -38,7 +39,7 @@ RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
| Resource | Covers |
|----------|--------|
| `zones` | zone definitions (create, delete, SOA, TTL, view) and zone options |
| `zones` | zone definitions (create, delete, SOA, TTL, view, DNSSEC signing and the DS check) and zone options |
| `records` | records in primary zones |
| `views` | views and view options |
| `acls` | ACLs |
+22 -1
View File
@@ -74,11 +74,32 @@ showing them.
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
| `option_syntax`, `option_value_invalid` | error | The value must parse (balanced braces, strings, the grammar's value type: yes/no, enums, numbers, durations, sizes, address match lists) |
| `option_duplicate` | error | Options that may appear once, appear once |
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`, a view's `match-*` |
| `option_managed` | error | Statements Jarl manages itself can't be raw options: `zone`, `view`, `acl`, `key`, `options`, a zone's `type`/`file`/`in-view`/`key-directory`, a view's `match-*`, and `dnssec-policy` in zones, views and server options (set per zone instead; top-level `dnssec-policy NAME { }` definitions are options) |
| `option_deprecated`, `option_obsolete`, `option_unavailable` | warning | Flags from the BIND 9.20 reference |
| `option_experimental` | info | |
| `settings_invalid` | error | Defaults for new zones are valid |
## DNSSEC
These rules come from [dnssec.md](dnssec.md#what-jarl-refuses-and-why).
Those that compare the change with the current state are checked when it is
applied.
| Code | Severity | Rule |
|------|----------|------|
| `dnssec_zone_type` | error | Only primary zones are signed by Jarl |
| `dnssec_policy_unknown` | error | The zone's policy must be `default`, `insecure` or a top-level `dnssec-policy` statement |
| `dnssec_policy_no_keys` | error | A custom policy with an empty `keys { }` list (named 9.20 crashes loading it) |
| `dnssec_ttl_too_long` | error | TTLs (and the default TTL) of a signed zone must be at most the policy's `max-zone-ttl` (a day for the built-in policies); named refuses to load the zone otherwise |
| `dnssec_inline_signing` | error | No `inline-signing no` on a signed zone |
| `dnssec_off_unsafe` | error | A signed zone can't go straight to unsigned: use the `insecure` policy first (or wipe, when the parent has no DS) |
| `dnssec_off_too_early`, `dnssec_off_unknown` | error | Switching off after `insecure` waits until named is done removing the signatures |
| `dnssec_delete_signed` | error | A signed zone can't be deleted; turn signing off first |
| `dnssec_keys_moved` | error | A signed zone can't move to another view (its keys would change) |
| `dnssec_policy_change` | warning | Changing the policy may replace the keys, and with them the DS at the parent |
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
## Security lints
| Code | Severity | What it catches |