mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-14 21:00:46 +01:00
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
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:
parent
f70af95b61
commit
ce33c935e4
@ -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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
201
linux_configuration/features/lib/tests/test_website_admin.sh
Executable file
201
linux_configuration/features/lib/tests/test_website_admin.sh
Executable 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"
|
||||
198
linux_configuration/features/lib/website_admin.sh
Executable file
198
linux_configuration/features/lib/website_admin.sh
Executable 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}"
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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}"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user