Alcuni link in questa pagina sono link di affiliazione: se ti registri tramite questi link, il sito riceve una commissione senza costi aggiuntivi per te. Prezzi e consigli non cambiano.

Ogni tutorial del tipo «pubblica un sito statico con GitHub Actions» finisce allo stesso modo: genera una chiave SSH, incolla la metà privata nei secret del repository, manda la build via rsync. Funziona al primo colpo, ed è proprio questo il problema. Avete appena creato un account interattivo sul vostro server, e a possederlo è un sistema che esegue codice proveniente dalle pull request.

Questo articolo è la stessa cosa con le protezioni: una chiave che sa fare esattamente un’operazione, una host key fissata da voi e tre controlli che impediscono a una build rotta di sostituire un sito funzionante. È la pipeline che pubblica questo blog, e costa forse un quarto d’ora di lavoro in più.

La parte server presuppone che una macchina ce l’abbiate. Se no, abbiamo confrontato i piani economici nel miglior VPS per il self-hosting.

Cosa concede davvero una chiave di deploy ingenua

Una riga in ~/.ssh/authorized_keys senza opzioni è un accesso completo. Chi possiede la chiave privata può:

  • aprire una shell interattiva come quell’utente;
  • leggere tutto ciò che quell’utente può leggere, compresi altri progetti sulla stessa macchina e qualsiasi .env lasciato in giro;
  • usare sudo, se l’utente ne ha diritto;
  • fare port forwarding e usare il vostro server come ponte verso tutto ciò che raggiunge.

Niente di tutto questo serve per copiare una cartella di HTML. E la chiave privata vive in un sistema di CI dove una modifica al workflow, una action di terze parti compromessa o un trigger pull_request_target mal configurato possono leggerla.

Abbiamo tenuto esattamente questo schema per mesi senza pensarci: è il motivo onesto per cui questo articolo esiste.

Vincolate la chiave a un solo comando

OpenSSH permette di aggiungere opzioni a ogni chiave in authorized_keys. Quella che conta è command=, che sostituisce ciò che il client ha chiesto di eseguire con ciò che decidete voi:

command="/usr/bin/rrsync -wo /home/deploy/site",restrict ssh-ed25519 AAAA... github-actions

Pezzo per pezzo:

  • rrsync arriva insieme a rsync (/usr/bin/rrsync su Debian e Ubuntu; su versioni più vecchie può essere uno script compresso in /usr/share/doc/rsync/scripts/). È un wrapper che accetta solo l’invocazione lato server di rsync e rifiuta tutto il resto.
  • -wo significa sola scrittura: la chiave può caricare nella cartella e non può scaricarne nulla. Se il vostro deploy si limita a spingere, prendete l’opzione più severa.
  • Il percorso finale è una prigione. Tutto ciò che sta fuori viene rifiutato, comprese le furbate con ...
  • restrict disabilita in una parola port forwarding, agent forwarding, X11 e allocazione di PTY. Su qualunque OpenSSH degli ultimi anni è meglio che elencare a mano no-port-forwarding,no-agent-forwarding,....

Verificate in modo diretto, dal vostro computer con quella chiave privata:

ssh -i deploy_key deploy@vostro-server 'id'      # deve fallire
rsync -rltz -e "ssh -i deploy_key" ./public/ deploy@vostro-server:/   # deve riuscire

Il fallimento del primo comando è tutto il punto. Se stampa un identificativo utente, le opzioni non si stanno applicando: controllate che sia tutto su una riga e che la riga cominci con command= e non con il tipo di chiave.

Notate il percorso di destinazione nella chiamata rsync: è relativo alla prigione, quindi / indica la cartella su cui avete vincolato la chiave.

Fissate la host key, non disattivate il controllo

L’altra scorciatoia dei tutorial è StrictHostKeyChecking=no, cioè spegnere la verifica che il server a cui state pubblicando sia davvero quello che pensate. Fissatela invece. Sul server:

ssh-keyscan -t ed25519 vostro-server.example

