From f6d53f51b1e302f0c1cf630351adf2912a32a9d1 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 9 Oct 2026 06:02:06 +0400 Subject: [PATCH] ci: deploy over pinned, single-purpose keys; root installs the gate The deploy reached VPS3 as root with one key, handed to appleboy actions referenced by tag, and on every run it reinstalled the edge-function ownership gate and its registry as root. That gate guards every project's functions in the shared volume, so any change merged here could rewrite the rule that protects pwap's functions. Now CI connects as miniapp-deploy with two keys, each pinned on the host (authorized_keys restrict,command=) to a script in ops/host: - MINIAPP_SITE_DEPLOY_KEY -> miniapp-site-receive: the site tar on stdin, into /var/www/telegram.pezkiwi.app, nothing else. - MINIAPP_FUNCTIONS_DEPLOY_KEY -> miniapp-functions-ssh: `gate-sha`, or `deploy` with functions.tgz on stdin, run through sudo for exactly those arguments. Root installs the gate and registry from ops/; the deploy checks the host's copies against them and refuses to deploy functions while they differ. The asset cleanup that ran after each deploy (/opt/cleanup-miniapp.sh, only on the host) protected lazy chunks by mtime and so removed live ones when a build was shipped more than two hours after it was made; it took seven pages down for about two minutes on 2026-10-09 while this was being tested, and they were restored from the same tar. The receive script now keeps every file of the new build and removes other assets only after four hours. Its tests cover that (red with the keep rule removed) and the refusals; CI runs them with shellcheck, and apply-repo-settings.sh requires the job. VPS3's host key is pinned. The commented-out VPS1 deploy is gone; that host no longer exists. Every action is pinned to a commit SHA. --- .github/workflows/auto-pr.yml | 2 +- .github/workflows/ci.yml | 29 +++++--- .github/workflows/code-quality.yml | 8 +-- .github/workflows/deploy.yml | 112 ++++++++++------------------- .github/workflows/security.yml | 12 ++-- ops/README.md | 44 ++++++++++-- ops/apply-repo-settings.sh | 6 +- ops/host/miniapp-functions-deploy | 30 ++++++++ ops/host/miniapp-functions-ssh | 17 +++++ ops/host/miniapp-site-receive | 50 +++++++++++++ ops/host/test-site-receive.sh | 57 +++++++++++++++ 11 files changed, 263 insertions(+), 104 deletions(-) create mode 100755 ops/host/miniapp-functions-deploy create mode 100755 ops/host/miniapp-functions-ssh create mode 100755 ops/host/miniapp-site-receive create mode 100755 ops/host/test-site-receive.sh diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml index 2fce8e7..1f6ac30 100644 --- a/.github/workflows/auto-pr.yml +++ b/.github/workflows/auto-pr.yml @@ -12,7 +12,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Create or update PR env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52c7ea9..213e2e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: name: ESLint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -37,8 +37,8 @@ jobs: name: TypeScript runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -49,8 +49,8 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -62,14 +62,25 @@ jobs: runs-on: ubuntu-latest needs: [lint, typecheck, test] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' - run: npm ci - run: npm run build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist/ + + # The scripts the deploy keys are pinned to are the whole of what CI can do + # on the host, so they are checked with every change. + host-scripts: + name: Host deploy scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - run: | + shellcheck -S warning ops/host/miniapp-* ops/host/test-site-receive.sh + bash ops/host/test-site-receive.sh diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 023f6f6..146c5d9 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -14,9 +14,9 @@ jobs: name: Code Complexity runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -35,9 +35,9 @@ jobs: name: Duplicate Code Detection runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e7bd8be..e4041d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -26,9 +26,9 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -37,35 +37,19 @@ jobs: - run: npm run build - # VPS1 (telegram.pezkuwichain.io) is currently running Zagros testnet - deployed manually - # Re-enable this step when testnet period is over - # - name: Deploy to telegram.pezkuwichain.io - # uses: appleboy/scp-action@v1.0.0 - # with: - # host: ${{ secrets.VPS1_HOST }} - # username: ${{ secrets.VPS1_USER }} - # key: ${{ secrets.VPS1_SSH_KEY }} - # source: 'dist/*' - # target: '/var/www/telegram.pezkuwichain.io' - # strip_components: 1 - - name: Deploy to telegram.pezkiwi.app - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'dist/*' - target: '/var/www/telegram.pezkiwi.app' - strip_components: 1 - - - name: Cleanup old assets on VPS - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - script: bash /opt/cleanup-miniapp.sh + env: + DEPLOY_KEY: ${{ secrets.MINIAPP_SITE_DEPLOY_KEY }} + # VPS3's host key, read from the host itself; any other key fails. + KNOWN_HOST: "217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ" + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$KNOWN_HOST" > "$RUNNER_TEMP/known_hosts" + # The key is pinned on the host to ops/host/miniapp-site-receive + # (authorized_keys restrict,command=): the site tar on stdin, then the + # cleanup of earlier deploys' assets; nothing else. + tar -C dist -cf - . | ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 deploy # Edge functions live in a Supabase volume shared with other projects, so they # are written through the ownership gate on the host rather than copied in @@ -77,7 +61,7 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 # telegram-bot and ask are excluded on purpose: both Telegram bots and the # news.pex.mom assistant are served from the cloud project, not from here. @@ -90,51 +74,27 @@ jobs: --exclude='functions/ask' \ functions - - name: Copy to staging on VPS - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'functions.tgz' - target: '/opt/miniapp-deploy-staging' - - # The gate and its registry are versioned here, so the server copy is - # replaced from source control on every deploy rather than edited in - # place. They previously existed only at /usr/local/bin — one copy, no - # history, no review, gone with the server. The mechanism built to stop - # silent drift was itself drifting. - - name: Sync ownership gate to host - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - source: 'ops/supabase-deploy-functions,ops/functions-registry.json' - target: '/opt/miniapp-deploy-staging' - strip_components: 1 - + # The ownership gate and its registry guard every project's functions + # in the shared volume, so this deploy does not install them; root does, + # from ops/ here. What the deploy does is refuse to run when the host's + # copy is not the one in this repository. - name: Deploy through ownership gate - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.VPS2_HOST }} - username: ${{ secrets.VPS2_USER }} - key: ${{ secrets.VPS2_SSH_KEY }} - script: | - set -e - BASE=/opt/miniapp-deploy-staging - - # Install the gate before using it, so a change to the gate ships in - # the same deploy as the functions it validates. - install -m 755 "$BASE/supabase-deploy-functions" /usr/local/bin/supabase-deploy-functions - install -m 644 "$BASE/functions-registry.json" /opt/supabase-self-hosted/functions-registry.json - trap 'rm -rf "$BASE"' EXIT - rm -rf "$BASE/functions" - tar xzf "$BASE/functions.tgz" -C "$BASE" - supabase-deploy-functions \ - --project pezkuwi-telegram-miniapp \ - --src "$BASE/functions" \ - --restart + env: + DEPLOY_KEY: ${{ secrets.MINIAPP_FUNCTIONS_DEPLOY_KEY }} + # VPS3's host key, read from the host itself; any other key fails. + KNOWN_HOST: "217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ" + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$KNOWN_HOST" > "$RUNNER_TEMP/known_hosts" + # The key is pinned on the host to ops/host/miniapp-functions-ssh. + want="$(sha256sum ops/supabase-deploy-functions ops/functions-registry.json | awk '{print $1}')" + have="$(ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 gate-sha)" + if [ "$want" != "$have" ]; then + echo "::error::the host's ownership gate or registry is not the one in ops/; install them as root (ops/host/README.md), then re-run" + exit 1 + fi + ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" miniapp-deploy@217.77.6.126 deploy < functions.tgz # The two Telegram bots and the news.pex.mom assistant run on the cloud # Supabase project, reached by webhook at @@ -147,7 +107,7 @@ jobs: needs: deploy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Deploy telegram-bot and ask env: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c5804b4..755c3e7 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -16,9 +16,9 @@ jobs: name: Dependency Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' cache: 'npm' @@ -41,10 +41,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: fail-on-severity: high deny-licenses: GPL-3.0, AGPL-3.0 @@ -53,12 +53,12 @@ jobs: name: Secret Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: TruffleHog Secret Scan - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2 with: path: ./ base: ${{ github.event.repository.default_branch }} diff --git a/ops/README.md b/ops/README.md index ed4b8d9..5fac4ec 100644 --- a/ops/README.md +++ b/ops/README.md @@ -62,11 +62,45 @@ server where nobody can see the change. ### Deployment -`.github/workflows/deploy.yml` copies both to the host on every deploy, so the -server copy is replaced from version control rather than edited in place. Before -this, the script existed only at `/usr/local/bin/supabase-deploy-functions` — one -copy, no history, no review, and gone with the server. The mechanism built to -stop silent drift was itself drifting. +The gate and its registry guard every project's functions in the shared +volume, so no project's CI installs them; that would let one project rewrite +the rule that protects the others. Root installs them from this directory: + +```bash +install -o root -g root -m 755 ops/supabase-deploy-functions /usr/local/bin/ +install -o root -g root -m 644 ops/functions-registry.json /opt/supabase-self-hosted/ +``` + +They are still versioned here, not edited in place on the server: before +2026-07 the script existed only at `/usr/local/bin` — one copy, no history, no +review. `deploy.yml` checks the host's copies against these files (`gate-sha`) +and refuses to deploy functions while they differ. + +## `host/`: what the deploy keys can do + +CI reaches vps3 as `miniapp-deploy`, with two keys. Neither opens a shell: each +is pinned in `authorized_keys` to one script here (`restrict,command=`). + +| key (secret) | forced command | input | +|---|---|---| +| `MINIAPP_SITE_DEPLOY_KEY` | `miniapp-site-receive` | site tar on stdin → `/var/www/telegram.pezkiwi.app`; removes earlier builds' assets after four hours, never the new build's | +| `MINIAPP_FUNCTIONS_DEPLOY_KEY` | `miniapp-functions-ssh` → sudo `miniapp-functions-deploy` | `gate-sha`, or `deploy` with functions.tgz on stdin | + +Install (root, on vps3): + +```bash +install -o root -g root -m 755 ops/host/miniapp-* /usr/local/sbin/ +useradd --system --create-home --shell /bin/bash miniapp-deploy +chown -R miniapp-deploy:miniapp-deploy /var/www/telegram.pezkiwi.app +# /etc/sudoers.d/miniapp-functions-deploy +miniapp-deploy ALL=(root) NOPASSWD: /usr/local/sbin/miniapp-functions-deploy deploy, /usr/local/sbin/miniapp-functions-deploy gate-sha +# ~miniapp-deploy/.ssh/authorized_keys +restrict,command="/usr/local/sbin/miniapp-site-receive" ssh-ed25519 ... miniapp-ci-site-deploy +restrict,command="/usr/local/sbin/miniapp-functions-ssh" ssh-ed25519 ... miniapp-ci-functions-deploy +``` + +`host/test-site-receive.sh` runs the site script against a scratch web root, +including what it must refuse; CI runs it with shellcheck. ## `apply-repo-settings.sh` diff --git a/ops/apply-repo-settings.sh b/ops/apply-repo-settings.sh index 811fa13..d7ae9d5 100755 --- a/ops/apply-repo-settings.sh +++ b/ops/apply-repo-settings.sh @@ -33,7 +33,7 @@ read -r -d '' PROTECTION <<'JSON' || true { "required_status_checks": { "strict": true, - "contexts": ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] + "contexts": ["Test", "ESLint", "TypeScript", "Build", "Secret Scan", "Host deploy scripts"] }, "enforce_admins": false, "required_pull_request_reviews": { @@ -65,7 +65,7 @@ import json, sys d = json.loads(sys.argv[1]) checks = d.get('required_status_checks') or {} rev = d.get('required_pull_request_reviews') or {} -expected_checks = ["Test", "ESLint", "TypeScript", "Build", "Secret Scan"] +expected_checks = ["Test", "ESLint", "TypeScript", "Build", "Secret Scan", "Host deploy scripts"] want = { 'required checks': (sorted(checks.get('contexts') or []), sorted(expected_checks)), 'strict': (checks.get('strict'), True), @@ -88,5 +88,5 @@ PY fi gh api -X PUT "repos/$REPO/branches/$BRANCH/protection" --input - <<<"$PROTECTION" >/dev/null -ok "protected: 1 approval, 5 required checks, no force push, no deletion" +ok "protected: 1 approval, 6 required checks, no force push, no deletion" echo "✔ done — verify with: $0 --check" diff --git a/ops/host/miniapp-functions-deploy b/ops/host/miniapp-functions-deploy new file mode 100755 index 0000000..733aa74 --- /dev/null +++ b/ops/host/miniapp-functions-deploy @@ -0,0 +1,30 @@ +#!/bin/bash +# Deploys this project's edge functions into the shared Supabase volume, +# through the ownership gate (supabase-deploy-functions), from a functions.tgz +# on stdin. Installed root-owned at /usr/local/sbin/miniapp-functions-deploy; +# reached only through miniapp-functions-ssh. +# +# The gate and its registry guard every project's functions in that volume, +# so CI does not install them: root does, from ops/ in this repository, and +# CI checks with `gate-sha` that the host runs what the repository says. +set -euo pipefail + +GATE=/usr/local/bin/supabase-deploy-functions +REGISTRY=/opt/supabase-self-hosted/functions-registry.json + +case "${1:-}" in + gate-sha) + sha256sum "$GATE" "$REGISTRY" | awk '{print $1}' + ;; + deploy) + BASE=$(mktemp -d /opt/miniapp-deploy-staging.XXXXXX) + trap 'rm -rf "$BASE"' EXIT + tar -xzf - -C "$BASE" --no-same-owner + [ -d "$BASE/functions" ] || { echo "miniapp-functions-deploy: the bundle has no functions/" >&2; exit 1; } + "$GATE" --project pezkuwi-telegram-miniapp --src "$BASE/functions" --restart + ;; + *) + echo "usage: miniapp-functions-deploy gate-sha|deploy" >&2 + exit 2 + ;; +esac diff --git a/ops/host/miniapp-functions-ssh b/ops/host/miniapp-functions-ssh new file mode 100755 index 0000000..e262d48 --- /dev/null +++ b/ops/host/miniapp-functions-ssh @@ -0,0 +1,17 @@ +#!/bin/bash +# Forced command for the miniapp functions deploy key. Installed root-owned +# at /usr/local/sbin/miniapp-functions-ssh: +# +# restrict,command="/usr/local/sbin/miniapp-functions-ssh" ssh-ed25519 ... +# +# ssh miniapp-deploy@ gate-sha # sha256 of the installed gate and registry +# ssh miniapp-deploy@ deploy < functions.tgz +# +# miniapp-functions-deploy, which sudo lets this user run with exactly those +# arguments and nothing else, does the work. +set -euo pipefail + +case "${SSH_ORIGINAL_COMMAND:-}" in + deploy|gate-sha) exec sudo -n /usr/local/sbin/miniapp-functions-deploy "$SSH_ORIGINAL_COMMAND" ;; + *) echo "miniapp-functions-ssh: the commands are 'gate-sha' and 'deploy' (functions.tgz on stdin)" >&2; exit 2 ;; +esac diff --git a/ops/host/miniapp-site-receive b/ops/host/miniapp-site-receive new file mode 100755 index 0000000..d724694 --- /dev/null +++ b/ops/host/miniapp-site-receive @@ -0,0 +1,50 @@ +#!/bin/bash +# Forced command for the miniapp site deploy key. Installed root-owned at +# /usr/local/sbin/miniapp-site-receive; authorized_keys pins the key to it: +# +# restrict,command="/usr/local/sbin/miniapp-site-receive" ssh-ed25519 ... +# +# CI runs `ssh miniapp-deploy@ deploy < site.tar`. The key can do +# nothing else: no shell, no other command, no forwarding, one web root. +set -euo pipefail + +TARGET=/var/www/telegram.pezkiwi.app + +if [ "${SSH_ORIGINAL_COMMAND:-}" != "deploy" ]; then + echo "miniapp-site-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" ]; then + echo "miniapp-site-receive: not a site build (index.html and assets/ are required)" >&2 + exit 1 +fi + +# -rt copies regular files and directories only: a symlink in the upload is +# skipped. --perms with --chmod sets the modes, so mktemp's 0700 never reaches +# a directory nginx reads. Assets first, index.html last. +rsync -rt --perms --chmod=D755,F644 --exclude=/index.html "$STAGE/" "$TARGET/" +install -m 644 "$STAGE/index.html" "$TARGET/index.html" + +# Earlier deploys' assets go once nothing can still ask for them: a file of +# this build is never removed, whatever its timestamp; a file that is not in +# this build stays for four hours, for tabs still open on the previous build. +# (The cleanup this replaces protected lazy chunks by their mtime and so +# removed live ones when a build was shipped more than two hours after it was +# made, which took seven pages down on 2026-10-09.) +removed=0 +while IFS= read -r -d '' f; do + name=${f#"$TARGET/"} + [ -e "$STAGE/$name" ] && continue + if [ -z "$(find "$f" -mmin -240)" ]; then + rm -f "$f" + removed=$((removed + 1)) + fi +done < <(find "$TARGET/assets" -type f -print0) +echo "miniapp-site-receive: deployed; removed $removed asset(s) of earlier builds" diff --git a/ops/host/test-site-receive.sh b/ops/host/test-site-receive.sh new file mode 100755 index 0000000..4d68f1b --- /dev/null +++ b/ops/host/test-site-receive.sh @@ -0,0 +1,57 @@ +#!/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 miniapp-site-receive against a scratch web root: what it +# installs, with which modes, and what it must refuse. The script under test +# is copied with its web root pointed at the scratch directory. +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT +mkdir -p "$T/web" "$T/tmp/stage" +export TMPDIR="$T/tmp/stage" # stage = $T/tmp/stage/tmp.X, so ../../.. is $T +sed -e "s#/var/www/telegram.pezkiwi.app#$T/web#" "$HERE/miniapp-site-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 () { mkdir -p "$1/assets"; echo "new" > "$1/index.html"; echo "x" > "$1/assets/index-NEW.js"; } +run () { ( cd "$2" && tar -cf - . ) | SSH_ORIGINAL_COMMAND="$1" "$T/receive" >/dev/null 2>&1; } + +mkdir -p "$T/web/assets"; echo old > "$T/web/assets/index-OLD.js"; echo "old" > "$T/web/index.html" + +site "$T/s1"; run deploy "$T/s1"; rc=$? +check "a valid build installs" '[ $rc = 0 ] && grep -q new "$T/web/index.html"' +check "recent chunks of the previous build stay" '[ -f "$T/web/assets/index-OLD.js" ]' +check "files are world-readable (644)" '[ "$(stat -c %a "$T/web/index.html")" = 644 ] && [ "$(stat -c %a "$T/web/assets/index-NEW.js")" = 644 ]' +check "directories are traversable (755)" '[ "$(stat -c %a "$T/web/assets")" = 755 ] && [ "$(stat -c %a "$T/web")" = 755 ]' + +before=$(cat "$T/web/index.html") +mkdir -p "$T/s2/assets"; echo x > "$T/s2/assets/a.js"; run deploy "$T/s2"; rc=$? +check "a tar without index.html is refused" '[ $rc != 0 ] && [ "$(cat "$T/web/index.html")" = "$before" ]' + +site "$T/s3"; run 'deploy; id' "$T/s3"; rc1=$?; run '' "$T/s3"; rc2=$? +check "any command but deploy is refused" '[ $rc1 = 2 ] && [ $rc2 = 2 ]' + +# The cleanup: a file of the new build stays however old it is (the old +# cleanup removed lazy chunks older than two hours); a file not in the build +# stays for four hours, then goes. +site "$T/c1"; echo chunk > "$T/c1/assets/Wallet-OLD.js"; touch -d '3 days ago' "$T/c1/assets/Wallet-OLD.js" +echo prev > "$T/web/assets/prev-recent.js"; echo gone > "$T/web/assets/prev-old.js"; touch -d '1 day ago' "$T/web/assets/prev-old.js" +run deploy "$T/c1"; rc=$? +check "an old file of the new build is kept" '[ $rc = 0 ] && [ -f "$T/web/assets/Wallet-OLD.js" ]' +check "a recent file of an earlier build is kept" '[ -f "$T/web/assets/prev-recent.js" ]' +check "an old file of an earlier build is removed" '[ ! -e "$T/web/assets/prev-old.js" ]' + +site "$T/s4"; ln -s /etc/passwd "$T/s4/assets/passwd.js"; run deploy "$T/s4"; rc=$? +check "a symlink in the upload is not installed" '[ $rc = 0 ] && [ ! -e "$T/web/assets/passwd.js" ] && [ ! -L "$T/web/assets/passwd.js" ]' + +site "$T/s5" +( cd "$T/s5" && tar -cf "$T/evil.tar" . && tar -rf "$T/evil.tar" --transform 's#^#../../../#' index.html ) 2>/dev/null +SSH_ORIGINAL_COMMAND=deploy "$T/receive" < "$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 ]