Jarl 0.1.0: manage BIND through a web UI, REST API and CLI

Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
# Let's Encrypt (ACME DNS-01) with Jarl
The DNS-01 challenge proves that you control a domain by publishing a TXT
record at `_acme-challenge.<domain>`. Jarl does this through its API, so no
dynamic updates (`allow-update`, nsupdate) are needed. That matters because
Jarl regenerates zone files and doesn't allow dynamic zones.
**When a `jarl acme` command returns, named is already serving the record.**
Jarl reloads the zone and checks the new serial with `rndc zonestatus`
before it reports success, so hooks need no "sleep and hope" delay. If you
also run secondary servers, they pick the change up through NOTIFY within
seconds. Add a short `--dns-propagation-seconds`/`--dnssleep` only if your
CA checks them too.
## 1. A token that can only publish challenges
```sh
jarl user create certbot --no-password --role acme --comment "Let's Encrypt"
jarl token create certbot --user certbot --role acme -q > certbot.token
sudo install -m 0600 certbot.token /etc/jarl/certbot.token && rm certbot.token
```
The `acme` role is `records:write names=_acme-challenge.* types=TXT`. The
token can add and remove challenge TXT records in any zone and nothing
else. To restrict it to your zones, use an explicit grant instead:
```sh
jarl user create certbot --no-password --grant "records:write zones=adbraun.lol names=_acme-challenge.* types=TXT"
```
## 2. What `jarl acme` does
```sh
jarl acme set DOMAIN VALUE... # publish
jarl acme unset DOMAIN [VALUE...] # remove these values, or all of them
```
* `DOMAIN` is the name on the certificate. `_acme-challenge.` is added for
you, and a leading `*.` is dropped, since a wildcard and its apex share one
challenge name. A name that already starts with `_acme-challenge.` is used
as it is.
* The zone is found automatically: the longest matching primary zone.
* If the zone exists in several **views** (split DNS), the record is written
into every view, so the CA (on the internet, in your external view) and
your internal resolvers see the same thing. `--view NAME` limits it to one.
* All values and views go into one changeset. Setting a value that is
already there succeeds, so a retried hook doesn't fail, and removing a
value that isn't there succeeds as well.
* The record TTL is 60 seconds (`--ttl`).
## 3. certbot
```sh
sudo certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-auth' \
--manual-cleanup-hook 'jarl --token @/etc/jarl/certbot.token acme certbot-cleanup' \
-d adbraun.lol -d '*.adbraun.lol'
```
The hooks read `CERTBOT_DOMAIN` and `CERTBOT_VALIDATION` from certbot. Add
`--server https://ns1.adbraun.lol:8053` when certbot runs on another host,
or `--server http://127.0.0.1:8053` on the DNS server itself (root's jarl
CLI has no configuration file). certbot stores the hooks in the renewal
configuration, so `certbot renew` works unattended.
## 4. acme.sh
Install the DNS API hook that comes with Jarl:
```sh
cp contrib/acme.sh/dns_jarl.sh ~/.acme.sh/dnsapi/ # or from /usr/local/share/doc/jarl/contrib/
export JARL_SERVER=https://ns1.adbraun.lol:8053
export JARL_TOKEN=$(cat /etc/jarl/certbot.token)
acme.sh --issue --dns dns_jarl -d adbraun.lol -d '*.adbraun.lol'
```
The hook calls the `jarl` CLI, which must be installed on the same host. It
remembers `JARL_SERVER`, `JARL_TOKEN`, `JARL_VIEW` (optional) and `JARL_BIN`
(path to jarl, optional) in acme.sh's account configuration for renewals.
When they are unset, jarl's own configuration file is used.
## 5. A dedicated ACME zone (CNAME delegation)
Instead of letting a token write into your main zone, point each challenge
name at a small zone that exists only for ACME. The token may then write
only there:
```sh
jarl zone create acme.adbraun.lol --ns ns1.adbraun.lol.
jarl record add adbraun.lol acme NS ns1.adbraun.lol. # delegate it
jarl record add adbraun.lol _acme-challenge CNAME apex.acme.adbraun.lol.
jarl record add adbraun.lol _acme-challenge.www CNAME www.acme.adbraun.lol.
jarl user create acme --no-password --grant "records:write zones=acme.adbraun.lol types=TXT"
jarl token create acme --user acme -q > acme.token
```
The CA follows the CNAME, so the TXT record has to be written at its
target:
* **certbot**: add `--follow-cname` to both hooks, e.g.
`jarl --token @/etc/jarl/acme.token acme certbot-auth --follow-cname`.
jarl looks up `_acme-challenge.<domain>` and writes the TXT record at the
target (`www.acme.adbraun.lol`). The lookup uses the host's resolvers.
Add `--resolver 127.0.0.1` to ask the Jarl server directly, for example
when the host's resolver sees a different view.
* **acme.sh** does the lookup itself:
* `--challenge-alias acme.adbraun.lol` puts every TXT record at
`_acme-challenge.acme.adbraun.lol`. Point all the CNAMEs there
(`_acme-challenge.www CNAME _acme-challenge.acme.adbraun.lol.`).
* `--domain-alias www.acme.adbraun.lol` writes at exactly that name.
The hook passes acme.sh's full record name to `jarl acme --exact`, so both
modes work.
A leaked ACME token can then only touch TXT records in the ACME zone, and
the main zone is never modified by automation.
## Troubleshooting
* `no primary zone that you can see contains ...`: the token's grants
don't cover the zone, or the zone isn't a primary zone in Jarl.
* `permission denied: this needs records:write ...` (exit code 3): the
record name or type isn't allowed for the token. Compare with
`jarl --token @FILE whoami`.
* The CA says the TXT record is missing: check from outside with
`dig @ns1.adbraun.lol _acme-challenge.adbraun.lol TXT`. With views, make
sure the record is in the view that matches the CA (it is by default)
and that `allow-query` lets the internet query the zone.
* `jarl audit --user certbot` shows every attempt.
+310
View File
@@ -0,0 +1,310 @@
# REST API reference
Everything the web UI and the CLI do goes through this API. The UI only uses
what is documented here.
* Base URL: `https://<jarld>/api/v1`
* JSON in, JSON out (`Content-Type: application/json`). Request bodies are
limited to 16 MiB (1 MiB for login).
* Times are RFC 3339. Names in responses are FQDNs with the final dot
(`www.adbraun.lol.`), and display helpers drop it (`display_name`,
`relative_name`).
* `GET /healthz` (no authentication) returns `{"ok": true, "version": ...}`.
## Authentication
**API tokens**, for scripts and the CLI:
```sh
curl -H "Authorization: Bearer jarl_xxxxxxxx_yyyyyyyy" https://ns1.adbraun.lol:8053/api/v1/zones
```
**Sessions**, as used by the web UI: `POST /auth/login` with
`{"username": "...", "password": "..."}` sets the `jarl_session` cookie and
returns the `whoami` object, which includes `csrf_token`. Requests that
change something (anything but GET/HEAD) must send it as `X-CSRF-Token`.
`POST /auth/logout` ends the session.
`GET /auth/whoami` returns the user, the token (if any), the effective
permissions and whether the caller is an administrator.
## Errors
```http
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "validation_failed",
"message": "www.adbraun.lol has a CNAME, so it can't have any other records (found A); ...",
"problems": [
{"severity": "error", "code": "cname_conflict", "message": "...", "object_type": "record",
"object_id": 15, "zone": "adbraun.lol.", "view": "internal", "name": "www.adbraun.lol."}
],
"check": [ ... named-checkconf messages, when it ran ... ],
"op": 2,
"result": { ... the full result, as for a dry run ... }
}
}
```
| Status | `code` | Meaning |
|--------|--------|---------|
| 400 | `bad_request` | Malformed JSON, unknown field value, record data that doesn't parse |
| 401 | `unauthenticated` | No or invalid credentials |
| 403 | `forbidden`, `csrf` | Not allowed, or missing CSRF token |
| 404 | `not_found` | No such object (or not visible to you) |
| 409 | `conflict`, `drift`, `not_adopted` | Object changed since it was staged, object in use, name taken, files changed outside Jarl |
| 422 | `validation_failed` | The resulting configuration would be invalid: see `problems` (Jarl's checks, [validation.md](validation.md)) and `check` (named-checkconf / named-checkzone messages) |
| 429 | `rate_limited` | Too many failed logins |
| 502 | `bind_failed` | A BIND tool couldn't run, writing the files failed, or named did not load the change (the previous files were restored) |
`op` is the 1-based index of the operation that failed in a batch.
## Changing things: apply, stage or dry-run
Every endpoint that changes the configuration (POST, PATCH, PUT and DELETE
on the resources below, except users and tokens) works the same way:
| Request | Effect | Status |
|---------|--------|--------|
| plain | Applied immediately as a one-operation changeset | 201 (create) / 200 |
| `?changeset=ID` or header `X-Jarl-Changeset: ID` | Staged into the open changeset `ID` (validated against the changeset's working state, not applied) | 202 |
| `?dry_run=1` | Validated, rendered and checked with named-checkconf, nothing installed | 200 |
| `?force=1` | Overwrite files that were changed outside Jarl | |
Reads accept `?changeset=ID` too. They then show the configuration as it
will be after the changeset, with `"pending": "add" | "change" | "delete"`
on affected zones and records.
The response of an applied change:
```json
{
"applied": true,
"changeset_id": 37,
"result": {
"valid": true,
"problems": [],
"existing_problems": [ ... warnings that were already there ... ],
"check_ok": true,
"diff": "--- /var/lib/bind/jarl/internal/adbraun.lol.zone\n+++ ...",
"files_changed": ["/var/lib/bind/jarl/internal/adbraun.lol.zone"],
"serials": [{"zone": "adbraun.lol.", "view": "internal", "old": 2026100715, "new": 2026100716}],
"reloaded": ["reload adbraun.lol [internal]"],
"ops": [{"seq": 1, "op": "record.create", "id": 295, "summary": "add doc.adbraun.lol. 300 IN A 192.168.1.77"}]
},
"object": { ... the created or updated object ... },
"warnings": []
}
```
A staged change returns `{"staged": true, "changeset_id": ..., "added": [ops], "problems": [...]}`.
Staging checks right away that the request doesn't introduce validation
errors into the changeset's working state (a request with several
operations is checked at its end state) and returns 422 if it does.
named-checkconf runs on preview and apply, and the whole changeset is
validated again when it is applied.
**Optimistic locking.** Every object has a `version`. `PATCH /records/{id}`
honours `If-Match: <version>`, and batch operations take `base_version`. If
the object changed in the meantime the request fails with 409. Staged
operations record the version they were staged against automatically.
## Resources
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
when the name exists in several views. `{id}` for views, ACLs and TSIG keys
may be the name.
### Zones
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
| POST | `/zones` | ZoneInput (below) |
| GET | `/zones/{zone}` | |
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
| POST | `/zones/{zone}/import` | `{"text": "<zone file>", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` |
ZoneInput:
```json
{
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
"view": "internal", // or "view_id"; moving a zone = PATCH with another view
"type": "primary", // secondary, stub, static-stub, forward, mirror, hint, redirect, in-view
"in_view": "internal", // type in-view only
"file": "...", // hint/redirect zones, or a custom file for secondaries
"soa": {"mname": "ns1.adbraun.lol.", "rname": "hostmaster@adbraun.lol", "serial": 0,
"refresh": 3600, "retry": 900, "expire": 1209600, "minimum": 300},
"default_ttl": 3600,
"serial_policy": "date", // date, increment, unixtime
"comment": "",
// create only:
"nameservers": ["ns1.adbraun.lol."], // default: settings
"records": [{"name": "ns1", "type": "A", "data": "192.0.2.1"}],
"options": [{"name": "allow-transfer", "value": "{ key xfer; }"}],
"clone_from": 12 // copy records from zone 12
}
```
### Records
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=` |
| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) |
| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match |
| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this |
| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset |
| GET | `/records/{id}` | |
| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) |
| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR |
RecordInput:
```json
{
"name": "www", // relative to the zone, "@" for the apex, or an FQDN with a final dot
"type": "A",
"data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text
"ttl": 300, // null or absent: the zone's default TTL
"comment": "web server",
"ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone
}
```
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
(the decoded value of TXT records).
### Views
| Method | Path | Body |
|--------|------|------|
| GET | `/views` | in match order |
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
`adopt_zones` moves zones that are not in a view into the new view. named
requires every zone to be in a view as soon as one view exists.
### ACLs and TSIG keys
| Method | Path | Body |
|--------|------|------|
| GET | `/acls` | each with `used_by` |
| POST | `/acls` | `{"name": "homelab", "elements": ["192.168.1.0/24", "!192.168.1.66", "key xfer", "localhost"], "comment": ""}` |
| GET / PATCH / DELETE | `/acls/{id}` | renaming updates references; deleting one in use → 409 |
| GET | `/tsig-keys` | without secrets |
| POST | `/tsig-keys` | `{"name": "xfer", "algorithm": "hmac-sha256", "secret": "<base64>"}` (secret generated when absent; the response `object` shows it once) |
| GET | `/tsig-keys/{id}` | `?secret=1` includes the secret (needs `tsig:write`, audited) |
| PATCH / DELETE | `/tsig-keys/{id}` | PATCH `{"secret": "..."}` rotates |
### Options and settings
| Method | Path | Body / query |
|--------|------|--------------|
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) |
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
| GET / PATCH / DELETE | `/options/{id}` | |
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
Option values are named.conf syntax without the final semicolon. They are
checked against the BIND 9.20 grammar for that scope.
### Catalogs and search
| Method | Path | Returns |
|--------|------|---------|
| GET | `/catalog/options` | scopes and zone types; with `?scope=options\|view\|toplevel\|zone:primary\|...` every statement allowed there (name, grammar, kind, status, ARM description). `?all=1` includes statements Jarl manages itself |
| GET | `/catalog/rrtypes` | record types with field hints and examples |
| GET | `/catalog/grants` | resources, roles and the grant syntax |
| GET | `/search?q=text` | zones, records and ACLs matching the text |
### Changesets
| Method | Path | Body / query |
|--------|------|--------------|
| POST | `/apply` | `{"description": "...", "ops": [Operation...], "dry_run": false, "force": false}`: apply several operations as one changeset |
| GET | `/changesets` | `?status=open\|applied\|discarded&mine=1&limit=100&before=ID` |
| POST | `/changesets` | `{"description": "...", "ops": [...], "apply": false}`: create (and optionally stage or apply) |
| GET / PATCH | `/changesets/{id}` | PATCH `{"description": "..."}` |
| POST | `/changesets/{id}/ops` | `{"ops": [Operation...]}`: stage more operations |
| DELETE | `/changesets/{id}/ops/{seq}` | remove one staged operation |
| GET | `/changesets/{id}/preview` | dry run of an open changeset: problems, check messages, diff |
| POST | `/changesets/{id}/apply` | `{"dry_run": false, "force": false}` |
| POST | `/changesets/{id}/discard` | |
| POST | `/changesets/{id}/revert` | creates a new open changeset that undoes an applied one |
An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
| `op` | `id` | `data` |
|------|------|--------|
| `zone.create` / `zone.update` / `zone.delete` | zone (update/delete) | ZoneInput |
| `record.create` | | RecordInput + `"zone": "adbraun.lol"` (or `zone_id`) and `"view"` |
| `record.update` | record | RecordInput |
| `record.delete` | record, or 0 with `zone`, `name`, `type`, optional `data`, `missing_ok`, `ptr` | |
| `rrset.replace` | | `{"zone", "view", "name", "type", "ttl", "data": [...]}` (empty data deletes the RRset) |
| `view.create` / `view.update` / `view.delete` / `view.reorder` | view (update/delete) | ViewInput / `{"ids": [...]}` |
| `acl.create` / `acl.update` / `acl.delete` | ACL | ACL fields |
| `tsig.create` / `tsig.update` / `tsig.delete` | key | key fields |
| `option.create` / `option.update` / `option.delete` | option | option fields |
| `settings.update` | | settings fields |
Operations later in a batch can refer to objects created earlier in it by
name (zone and view names). One request can therefore create a view, a zone
in it and its records.
### Status, system, audit
| Method | Path | Notes |
|--------|------|-------|
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts |
| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) |
| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) |
| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` |
### Users and tokens
| Method | Path | Body |
|--------|------|------|
| GET | `/users` | admin |
| POST | `/users` | `{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""}` (`password: ""` = service account) |
| GET / PATCH / DELETE | `/users/{id}` | PATCH also takes `disabled` |
| GET / POST | `/users/{id}/tokens` | admin (or yourself) |
| GET / POST | `/me/tokens` | `{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}`. The response's `token` field is the secret, shown once |
| GET | `/tokens` | every token (admin); `?all=1` includes revoked tokens |
| DELETE | `/tokens/{id}` | revoke |
| POST | `/me/password` | `{"current_password": "...", "password": "..."}` (sessions only) |
User and token changes take effect immediately. They are not changesets,
because they don't touch named, but they are audited.
## Examples
```sh
API=https://ns1.adbraun.lol:8053/api/v1
AUTH="Authorization: Bearer $JARL_TOKEN"
# add a record and its PTR
curl -sf -H "$AUTH" -H 'Content-Type: application/json' -X POST "$API/zones/adbraun.lol/records" \
-d '{"name": "nas", "type": "A", "data": "192.168.1.20", "ptr": true}'
# swap an A record for a CNAME in one changeset
curl -sf -H "$AUTH" -H 'Content-Type: application/json' -X POST "$API/apply" -d '{
"description": "www is now an alias",
"ops": [
{"op": "record.delete", "data": {"zone": "adbraun.lol", "name": "www", "type": "A"}},
{"op": "record.create", "data": {"zone": "adbraun.lol", "name": "www", "type": "CNAME", "data": "web1"}}
]}'
# stage, preview, apply
CS=$(curl -sf -H "$AUTH" -X POST "$API/changesets" -d '{"description": "lab"}' | jq .id)
curl -sf -H "$AUTH" -H "X-Jarl-Changeset: $CS" -X PUT "$API/zones/lab.adbraun.lol/rrsets/gw/A" -d '{"data": ["10.20.0.1"]}'
curl -sf -H "$AUTH" "$API/changesets/$CS/preview" | jq -r .diff
curl -sf -H "$AUTH" -X POST "$API/changesets/$CS/apply"
```
+238
View File
@@ -0,0 +1,238 @@
# The `jarl` command line client
`jarl` talks to jarld's REST API with an API token. It runs on the DNS server
or on any machine that can reach jarld. Every command has `--help` with
examples, and `jarl completion bash|zsh|fish` prints shell completion.
## Setup
```sh
jarl config set server https://ns1.adbraun.lol:8053
jarl config set token # paste the token at the prompt
jarl whoami
```
| Setting | Flag | Environment | Config file key |
|---------|------|-------------|-----------------|
| Server URL | `--server` | `JARL_SERVER` | `server` |
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
| Skip TLS verification | `--insecure` | | `insecure` |
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
Flags win over the environment, and the environment wins over the file. The
file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
`JARL_CLI_CONFIG`. `jarl config show` prints it with the token masked.
Get tokens from the web UI (Account → API tokens), from
`jarl token create`, or from `jarld init --token` on the server.
## Global flags
| Flag | Meaning |
|------|---------|
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. |
| `-n, --dry-run` | Validate and show the diff without changing anything. |
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
| `-q, --quiet` | Print only errors, or just the ID where a command creates something. |
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Other error (bad arguments, connection failure, ...) |
| 3 | Not authenticated, or not allowed (token lacks the permission) |
| 4 | Not found |
| 5 | Conflict: concurrent change, object in use, drift |
| 6 | Invalid request, or rejected by validation (e.g. a CNAME conflict, or named-checkconf found an error) |
| 7 | Server error, or BIND failed: a tool couldn't run, or named didn't load the change (it was rolled back) |
Validation errors are printed with the offending zone and view:
```
$ jarl record add adbraun.lol www A 192.168.1.99
error: www.adbraun.lol has a CNAME, so it can't have any other records (found A); a CNAME must be the only record at its name (RFC 1034) [zone adbraun.lol]
$ echo $?
6
```
## Zones
```sh
jarl zone list [--view V] [--type primary]
jarl zone show adbraun.lol
jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
jarl zone create 192.168.20.0/24 # reverse zone from a network
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
jarl zone update adbraun.lol --move-to-view internal
jarl zone delete lab.adbraun.lol [-y]
jarl zone file adbraun.lol # the generated zone file
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
```
`zone import` adds the records of a zone file (`-` reads stdin). With
`--replace` the zone is made equal to the file, so records that aren't in it
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
`zone create` sets any zone option in the same step.
When a zone exists in several views, add `--view NAME` to commands that
name it.
## Records
```sh
jarl record list adbraun.lol [--name www] [-t A] [--search nas]
jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology"
jarl record add adbraun.lol @ MX 10 mail
jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic
jarl record add adbraun.lol _sip._tcp SRV 0 5 5060 sip
jarl record add adbraun.lol @ CAA 0 issue "letsencrypt.org"
jarl record set adbraun.lol api A 192.168.1.30 192.168.1.31 --ttl 60 # replace the RRset
jarl record update 1234 --data 192.168.1.21 --ptr # IDs from 'record list'
jarl record update 1234 --ttl default
jarl record delete adbraun.lol www A # every A record of www
jarl record delete adbraun.lol www A 192.168.1.10 # just this one
jarl record delete --id 1234 [--ptr]
```
Names are relative to the zone (`@` is the apex) unless they end with a
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
adds the quotes and splits long values into 255-byte strings. `--ptr`
keeps the PTR record in the reverse zone in step (when that zone is managed
by Jarl). Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are
validated by parsing.
## Batches: `jarl apply -f`
Several changes that must succeed or fail together:
```yaml
# move-web.yaml
description: move the web server to the new VM
changes:
- add: {zone: adbraun.lol, name: web2, type: A, data: 192.168.1.40, ttl: 300, ptr: true}
- delete: {zone: adbraun.lol, name: www, type: CNAME}
- add: {zone: adbraun.lol, name: www, type: CNAME, data: web2}
- set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60}
- update: {id: 1235, comment: "old web server, remove in November"}
- delete: {id: 1240}
- op: acl.update # any API operation (see api.md)
id: 3
data: {elements: [192.168.1.0/24, 10.10.0.0/16]}
```
```sh
jarl apply -f move-web.yaml --dry-run # check and show the diff
jarl apply -f move-web.yaml
```
The whole file is validated as one changeset, at its end state, so the
order of the entries doesn't matter (the CNAME swap above works either way
round). Add `view: NAME` to entries
when a zone exists in several views. JSON works as well as YAML, and `-f -`
reads standard input.
## Changesets: stage, review, apply
```sh
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
jarl record set lab.adbraun.lol gw A 10.20.0.1
jarl record set lab.adbraun.lol nas A 10.20.0.10
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
jarl changeset apply $JARL_CHANGESET
unset JARL_CHANGESET
jarl changeset list [--status open|applied|discarded] [--mine]
jarl changeset show 42
jarl changeset discard 43
jarl history [--limit 50]
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
```
Changesets staged in the web UI show up here too, and the other way round.
## Views, ACLs, TSIG keys, options, settings
```sh
jarl view create internal --match-clients homelab,localhost --adopt-zones
jarl view create external --match-clients any
jarl view order internal external
jarl view update external --match-clients "!homelab,any"
jarl view delete old [--cascade]
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
jarl acl update homelab --rename lan # references are updated
jarl acl delete lan # refused while in use
jarl tsig create xfer [--algorithm hmac-sha512]
jarl tsig show xfer --named-conf # paste into the secondary's named.conf
jarl tsig rotate xfer
jarl tsig delete xfer
jarl option list [--view V | --zone Z | --toplevel | --all]
jarl option set recursion yes
jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
jarl option unset forwarders
jarl option describe # every option allowed at this scope
jarl option describe allow-query-cache # grammar and ARM description
jarl settings show
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
```
## Users, tokens, audit (administration)
```sh
jarl user list
jarl user create alice --role dns-editor # asks for a password
jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol"
jarl user update alice --admin
jarl user update alice --password # set a new password
jarl user update bob --disabled # logs bob out, blocks his tokens
jarl user delete bob -y
jarl token list [--user alice | --all] [--revoked]
jarl token create laptop --expires 365d
jarl token create backup-script --grant "*:read"
jarl token create acme --user certbot --role acme
jarl token revoke 7
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
jarl search 192.168.1.20
jarl status # named, adoption, drift, open changesets, problems
jarl check # validation + named-checkconf of the current configuration
jarl resync [--force] [--dry-run] # rewrite every file from the database
```
Grant syntax and roles are in [permissions.md](permissions.md).
## ACME DNS-01
```sh
jarl acme set www.adbraun.lol <validation> # TXT at _acme-challenge.www.adbraun.lol
jarl acme set '*.adbraun.lol' <value1> <value2> # wildcard + apex use the same name
jarl acme unset www.adbraun.lol [<validation>]
jarl acme certbot-auth / certbot-cleanup # certbot manual hooks
```
See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone.
## Scripting tips
* `-o json` gives full API objects. Pipe them to `jq`.
* `-q` with `create` commands prints just the ID or token.
* Check `$?` against the exit code table.
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which
keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature.
+221
View File
@@ -0,0 +1,221 @@
# How Jarl works
This page explains Jarl's design: where the data lives, how a change reaches
named, and why it works that way. Read it once before you adopt your server.
## The pieces
```
browser ──(session + CSRF)──┐
├──> jarld ──> SQLite DB (source of truth)
jarl CLI ──(API token)──────┤ │
scripts / certbot ──(token)─┘ ├──> /etc/bind/jarl/named.conf, keys.conf
├──> /var/lib/bind/jarl/*.zone
└──> named-checkconf / named-checkzone / rndc ──> named
```
* **jarld** is one Go binary. It serves the REST API and the web UI (embedded
in the binary; the UI uses only the public API), and it is the only thing
that writes BIND's files. It runs as the unprivileged user `jarl` under
systemd.
* **jarl** is the command line client. It talks to jarld's API with an API
token, so it works from any machine that can reach jarld.
* **The database** (SQLite, `/var/lib/jarl/jarl.db`) holds the whole BIND
configuration as structured data: views, ACLs, TSIG keys, zones, records,
options, and the defaults for new zones. It also holds users, tokens,
sessions, changesets and the audit log.
## The database is the source of truth
Jarl never edits BIND's files in place. Every time a change is applied it
renders the complete configuration from the database:
| File | Contents |
|------|----------|
| `/etc/bind/jarl/named.conf` | ACLs, options, views, zone statements, top-level statements such as `logging` and `controls` |
| `/etc/bind/jarl/keys.conf` | TSIG keys. They are kept apart so secrets never show up in diffs, history or the audit log |
| `/var/lib/bind/jarl/<view>/<zone>.zone` | One file per primary zone (`_default` when views aren't used) |
| `/var/cache/bind/jarl/...` | Written by named itself for secondary, stub and mirror zones |
`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.
Because files are always regenerated, anything you edit by hand is
overwritten. Jarl records a checksum of every file it writes and reports
**drift** when a file changes behind its back. It then refuses to overwrite
the file until you say so (`--force`, or `jarl resync --force`).
Comments are part of the data model: zones, records, ACLs, views, keys and
options can all carry a comment, and Jarl writes it into the generated file.
Comments in imported zone files are kept.
## Changesets: one change or a bundle, it's the same pipeline
Your brief asked whether each change should be checked and applied on its
own, or whether changes should be bundled and applied together. Jarl does
both, using a single mechanism:
* A **changeset** is an ordered list of operations (create a zone, add a
record, rename an ACL, ...). Applying one is all-or-nothing.
* Every API call that changes something is applied at once **as a one-operation
changeset** by default. Nothing extra is needed for small edits.
* To bundle changes, create a changeset and *stage* operations into it
(API: `?changeset=ID` or the `X-Jarl-Changeset` header; CLI: `--changeset ID`
or `JARL_CHANGESET`). Review the diff, then apply or discard it.
* `POST /api/v1/apply` (CLI: `jarl apply -f changes.yaml`) applies a list of
operations as one changeset in a single request.
* `?dry_run=1` (CLI: `--dry-run`) runs the whole pipeline except installing
files and reloading named, and shows the problems and the diff.
**Why bundling matters.** Some changes only make sense together. Turning
`www` from an A record into a CNAME means deleting the A record and adding
the CNAME, and named must never see the zone with neither, or with a
half-finished renumbering. A changeset reaches named in one step or not at
all.
Operations sent **in one request** (`jarl apply -f`, `POST /apply`, or one
staging request with several operations) are validated together, at their
end state, so their order doesn't matter. When operations are staged **one
at a time** (the web UI's review mode, `--changeset` on single commands),
each one is checked as it is added. You get immediate feedback, so delete
the A record before adding the CNAME. At apply time the whole changeset is
validated again against the configuration as it is then.
**Interfaces use different defaults**, because they are used differently:
| Interface | Default | Why |
|-----------|---------|-----|
| Web UI | *Review mode*: edits are collected in a draft changeset, the zone pages show pending rows (green = new, yellow = changed, struck out = deleted), and a banner leads to the review page, which has a diff and an Apply button. Users can switch to "Apply edits immediately". | People click through several related edits and want to see the result before it goes live. |
| CLI | *Immediate*: every command is applied (and validated) on its own. Bundle with `jarl apply -f` or `--changeset`. | Scripts want a clear success or failure per step, and ACME hooks need the record live when the command returns. |
| API | Immediate unless a changeset is named. | Same as the CLI. |
### What happens when a changeset is applied
1. **Replay.** The operations are replayed on a copy of the current
configuration, checking permissions and (for staged operations) that the
objects haven't changed since they were staged (optimistic locking via
object versions). Concurrent edits produce a `409 conflict` instead of
silently overwriting each other.
2. **Validate.** The resulting configuration is checked as a whole (see
[validation.md](validation.md)): DNS rules such as CNAME exclusivity,
glue, delegations and RRset TTLs; references to ACLs and keys; option
syntax and scope against the BIND 9.20 grammar; and security lints such
as open resolvers. Errors block the change, while warnings are shown and
allowed. Problems that already existed are reported separately and don't
block unrelated changes.
3. **Render and check.** The files are rendered into a staging directory and
checked with the real `named-checkconf -z` and `named-checkzone`. Their
messages are mapped back to the object that caused them.
4. **Install.** Changed files are written atomically (temp file + rename),
keeping the previous contents in memory.
5. **Reload.** `rndc reconfig` if `named.conf` changed, then `rndc reload ZONE`
for each changed zone.
6. **Verify.** named loads zones asynchronously and an `rndc reload` that
fails only shows up in named's log. So Jarl polls `rndc zonestatus` until
every changed zone reports its new serial. If one doesn't within
`reload_timeout` (15s), Jarl restores the previous files, reloads again
and reports the failure.
7. **Record.** The changeset is stored with its result, its diff and the
*undo operations*, so it can be reverted later (`jarl changeset revert ID`,
or the Revert button in History). A revert is itself a changeset you
review before applying.
Serials are bumped automatically, and only when a zone's DNS data actually
changes (a comment-only edit doesn't bump it). Each zone follows its serial
policy: `date` (YYYYMMDDnn, the default), `increment` or `unixtime`, using
RFC 1982 arithmetic.
## Model: views, zones, records, options
* **Views** are ordered; named uses the first view whose `match-clients` and
`match-destinations` match. Once one view exists, named requires every
zone to be in a view, so creating the first view offers to move existing
zones into it (`adopt_zones`).
* **Zones** have a type: `primary`, `secondary`, `stub`, `static-stub`,
`forward`, `mirror`, `hint`, `redirect`, or `in-view` (share a zone from
another view). Only primary zones have records in Jarl. The same zone name
can exist in several views (split DNS); copy one with `clone_from`.
A CIDR such as `192.168.1.0/24` or `2001:db8::/48` creates the matching
reverse zone.
* **Records** are stored canonically (owner FQDN, type, TTL or "zone
default", data in presentation format). TXT data is entered as plain text;
Jarl quotes it and splits it into 255-byte strings. Relative names are
resolved against the zone, and `www.example.com` without a final dot in a
zone `example.com` is caught as the classic `www.example.com.example.com.`
mistake. Adding an A/AAAA record with `ptr` also maintains the PTR record in
the matching reverse zone.
* **Options** are named.conf statements attached to a scope: server
`options { }`, a view, a zone, or the top level (`logging`, `controls`,
`statistics-channels`, `include`, ...). The UI and CLI know every BIND 9.20
statement (353 of them, from the ARM): where it may appear, its grammar,
and whether it is deprecated. Values are written in named.conf syntax, and
Jarl checks them before named does. Statements that Jarl manages itself
(`zone`, `view`, `acl`, `key`, a zone's `type`/`file`, a view's
`match-clients`, ...) can't be set as raw options.
* **Settings** are Jarl's defaults for new zones: name servers, SOA contact
and timers, default TTL, serial policy.
ACLs and TSIG keys are referenced by name. Jarl tracks every reference
(other ACLs, `match-clients`, `allow-*` options, `primaries ... key`, ...):
deleting something that is still in use is refused with the list of users,
and renaming updates every reference.
## Authentication and permissions
Your brief suggested HTTP basic auth for the web UI, with the web app
holding an API key. Jarl keeps the idea (each person has a username and
password, and every request is tied to a user and checked against that
user's permissions) but uses a login form and a session instead of basic
auth:
* Basic auth sends the password with every request and can't log out.
Browsers cache it until they restart, and it can't be rate limited
per user in a friendly way.
* A login form creates a session: an HttpOnly, SameSite=Strict cookie that
expires after 12 hours (2 hours idle), plus a CSRF token the UI sends with
every change. Failed logins are rate limited (8 per username and 20 per IP
per 15 minutes). Passwords are hashed with argon2id.
* The web UI has no key of its own. It calls the same API as the CLI,
authenticated as the logged-in user, so the audit log always names the
person rather than "the web app".
The CLI and scripts use **API tokens** (`jarl_<id>_<secret>`). Only a hash
of each token is stored, and the full token is shown once.
* Each token belongs to a user. By default it has the user's permissions,
or it can be **restricted** to a subset with its own grants and given an
expiry. A token can never do more than its user.
* **Grants** are granular: resource (`zones`, `records`, `views`, `acls`,
`tsig`, `options`, `audit`, `*`) × access (`read`/`write`), optionally
narrowed by zone, view, record name pattern and record type. For example,
`records:write zones=adbraun.lol names=_acme-challenge.* types=TXT` is
what the ready-made `acme` role contains.
* Administrators can do everything, including managing users and server
internals. Admin rights are never available through a restricted token.
Details: [permissions.md](permissions.md).
## Audit log
Jarl records every login (successful, failed or blocked by the rate
limit), every failed token, every changeset (created, staged, applied or
failed, discarded, reverted), token creation and revocation, user change,
password change, TSIG secret reveal and adoption. Each event has the user,
how they authenticated (session, which token, or local), the client IP and
the outcome. Tokens also remember when and from where they were last used. It is in the database
(searchable in the UI and with `jarl audit`) and is also written to the
journal through jarld's log.
## What Jarl deliberately doesn't do (yet)
* **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**: Jarl manages the named on its own host. Secondaries
elsewhere get zones by normal AXFR/IXFR (`allow-transfer`, `also-notify`,
TSIG keys).
+103
View File
@@ -0,0 +1,103 @@
# jarld configuration (`/etc/jarl/jarl.yaml`)
jarld reads `/etc/jarl/jarl.yaml` (or the file given with `-c/--config`,
which every jarld subcommand accepts). Every setting is optional. The
defaults suit Debian/Ubuntu with BIND 9.20. Unknown keys are an error, so
typos don't go unnoticed.
```sh
jarld config --example # a commented example file with every setting
jarld config # the effective configuration (file + defaults)
```
The CLI has its own small configuration file, described in [cli.md](cli.md).
## Settings
### Web UI and API
| Key | Default | Meaning |
|-----|---------|---------|
| `listen` | `["127.0.0.1:8053"]` | Addresses to listen on (`host:port`, several allowed). Keep these on localhost and management networks. |
| `tls.cert_file`, `tls.key_file` | (empty: plain HTTP) | PEM certificate chain and key. Both or neither. The files are rechecked every 10 minutes and on SIGHUP (`systemctl reload jarl`), so renewed certificates are used without a restart. |
| `trusted_proxies` | `[]` | Addresses or prefixes of reverse proxies whose `X-Forwarded-For` and `X-Forwarded-Proto` headers are believed. Needed for correct client IPs in the audit log and for rate limiting behind a proxy. |
| `session.lifetime` | `12h` | A web session ends this long after login... |
| `session.idle_timeout` | `2h` | ...or after this long without requests. |
| `session.cookie_secure` | `auto` | `auto` sets the cookie's Secure flag when the request came over HTTPS (directly or via a trusted proxy); `always` / `never` force it. |
Durations accept Go syntax (`90s`, `15m`, `12h`) and days (`30d`).
### Storage
| Key | Default | Meaning |
|-----|---------|---------|
| `database` | `/var/lib/jarl/jarl.db` | SQLite database: configuration, users, tokens, history, audit log. |
| `staging_dir` | `/var/lib/jarl/staging` | Scratch space where candidate configurations are rendered and checked with named-checkconf before they are installed. |
| `audit.retention_days` | `0` | Delete audit events older than this many days (0 keeps them forever). Changeset history is kept regardless. |
### Logging
| Key | Default | Meaning |
|-----|---------|---------|
| `log.level` | `info` | `debug`, `info`, `warn` or `error`. |
| `log.format` | `text` | `text` or `json`. Under systemd both go to the journal (`journalctl -u jarl`). |
Audit events are logged as `msg=audit` lines as well as stored in the
database.
### BIND
| Key | Default | Meaning |
|-----|---------|---------|
| `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.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_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. |
| `bind.rndc_args` | `[]` | Extra rndc arguments, e.g. `["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1", "-p", "953"]`. Debian's rndc finds `/etc/bind/rndc.key` by itself. |
| `bind.reload_timeout` | `15s` | How long to wait for named to load changed zones (checked with `rndc zonestatus`) before the change is rolled back. |
| `bind.no_reload` | `false` | Write files but never run rndc. Use it when named runs on another host or for testing. Changes are then not verified against a running named. |
The three directories must exist and be writable by the `jarl` user. The
tmpfiles.d entry creates them, and the systemd unit only allows writes
there (`ReadWritePaths`). If you change them, update both
`/usr/lib/tmpfiles.d/jarl.conf` and the unit (`systemctl edit jarl`):
```ini
[Service]
ReadWritePaths=/srv/bind/jarl /srv/bind/zones
```
## Example: management network with TLS
```yaml
listen:
- "127.0.0.1:8053"
- "192.168.10.2:8053"
tls:
cert_file: /etc/jarl/tls/fullchain.pem
key_file: /etc/jarl/tls/privkey.pem
log:
level: info
audit:
retention_days: 730
```
## Example: behind nginx
```yaml
listen: ["127.0.0.1:8053"]
trusted_proxies: ["127.0.0.1"]
```
```nginx
location / {
proxy_pass http://127.0.0.1:8053;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # applying a large changeset can take a while
}
```
+102
View File
@@ -0,0 +1,102 @@
# Developing Jarl
## Layout
```
cmd/jarld/ daemon: serve, init, import, adopt, check, render, resync, backup, user
cmd/jarl/ CLI
internal/
model/ configuration objects (State: views, ACLs, keys, zones, records, options) and changesets
dnsname/ DNS name handling: FQDNs, relative names, check-names rules, reverse zones
rr/ record data parsing/canonicalization (miekg/dns), TXT quoting, the record type catalog
namedconf/ named.conf lexer, parser and printer; address match lists
catalog/ every BIND 9.20 statement: grammar (from named.conf(5)) + ARM descriptions, embedded
validate/ whole-configuration validation and security lints
render/ State → named.conf, keys.conf and zone files, with a source map for error messages
bind/ named-checkconf, named-checkzone and rndc wrappers
engine/ changesets: expand requests into ops, replay, validate, render, check, deploy, reload, verify, revert;
import and adopt
importer/ reads an existing named.conf tree and its zone files
store/ SQLite (modernc.org/sqlite, no cgo): state, changesets, users, tokens, sessions, audit
auth/ argon2id passwords, tokens, sessions, grants and roles
server/ HTTP API, authentication, the web UI
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
config/ jarld's YAML configuration
deploy/ systemd unit, sysusers.d and tmpfiles.d files
contrib/acme.sh/ acme.sh DNS API hook
scripts/e2e.sh end-to-end test against a real named
```
The main flow is in `internal/engine/engine.go` (`applyOps`):
expand → replay → validate → render → check → install → reload → verify →
record. [concepts.md](concepts.md) describes it from the outside.
## Building and testing
```sh
make # bin/jarld, bin/jarl (CGO_ENABLED=0, version from ./VERSION)
make test # go vet, gofmt check, go test ./...
make e2e # scripts/e2e.sh
```
### Tests against real BIND
The render, engine and adopt tests run the real `named-checkconf`,
`named-checkzone`, `named`, `rndc` and `dig` when they can find them, and
skip otherwise. They are found through `JARL_BIND_ROOT` (an extracted
package tree) or on the system. To get them without root or AppArmor in the
way, extract the packages:
```sh
mkdir -p ~/bind-root/debs && cd ~/bind-root/debs
apt-get download bind9 bind9-utils bind9-libs bind9-dnsutils \
liblmdb0 libmaxminddb0 liburcu8t64 libuv1t64 libjemalloc2 libjson-c5 libnghttp2-14
for f in *.deb; do dpkg -x "$f" ~/bind-root; done
cd -
JARL_BIND_ROOT=~/bind-root make test
JARL_BIND_ROOT=~/bind-root make e2e
```
The tests start named unprivileged on random high ports in temporary
directories. On Debian/Ubuntu, AppArmor confines the installed
`/usr/sbin/named` to BIND's own directories, so the tests that *start*
named (engine integration tests, `make e2e`) need the extracted copy. The
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
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.
### The web UI
The UI is plain JavaScript modules under `internal/web/static/`, with no
framework and no build step. All DOM is built with the `h()` helper in
`ui.js`, which never uses `innerHTML`, so record data and comments can't
inject markup. The Content-Security-Policy only allows the app's own
scripts and styles. To work on it, run jarld and reload the browser: the
files are embedded at build time, so rebuild jarld after changes.
## Conventions
* **Every user-visible change goes into `CHANGELOG.md`** (under
"Unreleased"), and the docs in `docs/` are updated in the same change.
* Errors that reach users say what is wrong and how to fix it, naming the
object.
* New validation rules get a stable `code`, a test in
`internal/validate/validate_test.go`, and a row in
[validation.md](validation.md).
* New API operations go through the engine (`internal/engine/expand.go`,
`ops.go`) so they get permissions, validation, history and revert for
free, and are documented in [api.md](api.md).
* `gofmt` and `go vet` clean (`make vet`).
## Releasing
1. Move the "Unreleased" section of `CHANGELOG.md` under the new version
and date.
2. Put the version in `VERSION`.
3. `make test && JARL_BIND_ROOT=... make e2e && make dist`.
+262
View File
@@ -0,0 +1,262 @@
# Installing Jarl and adopting an existing BIND server
This guide targets Debian/Ubuntu with BIND 9.20 (`bind9` package,
`/etc/bind`, user and group `bind`, AppArmor). Other layouts work too: every
path is configurable in `/etc/jarl/jarl.yaml` (see
[configuration.md](configuration.md)).
The migration is designed to be safe. Your current configuration is
imported into Jarl and checked with `named-checkconf` before named is
touched. Adopting keeps a copy of the old `named.conf`, and if named fails
to load Jarl's configuration the old one is put back automatically.
## 1. Build
Jarl is two static binaries with no runtime dependencies (SQLite is
compiled in). Building needs Go 1.27 or newer, on any machine:
```sh
make # bin/jarld and bin/jarl
make test # optional: unit and integration tests
make dist # optional: dist/jarl-<version>-linux-{amd64,arm64}.tar.gz
```
If you build on another machine, copy the binaries plus the `deploy/`,
`docs/` and `contrib/` directories to the DNS server.
## 2. Install the files
On the DNS server, as root:
```sh
make install # or: make install PREFIX=/usr
```
This installs:
| File | Purpose |
|------|---------|
| `/usr/local/bin/jarld`, `/usr/local/bin/jarl` | daemon and CLI |
| `/etc/systemd/system/jarl.service` | systemd unit (`Type=notify`, sandboxed) |
| `/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` |
| `/etc/jarl/jarl.yaml` | example configuration (only if none exists) |
| `/usr/local/share/doc/jarl/` | these docs and the acme.sh hook |
Then create the user and directories:
```sh
systemd-sysusers jarl.conf
systemd-tmpfiles --create jarl.conf
chgrp jarl /etc/jarl/jarl.yaml
systemctl daemon-reload
```
Without `make` (from a release tarball), the same steps are:
```sh
install -m 0755 jarld jarl /usr/local/bin/
install -m 0644 deploy/systemd/jarl.service /etc/systemd/system/
install -m 0644 deploy/sysusers.d/jarl.conf /usr/lib/sysusers.d/
install -m 0644 deploy/tmpfiles.d/jarl.conf /usr/lib/tmpfiles.d/
systemd-sysusers jarl.conf && systemd-tmpfiles --create jarl.conf
jarld config --example > /etc/jarl/jarl.yaml
chgrp jarl /etc/jarl/jarl.yaml && chmod 0640 /etc/jarl/jarl.yaml
systemctl daemon-reload
```
### Why the jarl user is in group bind
jarld runs as `jarl`, not root. Membership of `bind` lets it read named's
configuration and `/etc/bind/rndc.key` (for `named-checkconf` and `rndc`),
and give the files it generates to group `bind` so named can read them.
named in turn can't write Jarl's configuration (`/etc/bind/jarl` is 2750),
but it can write journals in `/var/lib/bind/jarl` (2770).
## 3. Configure
Edit `/etc/jarl/jarl.yaml`. The defaults match Debian's BIND 9.20 layout,
so usually only `listen` and `tls` need changes:
```yaml
listen:
- "127.0.0.1:8053" # always keep localhost for the local CLI and hooks
- "192.168.1.2:8053" # a management address for the web UI
tls:
cert_file: /etc/jarl/tls/fullchain.pem
key_file: /etc/jarl/tls/privkey.pem
```
Keep the web UI and API **off the internet**: listen on localhost and a
management network only, and restrict the port in your firewall. When the
server becomes internet-facing for DNS, that changes nothing for Jarl.
A plain-HTTP listener is fine on localhost. Anything reachable over the
network should use TLS, either with `tls:` above or behind a reverse proxy
that terminates TLS (set `trusted_proxies` so client IPs in the audit log
are right). See "TLS certificate from Let's Encrypt" below.
Check the result with `jarld config` (prints the effective configuration).
## 4. Back up BIND
Jarl keeps a copy of `named.conf`, but take your own backup anyway:
```sh
tar czf /root/bind-before-jarl-$(date +%F).tgz /etc/bind /var/lib/bind /var/cache/bind
```
## 5. Create the database and the first administrator
```sh
sudo -u jarl jarld init --token \
--nameservers ns1.adbraun.lol. --contact hostmaster@adbraun.lol
```
It asks for the administrator's password (user `admin`, change it with
`-u NAME`). `--token` also prints an API token for the CLI, shown only
once. `--nameservers` and `--contact` are the defaults for zones you create
later, and you can change them in the UI or with `jarl settings set`.
## 6. Import the current configuration
```sh
sudo -u jarl jarld import --dry-run # see what would be imported
sudo -u jarl jarld import
```
The importer reads `/etc/bind/named.conf` and everything it includes: ACLs,
keys, options, logging, views, zones, and every primary zone file (with
comments, `$TTL`, `$ORIGIN` and `$INCLUDE`; `$GENERATE` ranges are expanded
into individual records). It converts legacy
spellings (`master`/`slave`/`masters`) and keeps `rndc.key` as an include.
If everything validates, the import is applied as changeset #2. Jarl writes
its files into `/etc/bind/jarl` and `/var/lib/bind/jarl`, but **named keeps
running on the old configuration**. If there are problems, the import is
left as an open changeset to fix in the web UI or CLI first, and the
command tells you what is wrong.
Look at what Jarl generated before going further:
```sh
less /etc/bind/jarl/named.conf
ls -R /var/lib/bind/jarl
sudo -u jarl jarld check # validation + named-checkconf -z
```
## 7. Adopt: switch named over to Jarl
```sh
sudo jarld adopt
```
This needs root because it rewrites `/etc/bind/named.conf`:
1. bumps the serial of every primary zone, so secondaries and caches notice
the change,
2. saves `named.conf` as `named.conf.pre-jarl-<date>` and replaces it with a
single `include "/etc/bind/jarl/named.conf";`,
3. runs `named-checkconf`, `rndc reconfig` and reloads every zone,
4. checks that named serves every zone with the new serial.
If any step fails, the previous `named.conf` is restored and named is
reloaded. Afterwards, check that the answers match:
```sh
dig @127.0.0.1 adbraun.lol SOA +short
dig @127.0.0.1 -x 192.168.1.1 +short
```
From now on, change DNS only through Jarl. Hand edits to the generated
files are detected as drift and are never silently overwritten (see
[operations.md](operations.md)).
**Going back.** `cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf && rndc reconfig`
restores the old setup. Your old zone files were never modified.
## 8. Start the daemon
```sh
systemctl enable --now jarl
systemctl status jarl
journalctl -u jarl -f
```
Open `https://<management-address>:8053/` and log in as `admin`.
## 9. Set up the CLI
The CLI can run on the server or on any machine that can reach jarld.
```sh
jarl config set server https://ns1.adbraun.lol:8053 # or http://127.0.0.1:8053 on the server
jarl config set token # paste the token from 'jarld init' at the prompt
jarl status
jarl zone list
```
The CLI's settings are stored in `~/.config/jarl/config.yaml` (mode 0600).
`JARL_SERVER` and `JARL_TOKEN` override them, which is handy in scripts.
Create more tokens in the web UI (Account → API tokens) or with
`jarl token create NAME`. See [cli.md](cli.md).
## TLS certificate from Let's Encrypt
Jarl can publish the DNS-01 challenge for its own certificate. On the DNS
server, run certbot with the hooks against the local listener:
```sh
# As yourself (with an admin token configured): a service account and a
# token that can only touch _acme-challenge TXT records.
jarl user create certbot --no-password --role acme
jarl token create certbot --user certbot --role acme -q > certbot.token
sudo install -m 0600 certbot.token /etc/jarl/certbot.token && rm certbot.token
# certbot runs as root and reads the token from the file.
sudo certbot certonly --manual --preferred-challenges dns \
--manual-auth-hook 'jarl --server http://127.0.0.1:8053 --token @/etc/jarl/certbot.token acme certbot-auth' \
--manual-cleanup-hook 'jarl --server http://127.0.0.1:8053 --token @/etc/jarl/certbot.token acme certbot-cleanup' \
-d ns1.adbraun.lol
```
certbot keeps its keys readable only by root, so give jarld a copy after
each renewal with a deploy hook, `/etc/letsencrypt/renewal-hooks/deploy/jarl`:
```sh
#!/bin/sh
set -e
install -d -m 0750 -g jarl /etc/jarl/tls
install -m 0640 -g jarl "$RENEWED_LINEAGE/fullchain.pem" "$RENEWED_LINEAGE/privkey.pem" /etc/jarl/tls/
systemctl reload jarl
```
`systemctl reload jarl` sends SIGHUP, and jarld rereads the certificate
without dropping connections. More in [acme.md](acme.md).
## Upgrading
```sh
make && sudo make install
sudo systemctl restart jarl
```
Database migrations run automatically at startup. Before an upgrade, back
up the database (it is safe while jarld runs):
```sh
sudo -u jarl jarld backup /var/lib/jarl/jarl-$(date +%F).db
```
## Uninstalling
```sh
cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf # or write a named.conf that includes Jarl's files
rndc reconfig
systemctl disable --now jarl
make uninstall
```
`/etc/bind/jarl` and `/var/lib/bind/jarl` contain plain named.conf and zone
files that keep working without Jarl. `make uninstall` leaves them and
Jarl's database in place.
+124
View File
@@ -0,0 +1,124 @@
# Running Jarl
## Day to day
| Task | How |
|------|-----|
| Is everything healthy? | `jarl status`, or the web UI dashboard: named reachable, adoption, drift, open changesets, configuration problems |
| 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` |
| Logs | `journalctl -u jarl` (jarld, including `msg=audit` lines), `journalctl -u named` |
| Restart / reload TLS | `systemctl restart jarl` / `systemctl reload jarl` |
Restarting jarld doesn't affect DNS: named keeps serving the files it
loaded. jarld is only needed to make changes.
## Backups
Everything Jarl knows is in its database. The BIND files can be generated
from it again at any time.
```sh
sudo -u jarl jarld backup /var/backups/jarl/jarl-$(date +%F).db # consistent copy, safe while jarld runs
```
For example, as a daily timer or cron job:
```sh
# /etc/cron.daily/jarl-backup
#!/bin/sh
set -e
install -d -o jarl -g jarl -m 0700 /var/backups/jarl
sudo -u jarl jarld backup /var/backups/jarl/jarl-$(date +%F).db
find /var/backups/jarl -name 'jarl-*.db' -mtime +30 -delete
```
The database contains password and token *hashes* and the TSIG secrets in
the clear, so keep backups private.
### Restoring
```sh
systemctl stop jarl
install -o jarl -g jarl -m 0600 /var/backups/jarl/jarl-2026-10-01.db /var/lib/jarl/jarl.db
rm -f /var/lib/jarl/jarl.db-wal /var/lib/jarl/jarl.db-shm
systemctl start jarl
jarl resync --dry-run # what differs between the restored database and the files
jarl resync --force # write everything and reload named
```
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. If you have secondaries, raise the serials after a restore:
`jarl zone update ZONE --serial <higher>`.
## Drift: files changed outside Jarl
Jarl keeps a checksum of every file it writes. If someone edits
`/etc/bind/jarl/named.conf` or a zone file by hand (or a restore puts old
files back), `jarl status` and the dashboard show **drift**, and changes
that would overwrite those files are refused with `409 drift`:
```
error: files managed by Jarl were changed outside Jarl: /var/lib/bind/jarl/internal/adbraun.lol.zone; review them and apply with force to overwrite
```
Decide which version is right:
* **Jarl's** (the usual case): `jarl resync --force` rewrites every file from
the database and reloads named. Any single change with `--force` also
overwrites the drifted files.
* **The hand edit**: put the same change into Jarl (for example
`jarl zone import ZONE /var/lib/bind/jarl/internal/adbraun.lol.zone` for
records), then `jarl resync --force`.
## When an apply fails
Jarl checks everything it can before it touches named, so failures are
rare. When one happens, nothing is left half-done:
| Stage | What you see | State afterwards |
|-------|--------------|------------------|
| validation | 422 with `problems` | Nothing written |
| named-checkconf / named-checkzone | 422 `validation_failed` with the checker's messages in `check`, mapped to the zone/option that caused them | Nothing written |
| `rndc reconfig` / `reload` fails, or a zone doesn't load within `reload_timeout` | 502 `bind_failed` | Previous files restored and named reloaded |
A failed change is never recorded as applied. A staged changeset stays open,
so you can fix it and apply again, and the failure is in the audit log
(`jarl audit --outcome failure`). named's own log usually explains a load
failure: `journalctl -u named`.
If named isn't running, changes fail at the reload step and are rolled back.
Start named, then apply again.
## Recovering access
| Problem | Fix (on the server) |
|---------|---------------------|
| Forgot the admin password | `sudo -u jarl jarld user passwd admin` |
| Locked out by the login rate limit | wait 15 minutes, or `systemctl restart jarl` (the limiter is in memory) |
| Lost every admin account | `sudo -u jarl jarld user add --admin rescue` |
| Need a token without the web UI | `sudo -u jarl jarld user token admin cli` |
## Taking Jarl out of the picture
The generated files are plain BIND configuration. To stop using Jarl,
disable the service. named keeps working with `/etc/bind/jarl/` exactly as
it is. To go back to the configuration you had before adopting:
```sh
cp /etc/bind/named.conf.pre-jarl-<date> /etc/bind/named.conf
rndc reconfig
```
## Monitoring
* `GET /healthz` (no authentication) reports that jarld is up.
* `GET /api/v1/status` with a read-only token reports named reachability,
drift and configuration problems. It is suitable for a monitoring check:
```sh
jarl --token @/etc/jarl/monitor.token status -o json | jq -e '.engine.drift == [] and .engine.adopted'
```
* The audit log records failed logins as `auth.login_failed` /
`auth.login_blocked`, and rejected tokens as `auth.token_failed`.
+117
View File
@@ -0,0 +1,117 @@
# Users, tokens and permissions
## Ways to authenticate
| Who | How | Notes |
|-----|-----|-------|
| People in the web UI | Username + password → session cookie | argon2id password hashes. The session cookie is HttpOnly and SameSite=Strict and lasts 12h (2h idle). Every change also needs the session's CSRF token, which the UI sends in `X-CSRF-Token`. Changing a password logs out the user's other sessions. |
| CLI, scripts, certbot | API token: `Authorization: Bearer jarl_...` | Shown once at creation, stored hashed. Optional expiry. Revocable. Last use time and IP are recorded. |
| Root on the server | `jarld` subcommands (`init`, `user`, `import`, `adopt`, `check`, `resync`, `backup`) | Work directly on the database. They are for bootstrapping and recovery, and are audited as "local". |
Failed logins are rate limited: 8 per username and 20 per client IP within
15 minutes. Failed token attempts count against the IP too. Every attempt
is in the audit log.
A user **without a password** (`--no-password`) is a service account. It
can't log in to the web UI and is used only through its tokens, which suits
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`.
A grant is written as:
```
RESOURCE:ACCESS [zones=PATTERNS] [views=PATTERNS] [names=PATTERNS] [types=TYPES]
```
| Resource | Covers |
|----------|--------|
| `zones` | zone definitions (create, delete, SOA, TTL, view) and zone options |
| `records` | records in primary zones |
| `views` | views and view options |
| `acls` | ACLs |
| `tsig` | TSIG keys (`write` is needed to see a secret) |
| `options` | server options, top-level statements, the defaults for new zones, `jarl check` |
| `audit` | everyone's audit events and changesets (without it, users see only their own) |
| `*` | all of the above |
`ACCESS` is `read` or `write` (write includes read). Filters narrow a grant.
Each takes comma separated patterns where `*` and `?` are wildcards, and a
missing filter matches everything:
* `zones=adbraun.lol,*.adbraun.lol`: zone names, without the final dot
* `views=internal`: view names (`_default` means zones outside any view)
* `names=_acme-challenge.*`: record owner names, as FQDNs without the final dot
* `types=TXT,CNAME`: record types
Examples:
```
*:read read everything
records:write zones=lab.adbraun.lol edit records in one zone
zones:write zones=*.lab.adbraun.lol create/delete zones below lab
records:write zones=adbraun.lol views=internal only the internal copy of a split zone
records:write names=_acme-challenge.* types=TXT ACME challenges anywhere
records:write zones=acme.adbraun.lol types=TXT only TXT records in a dedicated ACME zone
```
A user who may see some records of a zone may also see that the zone exists
(its name and type), but not its other records.
### Roles
Roles are ready-made grant lists. Choosing a role copies its grants, which
you can then edit.
| Role | Grants |
|------|--------|
| `readonly` | `*:read` |
| `dns-editor` | `zones:write`, `records:write`, `views:read`, `acls:read`, `options:read` |
| `operator` | `*:write` (everything except user management) |
| `acme` | `records:write names=_acme-challenge.* types=TXT` |
```sh
jarl user create alice --role dns-editor
jarl user update alice --grant "records:write zones=adbraun.lol" --grant "zones:read" # replaces all grants
jarl user update alice --role readonly
```
## Tokens
Every token belongs to a user, and every token request acts as that user.
The audit log shows the user and the token name.
* A token **without grants** has all of its user's permissions, and they
follow the user: if the user loses a permission, so does the token.
* A token **with grants** is restricted to them. Effective permission is
*user grants ∩ token grants*: a token can never do more than its user.
Admin rights don't pass through a restricted token.
* A restricted token can create further tokens, but only ones no wider than
itself.
* Disabling or deleting a user stops all their tokens at once.
```sh
jarl token create laptop # full rights of the user, no expiry
jarl token create ci --grant "records:write zones=lab.adbraun.lol" --expires 90d
jarl token create certbot --user certbot --role acme # admin: a token for another user
jarl token list --all
jarl token revoke 12
```
In the web UI: Account → API tokens (your own) and Users → *user* →
Tokens (admin).
## Recovering access
On the server, as root or the `jarl` user:
```sh
sudo -u jarl jarld user list
sudo -u jarl jarld user passwd admin # set a new password and re-enable the account
sudo -u jarl jarld user add --admin rescue # a new administrator
sudo -u jarl jarld user token admin cli # a new API token for a user
```
+97
View File
@@ -0,0 +1,97 @@
# Validation
Every change is checked twice before named sees it:
1. **Jarl's validator** checks the whole resulting configuration: the DNS
rules, references between objects, and option syntax against the BIND
9.20 grammar. Its messages name the record, zone or option and explain
the rule.
2. **BIND's own checkers**: `named-checkconf -z` on the complete rendered
configuration (which also loads every zone) and `named-checkzone`. Their
messages are mapped back to the object that caused them.
Problems have a severity:
* **error** blocks the change (HTTP 422 `validation_failed`, CLI exit code 6).
* **warning** is shown but allowed: probably a mistake, or a security risk.
* **info** is a hint.
Only problems that a change *introduces* block it. Problems that already
existed are reported separately (`existing_problems`) and don't stop
unrelated changes, though `jarl status`, `jarl check` and the dashboard keep
showing them.
## Records and zones
| Code | Severity | Rule |
|------|----------|------|
| `record_invalid` | error | Record data must parse for its type (addresses, numbers, names, base64/hex fields, ...). TXT/SPF strings are split into 255-byte pieces automatically |
| `record_name_invalid` | error | Owner names must be valid. Host names in A/AAAA/MX owners follow `check-names` rules (letters, digits, hyphens; `_` only where it is normal, e.g. SRV/TXT owners) |
| `record_out_of_zone` | error | The name must be inside the zone (catches `www.example.com` without the final dot) |
| `record_duplicate` | error | The same record can't exist twice |
| `cname_conflict` | error | A CNAME must be the only record at its name (RFC 1034), so no CNAME next to A, MX, TXT... |
| `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves |
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution |
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
| `apex_ns_missing` | error | A zone needs NS records at its apex |
| `ns_no_address` | error | An in-zone name server needs an A or AAAA record (named refuses to load the zone otherwise) |
| `ns_cname`, `mx_cname`, `srv_cname` | error | NS, MX and SRV targets must not be CNAMEs (RFC 2181 / RFC 2782) |
| `glue_missing` | error | A delegation to a name server inside the delegated zone needs glue records |
| `target_no_address` | warning | A delegation's name server has no address in the zone |
| `occluded` | warning | Records at or below a delegation point are not served (named answers with a referral) |
| `shadowed_by_zone` | warning | Records hidden because a more specific zone exists in the same view |
| `child_not_delegated` | info | A child zone on this server isn't delegated from its parent, so other resolvers can't find it |
| `ds_not_at_delegation` | error | DS records belong at a delegation point |
| `null_mx_not_alone` | error | A null MX (`0 .`) must be the only MX |
| `rrset_ttl_mismatch` | error | All records of an RRset need the same TTL (BIND would silently use the first one) |
| `rrset_too_large`, `too_many_types` | error | BIND 9.20's limits: 100 records per RRset (`max-records-per-type`), 100 types per name (`max-types-per-name`) |
| `spf_multiple`, `dmarc_multiple`, `dmarc_name` | warning | More than one SPF or DMARC policy (receivers then ignore them), or DMARC outside `_dmarc.` |
| `wildcard_unusual` | warning | Wildcard NS records |
| `soa_invalid`, `soa_warning`, `ttl_invalid` | error / warning | SOA fields and TTLs in range and sensible |
| `zone_name_invalid`, `zone_type_invalid`, `zone_duplicate` | error | Valid zone names and types. A zone exists once per view |
| `zone_outside_view` | error | Once views exist, every zone must be in one (named requires this) |
| `zone_missing_option` | error | Secondary, stub and mirror zones need `primaries` |
| `zone_file_required` / `zone_file_invalid` / `zone_file_managed` | error | Hint zones need a file. Forward zones have none. Primary zone files are Jarl's |
| `zone_inview_invalid`, `zone_inview_option` | error | `in-view` zones only inside views, and only with `forward`/`forwarders` |
| `zone_records_unmanaged` | error | Only primary zones can have records in Jarl |
| `zone_serial_policy` | error | `date`, `increment` or `unixtime` |
| `zone_no_forwarders`, `zone_hint_name`, `zone_name_slash` | info / warning | Unusual but legal setups |
## ACLs, keys, views, options
| Code | Severity | Rule |
|------|----------|------|
| `acl_element_invalid` | error | Address match elements: addresses, prefixes (host bits must be zero, like BIND requires), `!negation`, `key NAME`, ACL names, `{ nested lists }`, `any`/`none`/`localhost`/`localnets` |
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
| `acl_cycle` | error | ACLs that include each other |
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak |
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
| `view_unreachable` | warning | A view that comes after one matching every client is never used |
| `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_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 |
## Security lints
| Code | Severity | What it catches |
|------|----------|-----------------|
| `security_open_resolver` | warning | A view (or the server) where recursion is on and reachable by any client. This includes the BIND trap where `allow-recursion` is unset and inherits `allow-query { any; }`. Open resolvers are abused for DDoS amplification. Set `allow-recursion` to your own networks |
| `security_open_transfer` | warning | `allow-transfer` open to anyone. Restrict it to your secondaries or a TSIG key |
| `dynamic_update_unsupported` | error | `allow-update` / `update-policy` on a Jarl zone. named would write to files that Jarl regenerates, losing the updates. Use the API (e.g. `jarl acme`) instead |
## What only BIND checks
named-checkconf and named-checkzone run on every change, so anything Jarl's
validator doesn't model is still caught before named loads it. Option
values Jarl accepts as raw named.conf syntax, such as `logging` channels
and `rrset-order`, are the main example. Their errors come back as
`validation_failed` (HTTP 422) with the checker's messages in `check`, each
tied to the object it belongs to.