Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
588 lines
33 KiB
Markdown
588 lines
33 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. 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.
|
|
|
|
## 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
|
|
"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)
|
|
"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.
|
|
|
|
`/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
|
|
|
|
| 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 |
|
|
|
|
`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", "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 |
|
|
|
|
### 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": ""}` (`views` empty or absent: automatic); 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}` (what applies when blocking isn't paused) |
|
|
| 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) |
|
|
| 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 | `/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
|
|
|
|
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)"'
|
|
```
|
|
|
|
### 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"
|
|
```
|