Mettete quella riga in un secret del repository e scrivetela in known_hosts dentro il job. Così una host key cambiata fa fallire il deploy in modo rumoroso, invece di spedire in silenzio il vostro sito a chi risponde a quell’indirizzo.

Il workflow

name: Build and deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          submodules: recursive

      - name: Build
        run: vostro-comando-di-build

      - name: Guard against an empty build
        run: |
          set -euo pipefail
          test -s public/index.html
          test -s public/sitemap.xml

      - name: Deploy over rsync
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          set -euo pipefail
          mkdir -p ~/.ssh
          printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
          chmod 600 ~/.ssh/deploy_key
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
          rsync -rltz --delete --omit-dir-times \
            -e "ssh -i ~/.ssh/deploy_key -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts" \
            public/ "$DEPLOY_USER@$DEPLOY_HOST:/"

      - name: Smoke test
        run: |
          set -euo pipefail
          for url in https://example.com/ https://example.com/sitemap.xml; do
            code=$(curl -s -o /dev/null -w '%{http_code}' --retry 3 --retry-delay 5 "$url")
            echo "$url -> $code"
            test "$code" = "200"
          done

Le parti che non sono decorazione:

concurrency senza cancel-in-progress. Due deploy contemporanei con --delete sulla stessa cartella sono il modo migliore per servire un sito copiato a metà. Metterli in coda non costa nulla.

La protezione contro le build vuote. È --delete a rendere rsync un deploy invece di un semplice caricamento, ed è sempre lui a trasformare una build che in silenzio non ha prodotto niente in un sito cancellato. Tre righe test -s costano meno di quel disservizio. Controllate file che devono esistere e almeno uno che compare solo se la build è davvero andata a buon fine.

IdentitiesOnly=yes. Senza, ssh può offrire prima altre chiavi dall’agent e farsi rifiutare prima di arrivare alla vostra.

--omit-dir-times. In una prigione in sola scrittura i tentativi di rsync di impostare le date delle cartelle possono fallire rumorosamente. Questo tiene pulito l’output senza indebolire nulla.

Lo smoke test punta all’URL pubblico, non al server via ssh. È l’unico passo che dimostra che un visitatore vede il risultato: intercetta un container morto, un certificato scaduto e un proxy puntato sulla cartella sbagliata, cose di cui rsync non sa nulla.

Da cosa protegge e da cosa no

Protegge dallo scenario realistico: un secret di CI trapelato che diventa accesso shell al vostro server. La chiave può scrivere in una cartella e basta, non può rileggere, non può inoltrare porte, non può aprire un terminale.

Non protegge da chi può modificare il file di workflow, perché sostituirà il contenuto che pubblicate. Se il sito vi sta a cuore, proteggete il branch: richiedete la revisione e tenete il deploy solo su push verso il branch predefinito, mai su pull_request_target, che esegue il codice di un fork con i vostri secret a disposizione.

Non protegge nemmeno la cartella in sé. Tutto ciò che viene servito da quel percorso può essere sovrascritto dalla chiave. Tenete la destinazione del deploy separata da configurazioni, volumi di dati e dal resto.

Il retrofit, in ordine

Se avete già una chiave di deploy senza restrizioni, sistemarla richiede pochi minuti:

  1. Verificate che rrsync esista: ls -l /usr/bin/rrsync.
  2. Modificate authorized_keys e aggiungete command="/usr/bin/rrsync -wo /percorso/del/sito",restrict davanti alla riga della chiave esistente.
  3. Dal vostro computer con quella chiave, controllate che ssh ... 'id' fallisca e che un rsync di prova riesca ancora.
  4. Rilanciate il workflow con workflow_dispatch e guardate lo smoke test passare.
  5. Solo allora cancellate la copia di sicurezza della vecchia riga.

Cambiare la chiave nella stessa occasione è una buona abitudine, soprattutto se è finita in qualche log.

Prossimamente

Il lato server dello stesso impianto: Caddy e Docker in produzione - HTTPS automatico, più siti su una macchina e gli header di cache che decidono se i lettori vedono la pagina appena pubblicata.