Run the website's post editor as a loopback unit, no Caddy route
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

This commit is contained in:
Krzysztof Rudnicki 2026-08-29 17:50:03 +02:00
parent f70af95b61
commit ce33c935e4
6 changed files with 470 additions and 0 deletions

View File

@ -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 <the four shell 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."
}

View File

@ -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=<checkout>, 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 <generated unit>",
"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."
]
}

View File

@ -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 '<html></html>\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"

View File

@ -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 <this host>
#
# 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 <<EOF
# Managed by setup_personal_website.sh — do not edit by hand.
# The password itself is NOT here; only a scrypt hash of it.
PW_ADMIN_PASSWORD_HASH=$1
PW_ADMIN_SESSION_SECRET=$2
PW_ADMIN_PORT=${ADMIN_PORT}
PW_ADMIN_HOST=127.0.0.1
PW_ADMIN_ROOT=${WEBSITE_SRC}
EOF
}
# Idempotent on purpose: re-running setup must not silently change the
# password out from under a tunnel you already have open.
ensure_admin_secrets() {
ensure_dir "$WEBSITE_DATA_DIR"
if [[ -f $ADMIN_ENV_FILE ]] && grep -q '^PW_ADMIN_PASSWORD_HASH=scrypt' "$ADMIN_ENV_FILE"; then
log_info "Reusing the existing admin credentials in ${ADMIN_ENV_FILE}."
return 0
fi
local password hash secret
password="$(admin_new_password)"
hash="$(printf '%s' "$password" | admin_cli hash-password)" ||
die "Could not hash the admin password."
secret="$(admin_cli session-secret)" ||
die "Could not generate a session secret."
# umask inside a subshell: setting it for the rest of the process would
# change the mode of every file the caller writes afterwards.
(
umask 077
admin_env_text "$hash" "$secret" >"$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 <<EOF
# Managed by setup_personal_website.sh — do not edit by hand.
[Unit]
Description=Personal website admin editor (loopback only)
After=network.target
[Service]
Type=simple
User=${USER}
WorkingDirectory=${WEBSITE_SRC}
EnvironmentFile=${ADMIN_ENV_FILE}
Environment=PATH=${node_dir}:/usr/local/bin:/usr/bin
ExecStart=${node_bin} ${WEBSITE_SRC}/dist-admin/admin.js
Restart=always
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
# The service exists to write posts and rebuild the site, so its own checkout
# is the one place it must be able to change.
ReadWritePaths=${WEBSITE_SRC}
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
EOF
}
write_admin_unit() {
admin_unit_text | sudo tee "$ADMIN_UNIT_PATH" >/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 <<EOF
--- Post editor (not on the internet) --------------------------------------
Reach it by tunnelling to this host, then opening the local URL:
ssh -N -L ${ADMIN_PORT}:127.0.0.1:${ADMIN_PORT} $(hostname)
http://localhost:${ADMIN_PORT}/admin
Password: cat ${ADMIN_PASSWORD_FILE} (on this host, 0600)
Posts written there are ordinary files in ${WEBSITE_SRC}/src/content/blog —
commit them like any other change.
----------------------------------------------------------------------------
EOF
}
# The one entry point setup_cmd calls.
deploy_admin_service() {
build_website_admin
ensure_admin_secrets
write_admin_unit
start_admin_service
admin_report
}
# --- status -----------------------------------------------------------------
admin_status_lines() {
if systemctl is-active --quiet "$ADMIN_UNIT"; then
status_line 0 "${ADMIN_UNIT} active"
else
status_line 1 "${ADMIN_UNIT} not running"
fi
status_line "$(check_http "http://127.0.0.1:${ADMIN_PORT}/admin")" \
"admin editor answers on 127.0.0.1:${ADMIN_PORT}"
}

View File

@ -173,6 +173,7 @@ setup_cmd() {
reload_caddy
ensure_firewall
print_report
deploy_admin_service
log_ok "Personal website setup complete."
}
@ -194,6 +195,7 @@ check_http() {
status_cmd() {
print_setup_header "Personal website status"
admin_status_lines
if has_cmd node; then
status_line 0 "node present"

View File

@ -15,6 +15,8 @@
# - Moves Gitea to gitea.kuhy.duckdns.org via setup_gitea.sh (single source).
# - Builds the website and serves dist/ from a loopback caddy container on
# 127.0.0.1:8088, fronted by a reverse-proxy snippet on the root domain.
# - Runs the browser post editor as a loopback systemd unit on 127.0.0.1:4321,
# deliberately with NO Caddy route: reach it over an SSH tunnel.
# - Reuses the existing DuckDNS updater (wildcard already resolves) and opens
# the firewall via setup_wireguard_ssh.sh.
#
@ -169,6 +171,8 @@ build_cv_json() {
# shellcheck source=lib/website_build.sh
source "$SCRIPT_DIR/lib/website_build.sh"
# shellcheck source=lib/website_admin.sh
source "$SCRIPT_DIR/lib/website_admin.sh"
main() {
local cmd="${1:-setup}"