Files
jarl/scripts/e2e.sh
T
abrown38andClaude Opus 5.5 bd6b8778e4 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>
2026-10-07 15:03:13 +00:00

271 lines
12 KiB
Bash
Executable File

#!/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 ]