diff --git a/.tests/e2e-mock-endpoint.py b/.tests/e2e-mock-endpoint.py new file mode 100644 index 0000000..9f2800f --- /dev/null +++ b/.tests/e2e-mock-endpoint.py @@ -0,0 +1,48 @@ +# A stand-in for the Caddy bootstrap endpoint, for testing ops/run-e2e.sh +# without the operator's password. +# +# It reproduces the three behaviours the client depends on: +# correct credentials -> 200 with the blob +# wrong credentials -> 401 +# any other path -> connection closed with no response (Caddy's `abort`) +# +# The blob is composed IN MEMORY from credentials already present on this +# machine. Nothing is written to disk and nothing is ever logged. +import base64, os, http.server + +USER = os.environ['MU'] +PW = os.environ['MP'] +ROUTE = os.environ['MR'] + +with open(os.path.expanduser('~/.config/bitwarden/bws-token')) as f: + bws = f.read().strip() +BLOB = ('PRIVATE_REPO_URL=%s\nBWS_ACCESS_TOKEN=%s\n' % (os.environ['MREPO'], bws)).encode() +WANT = 'Basic ' + base64.b64encode(('%s:%s' % (USER, PW)).encode()).decode() + +class H(http.server.BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.0' + + def do_GET(self): + if self.path != '/%s/bootstrap.env' % ROUTE: + # Caddy's catch-all is `handle { abort }` -- no response at all, so + # curl reports 000. Closing without writing reproduces that. + self.close_connection = True + return + if self.headers.get('Authorization', '') != WANT: + self.send_response(401) + self.send_header('WWW-Authenticate', 'Basic realm="bootstrap"') + self.send_header('Content-Length', '0') + self.end_headers() + return + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.send_header('Content-Length', str(len(BLOB))) + self.end_headers() + self.wfile.write(BLOB) + + # the default handler logs every request line to stderr; the path contains + # the route, so it stays quiet + def log_message(self, *a): + pass + +http.server.HTTPServer(('0.0.0.0', 8099), H).serve_forever() diff --git a/.tests/e2e-private-tier.sh b/.tests/e2e-private-tier.sh new file mode 100755 index 0000000..dca0ecb --- /dev/null +++ b/.tests/e2e-private-tier.sh @@ -0,0 +1,194 @@ +#!/bin/bash +# Phase 5 end-to-end, inside a clean container. Public tier from the live +# anonymous URL, then the private tier through the real bootstrap endpoint. +# +# Credentials arrive in the environment (BOOT_URL/BOOT_USER/BOOT_PW) and are +# never echoed. Package set is scoped to the private path and its dependency: +# the full manifest install is covered by the fake-package-manager suite, and +# what has never run end to end is bootstrap -> bws -> private repo -> secrets. +set -u +export DEBIAN_FRONTEND=noninteractive +fail() { echo "FAIL: $*"; exit 1; } + +apt-get update -qq && apt-get install -y -qq git curl zsh ca-certificates expect >/dev/null 2>&1 + +# ---- 1. public tier, anonymously, exactly as a stranger would --------------- +sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply \ + "${E2E_PUBLIC_URL:?}" >/dev/null 2>&1 \ + || fail "public tier init" +export PATH="$HOME/.local/bin:$PATH" +command -v dotup >/dev/null || fail "dotup not on PATH after public apply" +echo "1. public tier applied: $(chezmoi managed -p absolute | wc -l) files" + +# absence assertions BEFORE the private tier exists +[ ! -e "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh present pre-bootstrap" +[ ! -e "$HOME/.config/bitwarden/bws-token" ] || fail "bws token present pre-bootstrap" +[ ! -d "$HOME/.local/share/dotfiles-private" ] || fail "private tier present pre-bootstrap" +echo "2. public-only machine is clean: no token, no secrets, no private source" + +# ---- 2. select the private rows -------------------------------------------- +STATE=${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles +mkdir -p "$STATE" +printf 'core/unzip\nprivate/private-repo\nprivate/bws-secrets\n' > "$STATE/selected" +echo " plan says:"; dotup plan 2>&1 | sed 's/^/ /' | grep -v '^ *$' | head -8 + +# ---- 3. install: unzip via apt. bws must NOT appear here ------------------- +dotup --yes install 2>&1 | sed 's/^/ /' | tail -8 +command -v unzip >/dev/null || fail "unzip not installed" +command -v bws >/dev/null && fail "bws installed by the package phase — it must come from the private tier" +echo "3. unzip installed; bws correctly absent (it is not a manifest package)" + + +# ---- 4. the private tier, through the real endpoint ------------------------ +# TEN answers, not three: dotup asks for the endpoint URL, username and +# password, and then the private repo's .chezmoi.toml.tmpl asks its own seven +# promptStringOnce questions. +# +# They must be answered one at a time, as each prompt appears. Two earlier +# attempts piped all ten in up front through `script` and hung forever on +# `git user.name?`. chezmoi's prompt is a full-screen TUI -- it puts the tty in +# raw mode, and a raw-mode switch done with TCSAFLUSH DISCARDS input already +# sitting in the buffer. Everything pre-fed was written to the pty before +# chezmoi started, so it was thrown away, and the pty then waited on a stdin +# that had already closed. No amount of pre-feeding can work against that +# prompt; the answers have to arrive after it draws. +# +# expect also drops `script`'s worst property: script mirrors its stdin into +# its own output, which published the endpoint password into a session +# transcript once already. Credentials reach expect through the environment +# ($env(...)), never argv, never stdin. +red() { sed -e "s|$BOOT_URL||g" -e "s|$BOOT_PW||g" \ + -e 's|[0-9a-f]\{32\}||g'; } +export E2E_NAME='E2E Test' E2E_EMAIL='e2e@example.invalid' +timeout 600 expect -f - > /tmp/private.log 2>&1 <<'EXP' +set timeout 180 +log_user 1 +spawn -noecho dotup private + +# A bare `expect -re {pat} { ... }` treats a timeout as "carry on", so a missed +# prompt does not fail -- it silently falls through and every later expect waits +# out its own timeout in turn, surfacing at the 600s ceiling as an unattributed +# hang. Name the prompt that was actually missed instead. +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: plain `read`, no TUI, 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 -- "$env(BOOT_PW)\r" +# The private repo's seven, each answered only once its TUI is genuinely ready +# to read. +# +# Matching the prompt TEXT is not enough, and this is a race that passes most of +# the time. chezmoi writes "git user.email?" while the tty is still in cooked +# mode, and only then switches to raw mode -- with TCSAFLUSH, which DISCARDS +# whatever is already sitting in the input buffer. An answer sent on the text +# alone can therefore land in the window before the switch and be thrown away. +# The value is gone, the field sits unsubmitted, and every later expect waits +# out its own timeout: one lost keystroke costs the full 600s ceiling and +# reports as "a prompt went unanswered". +# +# Observed exactly that way -- one run answered all seven, the next lost the +# Enter after user.email. Nothing about the two runs differed but timing. +# +# `\033[?2004h` is bracketed-paste-on, which the TUI emits only AFTER raw mode +# is established. Waiting for it turns "probably ready" into "demonstrably +# ready". Each prompt emits its own, so this is per-answer, not once. +proc ask {pat val} { + wait_for $pat "chezmoi prompt $pat" + wait_for "\033\\\[\\?2004h" "raw mode after $pat (TUI never became ready)" + send -- "$val\r" +} +ask {user\.name} "$env(E2E_NAME)" +ask {user\.email} "$env(E2E_EMAIL)" +# blank accepts the template's default, which is what a real operator does for +# the signing key and the four gitea URLs +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=$? +case $rc in + 124) fail "dotup private hit the 600s ceiling — expect itself wedged, which its own timeout handlers should have prevented" ;; + 3|4) red < /tmp/private.log | tr -d '\r' | tail -6 | sed 's/^/ /' + fail "a prompt went unanswered — see the TIMEOUT/EOF line above for which one" ;; +esac +# `script` mirrors its stdin into the pty output, so the three answers -- the +# secret path and the password among them -- are sitting at the top of that log. +# Redact before anything prints it. Learned the hard way: an earlier run of this +# test published both into a session transcript and they had to be rotated. +red < /tmp/private.log | tr -d '\r' | sed 's/^/ /' | tail -14 + +# ---- 5. assertions --------------------------------------------------------- +echo +echo "--- verification ---" +command -v bws >/dev/null || fail "bws not installed by the private tier" +echo "bws: $(bws --version 2>&1 | head -1), installed by the private tier" +[ -r "$HOME/.config/bitwarden/bws-token" ] || fail "bws token not written" +m=$(stat -c '%a' "$HOME/.config/bitwarden/bws-token") +[ "$m" = 600 ] || fail "bws token mode $m, want 600" +echo "bws token: present, mode 600" + +[ -d "$HOME/.local/share/dotfiles-private" ] || fail "private source not cloned" +sm=$(stat -c '%a' "$HOME/.local/share/dotfiles-private") +case $sm in *00) echo "private source: cloned, mode $sm (go-rwx applied)" ;; + *) fail "private source mode $sm — group/other can read it" ;; esac + +[ -f "$HOME/.config/chezmoi/private.toml" ] || fail "private.toml not created" +[ -f "$HOME/.config/chezmoi/chezmoi.toml" ] || fail "public chezmoi.toml missing" +d=$(grep -c '^\s*git' "$HOME/.config/chezmoi/private.toml" 2>/dev/null || echo 0) +echo "configs: chezmoi.toml + private.toml, private has $d identity keys" + +[ -r "$HOME/.config/zsh/secrets.zsh" ] || fail "secrets.zsh not generated" +sm=$(stat -c '%a' "$HOME/.config/zsh/secrets.zsh") +[ "$sm" = 600 ] || fail "secrets.zsh mode $sm, want 600" +n=$(grep -c '^export ' "$HOME/.config/zsh/secrets.zsh") +echo "secrets.zsh: mode 600, $n exports (want 8)" +[ "$n" -eq 8 ] || fail "expected 8 exports, got $n" + +# the credential must not be in any log dotup produced +if grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' /tmp/private.log; then + echo "FAIL: a credential-bearing URL appears in dotup output"; exit 1 +fi +echo "log hygiene: no credential-bearing URL in dotup output" + +# ...nor in the clone's own config, which is where it used to live forever. +GC="$HOME/.local/share/dotfiles-private/.git/config" +[ -f "$GC" ] || fail "no .git/config in the private source" +grep -qE '://[^/@[:space:]]*:[^/@[:space:]]+@' "$GC" \ + && fail "the git remote in .git/config still carries the token" +echo "git remote: clean — $(git -C "$HOME/.local/share/dotfiles-private" remote get-url origin)" + +CRED="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/private-credentials" +[ -r "$CRED" ] || fail "credential file not written to $CRED" +cm=$(stat -c '%a' "$CRED") +[ "$cm" = 600 ] || fail "credential file mode $cm, want 600" +echo "credential file: present, mode 600, $(wc -l < "$CRED") line" + +# the whole point: a later update must still authenticate, with the token only +# ever coming from that file +git -C "$HOME/.local/share/dotfiles-private" fetch -q 2>/dev/null \ + && echo "later fetch: authenticates from the credential file" \ + || fail "fetch failed — the credential helper did not survive the clone" + +# identity arrived +git config --get user.email >/dev/null 2>&1 || fail "git identity not configured" +echo "git identity: configured" + +zsh -ic 'exit' >/dev/null 2>&1 && echo "zsh: interactive login clean" \ + || echo "WARN: zsh -ic exited non-zero" +echo +echo "E2E PASS" diff --git a/.tests/e2e.sh b/.tests/e2e.sh new file mode 100755 index 0000000..8878a4e --- /dev/null +++ b/.tests/e2e.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# Phase 5 end-to-end against the live endpoint, in a throwaway container. +# +# RUN IT FROM A NORMAL TERMINAL, NOT FROM INSIDE A CLAUDE SESSION: +# +# bash .tests/e2e.sh +# +# Do NOT run it with the `!` prefix in Claude Code. The output is redacted, but +# the prompt is not worth risking -- and the whole point of this wrapper is that +# the password stays on your side. +# +# The password is never written to disk, never placed on any command line, and +# never printed. It is read from your terminal into a shell variable and handed +# to the container through the environment: +# +# `docker exec -e BOOT_PW` with NO `=value` tells docker to inherit the +# variable from this process. Writing `-e BOOT_PW=secret` would put it in +# docker's own argv, and /proc//cmdline is world-readable -- the exact +# bug this test suite was just fixed to stop doing in dotup. +# +# What it proves that the unit suite cannot: that a machine which has only ever +# seen the public repo can reach the real endpoint, authenticate, clone the +# private tier, install bws, render secrets, and end up with no credential in +# its logs or its .git/config. +set -u + +C=dotup-e2e +INNER=$(dirname "$0")/e2e-private-tier.sh +[ -r "$INNER" ] || { echo "missing $INNER"; exit 1; } +command -v docker >/dev/null || { echo "docker not installed"; exit 1; } + +cleanup() { docker rm -f "$C" >/dev/null 2>&1 || :; unset BOOT_PW; } +trap cleanup EXIT INT TERM + +# ---- credentials, from your terminal only ----------------------------------- +# No default URL, deliberately. Hardcoding the route here would copy it out of +# Bitwarden and into a git repository, where it would then have to be edited +# every time the route rotates -- and a stale default is worse than no default, +# because it fails looking like a password problem. +printf 'Bootstrap URL (paste from Bitwarden): ' >&2 +IFS= read -r BOOT_URL +[ -n "$BOOT_URL" ] || { echo "no URL given"; exit 1; } + +printf 'Username [ben]: ' >&2 +IFS= read -r BOOT_USER +BOOT_USER=${BOOT_USER:-ben} + +printf 'Password: ' >&2 +IFS= read -rs BOOT_PW; printf '\n' >&2 +[ -n "$BOOT_PW" ] || { echo "empty password"; exit 1; } +export BOOT_URL BOOT_USER BOOT_PW + +# ---- fail fast: do not spend ten minutes to discover a typo ------------------ +# curl -K - reads credentials from stdin rather than argv. +printf 'checking the endpoint... ' >&2 +code=$(printf 'user = %s:%s\nsilent\nwrite-out = "%%{http_code}"\noutput = "/dev/null"\n' \ + "$BOOT_USER" "$BOOT_PW" | curl -K - -m 20 "${BOOT_URL%/}/bootstrap.env" || true) +if [ "$code" != 200 ]; then + echo "HTTP $code -- wrong password, wrong route, or endpoint down. Nothing was run." >&2 + exit 1 +fi +echo "200" >&2 + +# ---- the run ---------------------------------------------------------------- +# A stock image with nothing preinstalled. If dotup needs a tool, dotup must +# install it -- that is half of what is being tested. +docker rm -f "$C" >/dev/null 2>&1 || : +docker run -d --name "$C" ubuntu:24.04 sleep infinity >/dev/null || exit 1 +docker cp "$INNER" "$C":/root/e2e.sh >/dev/null || exit 1 + +# -e with a bare NAME inherits from this shell. Never NAME=value. +docker exec -e BOOT_URL -e BOOT_USER -e BOOT_PW "$C" bash /root/e2e.sh +rc=$? + +echo +if [ "$rc" -eq 0 ]; then + echo "E2E exited 0" +else + echo "E2E exited $rc -- the container is left running as $C for inspection." + echo "If you shell into it, remember that /tmp/private.log inside contains the" + echo "credentials in cleartext: the inner script has a red() redactor, and the" + echo "one time it was bypassed for a quick tail, a live password was published" + echo "and had to be rotated. Pipe anything you read through it." + trap - EXIT INT TERM # leave the container up; still drop the password + unset BOOT_PW +fi +exit "$rc" diff --git a/README.md b/README.md index 2690bcf..c9a53f5 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ DOTUP_TEST_IMAGE=ubuntu:22.04 sh .tests/test.sh --docker DOTUP_TEST_IMAGE=debian:12 sh .tests/test.sh --docker ``` -81 assertions covering the toggle rule, the `@needs` closure both ways, the risk +101 assertions covering the toggle rule, the `@needs` closure both ways, the risk model as invariants rather than prose, match confinement (two-sided: `--exact` confines `nvidia` to three rows **and** fuzzy still over-matches, so removing `--exact` fails loudly), the fzf preflight, channel resolution on both @@ -209,6 +209,35 @@ for it. That is not theatre: it caught the apt/brew fallback running in both directions, which resolved Linux-only packages to `apt install davfs2` on a machine that has never had apt. The fallback is one-directional now. +### End to end + +The suite above installs nothing. `.tests/e2e.sh` does the opposite — a stock +`ubuntu:24.04` container, the public tier fetched from the anonymous URL, then +the private tier through a real bootstrap endpoint, then `bws`, then a rendered +`secrets.zsh`. + +```sh +bash .tests/e2e.sh # prompts for endpoint URL, username, password +``` + +The password is never written to disk and never placed on a command line: it +reaches the container as `docker exec -e BOOT_PW` with a bare name, which +inherits from the calling shell. `-e BOOT_PW=…` would put it in docker's own +argv, and `/proc//cmdline` is world-readable — the same bug this repo was +fixed to stop committing itself. + +It asserts what the unit suite structurally cannot: that a machine which has +only ever seen the public repo can authenticate, clone the private tier, and end +up with **no credential in its logs and none in `.git/config`**, with the token +in a 600-mode credential file that a later `fetch` still authenticates from. + +**Without an endpoint password**, `.tests/e2e-mock-endpoint.py` stands in for +Caddy — 200 with the blob, 401 on bad credentials, and a closed connection on +any other path, which is what `handle { abort }` does and why a retired route +reports `000` rather than `404`. Everything downstream stays real. That is how +the argv fix was verified, and running it that way immediately caught a rotation +script whose route regex had stopped matching. + ## Rules this repo lives by - **Zero credentials, forever.** `gitleaks detect` runs over the full history