206 lines
9.5 KiB
Bash
206 lines
9.5 KiB
Bash
#!/bin/bash
|
|
# The private tier, end to end, against a local stand-in for everything remote.
|
|
#
|
|
# Nothing here touches the real endpoint, the real repos or the real secrets
|
|
# manager, so it can be run as often as it takes and costs nothing when it
|
|
# fails. What it does exercise is the real code: dotup's prompt loop, the real
|
|
# credential splitting, chezmoi's real seven-question TUI, and the private
|
|
# tier's real `dotsecrets` -- copied verbatim into the fake source tree, so it
|
|
# is the shipping implementation being measured, not a rewrite of it.
|
|
#
|
|
# The first password is deliberately wrong. These credentials are asked for at
|
|
# the very end of a run, so before the retry loop existed one typo meant redoing
|
|
# the entire install. Getting it wrong on purpose is the only way to prove the
|
|
# recovery path is there and that the URL and username survive the mistake.
|
|
set -u
|
|
fail() { echo "FAIL: $*"; exit 1; }
|
|
ok() { echo " ok $*"; }
|
|
umask 022 # modes below assume it; do not let the daemon's umask decide
|
|
|
|
sudo apt-get update -qq && sudo apt-get install -y -qq git expect unzip >/dev/null 2>&1
|
|
echo " NOTE installed git by hand -- see ISSUE-1"
|
|
|
|
# ---- public tier first: the private tier is a continuation, never a start ----
|
|
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >/tmp/init.log 2>&1 \
|
|
|| { tail -5 /tmp/init.log; fail "public tier init"; }
|
|
export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
|
|
D=$HOME/.local/bin/dotup
|
|
[ -x "$D" ] || fail "no dotup after the public apply"
|
|
ok "public tier applied"
|
|
|
|
# absence assertions BEFORE the private tier exists, so their later presence
|
|
# means something
|
|
for f in "$HOME/.config/zsh/secrets.zsh" "$HOME/.config/bitwarden/bws-token" \
|
|
"$HOME/.local/share/dotfiles-private"; do
|
|
[ ! -e "$f" ] || fail "$f exists on a public-only machine"
|
|
done
|
|
ok "public-only machine carries no token, no secrets, no private source"
|
|
|
|
# ---- a fake bws, so nothing reaches the network -----------------------------
|
|
# `ensure_bws` checks `have bws` first and `have` searches ~/.local/bin, so
|
|
# putting the stub there is enough to keep the real download out of this run.
|
|
# The real ensure_bws (pin, checksum, musl target) is a separate scenario --
|
|
# faking it here would only prove the fake works.
|
|
mkdir -p "$HOME/.local/bin"
|
|
cat > "$HOME/.local/bin/bws" <<'BWS'
|
|
#!/bin/sh
|
|
# Stand-in for the Bitwarden Secrets Manager CLI. Answers exactly the call
|
|
# dotsecrets makes: `bws secret get <uuid> -o env`, printing KEY=VALUE.
|
|
# LAB_BWS_MODE bends it to drive the failure branches.
|
|
case "${LAB_BWS_MODE:-ok}" in
|
|
fail) exit 1 ;;
|
|
wrong) printf 'SOMETHING_ELSE=x\n'; exit 0 ;;
|
|
empty) printf 'LAB_ALPHA_API_KEY=\n'; exit 0 ;;
|
|
esac
|
|
[ "$1" = secret ] && [ "$2" = get ] || { echo "unsupported: $*" >&2; exit 2; }
|
|
case "$3" in
|
|
*0001) k=LAB_ALPHA_API_KEY ;; *0002) k=LAB_BRAVO_API_KEY ;;
|
|
*0003) k=LAB_CHARLIE_API_KEY ;; *0004) k=LAB_DELTA_API_KEY ;;
|
|
*0005) k=LAB_ECHO_API_KEY ;; *0006) k=LAB_FOXTROT_API_KEY ;;
|
|
*0007) k=LAB_GOLF_API_KEY ;; *) echo "unknown id $3" >&2; exit 1 ;;
|
|
esac
|
|
printf '%s=lab-value-for-%s\n' "$k" "$k"
|
|
BWS
|
|
chmod 755 "$HOME/.local/bin/bws"
|
|
ok "fake bws in place; this run reaches no network"
|
|
|
|
# ---- tick the private rows, as the picker would ------------------------------
|
|
S=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles
|
|
mkdir -p "$S"; printf 'private/private-repo\nprivate/bws-secrets\n' > "$S/selected"
|
|
|
|
# ---- drive it exactly as a person sitting at the keyboard would --------------
|
|
export E2E_NAME='Lab Tester' E2E_EMAIL='lab@example.invalid'
|
|
timeout 420 expect -f - >/tmp/private.log 2>&1 <<'EXP'
|
|
set timeout 120
|
|
log_user 1
|
|
spawn -noecho env PATH=$env(PATH) $env(HOME)/.local/bin/dotup private
|
|
|
|
# A bare `expect -re {pat} {}` treats a timeout as "carry on", so a missed
|
|
# prompt falls through silently and every later expect waits out its own
|
|
# timeout, surfacing much later as an unattributed hang. Name what was missed.
|
|
proc wait_for {pat what} {
|
|
expect {
|
|
-re $pat {}
|
|
timeout { send_user "\nTIMEOUT: never saw $what\n"; exit 3 }
|
|
eof { send_user "\nEOF before $what -- dotup exited early\n"; exit 4 }
|
|
}
|
|
}
|
|
|
|
# dotup's own three prompts are plain `read` in cooked mode, so the text is the
|
|
# whole signal.
|
|
wait_for {Bootstrap URL:} "the bootstrap URL prompt"
|
|
send -- "$env(BOOT_URL)\r"
|
|
wait_for {Username:} "the username prompt"
|
|
send -- "$env(BOOT_USER)\r"
|
|
wait_for {Password:} "the password prompt"
|
|
send -- "wrong-on-purpose\r"
|
|
|
|
wait_for {wrong username or password} "the 401 message naming the actual fault"
|
|
# The bracketed default is the proof that the URL and username were retained,
|
|
# so only the password has to be retyped.
|
|
wait_for {Bootstrap URL \[} "the retry prompt with the URL kept"
|
|
send -- "\r"
|
|
wait_for {Username \[} "the retry prompt with the username kept"
|
|
send -- "\r"
|
|
wait_for {Password:} "the retry password prompt"
|
|
send -- "$env(BOOT_PW)\r"
|
|
|
|
# chezmoi's seven are a full-screen TUI, and matching the prompt TEXT is not
|
|
# enough. chezmoi writes the prompt while the tty is still in cooked mode and
|
|
# only then switches to raw with TCSAFLUSH, which DISCARDS anything already
|
|
# buffered. An answer sent on the text alone can land in that window and be
|
|
# thrown away -- the field sits unsubmitted and the run burns its whole
|
|
# timeout. `\033[?2004h` is bracketed-paste-on, emitted only after raw mode is
|
|
# established, so waiting for it turns "probably ready" into "demonstrably
|
|
# ready". Each prompt emits its own.
|
|
proc ask {pat val} {
|
|
wait_for $pat "chezmoi prompt $pat"
|
|
wait_for "\033\\\[\\?2004h" "raw mode after $pat (the TUI never became ready)"
|
|
send -- "$val\r"
|
|
}
|
|
ask {user\.name} "$env(E2E_NAME)"
|
|
ask {user\.email} "$env(E2E_EMAIL)"
|
|
ask {signing key} ""
|
|
ask {WAN ssh} ""
|
|
ask {LAN ssh} ""
|
|
ask {WAN web} ""
|
|
ask {LAN web} ""
|
|
expect eof
|
|
catch wait result
|
|
exit [lindex $result 3]
|
|
EXP
|
|
rc=$?
|
|
red() { sed -e "s|$BOOT_PW|<BOOT_PW>|g" -e "s|$BOOT_URL|<BOOT_URL>|g"; }
|
|
case $rc in
|
|
124) red </tmp/private.log | tail -8 | sed 's/^/ /'
|
|
fail "hit the 420s ceiling -- expect wedged past its own handlers" ;;
|
|
3|4) red </tmp/private.log | tr -d '\r' | tail -8 | sed 's/^/ /'
|
|
fail "a prompt went unanswered -- the TIMEOUT/EOF line above names it" ;;
|
|
esac
|
|
red </tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ | /'
|
|
|
|
# ---- what must be true afterwards -------------------------------------------
|
|
echo "-- verification --"
|
|
m() { stat -c '%a' "$1" 2>/dev/null; }
|
|
|
|
[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned"
|
|
case $(m "$HOME/.local/share/dotfiles-private") in *00) ;; *)
|
|
fail "private source is mode $(m "$HOME/.local/share/dotfiles-private") — group/other can read it" ;; esac
|
|
ok "private source cloned, go-rwx"
|
|
|
|
[ "$(m "$HOME/.config/bitwarden/bws-token")" = 600 ] || fail "bws token mode $(m "$HOME/.config/bitwarden/bws-token"), want 600"
|
|
ok "bws token written, mode 600"
|
|
|
|
SEC=$HOME/.config/zsh/secrets.zsh
|
|
[ -r "$SEC" ] || fail "secrets.zsh not generated"
|
|
[ "$(m "$SEC")" = 600 ] || fail "secrets.zsh mode $(m "$SEC"), want 600"
|
|
n=$(grep -c '^export ' "$SEC")
|
|
[ "$n" -eq 8 ] || fail "secrets.zsh has $n exports, want 8 (7 secrets + 1 alias)"
|
|
grep -q '^export LAB_GOLF_ALIAS_KEY=' "$SEC" || fail "the alias export is missing"
|
|
ok "secrets.zsh mode 600, 8 exports, alias present"
|
|
|
|
for p in "$HOME/.config/zsh:700" "$HOME/.ssh:700" "$HOME/.ssh/config:600" \
|
|
"$HOME/.config/zsh/local.zsh:600" "$HOME/.local/bin/dotsecrets:700"; do
|
|
want=${p##*:}; path=${p%:*}
|
|
[ "$(m "$path")" = "$want" ] || fail "$path is mode $(m "$path"), want $want"
|
|
done
|
|
ok "every private destination carries the mode it claims"
|
|
|
|
# The credential must survive as a file and nowhere else.
|
|
CRED=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials
|
|
[ "$(m "$CRED")" = 600 ] || fail "credential file mode $(m "$CRED"), want 600"
|
|
GC=$HOME/.local/share/dotfiles-private/.git/config
|
|
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \
|
|
&& fail "the clone's .git/config still carries the token"
|
|
grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log \
|
|
&& fail "a credential-bearing URL appears in dotup's own output"
|
|
ok "token is in the 600-mode credential file only — not in .git/config, not in the log"
|
|
|
|
git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \
|
|
|| fail "a later fetch cannot authenticate — the credential helper did not survive"
|
|
ok "a later fetch still authenticates from that file"
|
|
|
|
# ---- the failure branches: yesterday's keys beat no keys --------------------
|
|
before=$(sha256sum "$SEC" | cut -d' ' -f1)
|
|
for mode in fail wrong empty; do
|
|
LAB_BWS_MODE=$mode "$HOME/.local/bin/dotsecrets" >/dev/null 2>&1
|
|
[ "$(sha256sum "$SEC" | cut -d' ' -f1)" = "$before" ] \
|
|
|| fail "bws mode '$mode' modified secrets.zsh — it must be left alone on failure"
|
|
done
|
|
ls "$HOME/.config/zsh"/.secrets.zsh.* >/dev/null 2>&1 && fail "a temp file survived a failed refresh"
|
|
ok "a failed refresh leaves secrets.zsh byte-identical and no temp file behind"
|
|
|
|
# ---- GitHub reads stay anonymous https after the tier applies ---------------
|
|
# The tier ships a git@github.com: rewrite for PUSHES. If it ever regresses to
|
|
# a blanket insteadOf, every https clone on a keyless fresh box breaks -- the
|
|
# first casualty was nvim's plugin bootstrap (live, 2026-08-22).
|
|
# Plain `git config --get`, not --global: --global does not follow the
|
|
# [include] seam by default, which made this pair pass vacuously.
|
|
git config --get 'url.git@github.com:.insteadof' >/dev/null 2>&1 \
|
|
&& fail "GitHub https READS are rewritten to ssh — a keyless box cannot clone plugins"
|
|
git config --get 'url.git@github.com:.pushinsteadof' >/dev/null 2>&1 \
|
|
|| fail "the GitHub push-over-ssh rewrite is gone entirely"
|
|
ok "GitHub reads stay https; only pushes are rewritten to ssh"
|
|
|
|
echo "PRIVATE TIER PASS"
|