diff --git a/docs/superpowers/contracts/website-admin-editor-unit-2026-08-29.json b/docs/superpowers/contracts/website-admin-editor-unit-2026-08-29.json new file mode 100644 index 00000000..fa80219a --- /dev/null +++ b/docs/superpowers/contracts/website-admin-editor-unit-2026-08-29.json @@ -0,0 +1,17 @@ +{ + "title": "Deploy the personal website's post editor as a loopback-only unit", + "objective": "The website repo now ships a browser post editor. Give it a home on the server: a systemd unit bound to 127.0.0.1, started by setup_personal_website.sh, with credentials generated once and no public route. Success is that after a setup run the editor answers on 127.0.0.1:4321/admin, survives a reboot, and is reachable from a laptop only through an SSH tunnel.", + "acceptance_criteria": [ + "setup_personal_website.sh builds the editor and fails if either half of the build is missing.", + "Credentials are generated once, stored as a scrypt hash in a 0600 env file, and are not rotated by a second setup run.", + "The unit binds to loopback, runs under ProtectSystem=strict with the checkout as its only writable path, and still completes a real build.", + "No Caddy snippet, no firewall opening, and no reference to the editor on the public domain.", + "The setup report prints the tunnel command and the path of the password file, never the password." + ], + "out_of_scope": [ + "Any public or Tailscale route for the editor.", + "TLS for the editor: the SSH tunnel terminates on localhost, which browsers already treat as a secure origin.", + "Changes to the website container, the shared Caddy edge, or the Gitea relocation." + ], + "verifier": "pre-commit run --files ; meta/scripts/shell_coverage_jail.sh over linux_configuration/features/lib/tests/test_website_admin.sh with --fail-on-case-error; systemd-analyze verify and a systemd-run rehearsal of the sandbox." +} diff --git a/docs/superpowers/evidence/website-admin-editor-unit-2026-08-29.json b/docs/superpowers/evidence/website-admin-editor-unit-2026-08-29.json new file mode 100644 index 00000000..be86ac4b --- /dev/null +++ b/docs/superpowers/evidence/website-admin-editor-unit-2026-08-29.json @@ -0,0 +1,48 @@ +{ + "intent": "Run the personal website's browser post editor on the server as a loopback systemd unit, reachable only over an SSH tunnel, with no Caddy route.", + "scope": [ + "linux_configuration/features/lib/website_admin.sh (new)", + "linux_configuration/features/lib/tests/test_website_admin.sh (new)", + "linux_configuration/features/setup_personal_website.sh (sources the new lib)", + "linux_configuration/features/lib/website_build.sh (calls deploy_admin_service and admin_status_lines)", + "Non-goal: any public route for the editor; the site stays a static dist/ behind Caddy" + ], + "changes": [ + "build_website_admin runs `pnpm run build:admin` and fails unless BOTH the service bundle and the editor page exist, because a missing page starts cleanly and serves a blank document.", + "ensure_admin_secrets generates a password and a session secret once, stores only the scrypt hash in a 0600 env file, and is idempotent so re-running setup does not rotate credentials under an open tunnel.", + "admin_unit_text writes a hardened unit: ProtectSystem=strict, ProtectHome=read-only, ReadWritePaths=, and a PATH carrying nvm's node, without which every save fails at the prerender step.", + "start_admin_service enables the unit and polls 127.0.0.1:4321/admin, failing loudly instead of reporting a service that never came up." + ], + "verification": [ + { + "command": "pre-commit run --files linux_configuration/features/setup_personal_website.sh linux_configuration/features/lib/website_build.sh linux_configuration/features/lib/website_admin.sh linux_configuration/features/lib/tests/test_website_admin.sh", + "result": "pass", + "evidence": "All hooks pass, shellcheck included; the 250-line cap holds for all three shell files." + }, + { + "command": "meta/scripts/shell_coverage_jail.sh --subject linux_configuration/features/lib/tests/test_website_admin.sh --bind /etc --measure website_admin.sh --min 0 --fail-on-case-error -- \"\"", + "result": "pass", + "evidence": "website_admin.sh: 53/66 lines = 80.30%; every assertion passed under --fail-on-case-error." + }, + { + "command": "systemd-analyze verify ", + "result": "pass", + "evidence": "No findings against the generated unit; the only output was pre-existing warnings about unrelated units on this host." + }, + { + "command": "systemd-run --user --wait -p ProtectSystem=strict -p ProtectHome=read-only -p ReadWritePaths=/home/kuhy/personal-website ... running the three real build steps", + "result": "pass", + "evidence": "vite build ok / ssr build ok / prerender ok, and `touch $HOME/.pw-sandbox-should-fail` was refused with 'Read-only file system'. The sandbox permits exactly the checkout." + } + ], + "risks": [ + "The unit has not yet been installed or started on the real server; only its text, its systemd-analyze verdict, and its sandbox behaviour under systemd-run have been checked here.", + "The generated password lands in a 0600 file on the server. Anyone who can already read that user's home can read it, which is the same trust boundary as the checkout itself.", + "PATH is pinned to whichever node is on PATH at setup time. An nvm version switch that removes that directory breaks the unit until setup is re-run." + ], + "rollback": [ + "sudo systemctl disable --now personal-website-admin.service && sudo rm /etc/systemd/system/personal-website-admin.service && sudo systemctl daemon-reload", + "Revert this commit; the website container, the Caddy snippet and the public site are untouched by it, so the site keeps serving either way.", + "Confirm https://kuhy.duckdns.org/ still returns 200 and that nothing listens on 127.0.0.1:4321." + ] +} diff --git a/linux_configuration/features/lib/tests/test_website_admin.sh b/linux_configuration/features/lib/tests/test_website_admin.sh new file mode 100755 index 00000000..9ba4ca7a --- /dev/null +++ b/linux_configuration/features/lib/tests/test_website_admin.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# Tests for website_admin.sh — the personal website's browser post editor. +# +# The subject is driven for real, including the sudo-write of the systemd unit, +# which the jail bind-mounts to a throwaway /etc. Only two things are stood in +# for: `pnpm`, because building the editor is the website repo's own test +# suite's job, and `admin_cli`, because hashing a password with scrypt costs +# ~100 ms per call and proves nothing here. +# +# What IS asserted is the content: a unit whose PATH lacks nvm's node, or an +# env file that lost PW_ADMIN_ROOT, starts cleanly and then fails on the first +# save — the exact failure a presence check would pass. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./features_harness.sh +. "$SCRIPT_DIR/features_harness.sh" + +# --- Refuse to run outside the jail ----------------------------------------- +if [[ -d /etc/systemd/system ]] && [[ ! -w /etc/systemd/system ]]; then + printf 'REFUSING: this suite must run under shell_coverage_jail.sh\n' >&2 + exit 1 +fi + +_t_setup_env +trap _t_teardown EXIT + +# The globals website_admin.sh inherits from setup_personal_website.sh above +# its source line, plus the log helpers it takes from common.sh. +WEBSITE_SRC="$TEST_TMPDIR/website" +WEBSITE_DATA_DIR="$TEST_TMPDIR/serve" +mkdir -p "$WEBSITE_SRC/dist-admin/client" + +log_info() { printf '[info] %s\n' "$1"; } +log_ok() { printf '[ok] %s\n' "$1"; } +log_warn() { printf '[warn] %s\n' "$1" >&2; } +log_error() { printf '[error] %s\n' "$1" >&2; } +die() { + log_error "$1" + exit 1 +} +ensure_dir() { mkdir -p "$1"; } +status_line() { printf '[status %s] %s\n' "$1" "$2"; } +check_http() { echo 1; } + +# shellcheck source=../website_admin.sh +. "$FEATURES_LIB_DIR/website_admin.sh" + +# Shadow sudo for this suite only. The jail already runs as uid 0, so `sudo` +# adds nothing here -- but it resets PATH, which would send +# `sudo systemctl daemon-reload` to the real binary and reload the HOST's +# systemd, one of the few things the namespace does not contain. +sudo() { "$@"; } + +# A stand-in scrypt hash. Escaped inside double quotes rather than written in +# single quotes: the format is $-separated, and this repo treats shellcheck's +# SC2016 as an error. +readonly FAKE_HASH="scrypt\$32768\$8\$1\$deadbeef\$cafe" + +# Stand in for the service's own CLI: deterministic, and 200x faster. +admin_cli() { + case "$1" in + hash-password) printf '%s\n' "$FAKE_HASH" ;; + session-secret) printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n' ;; + *) return 1 ;; + esac +} + +# --- admin_new_password ----------------------------------------------------- +password="$(admin_new_password)" +if [[ ${#password} -ge 16 ]]; then + _t_pass "admin_new_password returns something long enough to be a password" +else + _t_fail "admin_new_password returned only ${#password} characters" +fi +# `/` and `+` would survive a shell round trip badly and `=` reads as padding +# someone will trim; the generator strips all three rather than hoping. +if [[ $password =~ ^[A-Za-z0-9]+$ ]]; then + _t_pass "admin_new_password stays URL- and copy-paste-safe" +else + _t_fail "admin_new_password produced '${password}'" +fi +if [[ "$(admin_new_password)" == "$(admin_new_password)" ]]; then + _t_fail "admin_new_password returned the same value twice" +else + _t_pass "admin_new_password draws fresh entropy each call" +fi + +# --- admin_env_text --------------------------------------------------------- +env_text="$(admin_env_text "$FAKE_HASH" 'the-secret')" +_t_has "$env_text" "PW_ADMIN_PASSWORD_HASH=${FAKE_HASH}" "the env file carries the hash" +_t_has "$env_text" 'PW_ADMIN_SESSION_SECRET=the-secret' "the env file carries the session secret" +_t_has "$env_text" 'PW_ADMIN_HOST=127.0.0.1' "the service is pinned to loopback" +_t_has "$env_text" "PW_ADMIN_ROOT=${WEBSITE_SRC}" "the content root points at the checkout" + +# --- ensure_admin_secrets --------------------------------------------------- +ensure_admin_secrets +_t_file_has "$ADMIN_ENV_FILE" 'PW_ADMIN_PASSWORD_HASH=scrypt' "the env file is written" +_t_eq "600" "$(stat -c '%a' "$ADMIN_ENV_FILE")" "the env file is not world-readable" +_t_eq "600" "$(stat -c '%a' "$ADMIN_PASSWORD_FILE")" "the password file is not world-readable" + +# The password must never be recoverable from the file the unit reads. +stored_password="$(cat "$ADMIN_PASSWORD_FILE")" +if grep -qF "$stored_password" "$ADMIN_ENV_FILE"; then + _t_fail "the env file contains the plaintext password" +else + _t_pass "the env file holds only the hash, never the password" +fi + +# Re-running setup must not rotate a password someone is already using. +ensure_admin_secrets +_t_eq "$stored_password" "$(cat "$ADMIN_PASSWORD_FILE")" \ + "ensure_admin_secrets leaves existing credentials alone" + +# A truncated env file is not credentials, and must be regenerated rather than +# reused into a service that cannot authenticate anyone. +printf 'PW_ADMIN_PORT=4321\n' >"$ADMIN_ENV_FILE" +ensure_admin_secrets +_t_file_has "$ADMIN_ENV_FILE" 'PW_ADMIN_PASSWORD_HASH=scrypt' \ + "a hashless env file is rewritten instead of kept" + +# --- admin_unit_text -------------------------------------------------------- +unit_text="$(admin_unit_text)" +node_dir="$(dirname "$(command -v node)")" +_t_has "$unit_text" "Environment=PATH=${node_dir}:" \ + "the unit puts this node on PATH, so the build steps can shell out to it" +_t_has "$unit_text" "WorkingDirectory=${WEBSITE_SRC}" "the unit runs from the checkout" +_t_has "$unit_text" "EnvironmentFile=${ADMIN_ENV_FILE}" "the unit reads the generated env file" +_t_has "$unit_text" "ReadWritePaths=${WEBSITE_SRC}" \ + "ProtectSystem=strict is opened up for exactly the checkout" +_t_has "$unit_text" "NoNewPrivileges=yes" "the unit drops privilege escalation" +_t_has "$unit_text" "Restart=always" "the unit comes back after a crash or reboot" +if [[ $unit_text == *"${WEBSITE_SRC}/dist-admin/admin.js"* ]]; then + _t_pass "the unit starts the built service, not a source file" +else + _t_fail "the unit does not start dist-admin/admin.js" +fi + +# --- write_admin_unit ------------------------------------------------------- +_t_stub systemctl +write_admin_unit +_t_file_has "$ADMIN_UNIT_PATH" "ExecStart=" "the unit lands in /etc/systemd/system" +_t_called "daemon-reload" "write_admin_unit reloads systemd so the new unit is seen" + +# --- admin_report ----------------------------------------------------------- +report="$(admin_report)" +_t_has "$report" "-L ${ADMIN_PORT}:127.0.0.1:${ADMIN_PORT}" "the report gives the tunnel command" +_t_has "$report" "http://localhost:${ADMIN_PORT}/admin" \ + "the report sends you to localhost, which is where the Secure cookie works" +_t_has "$report" "$ADMIN_PASSWORD_FILE" "the report says where the password is" +if [[ $report == *"$stored_password"* ]]; then + _t_fail "the report prints the password into the terminal and the setup log" +else + _t_pass "the report names the password file rather than printing the password" +fi + +# --- build_website_admin ---------------------------------------------------- +_t_stub pnpm +# pnpm is stubbed, so nothing is produced: the build must notice rather than +# hand a missing bundle to systemd. +_t_exits_nonzero build_website_admin "build_website_admin fails when no bundle was produced" + +printf 'built\n' >"${WEBSITE_SRC}/dist-admin/admin.js" +_t_exits_nonzero build_website_admin \ + "build_website_admin fails when the service built but the editor page did not" + +printf '\n' >"${WEBSITE_SRC}/dist-admin/client/index.html" +if build_website_admin >/dev/null; then + _t_pass "build_website_admin passes once both halves exist" +else + _t_fail "build_website_admin rejected a complete build" +fi + +# --- start_admin_service ---------------------------------------------------- +# A curl that answers 200 stands in for a service that came up. The opposite +# case is deliberately not exercised here: wait_for_admin retries for 20 s +# before giving up, and a 20 s test is one nobody will keep running. +printf '#!/usr/bin/env bash\nprintf 200\n' >"$TEST_TMPDIR/bin/curl" +chmod +x "$TEST_TMPDIR/bin/curl" +if start_admin_service >/dev/null 2>&1; then + _t_pass "start_admin_service accepts a service that answers 200 on /admin" +else + _t_fail "start_admin_service rejected a healthy service" +fi +_t_called "enable --now ${ADMIN_UNIT}" "start_admin_service enables the unit for reboot survival" + +# --- deploy_admin_service --------------------------------------------------- +if deploy_admin_service >/dev/null 2>&1; then + _t_pass "deploy_admin_service runs build, secrets, unit and start in one pass" +else + _t_fail "deploy_admin_service failed on an otherwise healthy setup" +fi + +# --- admin_status_lines ----------------------------------------------------- +if admin_status_lines >/dev/null 2>&1; then + _t_pass "admin_status_lines reports without failing the status command" +else + _t_fail "admin_status_lines exited non-zero" +fi + +_t_report "test_website_admin.sh" diff --git a/linux_configuration/features/lib/website_admin.sh b/linux_configuration/features/lib/website_admin.sh new file mode 100755 index 00000000..7428809d --- /dev/null +++ b/linux_configuration/features/lib/website_admin.sh @@ -0,0 +1,198 @@ +#!/bin/bash +# The browser post editor that ships with the personal website. +# +# Sourced by setup_personal_website.sh; split out to keep every file under the +# 250-line cap. Sourced rather than run, so it inherits the caller's strict +# mode plus WEBSITE_SRC / WEBSITE_DATA_DIR from above the source line. +# +# Deliberately NOT fronted by Caddy. The site is public; its editor writes into +# the content directory and triggers a real build, so it binds to loopback and +# is reached over an SSH tunnel: +# +# ssh -N -L 4321:127.0.0.1:4321 +# +# A public route would also break the login outright: the session cookie is +# Secure, which a browser honours only over TLS or on localhost. + +readonly ADMIN_PORT="4321" +readonly ADMIN_UNIT="personal-website-admin.service" +readonly ADMIN_UNIT_PATH="/etc/systemd/system/${ADMIN_UNIT}" +readonly ADMIN_ENV_FILE="${WEBSITE_DATA_DIR}/admin.env" +# The generated password, written once so setup needs no interactive prompt. +readonly ADMIN_PASSWORD_FILE="${WEBSITE_DATA_DIR}/admin-password" + +# The service's own CLI, wrapped so the tests can stand in for it. +admin_cli() { + node "${WEBSITE_SRC}/dist-admin/admin.js" "$@" +} + +# --- Phase A: build the service and its editor page ------------------------- +build_website_admin() { + log_info "Building the admin editor…" + ( + cd "$WEBSITE_SRC" || die "Cannot enter ${WEBSITE_SRC}." + pnpm run build:admin + ) + [[ -f "${WEBSITE_SRC}/dist-admin/admin.js" ]] || + die "Build did not produce ${WEBSITE_SRC}/dist-admin/admin.js." + # Checked separately: without the page the service starts, answers, and + # serves a blank document -- a failure that looks like a browser problem. + [[ -f "${WEBSITE_SRC}/dist-admin/client/index.html" ]] || + die "Build did not produce the editor page (dist-admin/client/index.html)." + log_ok "Admin editor built to ${WEBSITE_SRC}/dist-admin." +} + +# --- Phase B: secrets ------------------------------------------------------- +# A URL-safe password from the kernel's entropy. Printed nowhere: it goes +# straight into a 0600 file whose path the report names. +admin_new_password() { + head -c 18 /dev/urandom | base64 | tr -d '\n/+=' +} + +admin_env_text() { + cat <"$ADMIN_ENV_FILE" + printf '%s\n' "$password" >"$ADMIN_PASSWORD_FILE" + ) + chmod 600 "$ADMIN_ENV_FILE" "$ADMIN_PASSWORD_FILE" + log_ok "Generated admin credentials; the password is in ${ADMIN_PASSWORD_FILE}." +} + +# --- Phase C: the unit ------------------------------------------------------ +# systemd starts the service with a minimal PATH, and node here comes from nvm +# rather than /usr/bin. The build steps shell out to `node` by name, so the +# directory holding it has to be on the unit's PATH or every save fails at the +# prerender step with a "node: not found" the editor reports as a failed build. +admin_unit_text() { + local node_bin node_dir + node_bin="$(command -v node)" || die "node is not on PATH." + node_dir="$(dirname "$node_bin")" + cat </dev/null + sudo systemctl daemon-reload + log_ok "Wrote ${ADMIN_UNIT_PATH}." +} + +# --- Phase D: start and verify ---------------------------------------------- +wait_for_admin() { + local code _ + for _ in $(seq 1 20); do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \ + "http://127.0.0.1:${ADMIN_PORT}/admin" || echo "000")" + if [[ $code == "200" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +start_admin_service() { + log_info "Starting ${ADMIN_UNIT}…" + sudo systemctl enable --now "$ADMIN_UNIT" + sudo systemctl restart "$ADMIN_UNIT" + if wait_for_admin; then + log_ok "Admin editor answering on 127.0.0.1:${ADMIN_PORT}." + else + sudo systemctl status --no-pager "$ADMIN_UNIT" || true + die "The admin editor did not come up on 127.0.0.1:${ADMIN_PORT}." + fi +} + +admin_report() { + cat <