Files
jarl/docs/api.md
T
abrown38andClaude Opus 5.5 293e6ddd33 Add backups, token expiry warnings and name server checks
- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:02:34 +00:00

392 lines
20 KiB
Markdown

# 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.
**The local socket**: on jarld's unix socket (`/run/jarl/jarl.sock`),
requests from root or jarld's own user need no `Authorization` header and
act as an administrator; other users must send a token:
```sh
sudo curl --unix-socket /run/jarl/jarl.sock http://localhost/api/v1/status
```
**HTTP Basic**, only on the lego `httpreq` routes (below): the username is
the token's user and the password the API token.
A response to a token that expires within 14 days carries
`Jarl-Token-Expires: <RFC 3339 time>`; the `jarl` CLI warns when it sees
it. Every response has `Server: Jarl/<version>`.
## 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) |
| 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` |
| 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
}
```
`/serials` and `/delegation` send DNS queries from the server, and only
when called ([operations.md](operations.md#checking-name-servers-and-the-delegation)).
`/serials` works for primary, secondary and mirror zones:
```json
{
"zone": "adbraun.lol.", "view": "", "type": "primary",
"serial": 2026100814, "serial_source": "named", // or "jarl" when named can't be asked
"servers": [
{"name": "ns1.adbraun.lol.", "address": "192.168.1.53", "source": "NS record", "status": "self", "serial": 2026100814},
{"name": "ns2.adbraun.lol.", "address": "192.168.1.54", "source": "NS record", "status": "behind",
"serial": 2026100812, "diff": -2, "rtt_ms": 1}
],
"notes": [], "checked_at": "..."
}
```
`status` is `self`, `in_sync`, `answered` (no serial here to compare),
`behind`, `ahead`, `not_authoritative`, `refused`, `unreachable`,
`no_route` (no route from this server, e.g. IPv6), `no_address` or
`error` (with `error`). `source` is `NS record`, `also-notify` or
`primaries`; `port` is set for a non-default port.
`/delegation` returns `parent`, `resolver`, `delegated`, `parent_ns`,
`jarl_ns`, `parent_servers` (each parent server's `status`: `delegated`,
`not_delegated`, `unreachable`, `error`, with `ns` and `glue`), `glue`
(`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.
### 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; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`) |
| 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` |
### Query statistics
Needs `stats:read`. When statistics are disabled, every endpoint but
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
| Method | Path | Query | Returns |
|--------|------|-------|---------|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` |
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]` |
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
```sh
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
```
### ACME: lego's httpreq provider
Off unless `acme.httpreq.enabled` is set, and by default HTTPS only. The
full description is in [acme.md](acme.md#6-lego-and-traefik-over-http-httpreq-without-the-jarl-cli).
| Method | Path | Body |
|--------|------|------|
| GET | `/acme/httpreq` | No authentication. `{"enabled", "https", "accepted", "transport"}`: whether the endpoint is on and how jarld sees this request (HTTPS or not, and why) |
| POST | `/acme/httpreq/present` | `{"fqdn": "_acme-challenge.adbraun.lol.", "value": "..."}`. Sets the TXT value in the longest matching primary zone, in every view that has it |
| POST | `/acme/httpreq/cleanup` | Same body. Removes that value (succeeds if it's already gone) |
| POST | `/acme/httpreq/view/{view}/present`, `.../cleanup` | The same, in one view only |
Authenticate with HTTP Basic (user and API token) or a Bearer token.
Success is `200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}`.
### 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 | `/tokens/{id}/rotate` | New secret, same token. Optional body `{"grace": "1h", "expires_in": "90d", "expires_at": "...", "no_expiry": false}`. The response's `token` field is the new secret, shown once; `previous_secret_valid_until` says when the old one stops. A restricted token may rotate itself and tokens no wider than itself |
| 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"
```