# Cases for ../host-modules/lib/attic-root-facts.nix. Each case runs the script # against a stub `bao` over a store kept in files, and compares what the script # left in its directory and which leaves it read. set -uo pipefail failures=0 count=0 case="setup" fail() { echo "FAILED: $case: $*" >&2 failures=$((failures + 1)) } expect() { # count=$((count + 1)) if [ "$2" != "$3" ]; then fail "$1: got '$2', wanted '$3'"; fi } dir=$(mktemp -d) mkdir -p "$dir/bin" sed -e "s|@DIR@|$dir|g" "$SCRIPT" >"$dir/script.sh" calls=$dir/calls export DIR=$dir # `login` answers from LOGIN_FAIL. `kv get -field=value ` prints the file # kept for the path; a missing one is the store's "no value found" and DENY_READ # is a policy that grants nothing. Every call is logged. cat >"$dir/bin/bao" <>"$calls" case "\$1 \$2" in "login -method=cert") if [ "\${LOGIN_FAIL:-0}" = 1 ]; then echo "certificate refused" >&2; exit 2; fi echo store-token ;; "kv get") [ "\${BAO_TOKEN-}" = store-token ] || { echo "kv without the login's token" >&2; exit 97; } f="\$DIR/store/\$(printf '%s' "\$4" | tr / _)" if [ "\${DENY_READ:-0}" = 1 ]; then echo "permission denied" >&2; exit 2; fi [ -f "\$f" ] || { echo "No value found at \$4" >&2; exit 2; } cat "\$f" ;; *) echo "unexpected call: \$*" >&2; exit 98 ;; esac STUB chmod +x "$dir/bin/bao" # `systemctl` answers for nix-daemon.service: DAEMON_STARTED is when it last # became active (empty: never, so it is not running) and every call is logged. cat >"$dir/bin/systemctl" <>"$dir/systemctl" case "\$1" in show) printf '%s\n' "\${DAEMON_STARTED-}" ;; is-failed) exit 1 ;; is-active) [ -n "\${DAEMON_STARTED-}" ] ;; try-restart) ;; *) echo "unexpected call: \$*" >&2; exit 98 ;; esac STUB chmod +x "$dir/bin/systemctl" export PATH="$dir/bin:$PATH" # Certificates made here, with fixed names and subjects: `system-ca` stands for # a CA the host's own bundle holds, `swarm-ca` for the swarm's root, `host-ca` # for a bundle the host's nix.conf names. `swarm-leaf` and `system-leaf` are # servers for localhost signed by the first two. pki=$dir/pki mkdir -p "$pki" newca() { # openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -keyout "$pki/$1.key" -out "$pki/$1.pem" -subj "/CN=$1" -days 2 \ -addext basicConstraints=critical,CA:TRUE -addext keyUsage=keyCertSign 2>/dev/null } newleaf() { # openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ -keyout "$pki/$2.key" -out "$pki/$2.csr" -subj "/CN=localhost" 2>/dev/null printf 'subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\n' >"$pki/$2.ext" openssl x509 -req -in "$pki/$2.csr" -CA "$pki/$1.pem" -CAkey "$pki/$1.key" \ -CAcreateserial -out "$pki/$2.pem" -days 2 -extfile "$pki/$2.ext" 2>/dev/null } newca system-ca newca swarm-ca newca host-ca newleaf swarm-ca swarm-leaf newleaf system-ca system-leaf certs() { grep -c -- '-----BEGIN CERTIFICATE-----' "$1" 2>/dev/null || echo 0; } KEYLEAF=secret_swarm_hives_alpha_attic_public-key TOKENLEAF=secret_swarm_hives_alpha_attic_pull-token reset() { rm -rf "$dir/store" "$dir/attic" "$dir/nix" "$dir/host-netrc" "$calls" "$dir/systemctl" cp "$pki/system-ca.pem" "$dir/system-bundle.crt" cp "$pki/swarm-ca.pem" "$dir/swarm-root.pem" cp "$pki/host-ca.pem" "$dir/host-ca.pem" mkdir -p "$dir/store" : >"$calls" : >"$dir/systemctl" } seed() { # printf '%s' "$1" >"$dir/store/$KEYLEAF" printf '%s' "$2" >"$dir/store/$TOKENLEAF" } # run [VAR=value…]: the script's exit status. run() { env "$@" bash "$dir/script.sh" >"$dir/out" 2>"$dir/err" } restarts() { grep -c '^systemctl try-restart' "$dir/systemctl"; } mode() { stat -c %a "$1" 2>/dev/null || echo missing; } ino() { stat -c %i "$1" 2>/dev/null || echo missing; } body() { cat "$1" 2>/dev/null || echo missing; } case="first run writes the key (0600, in a 0700 directory) and the netrc (0600)" reset seed "swarm:S0VZ" "tok-1" run expect "exit" $? 0 expect "key" "$(body "$dir/attic/public-key")" "swarm:S0VZ" expect "netrc" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1" expect "key mode" "$(mode "$dir/attic/public-key")" 600 expect "netrc mode" "$(mode "$dir/nix/netrc")" 600 expect "dir mode" "$(mode "$dir/attic")" 700 expect "fragment names the cache, the key, the root-only netrc and the CA bundle" "$(body "$dir/nix/cache.conf")" "extra-substituters = https://cache.example/swarm extra-trusted-public-keys = swarm:S0VZ netrc-file = $dir/nix/netrc ssl-cert-file = $dir/nix/ca-bundle.crt" expect "fragment mode" "$(mode "$dir/nix/cache.conf")" 644 expect "fragment dir mode" "$(mode "$dir/nix")" 755 expect "no secret in the fragment" "$(grep -c tok-1 "$dir/nix/cache.conf")" 0 expect "control: the token is in the netrc" "$(grep -c tok-1 "$dir/nix/netrc")" 1 expect "the netrc the fragment names is root-only" "$(mode "$dir/nix/netrc")" 600 case="a second run with the same facts rewrites nothing" k=$(ino "$dir/attic/public-key") n=$(ino "$dir/nix/netrc") c=$(ino "$dir/nix/cache.conf") run expect "exit" $? 0 expect "key untouched" "$(ino "$dir/attic/public-key")" "$k" expect "netrc untouched" "$(ino "$dir/nix/netrc")" "$n" expect "fragment untouched" "$(ino "$dir/nix/cache.conf")" "$c" case="a rotated token replaces the netrc and leaves the key" seed "swarm:S0VZ" "tok-2" run expect "exit" $? 0 expect "netrc" "$(body "$dir/nix/netrc")" "machine cache.example password tok-2" expect "netrc mode" "$(mode "$dir/nix/netrc")" 600 expect "key untouched" "$(ino "$dir/attic/public-key")" "$k" case="a rotated key replaces the key file" seed "swarm:Tk9XCg" "tok-2" run expect "exit" $? 0 expect "key" "$(body "$dir/attic/public-key")" "swarm:Tk9XCg" case="only the hive's own leaves are read, after a login" expect "reads" "$(grep -c '^bao kv get' "$calls")" "$(grep -c '^bao kv get -field=value secret/swarm/hives/alpha/attic/' "$calls")" expect "login first" "$(head -1 "$calls")" "bao login -method=cert -token-only" expect "something was read" "$([ "$(grep -c '^bao kv get' "$calls")" -ge 2 ] && echo yes || echo no)" yes # The merged netrc: the cache's entry first, then the host's other entries as # they are. A missing host file is no entries. HOSTNETRC='machine git.example login bob password hunter2 machine other.example login alice password s3cret' case="a host netrc with two other machines keeps both, after the cache's entry" reset seed "swarm:S0VZ" "tok-1" printf '%s\n' "$HOSTNETRC" >"$dir/host-netrc" run expect "exit" $? 0 expect "merged" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1 $HOSTNETRC" expect "mode" "$(mode "$dir/nix/netrc")" 600 expect "the host's file is not written" "$(body "$dir/host-netrc")" "$HOSTNETRC" case="a host entry for the cache's own machine is replaced and the rest is kept" reset seed "swarm:S0VZ" "tok-1" printf '%s\n%s\n' "$HOSTNETRC" "machine cache.example login old password stale" >"$dir/host-netrc" run expect "exit" $? 0 expect "merged" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1 $HOSTNETRC" expect "control: the stale entry was in the host file" "$(grep -c stale "$dir/host-netrc")" 1 case="a multi-line host entry for the cache's machine goes whole" reset seed "swarm:S0VZ" "tok-1" printf '%s\n%s\n' "machine cache.example login old password stale" "$HOSTNETRC" >"$dir/host-netrc" run expect "exit" $? 0 expect "merged" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1 $HOSTNETRC" case="an edit to the host netrc reaches the merged file on the next run" reset seed "swarm:S0VZ" "tok-1" printf '%s\n' "$HOSTNETRC" >"$dir/host-netrc" run printf '%s\n' "machine new.example login n password p" >>"$dir/host-netrc" run expect "exit" $? 0 expect "new entry merged" "$(grep -c new.example "$dir/nix/netrc")" 1 expect "old entries kept" "$(grep -c git.example "$dir/nix/netrc")" 1 case="a netrc left 0640 by an earlier deploy is rewritten 0600 though its content is current" reset seed "swarm:S0VZ" "tok-1" run chmod 640 "$dir/nix/netrc" run expect "exit" $? 0 expect "mode" "$(mode "$dir/nix/netrc")" 600 case="a store with no facts exits cleanly and creates nothing" reset run expect "exit" $? 0 expect "no key" "$(body "$dir/attic/public-key")" missing expect "no netrc" "$(body "$dir/nix/netrc")" missing case="one fact without the other creates nothing" reset printf '%s' "swarm:S0VZ" >"$dir/store/$KEYLEAF" run expect "exit" $? 0 expect "no key" "$(body "$dir/attic/public-key")" missing expect "no netrc" "$(body "$dir/nix/netrc")" missing # The failure cases start from a good state and an extra file in the directory. good() { reset seed "swarm:S0VZ" "tok-1" run touch "$dir/attic/other" } intact() { expect "key kept" "$(body "$dir/attic/public-key")" "swarm:S0VZ" expect "netrc kept" "$(body "$dir/nix/netrc")" "machine cache.example password tok-1" expect "other file kept" "$(body "$dir/attic/other")" "" } case="a store that refuses the login fails the run and removes nothing" good seed "swarm:NEW" "tok-new" run LOGIN_FAIL=1 expect "exit" $? 1 intact case="a store that refuses the read fails the run and removes nothing" good run DENY_READ=1 expect "exit" $? 1 intact case="a key with whitespace is refused and nothing changes" good seed "swarm:S0 VZ" "tok-new" run expect "exit" $? 1 intact case="a token with whitespace is refused and nothing changes" good seed "swarm:S0VZ" "tok new" run expect "exit" $? 1 intact case="a key without a name is refused and nothing changes" good seed "S0VZ" "tok-new" run expect "exit" $? 1 intact # A restart of nix-daemon is asked for only when the fragment was written after # the daemon last started; `now` is when the daemon "started", read between runs. now() { date -u '+%a %Y-%m-%d %H:%M:%S.%6N UTC'; } case="a new fragment restarts a daemon that started before it" reset seed "swarm:S0VZ" "tok-1" run DAEMON_STARTED="Sat 2000-01-01 00:00:00.000000 UTC" expect "exit" $? 0 expect "restart asked" "$(grep -c '^systemctl try-restart --no-block nix-daemon.service' "$dir/systemctl")" 1 expect "restart claimed" "$(grep -c "cache.conf changed after nix-daemon.service last started — restarting it\$" "$dir/out")" 1 case="a daemon that never ran is not restarted, and nothing claims it was" reset seed "swarm:S0VZ" "tok-1" run DAEMON_STARTED= expect "exit" $? 0 expect "fragment written" "$(grep -c "^extra-substituters" "$dir/nix/cache.conf")" 1 expect "no restart" "$(restarts)" 0 expect "no change-vs-start claim" "$(grep -c "changed after" "$dir/out")" 0 case="the same facts leave the fragment and the daemon alone" started=$(now) : >"$dir/systemctl" run DAEMON_STARTED="$started" expect "exit" $? 0 expect "no restart" "$(restarts)" 0 case="a rotated token changes the netrc but not the fragment, so no restart" started=$(now) seed "swarm:S0VZ" "tok-2" run DAEMON_STARTED="$started" expect "exit" $? 0 expect "netrc" "$(body "$dir/nix/netrc")" "machine cache.example password tok-2" expect "no restart" "$(restarts)" 0 case="a rotated key rewrites the fragment and restarts the daemon" started=$(now) seed "swarm:Tk9XCg" "tok-2" run DAEMON_STARTED="$started" expect "exit" $? 0 expect "key in the fragment" "$(grep -c 'extra-trusted-public-keys = swarm:Tk9XCg' "$dir/nix/cache.conf")" 1 expect "restart asked" "$(restarts)" 1 case="a store with no facts writes no fragment and restarts nothing" reset run DAEMON_STARTED="Sat 2000-01-01 00:00:00.000000 UTC" expect "exit" $? 0 expect "no fragment" "$(body "$dir/nix/cache.conf")" missing expect "no restart" "$(restarts)" 0 case="a key that is refused writes no fragment" reset seed "swarm:S0 VZ" "tok-1" run expect "exit" $? 1 expect "no fragment" "$(body "$dir/nix/cache.conf")" missing # The CA bundle the fragment names: the host's own CAs, then the swarm's root. bundle=$dir/nix/ca-bundle.crt case="the bundle holds the host's CAs and the swarm's root, 0644, and the fragment names it" reset seed "swarm:S0VZ" "tok-1" run expect "exit" $? 0 expect "bundle" "$(body "$bundle")" "$(cat "$pki/system-ca.pem") $(cat "$pki/swarm-ca.pem")" expect "two certificates" "$(certs "$bundle")" 2 expect "mode" "$(mode "$bundle")" 644 expect "fragment names it" "$(grep -c "^ssl-cert-file = $bundle\$" "$dir/nix/cache.conf")" 1 expect "control: the host's bundle held one certificate" "$(certs "$dir/system-bundle.crt")" 1 expect "the host's bundle is not written" "$(body "$dir/system-bundle.crt")" "$(cat "$pki/system-ca.pem")" case="the swarm and host servers verify against the bundle, and the swarm one does not against the host CAs alone" expect "swarm leaf, bundle" "$(openssl verify -CAfile "$bundle" "$pki/swarm-leaf.pem" >/dev/null 2>&1 && echo ok || echo bad)" ok expect "system leaf, bundle" "$(openssl verify -CAfile "$bundle" "$pki/system-leaf.pem" >/dev/null 2>&1 && echo ok || echo bad)" ok expect "control: swarm leaf, host's CAs alone" "$(openssl verify -CAfile "$dir/system-bundle.crt" "$pki/swarm-leaf.pem" >/dev/null 2>&1 && echo ok || echo bad)" bad # curl takes the file as nix does. A TLS server for localhost with the swarm's # leaf, on a port picked at random until one binds. case="curl trusts the swarm's server with the bundle and refuses it with the host's CAs alone" port= for _ in 1 2 3 4 5 6 7 8; do p=$((20000 + RANDOM % 20000)) openssl s_server -www -accept "$p" -cert "$pki/swarm-leaf.pem" -key "$pki/swarm-leaf.key" \ >"$dir/s_server.log" 2>&1 & server=$! for _ in 1 2 3 4 5 6 7 8 9 10; do if curl -s --cacert "$pki/swarm-ca.pem" "https://localhost:$p/" >/dev/null 2>&1; then port=$p; break; fi kill -0 "$server" 2>/dev/null || break sleep 0.3 done [ -n "$port" ] && break kill "$server" 2>/dev/null done if [ -z "$port" ]; then fail "no TLS server for the curl check came up: $(cat "$dir/s_server.log")" else curl -s --cacert "$bundle" "https://localhost:$port/" >/dev/null 2>&1 expect "with the bundle" $? 0 curl -s --cacert "$dir/system-bundle.crt" "https://localhost:$port/" >/dev/null 2>&1 expect "with the host's CAs alone" $? 60 kill "$server" 2>/dev/null fi case="a second run with the same CAs rewrites nothing" b=$(ino "$bundle") run expect "exit" $? 0 expect "bundle untouched" "$(ino "$bundle")" "$b" case="a bundle left 0640 is rewritten 0644 though its content is current" chmod 640 "$bundle" run expect "exit" $? 0 expect "mode" "$(mode "$bundle")" 644 case="a host that sets its own ssl-cert-file has that file merged in, not replaced" reset seed "swarm:S0VZ" "tok-1" sed -e "s|@DIR@|$dir|g" "$SCRIPT_HOSTCA" >"$dir/script-hostca.sh" cp "$dir/script.sh" "$dir/script-default.sh" cp "$dir/script-hostca.sh" "$dir/script.sh" run expect "exit" $? 0 expect "the host's file and the swarm's root" "$(body "$bundle")" "$(cat "$pki/host-ca.pem") $(cat "$pki/swarm-ca.pem")" line=$(sed -n 2p "$pki/system-ca.pem") expect "the default bundle's CA is not in it" "$(grep -c -x -F -- "$line" "$bundle")" 0 expect "control: the same search finds it in its own file" "$(grep -c -x -F -- "$line" "$pki/system-ca.pem")" 1 expect "the host's file is not written" "$(body "$dir/host-ca.pem")" "$(cat "$pki/host-ca.pem")" cat "$pki/system-ca.pem" >>"$dir/host-ca.pem" run expect "an edit to the host's file reaches the bundle" "$(certs "$bundle")" 3 cp "$dir/script-default.sh" "$dir/script.sh" case="a swarm root that is not on the host yet leaves the host's CAs alone, and a later run adds it" reset seed "swarm:S0VZ" "tok-1" rm "$dir/swarm-root.pem" run DAEMON_STARTED="Sat 2000-01-01 00:00:00.000000 UTC" expect "exit" $? 0 expect "bundle" "$(body "$bundle")" "$(cat "$pki/system-ca.pem")" expect "the fragment still names it" "$(grep -c "^ssl-cert-file = $bundle\$" "$dir/nix/cache.conf")" 1 expect "said so" "$(grep -c "swarm-root.pem is not on this host yet" "$dir/out")" 1 started=$(now) c=$(ino "$dir/nix/cache.conf") : >"$dir/systemctl" cp "$pki/swarm-ca.pem" "$dir/swarm-root.pem" run DAEMON_STARTED="$started" expect "exit" $? 0 expect "root added" "$(certs "$bundle")" 2 expect "fragment untouched" "$(ino "$dir/nix/cache.conf")" "$c" expect "no restart: nix reads the bundle per download" "$(restarts)" 0 case="a swarm root file holding no certificate is not appended" reset seed "swarm:S0VZ" "tok-1" echo "garbage" >"$dir/swarm-root.pem" run expect "exit" $? 0 expect "bundle" "$(body "$bundle")" "$(cat "$pki/system-ca.pem")" case="a host bundle that cannot be read fails the run and leaves the fragment and bundle" good cp "$bundle" "$dir/bundle-before" rm "$dir/system-bundle.crt" seed "swarm:NEW" "tok-new" run expect "exit" $? 1 expect "says why" "$(grep -c "cannot read the host.s CA bundle" "$dir/err")" 1 expect "bundle kept" "$(body "$bundle")" "$(body "$dir/bundle-before")" expect "fragment kept" "$(grep -c 'extra-trusted-public-keys = swarm:S0VZ' "$dir/nix/cache.conf")" 1 if [ "$failures" -ne 0 ]; then echo "$failures of $count checks failed" >&2 exit 1 fi echo "$count checks passed"