release / release (push) Successful in 1m42s
Every DNS query the name server checks send is now reported from exchangeHostPort as a dig command line with a short answer (addresses, referral, DS, NXDOMAIN) and its round-trip time; the serials check's SOA queries keep their own events with the server's result. The delegation check streams with ?stream=1 like /serials, and the Name servers tab shows its queries as they happen, keeps its result and follows a running check when you come back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
539 lines
28 KiB
Markdown
539 lines
28 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.
|
|
|
|
The response of an applied change:
|
|
|
|
```json
|
|
{
|
|
"applied": true,
|
|
"changeset_id": 37,
|
|
"result": {
|
|
"valid": true,
|
|
"problems": [],
|
|
"existing_problems": [ ... warnings that were already there ... ],
|
|
"check_ok": true,
|
|
"diff": "--- /var/lib/bind/jarl/internal/adbraun.lol.zone\n+++ ...",
|
|
"files_changed": ["/var/lib/bind/jarl/internal/adbraun.lol.zone"],
|
|
"serials": [{"zone": "adbraun.lol.", "view": "internal", "old": 2026100715, "new": 2026100716}],
|
|
"reloaded": ["reload adbraun.lol [internal]"],
|
|
"ops": [{"seq": 1, "op": "record.create", "id": 295, "summary": "add doc.adbraun.lol. 300 IN A 192.168.1.77"}]
|
|
},
|
|
"object": { ... the created or updated object ... },
|
|
"warnings": []
|
|
}
|
|
```
|
|
|
|
A staged change returns `{"staged": true, "changeset_id": ..., "added": [ops], "problems": [...]}`.
|
|
Staging checks right away that the request doesn't introduce validation
|
|
errors into the changeset's working state (a request with several
|
|
operations is checked at its end state) and returns 422 if it does.
|
|
named-checkconf runs on preview and apply, and the whole changeset is
|
|
validated again when it is applied.
|
|
|
|
**Optimistic locking.** Every object has a `version`. `PATCH /records/{id}`
|
|
honours `If-Match: <version>`, and batch operations take `base_version`. If
|
|
the object changed in the meantime the request fails with 409. Staged
|
|
operations record the version they were staged against automatically.
|
|
|
|
## Resources
|
|
|
|
`{zone}` is a zone ID or name (`root` for the root zone). Add `?view=NAME`
|
|
when the name exists in several views. `{id}` for views, ACLs and TSIG keys
|
|
may be the name.
|
|
|
|
### Zones
|
|
|
|
| Method | Path | Body / query |
|
|
|--------|------|--------------|
|
|
| GET | `/zones` | `?view=`, `?type=`, `?q=` (name contains) |
|
|
| POST | `/zones` | ZoneInput (below) |
|
|
| GET | `/zones/{zone}` | |
|
|
| PATCH | `/zones/{zone}` | ZoneInput (only the fields to change) |
|
|
| DELETE | `/zones/{zone}` | deletes the zone with its records and options |
|
|
| GET | `/zones/{zone}/file` | the generated zone file (text/plain) |
|
|
| 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=` |
|
|
| POST | `/zones/{zone}/records` | a RecordInput, or an array of them (one changeset) |
|
|
| DELETE | `/zones/{zone}/records` | `?name=&type=[&data=][&missing_ok=1][&ptr=1]`: delete by match |
|
|
| PUT | `/zones/{zone}/rrsets/{name}/{type}` | `{"data": ["192.0.2.1", "192.0.2.2"], "ttl": 60, "comment": "..."}`: make the RRset exactly this |
|
|
| DELETE | `/zones/{zone}/rrsets/{name}/{type}` | delete the whole RRset |
|
|
| GET | `/records/{id}` | |
|
|
| PATCH | `/records/{id}` | RecordInput fields to change (`If-Match` supported) |
|
|
| DELETE | `/records/{id}` | `?ptr=1` also deletes the matching PTR |
|
|
|
|
RecordInput:
|
|
|
|
```json
|
|
{
|
|
"name": "www", // relative to the zone, "@" for the apex, or an FQDN with a final dot
|
|
"type": "A",
|
|
"data": "192.0.2.10", // zone file syntax; TXT/SPF take plain text
|
|
"ttl": 300, // null or absent: the zone's default TTL
|
|
"comment": "web server",
|
|
"ptr": true // A/AAAA: also create/move/delete the PTR in the reverse zone
|
|
}
|
|
```
|
|
|
|
Responses add `zone`, `view`, `relative_name`, `effective_ttl`, and `text`
|
|
(the decoded value of TXT records).
|
|
|
|
### Views
|
|
|
|
| Method | Path | Body |
|
|
|--------|------|------|
|
|
| GET | `/views` | in match order |
|
|
| POST | `/views` | `{"name": "internal", "match_clients": ["homelab", "localhost"], "match_destinations": [], "match_recursive_only": null, "comment": "", "adopt_zones": true}` |
|
|
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
|
|
| POST | `/views/order` | `{"ids": [3, 1, 2]}` |
|
|
| 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 |
|
|
|
|
### 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; `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`) |
|
|
| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) |
|
|
| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) |
|
|
| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` |
|
|
|
|
### Query statistics
|
|
|
|
Needs `stats:read`. When statistics are disabled, every endpoint but
|
|
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
|
|
|
|
| Method | Path | Query | Returns |
|
|
|--------|------|-------|---------|
|
|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
|
|
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` |
|
|
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]` |
|
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
|
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
|
|
|
|
```sh
|
|
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
|
|
```
|
|
|
|
### 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"
|
|
```
|