jarl token rotate, a Rotate button in the web UI and
POST /api/v1/tokens/{id}/rotate give a token a new secret while it keeps
its ID, name, user and grants. The old secret stops at once or after a
grace period of up to 7 days, during which jarld logs who still uses it.
A token that expires gets its lifetime again. --save writes the new
secret where the CLI got the old one (its configuration or --token @FILE),
--write FILE to any file, and --current picks the token the CLI runs
with, so a restricted token can renew itself. A restricted token can't
rotate a wider token of its user.
Also fixes token create --save for named servers, and future times
(token expiry) shown as "just now" in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
320 lines
14 KiB
Markdown
320 lines
14 KiB
Markdown
# The `jarl` command line client
|
|
|
|
`jarl` talks to jarld's REST API with an API token. It runs on the DNS server
|
|
or on any machine that can reach jarld. Every command has `--help` with
|
|
examples, and `jarl completion bash|zsh|fish` prints shell completion.
|
|
|
|
## Setup
|
|
|
|
```sh
|
|
jarl config set server https://ns1.adbraun.lol:8053
|
|
jarl config set token # paste the token at the prompt
|
|
jarl whoami
|
|
```
|
|
|
|
| Setting | Flag | Environment | Config file key |
|
|
|---------|------|-------------|-----------------|
|
|
| Server URL, or a server or group name (below) | `-s/--server` | `JARL_SERVER` | `server` (`default` for a name) |
|
|
| API token (`@FILE` reads it from a file) | `--token` | `JARL_TOKEN` | `token` |
|
|
| Skip TLS verification | `--insecure` | | `insecure` |
|
|
| Extra trusted CA (PEM) | `--ca-cert` | | `ca_cert` |
|
|
| Changeset to stage into | `-C/--changeset` | `JARL_CHANGESET` | |
|
|
|
|
Flags win over the environment, and the environment wins over the file. The
|
|
file is `~/.config/jarl/config.yaml` (mode 0600), or `--config` /
|
|
`JARL_CLI_CONFIG`. `jarl config show` prints it with the token masked.
|
|
|
|
Get tokens from the web UI (Account → API tokens), from
|
|
`jarl token create`, or from `jarld init --token` on the server.
|
|
|
|
### Several servers and groups
|
|
|
|
With more than one Jarl (say a primary and a secondary, each with its own
|
|
jarld), give each a name; group them to run a command on several at once:
|
|
|
|
```sh
|
|
jarl config server add ns1 https://ns1.adbraun.lol:8053 # asks for ns1's token
|
|
jarl config server add ns2 https://ns2.adbraun.lol:8053
|
|
jarl config group set dns ns1 ns2
|
|
jarl config use ns1 # the default when --server is left out
|
|
|
|
jarl -s ns2 zone list # or JARL_SERVER=ns2
|
|
jarl -s dns acl update homelab --add 10.0.20.0/24
|
|
jarl -s all status # every named server
|
|
jarl -s ns2 config set token # change ns2's token
|
|
```
|
|
|
|
The file then looks like this:
|
|
|
|
```yaml
|
|
servers:
|
|
ns1: {url: "https://ns1.adbraun.lol:8053", token: jarl_...}
|
|
ns2: {url: "https://ns2.adbraun.lol:8053", token: "@/home/adam/.config/jarl/ns2.token"}
|
|
groups:
|
|
dns: [ns1, ns2]
|
|
default: ns1
|
|
```
|
|
|
|
`--server` takes a server name, a group (groups may contain groups),
|
|
`all`, a comma separated list of those, or a URL. A named server always uses
|
|
its own token; `--token` and `JARL_TOKEN` only fill in for servers without
|
|
one, so a token is never sent to the wrong server. The plain `server:` and
|
|
`token:` keys still work and are used when nothing is selected.
|
|
|
|
**How a command runs on a group.** It is first run on every server as a dry
|
|
run (in parallel). If any server rejects it (for example a record in a zone
|
|
that is a secondary there, an ACL that already exists, or a token without the
|
|
permission), the errors are shown and *nothing is changed anywhere*. Only
|
|
when it would succeed everywhere does it run for real, one server after
|
|
another, with a `== ns1 (URL) ==` header before each server's output. A
|
|
read-only command simply shows each server's answer. If a server still fails
|
|
in the real run (it went down in between, say), jarl stops and says which
|
|
servers already applied the change.
|
|
|
|
Commands that can't be dry-run (`token create`, `user ...`, `changeset
|
|
create/discard/revert`) refuse to run on a group, and `--changeset` can't be
|
|
combined with one, since changeset IDs belong to a single server. Headers go
|
|
to stderr with `-o json` or `-o yaml`, so stdout holds just the documents.
|
|
|
|
## Global flags
|
|
|
|
| Flag | Meaning |
|
|
|------|---------|
|
|
| `-o table\|json\|yaml` | Output format. JSON and YAML have the API's full objects, for scripts. |
|
|
| `-n, --dry-run` | Validate and show the diff without changing anything. |
|
|
| `-C, --changeset ID` | Stage the change into an open changeset instead of applying it. |
|
|
| `--force` | Overwrite BIND files that were changed outside Jarl (drift). |
|
|
| `-q, --quiet` | Print only errors, or just the ID where a command creates something. |
|
|
|
|
## Exit codes
|
|
|
|
| Code | Meaning |
|
|
|------|---------|
|
|
| 0 | Success |
|
|
| 1 | Other error (bad arguments, connection failure, ...) |
|
|
| 3 | Not authenticated, or not allowed (token lacks the permission) |
|
|
| 4 | Not found |
|
|
| 5 | Conflict: concurrent change, object in use, drift |
|
|
| 6 | Invalid request, or rejected by validation (e.g. a CNAME conflict, or named-checkconf found an error) |
|
|
| 7 | Server error, or BIND failed: a tool couldn't run, or named didn't load the change (it was rolled back) |
|
|
|
|
Validation errors are printed with the offending zone and view:
|
|
|
|
```
|
|
$ jarl record add adbraun.lol www A 192.168.1.99
|
|
error: www.adbraun.lol has a CNAME, so it can't have any other records (found A); a CNAME must be the only record at its name (RFC 1034) [zone adbraun.lol]
|
|
$ echo $?
|
|
6
|
|
```
|
|
|
|
## Zones
|
|
|
|
```sh
|
|
jarl zone list [--view V] [--type primary]
|
|
jarl zone show adbraun.lol
|
|
jarl zone create lab.adbraun.lol --ns ns1.adbraun.lol.
|
|
jarl zone create 192.168.20.0/24 # reverse zone from a network
|
|
jarl zone create 10.0.8.0/22 # four zones, 8-11.0.10.in-addr.arpa
|
|
jarl zone create 10.0.10.100/32 # a zone for just that address
|
|
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
|
jarl zone create partner.example --type secondary --primary "192.0.2.53 key xfer"
|
|
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
|
jarl zone update adbraun.lol --ttl 3600 --contact hostmaster@adbraun.lol --refresh 7200
|
|
jarl zone update adbraun.lol --move-to-view internal
|
|
jarl zone delete lab.adbraun.lol [-y]
|
|
jarl zone file adbraun.lol # the generated zone file
|
|
jarl zone import adbraun.lol db.adbraun.lol [--replace] [--use-soa]
|
|
```
|
|
|
|
Reverse names follow the dots (the nibbles in IPv6), so a reverse zone
|
|
covers a /8, /16 or /24 (a multiple of /4 for IPv6). A network in between is
|
|
covered by several zones, created together in one changeset: a /22 by four
|
|
/24 zones (up to 16 zones). A /32 (or /128) gets a zone of its own that
|
|
answers for that one address: useful when another server, such as your
|
|
router, answers for the rest of the /24. IPv4 networks smaller than a /24
|
|
(/25 to /31) live in the enclosing /24's zone.
|
|
|
|
`zone import` adds the records of a zone file (`-` reads stdin). With
|
|
`--replace` the zone is made equal to the file, so records that aren't in it
|
|
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
|
`zone create` sets any zone option in the same step.
|
|
|
|
When a zone exists in several views, add `--view NAME` to commands that
|
|
name it.
|
|
|
|
## Records
|
|
|
|
```sh
|
|
jarl record list adbraun.lol [--name www] [-t A] [--search nas]
|
|
jarl record add adbraun.lol nas A 192.168.1.20 --ptr --comment "synology"
|
|
jarl record add adbraun.lol @ MX 10 mail
|
|
jarl record add adbraun.lol @ TXT "v=spf1 mx -all" # plain text, quoting is automatic
|
|
jarl record add adbraun.lol _sip._tcp SRV 0 5 5060 sip
|
|
jarl record add adbraun.lol @ CAA 0 issue "letsencrypt.org"
|
|
jarl record set adbraun.lol api A 192.168.1.30 192.168.1.31 --ttl 60 # replace the RRset
|
|
jarl record update 1234 --data 192.168.1.21 --ptr # IDs from 'record list'
|
|
jarl record update 1234 --ttl default
|
|
jarl record delete adbraun.lol www A # every A record of www
|
|
jarl record delete adbraun.lol www A 192.168.1.10 # just this one
|
|
jarl record delete --id 1234 [--ptr]
|
|
|
|
# reverse zones: name records by address; the host name needs no final dot
|
|
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
|
|
```
|
|
|
|
Names are relative to the zone (`@` is the apex) unless they end with a
|
|
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
|
|
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
|
|
(`10.10.0.20`), and a PTR target such as `nas.adbraun.lol` is read as the full
|
|
host name (a bare `nas` is refused, since it would mean
|
|
`nas.0.10.10.in-addr.arpa.`). `--ptr`
|
|
keeps the PTR record in the reverse zone in step (when that zone is managed
|
|
by Jarl). Every record type BIND supports can be used. Jarl knows the
|
|
syntax of the common ones and checks them strictly, and the rest are
|
|
validated by parsing.
|
|
|
|
## Batches: `jarl apply -f`
|
|
|
|
Several changes that must succeed or fail together:
|
|
|
|
```yaml
|
|
# move-web.yaml
|
|
description: move the web server to the new VM
|
|
changes:
|
|
- add: {zone: adbraun.lol, name: web2, type: A, data: 192.168.1.40, ttl: 300, ptr: true}
|
|
- delete: {zone: adbraun.lol, name: www, type: CNAME}
|
|
- add: {zone: adbraun.lol, name: www, type: CNAME, data: web2}
|
|
- set: {zone: adbraun.lol, name: api, type: A, data: [192.168.1.41, 192.168.1.42], ttl: 60}
|
|
- update: {id: 1235, comment: "old web server, remove in November"}
|
|
- delete: {id: 1240}
|
|
- op: acl.update # any API operation (see api.md)
|
|
id: 3
|
|
data: {elements: [192.168.1.0/24, 10.10.0.0/16]}
|
|
```
|
|
|
|
```sh
|
|
jarl apply -f move-web.yaml --dry-run # check and show the diff
|
|
jarl apply -f move-web.yaml
|
|
```
|
|
|
|
The whole file is validated as one changeset, at its end state, so the
|
|
order of the entries doesn't matter (the CNAME swap above works either way
|
|
round). Add `view: NAME` to entries
|
|
when a zone exists in several views. JSON works as well as YAML, and `-f -`
|
|
reads standard input.
|
|
|
|
## Changesets: stage, review, apply
|
|
|
|
```sh
|
|
export JARL_CHANGESET=$(jarl changeset create "renumber lab" -q)
|
|
jarl record set lab.adbraun.lol gw A 10.20.0.1
|
|
jarl record set lab.adbraun.lol nas A 10.20.0.10
|
|
jarl changeset diff $JARL_CHANGESET # validation + named-checkconf + diff
|
|
jarl changeset remove $JARL_CHANGESET 2 # drop operation #2
|
|
jarl changeset apply $JARL_CHANGESET
|
|
unset JARL_CHANGESET
|
|
|
|
jarl changeset list [--status open|applied|discarded] [--mine]
|
|
jarl changeset show 42
|
|
jarl changeset discard 43
|
|
jarl history [--limit 50]
|
|
jarl changeset revert 42 -q | xargs jarl changeset apply # undo an applied changeset
|
|
```
|
|
|
|
Changesets staged in the web UI show up here too, and the other way round.
|
|
|
|
## Views, ACLs, TSIG keys, options, settings
|
|
|
|
```sh
|
|
jarl view create internal --match-clients homelab,localhost --adopt-zones
|
|
jarl view create external --match-clients any
|
|
jarl view order internal external
|
|
jarl view update external --match-clients "!homelab,any"
|
|
jarl view delete old [--cascade]
|
|
|
|
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
|
|
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
|
|
jarl acl update homelab --rename lan # references are updated
|
|
jarl acl delete lan # refused while in use
|
|
|
|
jarl tsig create xfer [--algorithm hmac-sha512]
|
|
jarl tsig show xfer --named-conf # paste into the secondary's named.conf
|
|
jarl tsig rotate xfer
|
|
jarl tsig delete xfer
|
|
|
|
jarl option list [--view V | --zone Z | --toplevel | --all]
|
|
jarl option set recursion yes
|
|
jarl option set allow-recursion '{ homelab; localhost; }'
|
|
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
|
|
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol
|
|
jarl option set response-policy '{ zone "rpz.local"; }' --view internal
|
|
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
|
|
jarl option unset forwarders
|
|
jarl option describe # every option allowed at this scope
|
|
jarl option describe allow-query-cache # grammar and ARM description
|
|
|
|
jarl settings show
|
|
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
|
|
```
|
|
|
|
## Users, tokens, audit (administration)
|
|
|
|
```sh
|
|
jarl user list
|
|
jarl user create alice --role dns-editor # asks for a password
|
|
jarl user create ci --no-password --grant "records:write zones=lab.adbraun.lol"
|
|
jarl user update alice --admin
|
|
jarl user update alice --password # set a new password
|
|
jarl user update bob --disabled # logs bob out, blocks his tokens
|
|
jarl user delete bob -y
|
|
|
|
jarl token list [--user alice | --all] [--revoked]
|
|
jarl token create laptop --expires 365d
|
|
jarl token create backup-script --grant "*:read"
|
|
jarl token create acme --user certbot --role acme
|
|
jarl token rotate laptop --save # new secret, same token and grants
|
|
jarl token rotate --current --save --grace 10m # the token this CLI uses
|
|
jarl token revoke 7
|
|
|
|
jarl audit [--user alice] [--action auth.login_failed] [--outcome failure] [--since 2026-10-01] [--limit 100]
|
|
jarl search 192.168.1.20
|
|
jarl status # named, adoption, drift, open changesets, problems
|
|
jarl check # validation + named-checkconf of the current configuration
|
|
jarl resync [--force] [--dry-run] # rewrite every file from the database
|
|
```
|
|
|
|
Grant syntax, roles and token rotation are in [permissions.md](permissions.md).
|
|
|
|
## Query statistics
|
|
|
|
```sh
|
|
jarl stats status
|
|
jarl stats summary --since 7d
|
|
jarl stats top names | clients | nxdomain | servfail | refused [--since 24h] [--limit 20]
|
|
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
|
|
jarl stats heatmap --since 28d
|
|
jarl stats breakdown qtype | rcode | view | zone | answer | rtt
|
|
```
|
|
|
|
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
|
|
See [stats.md](stats.md).
|
|
|
|
## ACME DNS-01
|
|
|
|
```sh
|
|
jarl acme set www.adbraun.lol <validation> # TXT at _acme-challenge.www.adbraun.lol
|
|
jarl acme set '*.adbraun.lol' <value1> <value2> # wildcard + apex use the same name
|
|
jarl acme unset www.adbraun.lol [<validation>]
|
|
jarl acme certbot-auth / certbot-cleanup # certbot manual hooks
|
|
```
|
|
|
|
See [acme.md](acme.md) for certbot, acme.sh and a dedicated ACME zone.
|
|
|
|
## Scripting tips
|
|
|
|
* `-o json` gives full API objects. Pipe them to `jq`.
|
|
* `-q` with `create` commands prints just the ID or token.
|
|
* Check `$?` against the exit code table.
|
|
* `jarl record delete ... --missing-ok` succeeds when nothing matches, which
|
|
keeps cleanup scripts idempotent. `jarl record set` is idempotent by nature.
|