Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 22 additions & 48 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <binary>` (stdin) and `deploy <branch>`.
# 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:
Expand Down Expand Up @@ -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 ;;
Expand Down Expand Up @@ -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:
Expand All @@ -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=<base64> 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()
Expand Down
96 changes: 96 additions & 0 deletions deploy/ci-deploy.sh
Original file line number Diff line number Diff line change
@@ -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 <binary> read one prebuilt binary (agentbbs | ascii-live) from
# stdin into a root-only staging dir; must be an ELF file
# deploy <branch> branch is main or master. stdin carries the app
# secrets as NAME=<base64> lines (allowlisted names only).
# Syncs /opt/agentbbs to origin/<branch>, 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 <binary> | deploy <branch>)"

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=<base64 value> 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 <binary> | deploy <branch>)"
;;
esac
5 changes: 4 additions & 1 deletion scripts/self-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 6 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading