Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
658 lines
39 KiB
Markdown
658 lines
39 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": ...}`.
|
|
* `GET /metrics` serves Prometheus metrics when `metrics.enabled` is set.
|
|
Access is set in jarl.yaml, not with API tokens ([metrics.md](metrics.md)).
|
|
|
|
## 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. For sessions it
|
|
also has `totp` (the user's two-factor login: `enabled`, `required`,
|
|
`recovery_codes_left`) and `totp_setup_required`.
|
|
|
|
**Two-factor login** ([permissions.md](permissions.md#two-factor-login)):
|
|
for a user with it, `POST /auth/login` answers a correct password with
|
|
`{"totp_required": true, "challenge": "..."}` and no cookie. Send
|
|
`POST /auth/login/totp` with `{"challenge": "...", "code": "123456"}` (or
|
|
a recovery code) within 5 minutes to get the session. Wrong codes answer
|
|
401 `totp_invalid`; after 5, or 5 minutes, `login_expired` (start again).
|
|
|
|
Creating or rotating tokens, changing users and resetting someone's
|
|
two-factor login need, in a session of a user with two-factor login, a
|
|
code proved in the last 5 minutes. Otherwise they answer 403
|
|
`totp_step_up`: send the request again with the current code in
|
|
`X-Jarl-TOTP: 123456` (a wrong one gives 403 `totp_invalid`). Tokens and
|
|
the local socket never need one.
|
|
|
|
When `totp.require` applies to a user who hasn't set it up, a session can
|
|
only call `/auth/whoami`, `/auth/logout`, `/me/password` and `/me/totp...`;
|
|
everything else answers 403 `totp_setup_required`.
|
|
|
|
**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 (`"disable"` or `"enable"` for a record
|
|
that is only being disabled or enabled).
|
|
|
|
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.
|
|
|
|
**Stale changesets.** When an object a staged operation needs was changed
|
|
or deleted after it was staged (from the CLI, say), preview and apply fail
|
|
on that operation: the error has `op` (its number in the changeset),
|
|
`op_summary` and a message saying what happened; remove the operation
|
|
(`DELETE /changesets/{id}/ops/{seq}`) or stage it again. A staged delete of
|
|
a record that is already gone is not an error: it counts as done, with a
|
|
warning.
|
|
|
|
## 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); `{"disabled": true}` disables the zone: named stops loading it, Jarl keeps it |
|
|
| 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` |
|
|
| GET | `/zones/{zone}/dnssec` | the zone's DNSSEC keys, DS record and phase ([DNSSEC](#dnssec)) |
|
|
| POST | `/zones/{zone}/dnssec/check-ds` | asks the parent for the zone's DS records and tells named; `?resolver=IP` |
|
|
| POST | `/zones/{zone}/dnssec/wipe` | `{"force": false, "dry_run": false}`: signing off at once, keys deleted (administrators) |
|
|
| GET | `/zones/{zone}/registrar` | name servers, glue and DS records at the registrar ([Registrar](#registrar-porkbun)) |
|
|
| POST | `/zones/{zone}/registrar/glue` | create or update glue records |
|
|
| PUT | `/zones/{zone}/registrar/ns` | set the domain's name servers |
|
|
| POST | `/zones/{zone}/registrar/ds` | publish the zone's DS record |
|
|
| DELETE | `/zones/{zone}/registrar/ds/{keytag}` | remove a DS record |
|
|
| POST | `/zones/{zone}/import` | `{"text": "<zone file>", "mode": "merge"\|"replace", "use_soa": false, "dry_run": false}` |
|
|
|
|
ZoneInput:
|
|
|
|
```json
|
|
{
|
|
"name": "adbraun.lol", // or a CIDR like "192.168.1.0/24" for a reverse zone
|
|
"fill": true, // reverse zones: PTR records for the view's A/AAAA records there
|
|
"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
|
|
"dnssec_policy": "default", // primary zones: "" (off), "default", a custom policy, "insecure" (turning off)
|
|
"auto_ptr": true, // keep the PTRs of the zone's A/AAAA records in step (docs/reverse.md)
|
|
"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
|
|
}
|
|
```
|
|
|
|
A reverse zone created without a view goes to the private view whose
|
|
records use its addresses. Zones come back with `prefix` (reverse zones:
|
|
the network) and `ptr_missing` (addresses in it that the view's A and AAAA
|
|
records use, without a PTR record).
|
|
|
|
`/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.
|
|
|
|
`/serials?stream=1` and `/delegation?stream=1` answer with
|
|
newline-delimited JSON (`application/x-ndjson`) while they work, which is
|
|
how the web UI shows a check's progress. Each DNS query is an event when it
|
|
is sent and another when it is answered: `{"kind": "start", "id": 2,
|
|
"query": "dig @192.0.2.1 example.org. SOA +norec"}`, then `{"kind": "end",
|
|
"id": 2, "answer": "serial 2026100900", "rtt_ms": 12}`. `answer` sums the
|
|
answer up (addresses, `referral to …`, `NXDOMAIN`, `no such record`);
|
|
`error` replaces it when there was none. The serials check's SOA queries
|
|
end with `result`, the `servers` entry, instead. ID 0 is `rndc zonestatus`,
|
|
which asks this server's named. The last line is `{"kind": "done",
|
|
"response": {...}}`, the endpoint's usual response.
|
|
|
|
`/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.
|
|
`parent_ds` lists the zone's DS records at the parent, and the findings
|
|
compare them with the zone's keys (a DS that matches no key, or a DS for a
|
|
zone that isn't signed, is an `error`).
|
|
|
|
Zones in responses also carry `registrar` (`"porkbun"`) when Jarl can
|
|
manage the domain at a registrar.
|
|
|
|
#### DNSSEC
|
|
|
|
Signing is the zone's `dnssec_policy` field, changed with `PATCH
|
|
/zones/{zone}` like any other field, so it goes through validation,
|
|
changesets and history. Jarl refuses unsafe steps there: `""` straight from
|
|
a signed policy (use `"insecure"` first), `""` before named has finished
|
|
removing the signatures, deleting a signed zone, or moving it to another
|
|
view ([dnssec.md](dnssec.md#what-jarl-refuses-and-why)).
|
|
|
|
`GET /zones/{zone}/dnssec`:
|
|
|
|
```json
|
|
{
|
|
"zone": "adbraun.lol.", "view": "external", "type": "primary",
|
|
"policy": "default",
|
|
"key_dir": "/var/lib/bind/jarl/dnssec-keys/view-3",
|
|
"keys": [{
|
|
"tag": 51126, "algorithm": 13, "algorithm_name": "ECDSAP256SHA256", "role": "CSK",
|
|
"dnskey": "adbraun.lol.\t3600\tIN\tDNSKEY\t257 3 13 ...",
|
|
"ds": {"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "record": "adbraun.lol.\t3600\tIN\tDS\t51126 13 2 FFBE..."},
|
|
"goal": "omnipresent", "dnskey_state": "rumoured", "ds_state": "hidden",
|
|
"zone_rrsig_state": "rumoured", "key_rrsig_state": "rumoured",
|
|
"created": "...", "published": "...", "active": "...", "publish_cds": "...",
|
|
"files": ["Kadbraun.lol.+013+51126.key", "Kadbraun.lol.+013+51126.private", "Kadbraun.lol.+013+51126.state"]
|
|
}],
|
|
"phase": {
|
|
"code": "signing", "summary": "named is signing the zone. ...", "next": "Wait until ...",
|
|
"ds_ready_at": "...", "can_publish_ds": false, "can_finish": false
|
|
},
|
|
"named": {"secure": true, "inline_signing": true, "serial": 2026100802, "signed_serial": 2026100804, "next_key_event": "..."},
|
|
"policies": ["default"],
|
|
"parent_zone": "", // set when the parent zone is a primary zone in Jarl
|
|
"registrar": "porkbun" // when the DS can be published through Jarl
|
|
}
|
|
```
|
|
|
|
`phase.code` is one of `off`, `leftover_keys`, `starting`, `signing`,
|
|
`ready`, `ds_pending`, `secure`, `removing_ds`, `ds_expiring`,
|
|
`removing_keys`, `unsigned` ([dnssec.md](dnssec.md#phases)).
|
|
|
|
`POST /dnssec/check-ds` (zones write) returns `parent` (the parent's
|
|
servers with their `ds`, the union `ds`, `delegated`, `reached`,
|
|
`consistent`, or `error`), `marked` (what named was told, `rndc dnssec
|
|
-checkds`), `notes` and the updated `status`.
|
|
|
|
`POST /dnssec/wipe` (administrators) asks the parent first and answers
|
|
`409` while it has a DS record for the zone, or while named still counts a
|
|
removed one as cached; when the parent can't be asked, it needs `"force":
|
|
true`. It returns `removed` (the files deleted) and `changeset_id`.
|
|
|
|
#### Registrar (Porkbun)
|
|
|
|
Administrators only, and only with a Porkbun key in jarl.yaml
|
|
([dnssec.md](dnssec.md#porkbun)). `GET /zones/{zone}/registrar`:
|
|
|
|
```json
|
|
{
|
|
"registrar": "porkbun", "domain": "adbraun.lol",
|
|
"ns": ["curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"], "jarl_ns": ["ns1.adbraun.lol", "ns2.adbraun.lol"],
|
|
"ns_match": false,
|
|
"glue": [{"host": "ns1.adbraun.lol", "registry": [], "jarl": ["203.0.113.1", "2001:db8::1"],
|
|
"needed": true, "in_use": false, "action": "create"}],
|
|
"ds": [{"key_tag": 51126, "algorithm": 13, "digest_type": 2, "digest": "FFBE...", "matches": true}],
|
|
"findings": [{"level": "warning", "message": "..."}],
|
|
"error": "" // e.g. "Porkbun: ... (API_ACCESS_DISABLED)"; the rest is then empty
|
|
}
|
|
```
|
|
|
|
The changes take `{"force": false, "dry_run": false}` (`PUT /ns` also
|
|
`"ns": [...]`, default the zone's NS records; `POST /ds` also `"key_tag"`)
|
|
and answer:
|
|
|
|
```json
|
|
{"done": ["create glue for ns1.adbraun.lol: 203.0.113.1, 2001:db8::1"], "dry_run": false,
|
|
"checks": [{"level": "ok"|"info"|"warning"|"error", "message": "..."}], "warnings": []}
|
|
```
|
|
|
|
Each change runs its checks first: the name servers answer for the zone,
|
|
glue matches, the DS points at a key the zone has, and the domain is
|
|
delegated to servers that serve the signed zone before a DS is published. An
|
|
`error` check refuses the change with `409` unless `force` is set; a dry run
|
|
returns the checks without changing anything. Errors from Porkbun are `502
|
|
registrar_error`.
|
|
|
|
### Records
|
|
|
|
In a linked zone ([views.md](views.md#linked-zones)), records carry
|
|
`shared_id` and `linked` (`shared` or `local`). Creating, updating
|
|
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
|
true` (this view only, an override) or `false` (shared; on an update or a
|
|
set it makes this view's version every view's). Zones carry `link_group`
|
|
and `linked_with` (the other views).
|
|
|
|
| Method | Path | Body |
|
|
|--------|------|------|
|
|
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
|
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
|
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
|
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack: `view_id`, `view`, `prefix`, `zone`, `why`, `fill` (PTR records it would get), `samples` |
|
|
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
|
|
|
|
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
|
that view's zone of the same name.
|
|
|
|
| Method | Path | Body / query |
|
|
|--------|------|--------------|
|
|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
|
| 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 |
|
|
| POST | `/zones/{zone}/records/disable` | same query as delete by match: disable the matching records |
|
|
| POST | `/zones/{zone}/records/enable` | same query: enable them again |
|
|
| 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 |
|
|
| POST | `/records/{id}/disable` | `?ptr=1` also disables the matching PTR |
|
|
| POST | `/records/{id}/enable` | `?ptr=1` also enables 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",
|
|
"disabled": false, // true: keep the record in Jarl but out of the zone
|
|
"ptr": true // A/AAAA: also create/move/delete (or disable/enable) the PTR in the reverse zone
|
|
}
|
|
```
|
|
|
|
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
|
(the decoded value of TXT records). Zones have `disabled_count` when
|
|
some of their records are disabled.
|
|
|
|
**Disabled records** stay in Jarl, with their ID, TTL and comment, but are
|
|
left out of the zone: the zone file lists them as comments (`; disabled:
|
|
www IN A 192.0.2.10`). Disabling and enabling are ordinary changes (staged,
|
|
reviewed, reverted like any other). Validation checks the zone as it is
|
|
served, so disabling the zone's last NS record is refused, and enabling a
|
|
record is checked as if it were added: a CNAME added at its name in the
|
|
meantime stops it (and gives a `disabled_conflict` warning until then).
|
|
`PUT /rrsets` makes the RRset exactly the given data: a disabled record
|
|
with one of the values is enabled, other disabled records are left alone.
|
|
Disabling a record that is already disabled is a 409; with `missing_ok=1`
|
|
(by match) it's "nothing to do".
|
|
|
|
### 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]}` |
|
|
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
|
|
| GET | `/views/{id}/key` | `{name, algorithm, secret}`: the TSIG key that selects the view (docs/views.md); needs `tsig:write`, audited |
|
|
|
|
`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.
|
|
|
|
`color` is how the web UI shows the view: one of `green`, `teal`,
|
|
`purple`, `pink`, `orange`, `yellow`, `red` and `grey`. When it's left out,
|
|
the view uses the link colour. It isn't part of the BIND configuration, so
|
|
`PUT /views/{id}/color` applies at once and creates no changeset. It needs
|
|
write access to the view, and it is logged in the audit log as
|
|
`view.color`. To colour a view that so far exists only in a changeset,
|
|
send the request with that changeset (`?changeset=` or
|
|
`X-Jarl-Changeset`).
|
|
|
|
### 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", "10.0.10.101-10.0.10.199", "key xfer", "localhost"], "comment": ""}` (ranges: [concepts.md](concepts.md#model-views-zones-records-options)) |
|
|
| 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 |
|
|
|
|
### Blocking
|
|
|
|
Needs `blocking:read` (`blocking:write` to change). See
|
|
[blocking.md](blocking.md).
|
|
|
|
| Method | Path | Body / query |
|
|
|--------|------|--------------|
|
|
| GET | `/blocking/lists` | each with `views` (where it applies), `auto_views` (not chosen: every view that offers recursion), `state` (ok, waiting, error, disabled, idle), `status` (`entries`, `records`, `skipped`, `checked_at`, `updated_at`, `error`) and `memory_mb` (named's memory for it, estimated) |
|
|
| POST | `/blocking/lists` | `{"name": "hagezi-multi", "url": "https://...", "views": ["internal"], "comment": "", "step": "normal"}` (`views` empty or absent: automatic; `step` the blocking step it starts at); jarld downloads it right after |
|
|
| GET / PATCH / DELETE | `/blocking/lists/{id or name}` | PATCH any of `name`, `url`, `views`, `disabled`, `comment` |
|
|
| POST | `/blocking/lists/{id or name}/refresh` | download now and wait; `?force=1` even when the server says nothing changed. Returns the list; a failed update is in `state` and `status.error` |
|
|
| GET | `/blocking/rules` | each with `views` and `auto_views` |
|
|
| POST | `/blocking/rules` | `{"name": "doubleclick.net", "action": "block", "exact": false, "views": [], "comment": ""}` (`action` block or allow; `exact`: only the name, not the names below it) |
|
|
| PATCH / DELETE | `/blocking/rules/{id}` | |
|
|
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
|
|
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list, step}` (what applies to normal devices when blocking isn't paused; `step` when only devices at that step or above are blocked) |
|
|
| GET / POST | `/blocking/steps` | the steps above normal, lowest first, with the `lists` and `groups` at each; POST `{"name": "strict", "comment": ""}` adds one on top |
|
|
| PATCH / DELETE | `/blocking/steps/{id or name}` | `{"name", "position", "comment"}`; delete is refused while lists or groups use it |
|
|
| POST | `/blocking/steps/order` | `{"steps": ["strict", "very-strict"]}`: every step above normal, lowest first |
|
|
| GET / POST | `/blocking/groups` | client groups; POST `{"name": "kids", "members": ["10.10.0.50", "tablets"], "step": "strict", "views": []}` |
|
|
| PATCH / DELETE | `/blocking/groups/{id or name}` | the same fields |
|
|
| GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
|
|
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
|
|
| POST | `/blocking/resume` | end a pause now |
|
|
|
|
Lists and rules are configuration like the rest: they can be staged, and
|
|
listing with `?changeset=` marks them `pending`.
|
|
|
|
### Options and settings
|
|
|
|
| Method | Path | Body / query |
|
|
|--------|------|--------------|
|
|
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see). `formatted` lays out a block over several lines, a list of three or more one element per line |
|
|
| GET | `/options/generated` | What Jarl adds to named.conf by itself: `where`, `text`, `why`, `setting` (what turns it on or off) |
|
|
| 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. `position` only
|
|
orders top-level statements; the options of a block are always listed and
|
|
written in Jarl's fixed order (docs/concepts.md).
|
|
|
|
### 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 | `/exposure` | which views are reachable from the internet, the exposure findings and their fixes ([exposure.md](exposure.md#api)); `POST /exposure/fix`, `/exposure/ack`, `/exposure/unack` |
|
|
| GET | `/catalog/ranges?r=A-B,C-D` | for each address range: `range` (canonical), `size` ("99 addresses"), and the `prefixes` it is written to named.conf as; or `error`. Text that isn't a range is left out |
|
|
| 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` | |
|
|
| `record.disable` / `record.enable` | record, or 0 with the same match fields as `record.delete` | stored as `record.update` operations |
|
|
| `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 |
|
|
| `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` |
|
|
| `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise |
|
|
| `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, `{"indefinitely": true}`, or `{}` to resume |
|
|
|
|
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`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` or `blocking_paused_indefinitely` (with `blocking:read`, while paused). `problems` include named-checkconf's warnings about the running configuration (`named_warning`) |
|
|
| 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
|
|
|
|
Every statistics endpoint below takes `views=internal,vps` to limit the
|
|
figures to those views ([stats.md](stats.md#views)); a breakdown that only
|
|
exists for the whole server then answers `400 not_per_view`.
|
|
|
|
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` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `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, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
|
| 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/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
|
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
|
|
|
|
```sh
|
|
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
|
```
|
|
|
|
### Encrypted DNS
|
|
|
|
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
|
|
/encrypted-dns`, `POST /encrypted-dns/check`.
|
|
|
|
### Certificates
|
|
|
|
Administrators only. See [certificates.md](certificates.md#api).
|
|
|
|
| Method | Path | Body |
|
|
|--------|------|------|
|
|
| GET | `/certificates` | `certificates` (with `status`, `next_attempt`, `issuing_since`, `serving`, `files`) and `account` |
|
|
| POST | `/certificates` | `{"name", "domains", "uses", "challenge", "key_type", "comment"}`, or `{"name", "source": "file", "cert_file", "key_file", "uses"}`; `"issue": false` doesn't start obtaining it |
|
|
| POST | `/certificates/check` | `{"domains", "challenge"}`: the route of each challenge |
|
|
| GET, PATCH, DELETE | `/certificates/{name}` | |
|
|
| POST | `/certificates/{name}/issue` | 202: being obtained (poll until `issuing_since` is gone); a file certificate is reread (200) |
|
|
| POST | `/certificates/{name}/check` | the routes of a certificate's names |
|
|
|
|
`GET /status` includes `certificates` for administrators: `total`,
|
|
`problems` (failing, expiring, expired), `next` and `next_after`,
|
|
`serving`.
|
|
|
|
### 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`, and `username` to rename. Users have `totp_enabled_at` (two-factor login on since) and `totp_required` |
|
|
| 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) |
|
|
| GET | `/me/totp` | Your two-factor login: `enabled`, `enabled_at`, `required`, `recovery_codes_left`, `policy` |
|
|
| POST | `/me/totp/setup` | `{"password": "..."}` (sessions only). A new secret: `secret`, `uri` (`otpauth://`), `qr` (an SVG `data:` URI), `issuer`, `account`. Not active until confirmed. With two-factor login already on (a new phone), it needs a recent code |
|
|
| POST | `/me/totp/enable` | `{"code": "123456"}`: confirms the secret from setup. Returns `{"recovery_codes": [...]}`, shown once, and logs out your other sessions |
|
|
| POST | `/me/totp/disable` | `{"password": "...", "code": "..."}` (a code or a recovery code). Refused (409) while `totp.require` applies to you |
|
|
| POST | `/me/totp/recovery-codes` | New recovery codes, replacing the old ones (needs a recent code) |
|
|
| DELETE | `/users/{id}/totp` | admin: turn off someone's two-factor login, delete their recovery codes and end their sessions |
|
|
|
|
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"
|
|
```
|