Files
jarl/docs/api.md
T
abrown38andClaude Opus 5.5 3b7bee67f8 Add optional query statistics
Off by default (stats in jarl.yaml). When enabled, Jarl adds a loopback
statistics channel and a response log to named's configuration (merged
into existing logging/statistics-channels statements) and switches
response logging with rndc responselog, because the responselog option
only counts when named starts. jarld polls named's counters every minute
and tails the response log (resuming after restarts and across
rotation). It stores aggregates, never individual queries, in a separate
SQLite database: per-minute, hourly and daily counters (queries, result
codes, record types, views, zones, cache hits, upstream latency, DNSSEC)
and hourly/daily top lists (names, clients, NXDOMAIN and SERVFAIL names,
refused clients), each with its own retention. Clients can be anonymized
or excluded.

- Web UI: Statistics page with KPI tiles, queries and failed answers over
  time, a weekday x hour heatmap, top lists and breakdowns; SVG charts
  with hover/keyboard readouts, a table view and light/dark palettes
  validated for colour-vision deficiency.
- CLI: jarl stats status|summary|top|timeline|heatmap|breakdown.
- API: /api/v1/stats/*, behind a new "stats" permission resource.
- jarld brings named's configuration up to date with jarl.yaml at
  startup (engine.SyncConfig), never overwriting hand-edited files.
- resync now refuses to overwrite drifted files without force, as
  documented.
- tmpfiles.d creates /var/log/named (allowed by named's AppArmor profile
  on Debian 13 and Ubuntu).
- The e2e harness runs named with -f like the Debian unit.
- docs/stats.md, plus API, CLI, configuration, permissions and README
  updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:21:34 +00:00

329 lines
16 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.
## 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` |
### 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)"'
```
### 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"
```