mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-10-20 18:56:16 +00:00
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:
1 parent
b09af9bfbd
commit
3ba40d4e52
10 files changed
+497
-241
No files matched your search
@@ -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
|
||||
```
|
||||
Executable
+76
@@ -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
|
||||
Executable
+18
@@ -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"
|
||||
Executable
+58
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+47
@@ -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")"
|
||||
Executable
+74
@@ -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 ]
|
||||
Reference in new issue
Block a user