From 2d21feba2468a5ff661bbfbde0d91a6054d8a399 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 09:20:11 +0000 Subject: [PATCH] ci(deploy): pin the deploy key to one forced command; self-update runs the new setup.sh The 10-27 incident showed what an unrestricted root deploy key is worth to an attacker. The CI key is now installed on the box as restrict,command="/usr/local/sbin/agentbbs-ci-deploy", so it gets no shell, scp, sftp, pty or forwarding. - deploy/ci-deploy.sh (installed by setup.sh): verbs `arch`, `upload agentbbs|ascii-live` (ELF on stdin, size-capped, root-only staging dir) and `deploy main|master` (app secrets as NAME= on stdin, allowlisted). It does what the old inline heredoc did: sync to origin, pause the self-update timer, install binaries, exec setup.sh SKIP_BUILD=1. - deploy.yml: uses those verbs instead of scp + `sudo bash -s`. - self-update.sh: reset to origin before exec'ing setup.sh, so a setup.sh change takes effect on the first timer run instead of the second. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 70 +++++++++----------------- deploy/ci-deploy.sh | 96 ++++++++++++++++++++++++++++++++++++ scripts/self-update.sh | 5 +- setup.sh | 6 +++ 4 files changed, 128 insertions(+), 49 deletions(-) create mode 100755 deploy/ci-deploy.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1b508ac..57a541f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,14 +15,16 @@ name: deploy # 16GB runner removes that failure mode entirely. # # Required repo secrets (Settings -> Secrets and variables -> Actions): -# DEPLOY_SSH_KEY private key whose public half is in the droplet admin -# user's ~/.ssh/authorized_keys +# DEPLOY_SSH_KEY private key whose public half is in root's authorized_keys +# pinned to ONE forced command: +# restrict,command="/usr/local/sbin/agentbbs-ci-deploy" ssh-ed25519 ... +# (deploy/ci-deploy.sh, installed by setup.sh). The key gets +# no shell, scp, sftp, pty or forwarding; it can only run +# `arch`, `upload ` (stdin) and `deploy `. # DEPLOY_HOST bbs.profullstack.com (or the droplet's public IP) # Optional (have sensible defaults below): # DEPLOY_USER admin SSH user (default: root) # DEPLOY_PORT admin SSH port (default: 2202 — setup.sh moves OpenSSH here) -# -# The admin user needs passwordless sudo (root already does). on: push: @@ -62,7 +64,7 @@ jobs: run: | uname_m="$(ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ - "${DEPLOY_USER}@${DEPLOY_HOST}" 'uname -m')" + "${DEPLOY_USER}@${DEPLOY_HOST}" arch)" case "$uname_m" in x86_64|amd64) goarch=amd64 ;; aarch64|arm64) goarch=arm64 ;; @@ -93,13 +95,12 @@ jobs: DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }} run: | - # scp can only name one remote target; copy each binary explicitly. - scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ - -o BatchMode=yes -o StrictHostKeyChecking=yes \ - dist/agentbbs "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-agentbbs" - scp -i ~/.ssh/id_deploy -P "$DEPLOY_PORT" \ - -o BatchMode=yes -o StrictHostKeyChecking=yes \ - dist/ascii-live "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/agentbbs-deploy-ascii-live" + # The forced command reads each binary from stdin (no scp/sftp). + for b in agentbbs ascii-live; do + ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "${DEPLOY_USER}@${DEPLOY_HOST}" "upload $b" < "dist/$b" + done - name: Provision / redeploy (idempotent, SKIP_BUILD=1) env: @@ -115,43 +116,16 @@ jobs: COINPAY_MERCHANT_ID: ${{ secrets.COINPAY_MERCHANT_ID }} AGENTBBS_QRYPT_ISSUER_KEY: ${{ secrets.AGENTBBS_QRYPT_ISSUER_KEY }} run: | - ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ + # The forced command (deploy/ci-deploy.sh) syncs /opt/agentbbs to + # origin/$DEPLOY_BRANCH, installs the uploaded binaries, pauses the + # self-update timer and execs setup.sh with SKIP_BUILD=1. App secrets + # travel on stdin as NAME= lines, never on the command line. + case "$DEPLOY_BRANCH" in main|master) ;; *) echo "::error::refusing to deploy $DEPLOY_BRANCH"; exit 1 ;; esac + for k in COINPAY_API_KEY COINPAY_MERCHANT_ID AGENTBBS_QRYPT_ISSUER_KEY; do + printf '%s=%s\n' "$k" "$(printf '%s' "${!k}" | base64 -w0)" + done | ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \ -o BatchMode=yes -o StrictHostKeyChecking=yes \ - "${DEPLOY_USER}@${DEPLOY_HOST}" \ - "sudo -n env BRANCH=$(printf %q "$DEPLOY_BRANCH") \ - COINPAY_API_KEY=$(printf %q "$COINPAY_API_KEY") \ - COINPAY_MERCHANT_ID=$(printf %q "$COINPAY_MERCHANT_ID") \ - AGENTBBS_QRYPT_ISSUER_KEY=$(printf %q "$AGENTBBS_QRYPT_ISSUER_KEY") \ - bash -s" <<'REMOTE' - set -euo pipefail - REPO=https://github.com/profullstack/agentbbs.git - BRANCH="${BRANCH:-main}" - SRC=/opt/agentbbs - # Bootstrap on a fresh box, then always sync to origin so we run the - # latest setup.sh (it may have changed in this very push). - if [ ! -d "$SRC/.git" ]; then - git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC" - fi - git -C "$SRC" fetch --depth 1 origin "$BRANCH" - git -C "$SRC" reset --hard "origin/$BRANCH" - # Preempt the self-update timer. It redeploys from source (no - # SKIP_BUILD) and can hold setup.sh's flock for >5min while compiling - # on this tiny droplet, which would starve and time out the deploy - # below. This CI push is authoritative (prebuilt binaries + reset to - # the exact commit), so stop any in-flight timer run to release the - # lock and pause the timer so it can't re-fire mid-deploy; setup.sh - # re-enables the timer at the end of its run. - systemctl stop agentbbs-update.timer agentbbs-update.service 2>/dev/null || true - # Install the runner-built binaries, then tell setup.sh not to compile. - install -m 0755 /tmp/agentbbs-deploy-agentbbs /usr/local/bin/agentbbs - install -m 0755 /tmp/agentbbs-deploy-ascii-live /usr/local/bin/ascii-live - rm -f /tmp/agentbbs-deploy-agentbbs /tmp/agentbbs-deploy-ascii-live - exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ - COINPAY_API_KEY="${COINPAY_API_KEY:-}" \ - COINPAY_MERCHANT_ID="${COINPAY_MERCHANT_ID:-}" \ - AGENTBBS_QRYPT_ISSUER_KEY="${AGENTBBS_QRYPT_ISSUER_KEY:-}" \ - "$SRC/setup.sh" - REMOTE + "${DEPLOY_USER}@${DEPLOY_HOST}" "deploy $DEPLOY_BRANCH" - name: Smoke-test that agentbbs serves :22 if: success() diff --git a/deploy/ci-deploy.sh b/deploy/ci-deploy.sh new file mode 100755 index 0000000..5001db6 --- /dev/null +++ b/deploy/ci-deploy.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# ci-deploy.sh: the ONLY thing the GitHub Actions deploy key may run on the box. +# +# Installed by setup.sh as /usr/local/sbin/agentbbs-ci-deploy and pinned in +# root's authorized_keys as +# restrict,command="/usr/local/sbin/agentbbs-ci-deploy" ssh-ed25519 ... github-actions-deploy@agentbbs-... +# so the key gets no shell, pty, forwarding, scp or sftp. The verb comes from +# SSH_ORIGINAL_COMMAND and must be one of: +# +# arch print `uname -m` +# upload read one prebuilt binary (agentbbs | ascii-live) from +# stdin into a root-only staging dir; must be an ELF file +# deploy branch is main or master. stdin carries the app +# secrets as NAME= lines (allowlisted names only). +# Syncs /opt/agentbbs to origin/, installs the +# staged binaries and execs setup.sh with SKIP_BUILD=1. +# +# Nothing in the command line is ever evaluated by a shell. +set -euo pipefail +umask 077 + +REPO=https://github.com/profullstack/agentbbs.git +SRC=/opt/agentbbs +STAGE=/var/lib/agentbbs-deploy +MAX_BINARY=$((256 * 1024 * 1024)) +SECRET_NAMES="COINPAY_API_KEY COINPAY_MERCHANT_ID AGENTBBS_QRYPT_ISSUER_KEY" + +die() { echo "agentbbs-ci-deploy: $*" >&2; exit 2; } + +read -r -a argv <<<"${SSH_ORIGINAL_COMMAND:-}" +[ "${#argv[@]}" -ge 1 ] || die "no command (expected: arch | upload | deploy )" + +case "${argv[0]}" in + arch) + [ "${#argv[@]}" -eq 1 ] || die "arch takes no arguments" + exec uname -m + ;; + + upload) + [ "${#argv[@]}" -eq 2 ] || die "usage: upload agentbbs|ascii-live" + case "${argv[1]}" in agentbbs|ascii-live) ;; *) die "unknown binary '${argv[1]}'" ;; esac + install -d -m 0700 "$STAGE" + tmp="$(mktemp "$STAGE/.upload.XXXXXX")" + trap 'rm -f "$tmp"' EXIT + head -c "$((MAX_BINARY + 1))" > "$tmp" + size="$(stat -c %s "$tmp")" + [ "$size" -gt 0 ] || die "empty upload" + [ "$size" -le "$MAX_BINARY" ] || die "upload larger than $MAX_BINARY bytes" + [ "$(head -c 4 "$tmp" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] || die "upload is not an ELF binary" + mv -f "$tmp" "$STAGE/${argv[1]}" + trap - EXIT + echo "agentbbs-ci-deploy: staged ${argv[1]} ($size bytes)" + ;; + + deploy) + [ "${#argv[@]}" -eq 2 ] || die "usage: deploy main|master" + BRANCH="${argv[1]}" + case "$BRANCH" in main|master) ;; *) die "refusing to deploy branch '$BRANCH'" ;; esac + for b in agentbbs ascii-live; do + [ -s "$STAGE/$b" ] || die "no staged $b binary; upload it first" + done + + # App secrets: NAME= per line, allowlisted names only, 64 KiB cap. + declare -A secret=() + while IFS='=' read -r name value; do + [ -n "$name" ] || continue + case " $SECRET_NAMES " in *" $name "*) ;; *) die "unexpected secret name '$name'" ;; esac + secret[$name]="$(printf '%s' "$value" | base64 -d 2>/dev/null)" || die "secret $name is not base64" + done < <(head -c 65536) + + if [ ! -d "$SRC/.git" ]; then + git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC" + fi + git -C "$SRC" fetch --depth 1 origin "$BRANCH" + git -C "$SRC" reset --hard "origin/$BRANCH" + echo "agentbbs-ci-deploy: at $(git -C "$SRC" log --oneline -1)" + + # Pause the self-update timer: it rebuilds from source and can hold + # setup.sh's flock for minutes. setup.sh re-enables it at the end. + systemctl stop agentbbs-update.timer agentbbs-update.service 2>/dev/null || true + install -m 0755 "$STAGE/agentbbs" /usr/local/bin/agentbbs + install -m 0755 "$STAGE/ascii-live" /usr/local/bin/ascii-live + rm -f "$STAGE/agentbbs" "$STAGE/ascii-live" + + exec env BRANCH="$BRANCH" SKIP_BUILD=1 \ + COINPAY_API_KEY="${secret[COINPAY_API_KEY]:-}" \ + COINPAY_MERCHANT_ID="${secret[COINPAY_MERCHANT_ID]:-}" \ + AGENTBBS_QRYPT_ISSUER_KEY="${secret[AGENTBBS_QRYPT_ISSUER_KEY]:-}" \ + "$SRC/setup.sh" + ;; + + *) + die "unknown command '${argv[0]}' (expected: arch | upload | deploy )" + ;; +esac diff --git a/scripts/self-update.sh b/scripts/self-update.sh index 35a6cfb..d51ffc2 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -39,5 +39,8 @@ if [ "$FORCE" -eq 0 ] \ fi echo "redeploying: ${local_rev:0:12} -> ${remote_rev:0:12} (force=$FORCE)" -# setup.sh does the reset --hard, rebuild, and restart under its own lock. +# Move the checkout to origin first so the NEW setup.sh runs. Exec'ing the +# on-disk copy before its own reset ran the previous commit's setup.sh, so a +# setup.sh change needed two timer runs to take effect. +git -C "$SRC_DIR" reset --hard "origin/${BRANCH}" exec "$SRC_DIR/setup.sh" diff --git a/setup.sh b/setup.sh index 80bae6d..288537a 100755 --- a/setup.sh +++ b/setup.sh @@ -642,6 +642,12 @@ else systemctl daemon-reload fi +# ---- 7c. CI deploy forced command -------------------------------------------- +# root's authorized_keys pins the GitHub Actions deploy key to this one script +# (restrict,command="/usr/local/sbin/agentbbs-ci-deploy"); keep it in sync with +# the repo so the CI verbs (arch / upload / deploy) always match deploy.yml. +install -m 0755 "$SRC_DIR/deploy/ci-deploy.sh" /usr/local/sbin/agentbbs-ci-deploy + # ---- 8. move admin OpenSSH to ADMIN_SSH_PORT (before agentbbs takes :22) ----- log "moving admin OpenSSH to :${ADMIN_SSH_PORT}" install -d -m 0755 /etc/ssh/sshd_config.d