Files
jarl/scripts/e2e.sh
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

610 lines
32 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}; LISTS=${LISTS_PORT:-28054}; METRICS=${METRICS_PORT:-28055}
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= LISTS_PID=
cleanup() {
[ -n "$LISTS_PID" ] && kill $LISTS_PID 2>/dev/null
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_user: $(id -un)
named_checkconf: $CHECKCONF
named_checkzone: $CHECKZONE
rndc: $RNDC
rndc_args: ["-k", "$E/legacy/rndc.key", "-s", "127.0.0.1", "-p", "$CTL"]
backup:
dir: $E/backups
metrics:
enabled: true
listen: ["127.0.0.1:$METRICS"]
token: e2e-metrics-token-0123456789
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; }
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; 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 "record disable with PTR" 0 $J record disable adbraun.test web A --ptr
expect_dig "disabled web A gone" "" web.adbraun.test A
expect_dig "disabled PTR 30 gone" "" 30.1.168.192.in-addr.arpa PTR
run "record list marks it" 0 $J record list adbraun.test --disabled
contains "\[disabled\]"
run "disabling twice says so (exit 5)" 5 $J record disable adbraun.test web A
contains "already disabled"
run "record enable with PTR" 0 $J record enable adbraun.test web A --ptr
run "record list -o id" 0 $J record list adbraun.test --name web -t A -o id
if echo "$OUT" | grep -qx '[0-9][0-9]*'; then ok "-o id is just the ID"; else bad "-o id gave '$OUT'"; fi
run "disable by the listed ID" 0 $J record disable --id "$OUT"
run "zone show -o field" 0 $J zone show adbraun.test -o field=name,record_count,disabled_count
contains "^adbraun.test. [0-9]* 1$"
run "enable by ID" 0 $J record enable --id "$($J record list adbraun.test --name web -t A -o id)"
expect_dig "enabled web A back" "192.168.1.30" web.adbraun.test A
expect_dig "enabled PTR 30 back" "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
# Address ranges: kept as written, written to named.conf as prefixes.
run "acl with ranges" 0 $J acl create ranged "127.0.0.2 - 127.0.0.3" 2001:db8::100-2001:db8::1ff
run "acl list keeps the ranges" 0 $J acl list
contains "127.0.0.2-127.0.0.3"
run "named.conf has the prefixes" 0 grep -A4 'acl "ranged"' $E/etc-jarl/named.conf
contains "127.0.0.2/31;"
contains "2001:db8::100/120;"
contains "// 127.0.0.2-127.0.0.3"
run "a reversed range is refused (exit 6)" 6 $J acl create badrange 127.0.0.9-127.0.0.1
contains "start is after the end"
run "zone allow-query by the range" 0 $J option set allow-query '{ ranged; }' --zone adbraun.test
range_dig() { $DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 ns1.adbraun.test A | awk '/status:/ {sub(",", "", $6); print $6}'; }
for src in 127.0.0.2 127.0.0.3; do
if [ "$(range_dig $src)" = NOERROR ]; then ok "range: $src may query"; else bad "range: $src got $(range_dig $src)"; fi
done
for src in 127.0.0.1 127.0.0.4; do
if [ "$(range_dig $src)" = REFUSED ]; then ok "range: $src is refused"; else bad "range: $src got $(range_dig $src)"; fi
done
run "unset the zone's allow-query" 0 $J option unset allow-query --zone adbraun.test
run "acl delete ranged" 0 $J acl delete ranged
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"
# View keys: a query signed with a view's key lands in that view, from
# any address (this host is in homelab, so unsigned it gets internal).
run "view key" 0 sh -c "$J view key external --named-conf > $E/external.key"
expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test A
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
run "view key shows a dig command" 0 $J view key external
contains "dig -y hmac-sha256:jarl-view-external:"
# named-checkconf's own warnings stay on the dashboard after the apply.
run "option set recursion no (external)" 0 $J option set recursion no --view external
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
contains "named-checkconf"
run "status keeps named's warning" 0 $J status
contains "named-checkconf: .*recursion"
run "option list: braces added" 0 $J option list --view external
contains "{ 192.0.2.1; }"
run "unset allow-recursion" 0 $J option unset allow-recursion --view external
run "unset recursion" 0 $J option unset recursion --view external
run "status: the warning is gone" 0 $J status
not_contains "named-checkconf"
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 "record add --view all" 0 $J record add adbraun.test both A 192.0.2.77 --view all
contains "applied"
expect_dig "both views: internal has it" "192.0.2.77" both.adbraun.test A
run "record list external has it too" 0 $J record list adbraun.test --view external
contains "192.0.2.77"
run "several views are all-or-nothing (exit 4)" 4 $J record add adbraun.test half A 192.0.2.78 --view internal,nosuch
contains "nothing was changed in any view"
expect_dig "so internal didn't get it" "" half.adbraun.test A
run "record delete --view internal,external" 0 $J record delete adbraun.test both A --view internal,external -y
expect_dig "deleted in both" "" both.adbraun.test A
run "and gone from external" 0 $J record list adbraun.test --view external
not_contains "192.0.2.77"
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"
# Name server checks: the external zone's NS (203.0.113.1) doesn't answer;
# the internal one lists only this server.
run "zone serials: internal, only this server" 0 $J zone serials adbraun.test --view internal
contains "this server"
run "zone serials: unreachable secondary (exit 2)" 2 $J zone serials adbraun.test --view external
contains "NO ANSWER"
# Server header and token expiry warnings
if curl -sI http://127.0.0.1:$HTTP/healthz | grep -qi "^Server: Jarl/"; then ok "Server header"; else bad "no Server: Jarl header"; fi
SOON=$($J token create soon --expires 3d -q)
run "expiring token works" 0 $J --token "$SOON" whoami
contains "warning: the API token expires"
run "token list --expiring" 0 $J token list --expiring
contains "soon"
run "status lists the expiring token" 0 $J status
contains "Token:.*soon"
# DNSSEC: sign the external view's zone (the parent can't be asked here).
api() { curl -s -H "Authorization: Bearer $(cat $E/admin.token)" -H 'Content-Type: application/json' "$@"; }
run "dnssec status: off" 0 $J zone dnssec status adbraun.test --view external
contains "Policy:.*none"
run "dnssec enable" 0 $J zone dnssec enable adbraun.test --view external
contains "named is signing"
for i in $(seq 50); do $J zone dnssec status adbraun.test --view external 2>/dev/null | grep -q "signed serial" && break; sleep 0.2; done
run "dnssec status: signing" 0 $J zone dnssec status adbraun.test --view external
contains "CSK.*ECDSAP256SHA256"
contains "named:.*signed serial"
contains "DS record for the parent zone"
contains "not yet: wait until"
run "dnssec: switching off at once is refused" 0 api -X PATCH -d '{"dnssec_policy":""}' "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test?view=external"
contains "switching DNSSEC off at once"
run "dnssec: a TTL over a day is refused" 6 $J record add adbraun.test longttl A 203.0.113.9 --view external --ttl 172800
contains "max-zone-ttl"
run "record in the signed zone" 0 $J record add adbraun.test signed A 203.0.113.5 --view external
run "check-ds without a reachable parent (exit 2)" 2 $J zone dnssec check-ds adbraun.test --view external --resolver 127.0.0.1
contains "named wasn't told anything"
KEYS=$(ls $E/zones/dnssec-keys/*/Kadbraun.test.+013+*.private 2>/dev/null | head -1)
if [ -n "$KEYS" ]; then ok "named's key is in Jarl's key directory"; else bad "no key in $E/zones/dnssec-keys"; fi
# Backups and restore
run "jarld backup" 0 $JARLD -c jarl.yaml backup
contains "Database backed up to $E/backups/jarl-"
contains "with 3 DNSSEC key files"
run "status shows the backup" 0 $J status
contains "Backup:.*$E/backups/jarl-"
run "restore refused while jarld runs" 1 $JARLD -c jarl.yaml restore latest --yes
contains "jarld is running"
run "a second jarld is refused" 1 $JARLD -c jarl.yaml serve
contains "in use by a running jarld"
run "record after the backup" 0 $J record add adbraun.test afterbackup A 192.168.1.99 --view internal
expect_dig "afterbackup A" "192.168.1.99" afterbackup.adbraun.test A
kill $JARLD_PID; wait $JARLD_PID 2>/dev/null
KEYSUM=$(sha256sum "$KEYS" | cut -d' ' -f1)
rm -f "$KEYS"
run "jarld restore lists backups" 0 $JARLD -c jarl.yaml restore
contains "jarl-.*\.db"
run "jarld restore latest" 0 $JARLD -c jarl.yaml restore latest --yes
contains "previous database is in"
contains "Put back 3 DNSSEC key files"
if [ "$(sha256sum "$KEYS" 2>/dev/null | cut -d' ' -f1)" = "$KEYSUM" ]; then ok "the private key is back"; else bad "the private key wasn't restored"; fi
$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
run "restored database lacks the later record" 0 $J record list adbraun.test --view internal
if echo "$OUT" | grep -q afterbackup; then bad "afterbackup still there after restore"; else ok "afterbackup gone after restore"; fi
run "resync after restore" 0 $J resync --force
contains "serial"
expect_dig "afterbackup gone from named" "" afterbackup.adbraun.test A
run "audit shows the restore" 0 $J audit --action system.restore
contains "system.restore"
# Turning DNSSEC off: insecure first; the DS never reached a parent, so
# named can finish at once. Then wipe the old keys.
run "dnssec disable starts turning off" 0 $J zone dnssec disable adbraun.test --view external --yes
contains "remove the DS record"
for i in $(seq 75); do $J zone dnssec status adbraun.test --view external 2>/dev/null | grep -q "Turning DNSSEC off: the parent has no DS\|has finished" && break; sleep 0.2; done
run "dnssec disable finishes" 0 $J zone dnssec disable adbraun.test --view external --yes
contains "DNSSEC is off"
run "dnssec status: old keys left" 0 $J zone dnssec status adbraun.test --view external
contains "keys from when the zone was signed"
run "dnssec wipe" 0 $J zone dnssec wipe adbraun.test --view external --yes --force --resolver 127.0.0.1
contains "removed .*Kadbraun.test"
run "dnssec status: clean" 0 $J zone dnssec status adbraun.test --view external
contains "Status:.*DNSSEC is off.$"
# DNSSEC for a zone whose parent is in Jarl: the DS goes in as a record.
run "child zone" 0 $J zone create sub.adbraun.test --view external --ns ns1.adbraun.test.
run "delegate the child" 0 $J record add adbraun.test sub NS ns1.adbraun.test. --view external
run "dnssec enable the child" 0 $J zone dnssec enable sub.adbraun.test --view external
for i in $(seq 50); do $J zone dnssec status sub.adbraun.test --view external 2>/dev/null | grep -q "signed serial" && break; sleep 0.2; done
run "child status gives the jarl command" 0 $J zone dnssec status sub.adbraun.test --view external
contains "in the parent zone adbraun.test, which is in Jarl"
contains "jarl record add adbraun.test sub DS [0-9]* 13 2 [0-9A-F]* --view external"
DSCMD=$(echo "$OUT" | grep -o "jarl record add adbraun.test sub DS .*")
run "the printed command adds the DS" 0 $J ${DSCMD#jarl }
run "the parent's zone file has the DS" 0 $J zone file adbraun.test --view external
contains "^sub.*IN.DS.$(echo "$DSCMD" | sed 's/.* DS \(.*\) --view.*/\1/')"
run "child status: parent has it" 0 $J zone dnssec status sub.adbraun.test --view external
contains "adbraun.test has it."
run "remove the child's DS" 0 $J record delete adbraun.test sub DS --view external --yes
run "wipe the child" 0 $J zone dnssec wipe sub.adbraun.test --view external --yes --force --resolver 127.0.0.1
run "delete the child" 0 $J zone delete sub.adbraun.test --view external --yes
run "remove the delegation" 0 $J record delete adbraun.test sub NS --view external --yes
# Disabled zones: kept in Jarl, not loaded by named.
run "zone for disabling" 0 $J zone create parked.test --view internal --ns ns1.adbraun.test.
run "record in it" 0 $J record add parked.test www A 192.0.2.70 --view internal
expect_dig "parked www served" "192.0.2.70" www.parked.test A
run "zone disable" 0 $J zone disable parked.test --view internal
contains "recursion"
expect_dig "disabled zone not served" "" www.parked.test A
run "named.conf says it's disabled" 0 grep -q 'zone "parked.test" is disabled in Jarl' $E/etc-jarl/named.conf
run "zone list marks it" 0 $J zone list
contains "parked.test.*primary (disabled)"
run "editing a disabled zone works (no reload of a zone named lacks)" 0 $J record add parked.test api A 192.0.2.71 --view internal
run "zone enable" 0 $J zone enable parked.test --view internal
expect_dig "enabled zone served again" "192.0.2.70" www.parked.test A
expect_dig "with the change made while disabled" "192.0.2.71" api.parked.test A
run "delete it" 0 $J zone delete parked.test --view internal --yes
# Blocking (docs/blocking.md). It only changes answers to recursive
# queries, so these ask with recursion and look at which policy zone (if
# any) the answer came from.
rpz() {
$DIG +tries=1 +time=3 -p $DNS @127.0.0.1 "$1" A | awk '$4 == "SOA" && $1 ~ /jarl\.invalid\.$/ {print $1; f = 1} END {if (!f) print "none"}'
}
expect_rpz() { # DESC NAME WANT: named reloads in the background, so wait a little
local got
for i in $(seq 30); do got=$(rpz "$2"); [ "$got" = "$3" ] && break; sleep 0.2; done
if [ "$got" = "$3" ]; then ok "$1: $2 -> $3"; else bad "$1: $2 -> $got, want $3"; fi
}
mkdir -p $E/lists
printf '# test list\n||tracker.adbraun.test^\n0.0.0.0 ads.adbraun.test\nnot a domain\n' > $E/lists/list.txt
(cd $E/lists && exec python3 -m http.server --bind 127.0.0.1 $LISTS) > /dev/null 2>&1 &
LISTS_PID=$!
run "block add" 0 $J block add blocked.adbraun.test
expect_rpz "a rule blocks the name" blocked.adbraun.test rules.jarl.invalid.
expect_rpz "and the names below it" a.blocked.adbraun.test rules.jarl.invalid.
expect_rpz "other names are answered" www.adbraun.test none
run "named.conf has the policy" 0 grep -q 'response-policy' $E/etc-jarl/named.conf
for i in $(seq 25); do curl -s -o /dev/null http://127.0.0.1:$LISTS/list.txt && break; sleep 0.2; done
run "blocklist add" 0 $J blocklist add test http://127.0.0.1:$LISTS/list.txt
run "blocklist refresh" 0 $J blocklist refresh test
contains "test: 2 domains (1 lines skipped)"
expect_rpz "an Adblock entry blocks the names below it" x.tracker.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "a hosts entry blocks the name" ads.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "but not the names below it" sub.ads.adbraun.test none
run "blocklist list" 0 $J blocklist list
contains "^test *ok *normal *2 "
run "block allow" 0 $J block allow ok.tracker.adbraun.test
expect_rpz "an allow rule beats the list" deep.ok.tracker.adbraun.test none
expect_rpz "the rest of the list still blocks" x.tracker.adbraun.test test.blocklist.jarl.invalid.
run "block check" 0 $J block check deep.ok.tracker.adbraun.test
contains "allowed by your rule"
run "block pause" 0 $J block pause 5m
expect_rpz "paused: a listed name is answered" x.tracker.adbraun.test none
expect_rpz "paused: so is a blocked rule's name" blocked.adbraun.test none
run "block list says it's paused" 0 $J block list
contains "Blocking is paused until"
run "block resume" 0 $J block resume
run "block pause indefinitely" 0 $J block pause indefinitely
expect_rpz "paused until resumed: a listed name is answered" x.tracker.adbraun.test none
run "block list says until resumed" 0 $J block list
contains "paused until you resume it"
run "block resume after an indefinite pause" 0 $J block resume
expect_rpz "resumed: the list blocks again" x.tracker.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "resumed: the rule blocks again" blocked.adbraun.test rules.jarl.invalid.
# The test server dates the file to the second, and the download above
# remembers that date: change it a second later, as a real list would.
sleep 1.1
printf '||new.adbraun.test^\n' > $E/lists/list.txt
run "blocklist refresh after the list changed" 0 $J blocklist refresh test
expect_rpz "the new version applies" x.new.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "and replaces the old one" x.tracker.adbraun.test none
printf '<html>gone</html>\n' > $E/lists/list.txt
run "a broken download fails (exit 2)" 2 $J blocklist refresh test --force
contains "no domains found"
expect_rpz "and the previous version stays" x.new.adbraun.test test.blocklist.jarl.invalid.
# Client groups and steps: per-device blocking in one view, by client
# address. 127.0.0.2 and .3 are in homelab too, so they get view internal.
rpz_from() { # SOURCE NAME
$DIG +tries=1 +time=3 -b "$1" -p $DNS @127.0.0.1 "$2" A | awk '$4 == "SOA" && $1 ~ /jarl\.invalid\.$/ {print $1; f = 1} END {if (!f) print "none"}'
}
expect_rpz_from() { # DESC SOURCE NAME WANT
local got
for i in $(seq 30); do got=$(rpz_from "$2" "$3"); [ "$got" = "$4" ] && break; sleep 0.2; done
if [ "$got" = "$4" ]; then ok "$1: $3 from $2 -> $4"; else bad "$1: $3 from $2 -> $got, want $4"; fi
}
printf '||social.adbraun.test^\n' > $E/lists/strict.txt
run "block step add" 0 $J block step add strict
run "blocklist add at a step" 0 $J blocklist add social http://127.0.0.1:$LISTS/strict.txt --step strict
contains "applied"
run "a step no group reaches: warned" 0 $J status
contains "blocks nothing"
run "block group add (off)" 0 $J block group add work 127.0.0.2 --step off
run "block group add (strict, with an IPv6 network)" 0 $J block group add kids 127.0.0.3 2001:db8:77::/64 --step strict
run "block group list" 0 $J block group list
contains "kids *strict *127.0.0.3, 2001:db8:77::/64"
expect_rpz_from "normal: the normal list" 127.0.0.1 x.new.adbraun.test test.blocklist.jarl.invalid.
expect_rpz_from "normal: not the strict list" 127.0.0.1 x.social.adbraun.test none
expect_rpz_from "off: no lists" 127.0.0.2 x.new.adbraun.test none
expect_rpz_from "off: but your rules" 127.0.0.2 blocked.adbraun.test rules.jarl.invalid.
expect_rpz_from "strict: the strict list" 127.0.0.3 x.social.adbraun.test social.blocklist.jarl.invalid.
expect_rpz_from "strict: and the normal list" 127.0.0.3 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block check names the step" 0 $J block check x.social.adbraun.test
contains "for clients at step strict and above"
run "a step in use can't be deleted (exit 5)" 5 $J block step delete strict
run "move the work laptop to normal" 0 $J block group update work --step normal
expect_rpz_from "now normal" 127.0.0.2 x.new.adbraun.test test.blocklist.jarl.invalid.
run "block group delete" 0 $J block group delete work
run "block group delete kids" 0 $J block group delete kids
run "blocklist delete social" 0 $J blocklist delete social --yes
run "block step delete" 0 $J block step delete strict
run "no gates left" 1 grep -q 'gate.jarl.invalid' $E/etc-jarl/named.conf
run "blocklist disable" 0 $J blocklist disable test
expect_rpz "a disabled list doesn't block" x.new.adbraun.test none
run "blocklist delete" 0 $J blocklist delete test --yes
run "block delete" 0 $J block delete blocked.adbraun.test
expect_rpz "a deleted rule doesn't block" blocked.adbraun.test none
run "block delete (allow rule)" 0 $J block delete ok.tracker.adbraun.test
run "no blocking left in named.conf" 1 grep -q 'response-policy' $E/etc-jarl/named.conf
kill $LISTS_PID 2>/dev/null; LISTS_PID=
# Prometheus metrics (docs/metrics.md)
run "metrics on the main listener" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains "^jarl_named_up 1$"
contains "^jarl_adopted 1$"
contains '^jarl_problems{severity="error"} 0$'
contains '^jarl_objects{kind="zones"} '
run "metrics on metrics.listen" 0 curl -sf http://127.0.0.1:$METRICS/metrics
contains "^jarl_build_info{version="
run "metrics.listen serves nothing else" 0 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:$METRICS/api/v1/zones
contains "^404$"
run "metrics with the token" 0 curl -sf -H "Authorization: Bearer e2e-metrics-token-0123456789" http://127.0.0.1:$METRICS/metrics
run "metrics with a wrong token" 0 curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token-0123456789" http://127.0.0.1:$METRICS/metrics
contains "^401$"
run "metrics on the socket" 0 curl -sf --unix-socket $SOCK http://jarl/metrics
contains "^jarl_open_changesets "
run "status at the end" 0 $J status
contains "Drift:.*none"
echo
echo "RESULT: $PASS passed, $FAIL failed"
[ $FAIL = 0 ]