jarld also listens on /run/jarl/jarl.sock (socket in jarl.yaml). Requests on it from root or jarld's own user need no token and act as an administrator; the kernel's SO_PEERCRED says who is connecting, and anyone else must send a token. The jarl CLI uses the socket when no server is configured, or with -s local, so 'sudo jarl status' works on the server with nothing set up and a lost token or password never locks root out. Changes are audited under the system user's name via "socket". The systemd unit creates /run/jarl (0750). Socket paths over 107 bytes are refused with a clear message. The docs explain the socket and what happens when a token is revoked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
13 KiB
Bash
Executable File
295 lines
13 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
|
|
# -f like the Debian unit (-g would send all logging, including the
|
|
# statistics response log, to stderr).
|
|
$NAMED -c $E/legacy/named.conf -f -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
|
|
|
|
# The local socket (a short path: unix socket paths are limited to 107 bytes).
|
|
SOCK=${XDG_RUNTIME_DIR:-/tmp}/jarl-e2e-$HTTP.sock
|
|
cat > $E/jarl.yaml <<EOF
|
|
listen: ["127.0.0.1:$HTTP"]
|
|
socket: $SOCK
|
|
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; }
|
|
|
|
# With nothing configured, the CLI uses jarld's local socket, where
|
|
# jarld's own user (this script's) is an administrator without a token.
|
|
run "socket: nothing configured uses local jarld" 0 env JARL_SOCKET=$SOCK $J whoami
|
|
contains "admin (everything)"
|
|
run "socket: missing socket is explained" 1 env JARL_SOCKET=${XDG_RUNTIME_DIR:-/tmp}/jarl-e2e-none.sock $J status
|
|
contains "no local jarld"
|
|
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"
|
|
run "socket: -s local with a server configured" 0 env JARL_SOCKET=$SOCK $J -s local 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"
|
|
cp $E/acme.token $E/acme.token.old
|
|
run "acme token rotates itself (grace)" 0 $J $ACME token rotate --current --save --grace 1h
|
|
run "rotated token works" 0 $J $ACME whoami
|
|
run "old secret works during grace" 0 $J --token @$E/acme.token.old whoami
|
|
run "rotate again without grace" 0 $J $ACME token rotate --current --save
|
|
run "old secret refused after rotation (exit 3)" 3 $J --token @$E/acme.token.old whoami
|
|
run "rotated token keeps its grants" 0 $J $ACME acme set adbraun.test rotated
|
|
run "acme unset after rotation" 0 $J $ACME acme unset adbraun.test rotated
|
|
run "acme token cannot rotate the admin's token (exit 4)" 4 $J $ACME token rotate 1
|
|
run "token list shows rotation" 0 $J token list --user certbot
|
|
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 ]
|