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
.envlasciato 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:
rrsyncarriva insieme a rsync (/usr/bin/rrsyncsu 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.-wosignifica 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
... restrictdisabilita in una parola port forwarding, agent forwarding, X11 e allocazione di PTY. Su qualunque OpenSSH degli ultimi anni è meglio che elencare a manono-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:
- Verificate che
rrsyncesista:ls -l /usr/bin/rrsync. - Modificate
authorized_keyse aggiungetecommand="/usr/bin/rrsync -wo /percorso/del/sito",restrictdavanti alla riga della chiave esistente. - Dal vostro computer con quella chiave, controllate che
ssh ... 'id'fallisca e che un rsync di prova riesca ancora. - Rilanciate il workflow con
workflow_dispatche guardate lo smoke test passare. - 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.