Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
Executable
+270
@@ -0,0 +1,270 @@
|
||||
#!/bin/bash
|
||||
# End-to-end test: builds a legacy BIND setup, starts an unprivileged named,
|
||||
# imports and adopts it with jarld, runs jarld, and drives every jarl CLI
|
||||
# command, checking the answers with dig. Needs no root.
|
||||
#
|
||||
# BIND tools come from $JARL_BIND_ROOT (an extracted bind9 package tree, see
|
||||
# docs/development.md) or from PATH. Run from the repository root:
|
||||
#
|
||||
# make e2e or JARL_BIND_ROOT=... scripts/e2e.sh
|
||||
#
|
||||
# Set KEEP=1 to leave named and jarld running afterwards (in $WORK).
|
||||
set -u
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
WORK=${WORK:-$(mktemp -d /tmp/jarl-e2e.XXXXXX)}
|
||||
E=$WORK
|
||||
DNS=${DNS_PORT:-25353}; CTL=${CTL_PORT:-25953}; HTTP=${HTTP_PORT:-28053}
|
||||
if [ -n "${JARL_BIND_ROOT:-}" ]; then
|
||||
B=$JARL_BIND_ROOT
|
||||
export LD_LIBRARY_PATH=$(echo $B/usr/lib/*-linux-gnu | cut -d' ' -f1)
|
||||
NAMED=$B/usr/sbin/named RNDC=$B/usr/sbin/rndc TSIGKEYGEN=$B/usr/sbin/tsig-keygen
|
||||
CHECKCONF=$B/usr/bin/named-checkconf CHECKZONE=$B/usr/bin/named-checkzone DIG=$B/usr/bin/dig
|
||||
else
|
||||
NAMED=$(command -v named || echo /usr/sbin/named) RNDC=$(command -v rndc || echo /usr/sbin/rndc)
|
||||
TSIGKEYGEN=$(command -v tsig-keygen || echo /usr/sbin/tsig-keygen)
|
||||
CHECKCONF=$(command -v named-checkconf) CHECKZONE=$(command -v named-checkzone) DIG=$(command -v dig)
|
||||
fi
|
||||
for t in "$NAMED" "$RNDC" "$TSIGKEYGEN" "$CHECKCONF" "$CHECKZONE" "$DIG"; do
|
||||
[ -x "$t" ] || { echo "missing BIND tool: $t (set JARL_BIND_ROOT)"; exit 2; }
|
||||
done
|
||||
mkdir -p $E/legacy $E/cache $E/data
|
||||
JARLD=$E/jarld J=$E/jarl
|
||||
(cd "$ROOT" && go build -o $JARLD ./cmd/jarld && go build -o $J ./cmd/jarl) || exit 1
|
||||
NAMED_PID= JARLD_PID=
|
||||
cleanup() {
|
||||
if [ "${KEEP:-}" = 1 ]; then echo "left running in $WORK (named $NAMED_PID, jarld $JARLD_PID)"; return; fi
|
||||
[ -n "$JARLD_PID" ] && kill $JARLD_PID 2>/dev/null
|
||||
[ -n "$NAMED_PID" ] && kill $NAMED_PID 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---- legacy setup, import, adopt, serve
|
||||
|
||||
$TSIGKEYGEN -a hmac-sha256 rndc-key > $E/legacy/rndc.key
|
||||
cat > $E/legacy/named.conf <<EOF
|
||||
include "$E/legacy/named.conf.options";
|
||||
include "$E/legacy/named.conf.local";
|
||||
include "$E/legacy/rndc.key";
|
||||
controls { inet 127.0.0.1 port $CTL allow { 127.0.0.1; } keys { "rndc-key"; }; };
|
||||
EOF
|
||||
cat > $E/legacy/named.conf.options <<EOF
|
||||
acl homelab { 127.0.0.0/8; 192.168.1.0/24; };
|
||||
options {
|
||||
directory "$E/cache";
|
||||
pid-file "$E/cache/named.pid";
|
||||
session-keyfile "$E/cache/session.key";
|
||||
listen-on port $DNS { 127.0.0.1; };
|
||||
listen-on-v6 { none; };
|
||||
recursion yes;
|
||||
allow-recursion { homelab; };
|
||||
allow-query { any; };
|
||||
};
|
||||
EOF
|
||||
cat > $E/legacy/named.conf.local <<EOF
|
||||
zone "adbraun.test" { type master; file "$E/legacy/db.adbraun.test"; };
|
||||
zone "1.168.192.in-addr.arpa" { type master; file "$E/legacy/db.192.168.1"; };
|
||||
EOF
|
||||
cat > $E/legacy/db.adbraun.test <<'EOF'
|
||||
$TTL 3600
|
||||
@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 )
|
||||
IN NS ns1
|
||||
ns1 IN A 127.0.0.1
|
||||
nas IN A 192.168.1.20 ; synology
|
||||
www 300 IN CNAME nas
|
||||
EOF
|
||||
cat > $E/legacy/db.192.168.1 <<'EOF'
|
||||
$TTL 86400
|
||||
@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 )
|
||||
@ IN NS ns1.adbraun.test.
|
||||
20 IN PTR nas.adbraun.test.
|
||||
EOF
|
||||
$NAMED -c $E/legacy/named.conf -g -n 1 > $E/named.log 2>&1 &
|
||||
NAMED_PID=$!
|
||||
for i in $(seq 50); do $RNDC -k $E/legacy/rndc.key -s 127.0.0.1 -p $CTL status >/dev/null 2>&1 && break; sleep 0.2; done
|
||||
|
||||
cat > $E/jarl.yaml <<EOF
|
||||
listen: ["127.0.0.1:$HTTP"]
|
||||
database: $E/data/jarl.db
|
||||
staging_dir: $E/data/staging
|
||||
log: {level: info}
|
||||
bind:
|
||||
main_config: $E/legacy/named.conf
|
||||
config_dir: $E/etc-jarl
|
||||
zone_dir: $E/zones
|
||||
cache_dir: $E/cache/jarl
|
||||
file_group: ""
|
||||
named_checkconf: $CHECKCONF
|
||||
named_checkzone: $CHECKZONE
|
||||
rndc: $RNDC
|
||||
rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"]
|
||||
EOF
|
||||
cd $E
|
||||
echo "secret-pass-123" | $JARLD -c jarl.yaml init --password-stdin --token --nameservers ns1.adbraun.test. --contact admin@adbraun.test > init.out 2>&1 || { cat init.out; exit 1; }
|
||||
grep -o 'jarl_[A-Za-z0-9_]*' init.out | head -1 > admin.token
|
||||
$JARLD -c jarl.yaml import > import.out 2>&1 || { cat import.out; exit 1; }
|
||||
$JARLD -c jarl.yaml adopt > adopt.out 2>&1 || { cat adopt.out; exit 1; }
|
||||
$JARLD -c jarl.yaml serve > jarld.log 2>&1 &
|
||||
JARLD_PID=$!
|
||||
for i in $(seq 50); do curl -s -o /dev/null http://127.0.0.1:$HTTP/healthz && break; sleep 0.2; done
|
||||
echo "setup done in $WORK"
|
||||
|
||||
# ---- CLI walkthrough
|
||||
export JARL_CLI_CONFIG=$E/cli.yaml
|
||||
unset JARL_SERVER JARL_TOKEN JARL_CHANGESET
|
||||
PASS=0; FAIL=0
|
||||
dig() { $DIG +short +norec -p $DNS @127.0.0.1 "$@" | sort | tr '\n' ' ' | sed 's/ $//'; }
|
||||
ok() { echo "PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
# run DESC EXPECTED_RC CMD...: runs a command and checks its exit code;
|
||||
# the output is kept in $OUT.
|
||||
run() {
|
||||
local desc=$1 want=$2; shift 2
|
||||
OUT=$("$@" 2>&1); local rc=$?
|
||||
if [ "$rc" = "$want" ]; then ok "$desc (rc=$rc)"; else bad "$desc: rc=$rc want $want"; echo "$OUT" | sed 's/^/ /'; fi
|
||||
}
|
||||
expect_dig() { # DESC WANT DIG-ARGS...
|
||||
local desc=$1 want=$2; shift 2
|
||||
local got; got=$(dig "$@")
|
||||
if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi
|
||||
}
|
||||
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
|
||||
|
||||
run "config set server" 0 $J config set server http://127.0.0.1:$HTTP
|
||||
run "config set token (from stdin)" 0 sh -c "$J config set token < $E/admin.token"
|
||||
run "config show masks token" 0 $J config show
|
||||
contains "jarl_"
|
||||
run "status" 0 $J status
|
||||
contains "Adopted:.*yes"
|
||||
run "whoami" 0 $J whoami
|
||||
contains "admin (everything)"
|
||||
run "zone list" 0 $J zone list
|
||||
contains "adbraun.test"
|
||||
|
||||
# Records
|
||||
run "record add with PTR" 0 $J record add adbraun.test web A 192.168.1.30 --ptr --ttl 300
|
||||
expect_dig "web A" "192.168.1.30" web.adbraun.test A
|
||||
expect_dig "PTR 30" "web.adbraun.test." 30.1.168.192.in-addr.arpa PTR
|
||||
run "CNAME conflict rejected (exit 6)" 6 $J record add adbraun.test www A 192.168.1.99
|
||||
contains "CNAME"
|
||||
run "record add TXT with spaces" 0 $J record add adbraun.test @ TXT "v=spf1 mx -all"
|
||||
expect_dig "apex TXT" '"v=spf1 mx -all"' adbraun.test TXT
|
||||
run "record set RRset" 0 $J record set adbraun.test api A 192.168.1.40 192.168.1.41 --ttl 60
|
||||
expect_dig "api A" "192.168.1.40 192.168.1.41" api.adbraun.test A
|
||||
run "dry run changes nothing" 0 $J record add adbraun.test dry A 192.168.1.50 --dry-run
|
||||
contains "dry run"
|
||||
expect_dig "dry A" "" dry.adbraun.test A
|
||||
run "invalid IPv4 rejected" 6 $J record add adbraun.test bad A 192.168.1.300
|
||||
run "record list json" 0 $J record list adbraun.test -o json
|
||||
contains '"relative_name": "api"'
|
||||
|
||||
# Batch file
|
||||
cat > $E/batch.yaml <<'EOF'
|
||||
description: e2e batch
|
||||
changes:
|
||||
- add: {zone: adbraun.test, name: printer, type: A, data: 192.168.1.60, ptr: true}
|
||||
- delete: {zone: adbraun.test, name: api, type: A, data: 192.168.1.41}
|
||||
- set: {zone: adbraun.test, name: mail, type: MX, data: ["10 nas"]}
|
||||
EOF
|
||||
run "apply -f batch" 0 $J apply -f $E/batch.yaml
|
||||
expect_dig "printer A" "192.168.1.60" printer.adbraun.test A
|
||||
expect_dig "api A after batch" "192.168.1.40" api.adbraun.test A
|
||||
expect_dig "mail MX" "10 nas.adbraun.test." mail.adbraun.test MX
|
||||
cat > $E/badbatch.yaml <<'EOF'
|
||||
changes:
|
||||
- add: {zone: adbraun.test, name: ok1, type: A, data: 192.168.1.70}
|
||||
- add: {zone: adbraun.test, name: www, type: TXT, data: "conflicts with CNAME"}
|
||||
EOF
|
||||
run "bad batch is all-or-nothing" 6 $J apply -f $E/badbatch.yaml
|
||||
expect_dig "ok1 not created" "" ok1.adbraun.test A
|
||||
|
||||
# Changesets
|
||||
CS=$($J changeset create "e2e staged" -q)
|
||||
run "stage into changeset" 0 $J record add adbraun.test staged A 192.168.1.80 --changeset $CS
|
||||
expect_dig "staged not live yet" "" staged.adbraun.test A
|
||||
run "changeset diff" 0 $J changeset diff $CS
|
||||
contains "staged"
|
||||
run "changeset apply" 0 $J changeset apply $CS
|
||||
expect_dig "staged now live" "192.168.1.80" staged.adbraun.test A
|
||||
REV=$($J changeset revert $CS -q)
|
||||
run "apply revert changeset" 0 $J changeset apply $REV
|
||||
expect_dig "staged reverted" "" staged.adbraun.test A
|
||||
run "history" 0 $J history
|
||||
contains "e2e batch"
|
||||
|
||||
# ACLs, views, options, TSIG
|
||||
run "acl create" 0 $J acl create trusted 10.0.0.0/8 192.168.1.0/24
|
||||
run "acl update add" 0 $J acl update trusted --add 172.16.0.0/12
|
||||
run "acl list" 0 $J acl list
|
||||
contains "172.16.0.0/12"
|
||||
run "acl delete in-use refused" 5 sh -c "$J option set allow-transfer '{ trusted; }' --zone adbraun.test && $J acl delete trusted"
|
||||
run "option unset" 0 $J option unset allow-transfer --zone adbraun.test
|
||||
run "acl delete" 0 $J acl delete trusted
|
||||
run "tsig create" 0 $J tsig create xfer-test
|
||||
contains "secret:"
|
||||
run "tsig rotate" 0 $J tsig rotate xfer-test
|
||||
run "tsig show named-conf" 0 $J tsig show xfer-test --named-conf
|
||||
contains 'algorithm hmac-sha256'
|
||||
run "tsig delete" 0 $J tsig delete xfer-test
|
||||
run "option describe" 0 $J option describe allow-recursion
|
||||
run "option list" 0 $J option list
|
||||
contains "allow-recursion"
|
||||
run "check" 0 $J check
|
||||
contains "valid"
|
||||
run "search" 0 $J search printer
|
||||
contains "printer"
|
||||
|
||||
# Users, tokens, ACME
|
||||
run "user create acme service account" 0 $J user create certbot --no-password --role acme --comment "e2e"
|
||||
run "token create for certbot" 0 sh -c "$J token create certbot-hook --user certbot --role acme --expires 30d -q > $E/acme.token"
|
||||
ACME="--token @$E/acme.token"
|
||||
run "acme whoami" 0 $J $ACME whoami
|
||||
contains "_acme-challenge"
|
||||
run "acme set" 0 $J $ACME acme set adbraun.test value-one
|
||||
expect_dig "acme TXT" '"value-one"' _acme-challenge.adbraun.test TXT
|
||||
run "acme set again (idempotent)" 0 $J $ACME acme set adbraun.test value-one
|
||||
run "acme set wildcard second value" 0 $J $ACME acme set '*.adbraun.test' value-two
|
||||
expect_dig "acme TXT both" '"value-one" "value-two"' _acme-challenge.adbraun.test TXT
|
||||
run "acme token cannot touch A records (exit 3)" 3 $J $ACME record add adbraun.test evil A 192.168.1.66
|
||||
run "certbot hooks" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-auth
|
||||
expect_dig "certbot TXT" '"cb-value"' _acme-challenge.nas.adbraun.test TXT
|
||||
run "certbot cleanup" 0 env CERTBOT_DOMAIN=nas.adbraun.test CERTBOT_VALIDATION=cb-value $J $ACME acme certbot-cleanup
|
||||
expect_dig "certbot TXT gone" "" _acme-challenge.nas.adbraun.test TXT
|
||||
run "acme unset one value" 0 $J $ACME acme unset adbraun.test value-one
|
||||
expect_dig "acme TXT one left" '"value-two"' _acme-challenge.adbraun.test TXT
|
||||
run "acme unset all" 0 $J $ACME acme unset adbraun.test
|
||||
expect_dig "acme TXT none" "" _acme-challenge.adbraun.test TXT
|
||||
run "acme unset when nothing there" 0 $J $ACME acme unset adbraun.test
|
||||
run "acme in unknown zone" 1 $J $ACME acme set example.org v
|
||||
run "token list --all" 0 $J token list --all
|
||||
contains "certbot-hook"
|
||||
TID=$($J token list --user certbot -o json | grep -o '"id": [0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
run "token revoke" 0 $J token revoke $TID
|
||||
run "revoked token refused (exit 3)" 3 $J $ACME whoami
|
||||
run "user update disable" 0 $J user update certbot --disabled
|
||||
run "user list" 0 $J user list
|
||||
run "user delete" 0 $J user delete certbot -y
|
||||
run "audit" 0 $J audit --limit 10
|
||||
contains "token.revoke"
|
||||
|
||||
# Views
|
||||
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
|
||||
run "view create external" 0 $J view create external --match-clients any
|
||||
run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1"
|
||||
run "zone list shows both views" 0 $J zone list
|
||||
contains "external"
|
||||
expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A
|
||||
run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90
|
||||
run "acme set goes into both views" 0 $J acme set adbraun.test both-views
|
||||
expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT
|
||||
run "acme set --view external only" 0 $J acme unset adbraun.test --view internal
|
||||
expect_dig "acme TXT removed from internal" "" _acme-challenge.adbraun.test TXT
|
||||
run "zone file" 0 $J zone file adbraun.test --view external
|
||||
contains "_acme-challenge"
|
||||
run "status at the end" 0 $J status
|
||||
contains "Drift:.*none"
|
||||
|
||||
echo
|
||||
echo "RESULT: $PASS passed, $FAIL failed"
|
||||
[ $FAIL = 0 ]
|
||||
Reference in new issue
Block a user