Files
jarl/docs/api.md
T
2026-10-09 15:17:49 +00:00

28 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. 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.

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)
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
  "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). /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.

/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"],
  "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).

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

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]}
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.

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.

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, 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"