From 438236fd828f1c4b579185c2b101a7272d8b8a13 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:07:07 +0000 Subject: [PATCH 1/2] fix(deploy): run site deploys as tfmc through psdeploy The Deploy workflow logged in as ryan and site-deploy worked through ryan's bindfs views of tfmc's checkouts, which made git write files as 660 (hence the chmod loop) and tied deploys to one person's account. - The deploy key logs in as psdeploy, pinned to site-deploy-ssh, which runs a root-owned /usr/local/sbin/site-deploy as tfmc through sudo. - site-deploy works on /home/tfmc directly, calls ps-compose without sudo, sets umask 022 and drops the chmod loop. - The preview key keeps its own forced command, so it still cannot deploy a site. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/server_ssh.sh | 6 ++--- .github/workflows/deploy.yml | 2 +- scripts/site-deploy-ssh.sh | 11 +++++++++ scripts/site-deploy.sh | 44 ++++++++++++++--------------------- 4 files changed, 33 insertions(+), 30 deletions(-) create mode 100755 scripts/site-deploy-ssh.sh diff --git a/.github/scripts/server_ssh.sh b/.github/scripts/server_ssh.sh index 8c208dfa..8a3b76b6 100755 --- a/.github/scripts/server_ssh.sh +++ b/.github/scripts/server_ssh.sh @@ -1,8 +1,8 @@ #!/bin/bash # Run one command on the TF server as SSH_USER with SSH_KEY. The server pins -# each deploy key to one forced command: psdeploy runs ps-preview -# (up | down | list) and ryan's deploy key runs -# site-deploy (deploy ). +# each psdeploy key to one forced command: the preview key runs ps-preview +# (up | down | list) and the deploy key runs site-deploy +# (deploy ). set -euo pipefail : "${SSH_USER:?}" "${SSH_KEY:?}" "${SSH_HOST:?}" "${SSH_KNOWN_HOSTS:?}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a1437f3f..15219894 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -58,7 +58,7 @@ jobs: echo "Deploying ${sha::7} ($(git log -1 --format=%s "$sha")) to $SITE" - name: Deploy env: - SSH_USER: ryan + SSH_USER: psdeploy SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }} SSH_KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }} diff --git a/scripts/site-deploy-ssh.sh b/scripts/site-deploy-ssh.sh new file mode 100755 index 00000000..e483dc03 --- /dev/null +++ b/scripts/site-deploy-ssh.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Forced command for the Deploy workflow's key on psdeploy. Installed as root's +# /usr/local/sbin/site-deploy-ssh; sudoers lets psdeploy run site-deploy as tfmc: +# psdeploy ALL=(tfmc) NOPASSWD: /usr/local/sbin/site-deploy +set -euo pipefail +read -r -a args <<< "${SSH_ORIGINAL_COMMAND:-}" +if [[ ${#args[@]} -ne 3 || ${args[0]} != deploy ]]; then + echo "usage: deploy " >&2 + exit 2 +fi +exec sudo -n -u tfmc /usr/local/sbin/site-deploy "${args[@]}" diff --git a/scripts/site-deploy.sh b/scripts/site-deploy.sh index 6eaf8517..4ffefa40 100755 --- a/scripts/site-deploy.sh +++ b/scripts/site-deploy.sh @@ -1,11 +1,11 @@ #!/bin/bash # Deploy a commit on main to www.tfminecraft.net or dev.tfminecraft.net. # -# Installed on the TF server as ryan's ~/bin/site-deploy (copy it there after -# changing it; never run it from a checkout it updates). The Deploy workflow's -# key may run only this script: -# restrict,command="/home/ryan/bin/site-deploy" ssh-ed25519 ... github-site-deploy -# By hand: site-deploy deploy +# Installed on the TF server as root's /usr/local/sbin/site-deploy and run as +# tfmc, which owns both checkouts. The Deploy workflow's key logs in as +# psdeploy, pinned to site-deploy-ssh, which runs this through sudo: +# restrict,command="/usr/local/sbin/site-deploy-ssh" ssh-ed25519 ... github-site-deploy +# By hand: sudo -u tfmc site-deploy deploy # # Exit codes: 0 deployed or already deployed, 2 bad request, 4 commit not on # main, 5 site did not come up and the previous code was put back, 1 other. @@ -16,24 +16,26 @@ usage() { exit 2 } -read -r -a args <<< "${SSH_ORIGINAL_COMMAND:-$*}" -[[ ${#args[@]} -eq 3 && ${args[0]} == deploy ]] || usage -site=${args[1]} -sha=${args[2]} +[[ $# -eq 3 && $1 == deploy ]] || usage +site=$2 +sha=$3 [[ $sha =~ ^[0-9a-f]{40}$ ]] || usage case "$site" in - www) target=main repo=$HOME/work/provincesystem data=$HOME/work/provincesystem-data front=3000 back=8000 ;; - dev) target=dev repo=$HOME/work/provincesystem-dev data=$HOME/work/provincesystem-dev-data front=13001 back=18001 ;; + www) target=main repo=/home/tfmc/ProvinceSystem data=/home/tfmc/provincesystem-data front=3000 back=8000 ;; + dev) target=dev repo=/home/tfmc/tfmc-staging data=/home/tfmc/provincesystem-dev-data front=13001 back=18001 ;; *) usage ;; esac +[[ $(id -un) == tfmc ]] || { echo "run as tfmc: sudo -u tfmc $0 deploy $site $sha" >&2; exit 2; } +umask 022 +state=/home/tfmc -exec 9> "$HOME/.site-deploy-$site.lock" +exec 9> "$state/.site-deploy-$site.lock" flock 9 log() { echo "$*" >&2; } -compose() { sudo -n -u tfmc /usr/local/sbin/ps-compose "$target" "$@"; } -record() { printf '%s %s %s -> %s %s\n' "$(date -u +%FT%TZ)" "$site" "${old:0:7}" "${sha:0:7}" "$*" >> "$HOME/site-deploy.log"; } +compose() { /usr/local/sbin/ps-compose "$target" "$@"; } +record() { printf '%s %s %s -> %s %s\n' "$(date -u +%FT%TZ)" "$site" "${old:0:7}" "${sha:0:7}" "$*" >> "$state/site-deploy.log"; } cd "$repo" git fetch -q origin @@ -58,8 +60,8 @@ services=() grep -qE '^(frontend|shared)/|^docker-compose' <<< "$changed" && services+=(frontend) grep -qE '^(backend|shared|frontend/lib/skins)/|^docker-compose' <<< "$changed" && services+=(backend) -# A consistent copy needs SQLite's backup API inside the container; the ~/work -# mounts don't pass file locks through. +# SQLite's backup API inside the container keeps the copy consistent while the +# site writes. log "backing up province.db" compose exec -T backend python3 - <<'PYTHON' import sqlite3 @@ -81,16 +83,6 @@ checkout() { # Keeps dev's local edits; refuses if they would be overwritten. git checkout -q --detach "$1" || return 1 fi - # Git writes new files here as 660, which the containers cannot read. - git diff --name-only -z "$old" "$sha" | while IFS= read -r -d '' file; do - [[ -e $file ]] || continue - chmod a+r "$file" - dir=$(dirname "$file") - while [[ $dir != . ]]; do - chmod a+rx "$dir" - dir=$(dirname "$dir") - done - done } healthy() { From 20f0720406d6ca8eb453a6d706549ffdc5109ba2 Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:20:20 +0000 Subject: [PATCH 2/2] fix(deploy): validate deploy arguments before sudo and set HOME Co-Authored-By: Claude Opus 5.5 --- scripts/site-deploy-ssh.sh | 4 ++-- scripts/site-deploy.sh | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/site-deploy-ssh.sh b/scripts/site-deploy-ssh.sh index e483dc03..e23bafc2 100755 --- a/scripts/site-deploy-ssh.sh +++ b/scripts/site-deploy-ssh.sh @@ -4,8 +4,8 @@ # psdeploy ALL=(tfmc) NOPASSWD: /usr/local/sbin/site-deploy set -euo pipefail read -r -a args <<< "${SSH_ORIGINAL_COMMAND:-}" -if [[ ${#args[@]} -ne 3 || ${args[0]} != deploy ]]; then +if [[ ${#args[@]} -ne 3 || ${args[0]} != deploy || ! ${args[1]} =~ ^(www|dev)$ || ! ${args[2]} =~ ^[0-9a-f]{40}$ ]]; then echo "usage: deploy " >&2 exit 2 fi -exec sudo -n -u tfmc /usr/local/sbin/site-deploy "${args[@]}" +exec sudo -H -n -u tfmc /usr/local/sbin/site-deploy "${args[@]}" diff --git a/scripts/site-deploy.sh b/scripts/site-deploy.sh index 4ffefa40..085e6ae3 100755 --- a/scripts/site-deploy.sh +++ b/scripts/site-deploy.sh @@ -28,7 +28,8 @@ case "$site" in esac [[ $(id -un) == tfmc ]] || { echo "run as tfmc: sudo -u tfmc $0 deploy $site $sha" >&2; exit 2; } umask 022 -state=/home/tfmc +export HOME=/home/tfmc +state=$HOME exec 9> "$state/.site-deploy-$site.lock" flock 9