Deploy over pinned, single-purpose keys from GitHub-hosted runners (#51)

- ops: deploy scripts the CI keys are pinned to
- ci: deploy over pinned, single-purpose keys from GitHub-hosted runners
- ops: tell shellcheck the test's variables are read through eval
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-10-09 04:11:51 +04:00
1 parent b09af9bfbd
commit 3ba40d4e52
10 files changed
+497 -241

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
# Deploy keys and the scripts they are pinned to
CI reaches the hosts with four SSH keys, one per target. None of them opens a
shell. Each is pinned in `authorized_keys` to one script from this directory
(`restrict,command=`), so what CI can do on a host is exactly what that script
does, with the input it validates:
| target | host | user | key secret | forced command | input |
|---|---|---|---|---|---|
| app.pezkuwichain.io | NEW-7 `89.117.59.63` | `app-deploy` | `PWAP_APP_DEPLOY_KEY` | `pwap-web-receive app` | site tar on stdin |
| pex.mom | VPS3 `217.77.6.126` | `pex-deploy` | `PWAP_PEX_DEPLOY_KEY` | `pwap-web-receive pex` | site tar on stdin |
| indexer | NEW-7 `89.117.59.63` | `indexer-deploy` | `PWAP_INDEXER_DEPLOY_KEY` | `pwap-indexer-ssh` → sudo `pwap-indexer-deploy` | image SHA |
| Supabase | VPS3 `217.77.6.126` | `supabase-deploy` | `PWAP_SUPABASE_DEPLOY_KEY` | `pwap-supabase-ssh` → sudo `pwap-supabase-deploy` | bundle on stdin |
The hosts and their host keys are in the workflow env (`APP_HOST` …,
`DEPLOY_KNOWN_HOSTS`, the same lines as `ops/known_hosts`). A deploy to a host
that presents another key fails.
No key for these hosts belongs in root's `authorized_keys`.
## Installing on a host
Scripts are root-owned and not writable by the deploy users:
```sh
install -o root -g root -m 755 ops/host/pwap-web-receive /usr/local/sbin/
install -o root -g root -m 755 ops/host/pwap-indexer-ssh ops/host/pwap-indexer-deploy /usr/local/sbin/ # NEW-7
install -o root -g root -m 755 ops/host/pwap-supabase-ssh ops/host/pwap-supabase-deploy /usr/local/sbin/ # VPS3
```
Users (system accounts, no password):
```sh
useradd --system --create-home --shell /bin/bash app-deploy # NEW-7
useradd --system --create-home --shell /bin/bash indexer-deploy # NEW-7
useradd --system --create-home --shell /bin/bash supabase-deploy # VPS3 (pex-deploy already exists)
```
Web roots belong to their deploy user, group `www-data`, setgid:
```sh
chown -R app-deploy:www-data /var/www/subdomains/app # NEW-7
chmod -R g+rwX /var/www/subdomains/app
find /var/www/subdomains/app -type d -exec chmod g+s {} +
```
sudo, for the two deploys that have to run docker as root (`visudo -f`):
```
# /etc/sudoers.d/pwap-indexer-deploy (NEW-7)
indexer-deploy ALL=(root) NOPASSWD: /usr/local/sbin/pwap-indexer-deploy
# /etc/sudoers.d/pwap-supabase-deploy (VPS3)
supabase-deploy ALL=(root) NOPASSWD: /usr/local/sbin/pwap-supabase-deploy
```
The indexer's settings, `/etc/pwap-indexer.env` on NEW-7 (root, 644):
```
WS_ENDPOINT=wss://rpc.pezkuwichain.io
HOST_PORT=3011
```
`authorized_keys` of each deploy user, one line:
```
restrict,command="/usr/local/sbin/pwap-web-receive app" ssh-ed25519 AAAA... pwap-ci-app-deploy
```
## Checking it
`ops/host/test-web-receive.sh` runs the web script against a scratch web root,
including everything it must refuse; CI runs it with shellcheck in the
`workflow-guard` job. On a host, a key must fail anything but its command:
```sh
ssh -i key app-deploy@89.117.59.63 id # refused: the only command is deploy
ssh -i key -N -L 1:127.0.0.1:22 app-deploy@89.117.59.63 # refused: restrict
```
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
# Runs the pwap indexer container at a given image SHA, health-checked, and
# rolls back to the previous SHA if the new one does not come up. Installed
# root-owned at /usr/local/sbin/pwap-indexer-deploy; reached only through
# pwap-indexer-ssh (sudoers: indexer-deploy may run this, nothing else).
#
# Host settings live in /etc/pwap-indexer.env, not in CI:
# WS_ENDPOINT=wss://rpc.pezkuwichain.io
# HOST_PORT=3011
set -euo pipefail
SHA="${1:-}"
if ! [[ "$SHA" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "pwap-indexer-deploy: expected an image SHA" >&2
exit 2
fi
# shellcheck source=/dev/null
. /etc/pwap-indexer.env
: "${WS_ENDPOINT:?set in /etc/pwap-indexer.env}" "${HOST_PORT:?set in /etc/pwap-indexer.env}"
REGISTRY_IMAGE=ghcr.io/pezkuwichain/pwap-indexer
NAME=pwap-indexer
STATE_DIR=/opt/pwap-indexer
mkdir -p "$STATE_DIR"
PREV="$(cat "$STATE_DIR/.deploy-sha" 2>/dev/null || true)"
echo "Deploying $REGISTRY_IMAGE:$SHA (previous: ${PREV:-none})"
# The image is public; no registry credential is needed or kept here.
docker pull -q "$REGISTRY_IMAGE:$SHA"
run_container () {
docker rm -f "$NAME" >/dev/null 2>&1 || true
# pwap-indexer-db holds the sqlite database; it is never removed here.
# The port is published on loopback only: nginx is the public side.
docker run -d --name "$NAME" --restart unless-stopped \
-p "127.0.0.1:${HOST_PORT}:3001" \
-v pwap-indexer-db:/data \
-e DB_PATH=/data/transactions.db \
-e PORT=3001 \
-e WS_ENDPOINT="$WS_ENDPOINT" \
"$1" >/dev/null
}
healthy () {
for i in $(seq 1 12); do
if curl -fsS --max-time 5 "http://127.0.0.1:${HOST_PORT}/health" >/dev/null 2>&1; then
return 0
fi
echo "health attempt $i/12 failed, retry in 5s"
sleep 5
done
return 1
}
run_container "$REGISTRY_IMAGE:$SHA"
if healthy; then
echo "$SHA" > "$STATE_DIR/.deploy-sha"
docker image prune -f >/dev/null 2>&1 || true
echo "Backend healthy on $SHA"
exit 0
fi
echo "Health check failed for $SHA"
if [ -n "$PREV" ]; then
echo "Rolling back to $PREV"
docker pull -q "$REGISTRY_IMAGE:$PREV"
run_container "$REGISTRY_IMAGE:$PREV"
if healthy; then
echo "Rolled back to $PREV; backend healthy"
else
echo "Rollback to $PREV also failed"
fi
fi
# The new SHA did not deploy, whatever the rollback did.
exit 1
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Forced command for the pwap indexer deploy key, installed root-owned at
# /usr/local/sbin/pwap-indexer-ssh:
#
# restrict,command="/usr/local/sbin/pwap-indexer-ssh" ssh-ed25519 ...
#
# CI runs `ssh indexer-deploy@<host> <sha>`. The SHA is the only input; the
# deploy itself is pwap-indexer-deploy, which sudo lets this user run and
# nothing else.
set -euo pipefail
SHA="${SSH_ORIGINAL_COMMAND:-}"
if ! [[ "$SHA" =~ ^[0-9a-f]{7,40}$ ]]; then
echo "pwap-indexer-ssh: expected an image SHA" >&2
exit 2
fi
exec sudo -n /usr/local/sbin/pwap-indexer-deploy "$SHA"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Deploys pwap's Supabase edge functions and migrations from a bundle on
# stdin (functions/, migrations/, deploy/ from web/supabase), then checks
# that the fund-custody guards are in force. Installed root-owned at
# /usr/local/sbin/pwap-supabase-deploy; reached only through pwap-supabase-ssh
# (sudoers: supabase-deploy may run this, nothing else).
set -euo pipefail
BASE=$(mktemp -d /opt/supabase-self-hosted/deploy-staging.XXXXXX)
trap 'rm -rf "$BASE"' EXIT
STAGING="$BASE/tree"
DB=supabase-db
echo "── 0. Unpack bundle ──"
mkdir -p "$STAGING"
tar -xzf - -C "$STAGING" --no-same-owner
for d in functions migrations deploy; do
[ -d "$STAGING/$d" ] || { echo "bundle has no $d/" >&2; exit 1; }
done
echo "── 1. Sync edge functions (through the ownership gate) ──"
supabase-deploy-functions --project pwap-web --src "$STAGING/functions"
echo "── 2. Pre-flight: required edge-runtime secrets ──"
EF_ENV="$(docker exec supabase-edge-functions env 2>/dev/null || true)"
miss=""
for k in SUPABASE_URL SUPABASE_SERVICE_ROLE_KEY PLATFORM_PRIVATE_KEY; do
grep -q "^$k=" <<<"$EF_ENV" || miss="$miss $k"
done
if [ -n "$miss" ]; then
echo "::warning::edge-runtime missing secrets:$miss (fund functions may fail until set)"
fi
echo "── 3. Apply pending migrations (transactional, tracked) ──"
bash "$STAGING/deploy/apply-migrations.sh" "$STAGING/migrations" "$DB"
echo "── 4. Verify the fund-custody guards actually took effect ──"
q() { docker exec -i "$DB" psql -U postgres -tAq -c "$1"; }
fail=0
for fn in release_escrow_internal lock_escrow_internal refund_escrow_internal request_withdraw; do
oid="$(q "SELECT oid FROM pg_proc WHERE proname='$fn' LIMIT 1;")"
if [ -n "$oid" ]; then
priv="$(q "SELECT has_function_privilege('anon', $oid, 'EXECUTE');")"
echo " anon EXECUTE $fn = $priv"
[ "$priv" = "f" ] || { echo "::error::anon still has EXECUTE on $fn"; fail=1; }
fi
done
trg="$(q "SELECT tgname FROM pg_trigger WHERE tgname IN ('trg_freeze_trade_financials','trg_freeze_offer_financials');")"
n_trg="$(printf '%s' "$trg" | grep -c . || true)"
echo " freeze triggers present: ${trg:-<none>} (count=$n_trg)"
[ "$n_trg" = "2" ] || { echo "::error::trade/offer freeze triggers missing (expected 2, got $n_trg)"; fail=1; }
if [ "$fail" = "0" ]; then
echo "Supabase deploy verified"
else
echo "post-deploy verification failed"
exit 1
fi
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Forced command for the pwap Supabase deploy key, installed root-owned at
# /usr/local/sbin/pwap-supabase-ssh:
#
# restrict,command="/usr/local/sbin/pwap-supabase-ssh" ssh-ed25519 ...
#
# CI runs `ssh supabase-deploy@<host> deploy < supabase-deploy.tgz`. The
# bundle on stdin is the only input; pwap-supabase-deploy, which sudo lets
# this user run and nothing else, does the work.
set -euo pipefail
if [ "${SSH_ORIGINAL_COMMAND:-}" != "deploy" ]; then
echo "pwap-supabase-ssh: the only command is 'deploy', with the bundle on stdin" >&2
exit 2
fi
exec sudo -n /usr/local/sbin/pwap-supabase-deploy
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Forced command for the pwap web deploy keys. Installed root-owned at
# /usr/local/sbin/pwap-web-receive; authorized_keys pins each key to one site:
#
# restrict,command="/usr/local/sbin/pwap-web-receive app" ssh-ed25519 ...
#
# CI runs `ssh <user>@<host> deploy < site.tar`. The key can do nothing else:
# no shell, no other command, no forwarding, and only the one web root.
set -euo pipefail
case "${1:-}" in
app) TARGET=/var/www/subdomains/app ;;
pex) TARGET=/var/www/pex.mom ;;
*) echo "pwap-web-receive: unknown site '${1:-}'" >&2; exit 2 ;;
esac
if [ "${SSH_ORIGINAL_COMMAND:-}" != "deploy" ]; then
echo "pwap-web-receive: the only command is 'deploy', with the site tar on stdin" >&2
exit 2
fi
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
# GNU tar drops leading slashes and refuses members containing '..'.
tar -xf - -C "$STAGE" --no-same-owner --no-same-permissions
if [ ! -f "$STAGE/index.html" ] || [ ! -d "$STAGE/assets" ] || [ ! -f "$STAGE/.deploy-sha" ]; then
echo "pwap-web-receive: not a site build (index.html, assets/ and .deploy-sha are required)" >&2
exit 1
fi
if ! grep -qxE '[0-9a-f]{7,40}' "$STAGE/.deploy-sha"; then
echo "pwap-web-receive: .deploy-sha is not a commit SHA" >&2
exit 1
fi
# -rt copies regular files and directories only: a symlink in the upload is
# skipped rather than placed in the web root. The modes are set explicitly,
# because mktemp's 0700 must never reach a directory nginx has to read.
# Assets go first and index.html last, so a browser that loads the new index
# finds every chunk it names. Nothing is deleted: tabs still open on the
# previous build keep loading their chunks.
rsync -rt --perms --chmod=D2775,F664 --exclude=/index.html --exclude=/.deploy-sha "$STAGE/" "$TARGET/"
install -m 664 "$STAGE/index.html" "$TARGET/index.html"
install -m 664 "$STAGE/.deploy-sha" "$TARGET/.deploy-sha"
echo "pwap-web-receive: $1 now serves $(cat "$TARGET/.deploy-sha")"
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# The rc/before variables are read inside the check expressions, which
# check() runs with eval; shellcheck cannot see into those strings.
# shellcheck disable=SC2034
# Exercises pwap-web-receive against a scratch web root: what it installs,
# with which modes, and everything it must refuse. The script under test is
# copied with its web roots pointed at the scratch directory; nothing else
# about it changes.
set -uo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
T=$(mktemp -d)
trap 'rm -rf "$T"' EXIT
mkdir -p "$T/app" "$T/pex" "$T/tmp/stage"
export TMPDIR="$T/tmp/stage" # stage = $T/tmp/stage/tmp.X, so ../../.. is $T
sed -e "s#/var/www/subdomains/app#$T/app#" -e "s#/var/www/pex.mom#$T/pex#" \
"$HERE/pwap-web-receive" > "$T/receive"
chmod +x "$T/receive"
pass=0; fail=0
check () { if eval "$2"; then echo "ok $1"; pass=$((pass+1)); else echo "FAIL $1"; fail=$((fail+1)); fi; }
site () { # site <dir> <sha> [index]
mkdir -p "$1/assets"
echo "${3:-<html>new</html>}" > "$1/index.html"
echo "console.log(1)" > "$1/assets/index-NEW.js"
echo "$2" > "$1/.deploy-sha"
}
run () { # run <site-arg> <ssh-command> <tar-dir>
( cd "$3" && tar -cf - . ) | SSH_ORIGINAL_COMMAND="$2" "$T/receive" "$1" >/dev/null 2>&1
}
# an earlier build is live, with a chunk the new one no longer has
mkdir -p "$T/app/assets"; echo old > "$T/app/assets/index-OLD.js"; echo "<html>old</html>" > "$T/app/index.html"
site "$T/s1" b09af9bfbd02e477749d81611bb91ecabf6b0105
run app deploy "$T/s1"; rc=$?
check "a valid build installs" '[ $rc = 0 ] && grep -q new "$T/app/index.html"'
check "it is the build that was sent" 'cmp -s "$T/s1/assets/index-NEW.js" "$T/app/assets/index-NEW.js"'
check "the SHA is recorded" 'grep -qx b09af9bfbd02e477749d81611bb91ecabf6b0105 "$T/app/.deploy-sha"'
check "chunks of the previous build stay" '[ -f "$T/app/assets/index-OLD.js" ]'
check "files are world-readable (664)" '[ "$(stat -c %a "$T/app/index.html")" = 664 ] && [ "$(stat -c %a "$T/app/assets/index-NEW.js")" = 664 ]'
check "directories are traversable (2775)" '[ "$(stat -c %a "$T/app/assets")" = 2775 ]'
check "the other site is untouched" '[ -z "$(ls -A "$T/pex")" ]'
before=$(cat "$T/app/index.html")
mkdir -p "$T/s2/assets"; echo x > "$T/s2/assets/a.js"; echo abc1234 > "$T/s2/.deploy-sha"
run app deploy "$T/s2"; rc=$?
check "a tar without index.html is refused" '[ $rc != 0 ] && [ "$(cat "$T/app/index.html")" = "$before" ]'
site "$T/s3" 'not-a-sha; rm -rf /'
run app deploy "$T/s3"; rc=$?
check "a .deploy-sha that is not a SHA is refused" '[ $rc != 0 ] && [ "$(cat "$T/app/index.html")" = "$before" ]'
site "$T/s4" abc1234
run app 'deploy; id' "$T/s4"; rc1=$?
run app '' "$T/s4"; rc2=$?
check "any command but deploy is refused" '[ $rc1 = 2 ] && [ $rc2 = 2 ] && [ "$(cat "$T/app/index.html")" = "$before" ]'
run other deploy "$T/s4"; rc=$?
check "an unknown site is refused" '[ $rc = 2 ]'
site "$T/s5" abc1234
ln -s /etc/passwd "$T/s5/assets/passwd.js"
run app deploy "$T/s5"; rc=$?
check "a symlink in the upload is not installed" '[ $rc = 0 ] && [ ! -e "$T/app/assets/passwd.js" ] && [ ! -L "$T/app/assets/passwd.js" ]'
site "$T/s6" abc1234
( cd "$T/s6" && tar -cf "$T/evil.tar" . && tar -rf "$T/evil.tar" --transform 's#^#../../../#' index.html ) 2>/dev/null
SSH_ORIGINAL_COMMAND=deploy "$T/receive" app < "$T/evil.tar" >/dev/null 2>&1
check "a ../ member cannot write outside the stage" '[ ! -e "$T/index.html" ] && [ ! -e "$T/tmp/index.html" ]'
echo "RESULT: $pass passed, $fail failed"
[ "$fail" = 0 ]