jarld can now answer lego's httpreq DNS provider (POST /api/v1/acme/httpreq/present and /cleanup), so lego and Traefik publish DNS-01 challenges with only environment variables, without the jarl CLI. lego's username and API token arrive as HTTP Basic auth, accepted on these routes only. The endpoint is off by default (acme.httpreq.enabled) and HTTPS-only by default: jarld's own TLS, or a trusted proxy that says so with X-Forwarded-Proto or Forwarded (the last value counts). Plain HTTP can be allowed from loopback only or everywhere. GET /api/v1/acme/httpreq shows how jarld sees a request, to check a proxy setup. jarl acme and the endpoint share their code in internal/acme. Documented in docs/acme.md, configuration.md, api.md and permissions.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 KiB
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:
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.
HTTP Basic, only on the lego httpreq routes (below): the username is
the token's user and the password the API token.
Errors
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) 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:
{
"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:
{
"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:
{
"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.
| 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}] |
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.
| Method | Path | Body |
|---|---|---|
| GET | /acme/httpreq |
No authentication. {"enabled", "https", "accepted", "transport"}: whether the endpoint is on and how jarld sees this request (HTTPS or not, and why) |
| POST | /acme/httpreq/present |
{"fqdn": "_acme-challenge.adbraun.lol.", "value": "..."}. Sets the TXT value in the longest matching primary zone, in every view that has it |
| POST | /acme/httpreq/cleanup |
Same body. Removes that value (succeeds if it's already gone) |
| POST | /acme/httpreq/view/{view}/present, .../cleanup |
The same, in one view only |
Authenticate with HTTP Basic (user and API token) or a Bearer token.
Success is 200 {"ok": true, "fqdn": ..., "changeset_id": ..., "zones": [...]}.
Users and tokens
| Method | Path | Body |
|---|---|---|
| GET | /users |
admin |
| POST | /users |
{"username": "alice", "password": "...", "admin": false, "role": "dns-editor", "grant_strings": ["records:write zones=lab.adbraun.lol"], "comment": ""} (password: "" = service account) |
| GET / PATCH / DELETE | /users/{id} |
PATCH also takes disabled |
| GET / POST | /users/{id}/tokens |
admin (or yourself) |
| GET / POST | /me/tokens |
{"name": "laptop", "role": "", "grant_strings": [...], "expires_in": "90d"}. The response's token field is the secret, shown once |
| GET | /tokens |
every token (admin); ?all=1 includes revoked tokens |
| DELETE | /tokens/{id} |
revoke |
| POST | /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
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"