Certains liens de cette page sont des liens d'affiliation : si vous vous inscrivez via ces liens, ce site touche une commission, sans surcoût pour vous. Les prix et les recommandations ne changent pas.
Tous les tutoriels « déployer un site statique avec GitHub Actions » finissent pareil : générez une clé SSH, collez la partie privée dans les secrets du dépôt, envoyez le build par rsync. Ça marche du premier coup, et c’est précisément le problème. Vous venez de créer un compte interactif sur votre serveur, détenu par un système qui exécute du code venu de pull requests.
Cet article, c’est la même chose avec des garde-fous : une clé qui ne sait faire qu’une seule chose, une clé d’hôte que vous avez épinglée vous-même, et trois vérifications qui empêchent un build cassé de remplacer un site qui fonctionne. C’est la chaîne qui publie ce blog, et elle coûte une quinzaine de minutes de travail en plus.
La partie serveur suppose que vous avez une machine. Sinon, nous avons comparé les offres bon marché dans le meilleur VPS pour l’auto-hébergement.
Ce qu’accorde réellement une clé de déploiement naïve
Une ligne dans ~/.ssh/authorized_keys sans options, c’est un accès. Quiconque détient la clé privée peut :
- ouvrir un shell interactif sous cet utilisateur ;
- lire tout ce que cet utilisateur peut lire, y compris les autres projets de la machine et n’importe quel
.envqui traîne ; - utiliser
sudosi l’utilisateur y a droit ; - faire du transfert de ports et se servir de votre serveur comme rebond vers tout ce qu’il atteint.
Rien de tout cela n’est nécessaire pour copier un répertoire de HTML. Et la clé privée vit dans un système de CI où un changement de workflow, une action tierce compromise ou un déclencheur pull_request_target mal configuré peuvent la lire.
Nous avons fait tourner exactement ce montage pendant des mois sans y penser : c’est la raison honnête de cet article.
Verrouiller la clé sur une seule commande
OpenSSH permet d’attacher des options à chaque clé dans authorized_keys. La plus importante est command=, qui remplace ce que le client a demandé d’exécuter par ce que vous imposez :
command="/usr/bin/rrsync -wo /home/deploy/site",restrict ssh-ed25519 AAAA... github-actions
Morceau par morceau :
rrsyncest livré avec rsync (/usr/bin/rrsyncsur Debian et Ubuntu ; sur des versions plus anciennes, ce peut être un script compressé dans/usr/share/doc/rsync/scripts/). C’est une enveloppe qui n’accepte que l’invocation serveur de rsync et refuse tout le reste.-wosignifie écriture seule : la clé peut téléverser dans le répertoire et ne peut rien en télécharger. Si votre déploiement ne fait que pousser, prenez l’option la plus stricte.- Le chemin final est une prison. Tout ce qui en sort est refusé, y compris les astuces à base de
... restrictdésactive en un mot le transfert de ports, le transfert d’agent, X11 et l’allocation de PTY. Sur tout OpenSSH récent, c’est préférable à l’énumération manuelle deno-port-forwarding,no-agent-forwarding,....
Testez directement, depuis votre poste, avec cette clé privée :
ssh -i deploy_key deploy@votre-serveur 'id' # doit échouer
rsync -rltz -e "ssh -i deploy_key" ./public/ deploy@votre-serveur:/ # doit réussir
L’échec de la première commande, c’est tout l’intérêt. Si elle affiche un identifiant d’utilisateur, les options ne s’appliquent pas : vérifiez que tout tient sur une seule ligne et que la ligne commence par command=, pas par le type de clé.
Notez le chemin de destination dans l’appel rsync : il est relatif à la prison, donc / désigne le répertoire sur lequel vous avez verrouillé la clé.
Épingler la clé d’hôte plutôt que désactiver la vérification
L’autre raccourci des tutoriels, c’est StrictHostKeyChecking=no, qui désactive la vérification que le serveur visé est bien celui que vous croyez. Épinglez-la plutôt. Sur le serveur :
ssh-keyscan -t ed25519 votre-serveur.example
Mettez cette ligne dans un secret du dépôt et écrivez-la dans known_hosts au début du job. Une clé d’hôte modifiée fera alors échouer le déploiement bruyamment, au lieu d’envoyer silencieusement votre site à qui répond à cette adresse.
Le 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: votre-commande-de-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
Les parties qui ne sont pas décoratives :
concurrency sans cancel-in-progress. Deux déploiements simultanés avec --delete sur le même répertoire, c’est la recette pour servir un site à moitié copié. Les mettre en file d’attente ne coûte rien.
Le garde-fou contre les builds vides. C’est --delete qui fait de rsync un déploiement plutôt qu’un simple envoi, et c’est lui qui transforme un build ayant silencieusement produit le vide en site supprimé. Trois lignes test -s coûtent moins cher que cette panne. Vérifiez des fichiers qui doivent exister, et au moins un qui n’apparaît que si le build a réellement tourné.
IdentitiesOnly=yes. Sans cela, ssh peut proposer d’abord d’autres clés de l’agent et se faire refuser avant d’arriver à la vôtre.
--omit-dir-times. Dans une prison en écriture seule, les tentatives de rsync pour fixer les dates des répertoires peuvent échouer bruyamment. Cela garde la sortie propre sans rien affaiblir.
Le test de fumée vise l’URL publique, pas le serveur en ssh. C’est la seule étape qui prouve qu’un visiteur voit le résultat : elle attrape un conteneur mort, un certificat expiré et un proxy qui pointe sur le mauvais répertoire - autant de choses que rsync ignore.
Ce que cela protège, et ce que cela ne protège pas
Cela protège du scénario réaliste : une fuite de secret de CI qui se transforme en accès shell sur votre serveur. La clé peut écrire dans un répertoire et rien d’autre, ne peut pas relire, ne peut pas rediriger de ports, ne peut pas ouvrir de terminal.
Cela ne protège pas de quelqu’un qui peut modifier le fichier de workflow, puisqu’il remplacera le contenu que vous publiez. Si le site compte pour vous, protégez la branche : exigez une revue et gardez le déploiement sur push vers la branche par défaut uniquement, jamais sur pull_request_target, qui exécute le code d’un fork avec vos secrets à disposition.
Cela ne protège pas non plus le répertoire lui-même. Tout ce qui est servi depuis ce chemin peut être écrasé par la clé. Gardez la cible de déploiement séparée de la configuration, des volumes de données et du reste.
La mise à niveau, dans l’ordre
Si vous avez déjà une clé de déploiement sans restriction, la corriger prend quelques minutes :
- Vérifiez la présence de
rrsync:ls -l /usr/bin/rrsync. - Modifiez
authorized_keyset ajoutezcommand="/usr/bin/rrsync -wo /chemin/vers/le/site",restrictdevant la ligne de clé existante. - Depuis votre poste, avec cette clé, vérifiez que
ssh ... 'id'échoue et qu’un rsync de test réussit toujours. - Relancez le workflow via
workflow_dispatchet regardez le test de fumée passer. - Seulement ensuite, supprimez la sauvegarde de l’ancienne ligne.
Profiter de l’occasion pour changer la clé est une bonne habitude, surtout si elle a un jour été imprimée dans un journal.
La suite
Le côté serveur du même montage : Caddy et Docker en production - HTTPS automatique, plusieurs sites sur une machine, et les en-têtes de cache qui décident si vos lecteurs voient la page que vous venez de publier.