diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5ddd24af..929fa9d0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,20 +28,20 @@ jobs: language: [javascript-typescript] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@9f759ee644a3e7c15c1390abf49868036c00067b # v3.38.3 with: languages: ${{ matrix.language }} # OWASP top-10 + injection + auth flaws + prototype pollution queries: security-extended,security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@9f759ee644a3e7c15c1390abf49868036c00067b # v3.38.3 - name: Perform analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@9f759ee644a3e7c15c1390abf49868036c00067b # v3.38.3 with: category: /language:${{ matrix.language }} # GitHub Advanced Security dashboard upload requires paid plan. @@ -50,7 +50,7 @@ jobs: output: /tmp/codeql-results - name: Upload SARIF as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 continue-on-error: true with: name: codeql-sarif-${{ matrix.language }} diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index bfbd7ba0..45bad253 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -26,6 +26,18 @@ env: REGISTRY: ghcr.io IMAGE_NAME: pezkuwichain/pwap-web BACKEND_IMAGE_NAME: pezkuwichain/pwap-indexer + # Deploy targets. Each deploy key is pinned on its host to one fixed script + # in ops/host (authorized_keys restrict,command=), so these are the only + # places CI can write, and only through those scripts. + APP_HOST: 89.117.59.63 + INDEXER_HOST: 89.117.59.63 + PEX_HOST: 217.77.6.126 + SUPABASE_HOST: 217.77.6.126 + # Host keys, read from the hosts themselves (ops/known_hosts); a deploy to a + # host that presents any other key fails. + DEPLOY_KNOWN_HOSTS: | + 89.117.59.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGBR32BTb3hC4stTUvoCN/ZKZcAretzHttdkMOodgS4W + 217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ jobs: # ======================================== @@ -37,15 +49,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' - name: Cache npm dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: web/node_modules key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json') }} @@ -85,7 +97,7 @@ jobs: VITE_TURNSTILE_SITE_KEY: 0x4AAAAAAEDkUkbsUgw2D9zN - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: web-dist path: web/dist/ @@ -101,15 +113,15 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' - name: Cache npm dependencies - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: backend/node_modules key: ${{ runner.os }}-backend-${{ hashFiles('backend/package-lock.json') }} @@ -148,10 +160,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -174,7 +186,7 @@ jobs: # ======================================== build-image: name: Build & Push Image - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [web, notify-deploy-pending] if: | github.ref == 'refs/heads/main' && @@ -189,17 +201,17 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 with: cosign-release: 'v2.4.1' - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -215,7 +227,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ./ file: ./web/Dockerfile @@ -257,7 +269,7 @@ jobs: # ======================================== build-image-backend: name: Build & Push Backend Image - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [backend, notify-deploy-pending] if: | (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && @@ -272,17 +284,17 @@ jobs: image_digest: ${{ steps.build.outputs.digest }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 with: cosign-release: 'v2.4.1' - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -298,7 +310,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ./backend file: ./backend/Dockerfile @@ -334,7 +346,7 @@ jobs: # ======================================== notify-deploy-pending: name: Notify approver - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [web, security-audit] if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') timeout-minutes: 5 @@ -412,7 +424,7 @@ jobs: # Waiting for approval costs no runner: GitHub holds the job before dispatch. approve-deploy: name: Await deploy approval - runs-on: pwap-runner + runs-on: ubuntu-latest environment: production needs: [notify-deploy-pending] timeout-minutes: 5 @@ -427,7 +439,7 @@ jobs: # ======================================== bump-version: name: Bump Version - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [web, security-audit, notify-deploy-pending, build-image] # Skip on rollback (workflow_dispatch with rollback_to set) if: | @@ -439,13 +451,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -473,7 +485,7 @@ jobs: # ======================================== deploy-app: name: Deploy app.pezkuwichain.io - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [approve-deploy, notify-deploy-pending, bump-version, build-image] if: | always() && @@ -508,14 +520,14 @@ jobs: echo "prev=$PREV" >> $GITHUB_OUTPUT - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Install cosign (for verify) - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 with: cosign-release: 'v2.4.1' @@ -544,16 +556,16 @@ jobs: echo "${{ steps.sha.outputs.sha }}" > dist/.deploy-sha ls -la dist/ | head -10 - - name: Deploy to DEV VPS - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - port: ${{ secrets.VPS_SSH_PORT || 2222 }} - source: 'dist/*' - target: '/var/www/subdomains/app' - strip_components: 1 + - name: Deploy to app.pezkuwichain.io (NEW-7) + env: + DEPLOY_KEY: ${{ secrets.PWAP_APP_DEPLOY_KEY }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-web-receive on the host (ops/host): it + # takes the site tar on stdin and can do 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" app-deploy@"$APP_HOST" deploy - name: Health check (60s window) id: healthcheck @@ -587,15 +599,15 @@ jobs: - name: SCP rollback artifact if: steps.rollback.outcome == 'success' - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - port: ${{ secrets.VPS_SSH_PORT || 2222 }} - source: 'dist/*' - target: '/var/www/subdomains/app' - strip_components: 1 + env: + DEPLOY_KEY: ${{ secrets.PWAP_APP_DEPLOY_KEY }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-web-receive on the host (ops/host): it + # takes the site tar on stdin and can do 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" app-deploy@"$APP_HOST" deploy - name: Re-health-check after rollback if: steps.rollback.outcome == 'success' @@ -643,7 +655,7 @@ jobs: # ======================================== deploy-pex: name: Deploy pex.mom - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [approve-deploy, notify-deploy-pending, bump-version, build-image] if: | always() && @@ -676,14 +688,14 @@ jobs: echo "prev=$PREV" >> $GITHUB_OUTPUT - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Install cosign (for verify) - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 with: cosign-release: 'v2.4.1' @@ -710,15 +722,15 @@ jobs: echo "${{ steps.sha.outputs.sha }}" > dist/.deploy-sha - name: Deploy to VPS3 - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS_PEX_HOST }} - username: ${{ secrets.VPS_PEX_USER }} - key: ${{ secrets.VPS_PEX_SSH_KEY }} - port: ${{ secrets.VPS_PEX_SSH_PORT || 22 }} - source: 'dist/*' - target: '/var/www/pex.mom' - strip_components: 1 + env: + DEPLOY_KEY: ${{ secrets.PWAP_PEX_DEPLOY_KEY }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-web-receive on the host (ops/host): it + # takes the site tar on stdin and can do 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" pex-deploy@"$PEX_HOST" deploy - name: Health check (60s window) id: healthcheck @@ -751,15 +763,15 @@ jobs: - name: SCP rollback artifact if: steps.rollback.outcome == 'success' - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.VPS_PEX_HOST }} - username: ${{ secrets.VPS_PEX_USER }} - key: ${{ secrets.VPS_PEX_SSH_KEY }} - port: ${{ secrets.VPS_PEX_SSH_PORT || 22 }} - source: 'dist/*' - target: '/var/www/pex.mom' - strip_components: 1 + env: + DEPLOY_KEY: ${{ secrets.PWAP_PEX_DEPLOY_KEY }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-web-receive on the host (ops/host): it + # takes the site tar on stdin and can do 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" pex-deploy@"$PEX_HOST" deploy - name: Re-health-check after rollback if: steps.rollback.outcome == 'success' @@ -821,25 +833,25 @@ jobs: # failure/alert) instead of erroring on "missing server host" every push. backend-cfg: name: Backend deploy config check - runs-on: pwap-runner + runs-on: ubuntu-latest if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') outputs: configured: ${{ steps.c.outputs.configured }} steps: - id: c env: - BH: ${{ secrets.BACKEND_VPS_HOST }} + BH: ${{ secrets.PWAP_INDEXER_DEPLOY_KEY }} run: | if [ -n "$BH" ]; then echo "configured=true" >> "$GITHUB_OUTPUT" else - echo "::notice::BACKEND_VPS_HOST unset — backend indexer deploy skipped (provision the host to enable)" + echo "::notice::PWAP_INDEXER_DEPLOY_KEY unset — backend indexer deploy skipped (provision the host to enable)" echo "configured=false" >> "$GITHUB_OUTPUT" fi deploy-backend: name: Deploy Backend (indexer) - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [approve-deploy, notify-deploy-pending, build-image-backend, backend-cfg] if: | always() && @@ -865,12 +877,12 @@ jobs: fi - name: Install cosign (for verify) - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 with: cosign-release: 'v2.4.1' - name: Log in to GHCR (runner-side, for verify) - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -891,93 +903,18 @@ jobs: - name: Deploy container to backend host (health-checked, auto-rollback) id: deploy - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.BACKEND_VPS_HOST }} - username: ${{ secrets.BACKEND_VPS_USER }} - key: ${{ secrets.BACKEND_VPS_SSH_KEY }} - port: ${{ secrets.BACKEND_VPS_SSH_PORT || 22 }} - envs: IMAGE_REF,NEW_SHA,GHCR_USER,GHCR_TOKEN,WS_ENDPOINT,HOST_PORT - command_timeout: 10m - script_stop: true - script: | - set -e - REGISTRY_IMAGE="ghcr.io/pezkuwichain/pwap-indexer" - IMAGE="$REGISTRY_IMAGE:$NEW_SHA" - STATE_DIR="/opt/pwap-indexer" - NAME="pwap-indexer" - HOST_PORT="${HOST_PORT:-3001}" - mkdir -p "$STATE_DIR" - PREV="$(cat "$STATE_DIR/.deploy-sha" 2>/dev/null || echo "")" - echo "Deploying $IMAGE (previous: ${PREV:-none})" - - echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin - docker pull "$IMAGE" - - run_container () { - local img="$1" - docker rm -f "$NAME" >/dev/null 2>&1 || true - # Named volume pwap-indexer-db = stateful sqlite DB. NEVER removed - # by this script — it is created on first run and reused forever. - # Config comes from the workflow (GitHub secrets/vars), NOT a - # hand-placed file on the host. The indexer (src/index.js) only - # needs WS_ENDPOINT beyond the fixed PORT/DB_PATH; it runs no - # KYC/council service, so no seed/Supabase secret is required. - # Host port is configurable (HOST_PORT, default 3001) so the indexer - # can co-exist with whatever else runs on the box; the container - # always listens on 3001 internally. - docker run -d --name "$NAME" --restart unless-stopped \ - -p "${HOST_PORT}:3001" \ - -v pwap-indexer-db:/data \ - -e DB_PATH=/data/transactions.db \ - -e PORT=3001 \ - -e WS_ENDPOINT="$WS_ENDPOINT" \ - "$img" - } - - healthy () { - for i in $(seq 1 12); do - if curl -fsS --max-time 5 "http://localhost:${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 "$IMAGE" - if healthy; then - echo "$NEW_SHA" > "$STATE_DIR/.deploy-sha" - echo "✅ Backend healthy on $NEW_SHA" - docker image prune -f >/dev/null 2>&1 || true - exit 0 - fi - - echo "❌ Health check failed for $NEW_SHA" - if [ -n "$PREV" ]; then - echo "🔄 Rolling back to $PREV" - docker pull "$REGISTRY_IMAGE:$PREV" - run_container "$REGISTRY_IMAGE:$PREV" - if healthy; then - echo "$PREV" > "$STATE_DIR/.deploy-sha" - echo "✅ Rolled back to $PREV — backend healthy" - exit 1 # still fail the job: the new SHA did not deploy - fi - echo "🚨 Rollback to $PREV ALSO failed" - exit 1 - fi - echo "No previous SHA to roll back to." - exit 1 env: - IMAGE_REF: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.sha.outputs.sha }} + DEPLOY_KEY: ${{ secrets.PWAP_INDEXER_DEPLOY_KEY }} NEW_SHA: ${{ steps.sha.outputs.sha }} - GHCR_USER: ${{ github.actor }} - GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Non-secret chain endpoint; override via the INDEXER_WS_ENDPOINT secret if needed. - WS_ENDPOINT: ${{ secrets.INDEXER_WS_ENDPOINT || 'wss://rpc.pezkuwichain.io' }} - # Host port for the indexer (container is always 3001 internally). - HOST_PORT: ${{ secrets.INDEXER_HOST_PORT || '3001' }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-indexer-ssh on the host (ops/host): the + # image SHA is its only input. The host pulls the public image, runs + # it with its own settings (/etc/pwap-indexer.env), health-checks it + # and rolls back to the previous SHA if it does not come up. + ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" indexer-deploy@"$INDEXER_HOST" "$NEW_SHA" - name: Post-deploy notification if: success() @@ -1004,7 +941,7 @@ jobs: # ======================================== deploy-supabase: name: Deploy Supabase (functions + migrations) - runs-on: pwap-runner + runs-on: ubuntu-latest needs: [approve-deploy, notify-deploy-pending] if: | always() && @@ -1016,7 +953,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Pack supabase tree run: | @@ -1024,76 +961,18 @@ jobs: tar czf supabase-deploy.tgz -C web/supabase \ --exclude='__tests__' functions migrations deploy - - name: Upload bundle to staging - uses: appleboy/scp-action@v1.0.0 - with: - host: ${{ secrets.SUPABASE_VPS_HOST }} - username: ${{ secrets.SUPABASE_VPS_USER }} - key: ${{ secrets.SUPABASE_VPS_SSH_KEY }} - port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }} - source: "supabase-deploy.tgz" - target: /opt/supabase-self-hosted/deploy-staging - - name: Sync edge functions + apply migrations (health-checked) id: apply - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.SUPABASE_VPS_HOST }} - username: ${{ secrets.SUPABASE_VPS_USER }} - key: ${{ secrets.SUPABASE_VPS_SSH_KEY }} - port: ${{ secrets.SUPABASE_VPS_SSH_PORT || 22 }} - command_timeout: 15m - script: | - set -euo pipefail - BASE=/opt/supabase-self-hosted/deploy-staging - STAGING="$BASE/tree" - DB=supabase-db - - echo "── 0. Unpack bundle ──" - rm -rf "$STAGING" && mkdir -p "$STAGING" - tar xzf "$BASE/supabase-deploy.tgz" -C "$STAGING" - - echo "── 1. Sync edge functions (through the ownership gate) ──" - # The runtime volume is shared with pezkuwi-telegram-miniapp. A plain - # rsync here silently replaced that project's telegram-auth on - # 2026-06-28 and broke its sign-in for a month, so writes now go - # through a gate that refuses any name this project does not own. - # Ownership: /opt/supabase-self-hosted/functions-registry.json - # __tests__ are Deno test dirs — the gate skips __-prefixed dirs. - 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 - # any signature match; expect NO anon EXECUTE - 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:-} (count=$n_trg)" - [ "$n_trg" = "2" ] || { echo "::error::trade/offer freeze triggers missing (expected 2, got $n_trg)"; fail=1; } - - rm -rf "$BASE" - [ "$fail" = "0" ] && echo "✅ Supabase deploy verified" || { echo "❌ post-deploy verification failed"; exit 1; } + env: + DEPLOY_KEY: ${{ secrets.PWAP_SUPABASE_DEPLOY_KEY }} + run: | + umask 077 + printf '%s\n' "$DEPLOY_KEY" > "$RUNNER_TEMP/deploy_key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/known_hosts" + # The key is pinned to pwap-supabase-ssh on the host (ops/host): the + # bundle on stdin is its only input. pwap-supabase-deploy syncs the + # functions, applies the migrations and verifies the guards. + ssh -i "$RUNNER_TEMP/deploy_key" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile="$RUNNER_TEMP/known_hosts" supabase-deploy@"$SUPABASE_HOST" deploy < supabase-deploy.tgz - name: Telegram notify if: always() @@ -1121,12 +1000,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' @@ -1141,7 +1020,7 @@ jobs: - name: TruffleHog — PR diff (verified secrets only) if: github.event_name == 'pull_request' - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2 with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} @@ -1149,7 +1028,7 @@ jobs: - name: TruffleHog — full repo scan (verified secrets only) if: github.event_name != 'pull_request' - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2 with: path: ./ extra_args: --only-verified @@ -1187,7 +1066,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install psql run: sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client @@ -1254,11 +1133,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Every deploy job must sit behind the approval gate run: python3 ops/check-deploy-gate.py + # The scripts the deploy keys are pinned to (ops/host). They are the + # whole of what CI can do on the hosts, so they are checked here. + - name: Host deploy scripts + run: | + shellcheck -S warning ops/host/pwap-* ops/host/test-web-receive.sh + bash ops/host/test-web-receive.sh + ci-gate: name: CI Gate ✅ runs-on: ubuntu-latest diff --git a/ops/host/README.md b/ops/host/README.md new file mode 100644 index 00000000..dcb0901b --- /dev/null +++ b/ops/host/README.md @@ -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 +``` diff --git a/ops/host/pwap-indexer-deploy b/ops/host/pwap-indexer-deploy new file mode 100755 index 00000000..48223bcd --- /dev/null +++ b/ops/host/pwap-indexer-deploy @@ -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 diff --git a/ops/host/pwap-indexer-ssh b/ops/host/pwap-indexer-ssh new file mode 100755 index 00000000..1e1c2eb8 --- /dev/null +++ b/ops/host/pwap-indexer-ssh @@ -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@ `. 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" diff --git a/ops/host/pwap-supabase-deploy b/ops/host/pwap-supabase-deploy new file mode 100755 index 00000000..526e690d --- /dev/null +++ b/ops/host/pwap-supabase-deploy @@ -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:-} (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 diff --git a/ops/host/pwap-supabase-ssh b/ops/host/pwap-supabase-ssh new file mode 100755 index 00000000..408edcda --- /dev/null +++ b/ops/host/pwap-supabase-ssh @@ -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@ 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 diff --git a/ops/host/pwap-web-receive b/ops/host/pwap-web-receive new file mode 100755 index 00000000..b911eb86 --- /dev/null +++ b/ops/host/pwap-web-receive @@ -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 @ 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")" diff --git a/ops/host/test-web-receive.sh b/ops/host/test-web-receive.sh new file mode 100755 index 00000000..545812e0 --- /dev/null +++ b/ops/host/test-web-receive.sh @@ -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 [index] + mkdir -p "$1/assets" + echo "${3:-new}" > "$1/index.html" + echo "console.log(1)" > "$1/assets/index-NEW.js" + echo "$2" > "$1/.deploy-sha" +} +run () { # run + ( 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 "old" > "$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 ] diff --git a/ops/known_hosts b/ops/known_hosts new file mode 100644 index 00000000..a218b2b8 --- /dev/null +++ b/ops/known_hosts @@ -0,0 +1,2 @@ +89.117.59.63 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGBR32BTb3hC4stTUvoCN/ZKZcAretzHttdkMOodgS4W +217.77.6.126 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMxxRmMEYyng+B9O3jcUCMnUa31SyWQ+l7aj18/soWtJ