The header says "also in VIEW" for every zone of the same name in another view, linked or not, in the view's colour, keeping the open tab where the other zone has it. The zone API gains in_views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 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": ...}.GET /metricsserves Prometheus metrics whenmetrics.enabledis set. Access is set in jarl.yaml, not with API tokens (metrics.md).
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. 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):
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:
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/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 ("disable" or "enable" for a record
that is only being disabled or enabled).
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.
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) |
| 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) |
| 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:
{
"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).
/serials works for primary, secondary and mirror zones:
{
"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).
GET /zones/{zone}/dnssec:
{
"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"],
"ds_at": "registrar", // parent_zone, registrar, parent, reverse or none
"ds_place": "at your registrar", // completes "add the DS record ..."
"ds_note": "", // for ds_at none: why it can't have one
"parent_zone": "", // set when the parent zone is a primary zone in Jarl (any view, its own first)
"registrar": "porkbun" // when the DS can be published through Jarl
}
ds_at says where the DS record goes (dnssec.md):
parent_zone (a zone in Jarl: parent_zone, parent_zone_id,
parent_view), registrar (the zone is a registered domain), parent (a
subdomain whose parent zone is hosted elsewhere), reverse (whoever
delegates the addresses) or none (nothing on the internet delegates it).
phase.code is one of off, leftover_keys, starting, signing,
ready, ds_pending, secure, removing_ds, ds_expiring,
removing_keys, unsigned (dnssec.md).
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). GET /zones/{zone}/registrar:
{
"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:
{"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), 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). Every zone in a view carries
in_views: the zones of the same name in the other views, linked or not
and of any type, in view order ([{"id": 289, "view": "external", "type": "primary", "linked": true}]).
| 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) |
| GET | /reverse-zones/suggestions |
The reverse zones the private views lack (?all=1: also the ones not suggested unasked, with hidden: public view, suggestions off, dismissed; ?view=: one view): view_id, view, prefix, zone, group (yours, or public: only if the address is yours), why, fill (PTR records it would get), samples |
| POST | /reverse-zones/dismiss |
{"view": "internal", "prefix": "185.199.108.153/32"} (or "prefixes": [...] for several): don't suggest it again (/undismiss brings it back). A view's reverse_suggestions: false (PATCH /views/{id}) stops its suggestions |
| GET | /zones/{zone}/compare |
The zone compared with the same zone in other views, from its own view (views.md): ?with=a,b (default: all), ?name=www&type=A (one name, the same records included), ?all=1. Returns base, views, linked, summary (per other view: different, only_base, only_other, same), different and only (views, rows) rows (name, fqdn, type, per view records, data, ids, ttl, linked), same, same_rows. A zone named without its view is compared from the first view that has it |
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:
{
"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". A zone update that leaves the zone as it
is (the same SOA, TTL, comment...) changes nothing, so applying it is
"nothing to do" and staging it is "nothing to change".
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) |
| 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.
| 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); 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; 400 "nothing to change" if they change nothing |
| 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); 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.
| 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/explore |
?client=&name=&domain=&view=&type=&rcode=&blocked=yes|no|LIST&since=24h&views=; each takes several values, comma separated (any of them), !value to leave one out, and names *.example.com for it and below |
The query log for a filter (stats.md): total, blocked, series (per step seconds: t, count, blocked), by (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with name), recent (latest 100: ts, client, client_name, view, name, type, rcode, blocked), oldest, retention, client_name. 404 query_log_off without a query log |
| 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}], database: {path, bytes, query_log_rows}}; 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}] |
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
Encrypted DNS
See encrypted-dns.md: GET, POST and DELETE /encrypted-dns, POST /encrypted-dns/check.
Certificates
Administrators only. See certificates.md.
| 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.
| 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
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"