Site: published by CI after a push to main and after a release (#79)
CI / build (pull_request) Successful in 1m20s
Site / build (pull_request) Successful in 11s

The Site workflow's last step, and the release workflow after publishing,
ask the web server over SSH to rebuild the site. The key CI holds is tied
on the server to one forced command (restrict,command=...), so CI sends no
command and a leaked key can only refresh the site. The server, the user,
the key and the server's host key are Gitea secrets; with none of them set
the step does nothing.

scripts/site_refresh.sh is what both workflows run;
scripts/site_deploy_keygen.sh makes the key and prints where each half goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-07 14:34:02 +02:00
co-authored by Claude Opus 5.5
parent 55c9ad2eb4
commit 12c88c98d3
6 changed files with 220 additions and 6 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Creates the key CI uses to ask the web server for a site refresh, once (issue #79,
# docs/milestones/W1.md), and says where each half goes. The private key stays in
# ~/.config/roro9stack/ until it is pasted into the Gitea secret; it is never committed and this
# script doesn't print it.
#
# scripts/site_deploy_keygen.sh [/full/path/to/rororefresh.sh] [the runner's address]
set -euo pipefail
KEY="${RORO_SITE_DEPLOY_KEY:-$HOME/.config/roro9stack/site-deploy-key}"
COMMAND="${1:-/full/path/to/rororefresh.sh}"
FROM="${2:-}"
if [ -e "$KEY" ]; then
echo "A site deploy key already exists at $KEY; not overwriting it." >&2
else
mkdir -p "$(dirname "$KEY")"
( umask 077; ssh-keygen -q -t ed25519 -N "" -C roro9stack-ci-site-refresh -f "$KEY" )
fi
options="restrict,command=\"$COMMAND\""
[ -z "$FROM" ] || options="from=\"$FROM\",$options"
cat <<TEXT
1. On the web server, as the user that runs the refresh, add this one line to ~/.ssh/authorized_keys:
$options $(cat "$KEY.pub")
restrict: no terminal, no forwarding of any kind. command=: whatever the client asks for, this
runs instead.$([ -n "$FROM" ] || printf '\n Give the runner'"'"'s address as the second argument to add from="...": the key then works from there only.')
2. In Gitea, the repository's Settings > Actions > Secrets:
SITE_DEPLOY_KEY the whole of $KEY (the private key, with its BEGIN and END lines)
SITE_DEPLOY_HOST the server's address as the runner reaches it, or address:port
SITE_DEPLOY_USER that user's name
SITE_DEPLOY_KNOWN_HOSTS the server's host key, one line, from a machine you trust the network of:
ssh-keyscan -t ed25519 <address> (or: -p <port> <address>)
and compare it with the server's own:
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub (on the server)
ssh-keyscan -t ed25519 <address> | ssh-keygen -lf - (here)
3. Try it, from here, with the same four values in the environment:
SITE_DEPLOY_KEY="\$(cat $KEY)" SITE_DEPLOY_HOST=... SITE_DEPLOY_USER=... \\
SITE_DEPLOY_KNOWN_HOSTS="\$(ssh-keyscan -t ed25519 ... 2>/dev/null)" scripts/site_refresh.sh
(with from= set, this works from the runner's address only.) Then, to see that the key can do
nothing else: ssh -i $KEY <user>@<address> id must run the refresh, not \`id\`.
Once the secret is in Gitea, the copy at $KEY can be deleted.
TEXT
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Asks the web server to rebuild the site (issue #79, docs/milestones/W1.md). Run by CI after a push
# to main that changed the site, and after a release is published (the home page and Downloads
# name the latest release when they are built).
#
# It only connects: the server's authorized_keys line forces the one command this key may run, so
# nothing sent from here chooses what happens there. From the environment (Gitea secrets):
# SITE_DEPLOY_KEY the private key (scripts/site_deploy_keygen.sh makes it)
# SITE_DEPLOY_HOST the server, or server:port
# SITE_DEPLOY_USER the user there
# SITE_DEPLOY_KNOWN_HOSTS the server's host key, as a known_hosts line: nothing else is trusted
# With none of them set it does nothing (a fork, or before the key is installed); with only some, it fails.
set -euo pipefail
set_count=0
for v in SITE_DEPLOY_KEY SITE_DEPLOY_HOST SITE_DEPLOY_USER SITE_DEPLOY_KNOWN_HOSTS; do
[ -z "${!v:-}" ] || set_count=$((set_count + 1))
done
if [ "$set_count" = 0 ]; then
echo "site refresh: no SITE_DEPLOY_* secrets here, nothing done"
exit 0
fi
if [ "$set_count" != 4 ]; then
echo "site refresh: SITE_DEPLOY_KEY, _HOST, _USER and _KNOWN_HOSTS are needed, and only $set_count of them are set" >&2
exit 1
fi
if ! command -v ssh >/dev/null; then
apt-get update -qq
apt-get install -y -qq --no-install-recommends openssh-client >/dev/null
fi
host="$SITE_DEPLOY_HOST" port=22
case "$host" in
*:*) port="${host##*:}" host="${host%:*}" ;;
esac
# The key and the host key exist as files only while this runs, in a container that goes with the job.
umask 077
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
printf '%s\n' "$SITE_DEPLOY_KEY" > "$tmp/key"
printf '%s\n' "$SITE_DEPLOY_KNOWN_HOSTS" > "$tmp/known_hosts"
# -F none: no configuration but this line. -T and no command: the server's forced command runs.
ssh -F none -T -p "$port" -i "$tmp/key" \
-o IdentitiesOnly=yes -o BatchMode=yes \
-o StrictHostKeyChecking=yes -o UserKnownHostsFile="$tmp/known_hosts" -o GlobalKnownHostsFile=/dev/null \
-o ConnectTimeout=20 -o ServerAliveInterval=15 -o ServerAliveCountMax=8 \
"$SITE_DEPLOY_USER@$host"
echo "site refresh: done"