New metrics: section (enabled, listen, allow, token). /metrics is served on the main listeners, the local socket (root and jarld's user) and optional metrics-only addresses; a scrape needs an allowed address or the bearer token. Exports health (named up, problems, drift, backups, blocklists, DNSSEC phases, token expiry) and, with stats on, named's and Jarl's query counters since jarld started plus named's process and cache gauges. docs/metrics.md has example queries and alerts checked with promtool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
590 lines
31 KiB
Bash
Executable File
590 lines
31 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
|
|
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 ]
|