Durcissement du runner GitLab self-hosted¶
Ce guide explique comment et pourquoi le runner GitLab self-hosted a été durci. Il sert aussi de runbook reproductible : chaque couche montre les commandes réellement appliquées, et la fin documente la restauration.
ADR lié (le pourquoi des décisions CI) : GitLab CI Build Pipeline.
Placeholders. Les valeurs réseau réelles ne sont pas commitées. Remplace dans les commandes :
<PORT_SSH>: port SSH non standard (ex.2222)<LAN>: sous-réseau du homelab (ex.192.168.X.0/24)<GW_DNS>: passerelle, qui sert aussi de DNS (ex.192.168.X.254)<HOTE_TEST>: IP d'un autre hÎte homelab, pour le test négatif (NAS, ESXi...)Filet permanent. Le runner est un SPOF (INC-041). Avant chaque phase : snapshot daté de la VM cÎté hyperviseur, et console de secours ouverte (la modif SSH peut verrouiller). à la moindre CI rouge inexplicable : on restaure le snapshot, on ne débugge pas en aveugle.
- Pourquoi durcir ce runner
- L'idée en une image
- Couche 1 : accÚs (OS / réseau admin)
- Couche 2 : isolation de l'executor Docker
- Couche 3 : cloisonnement réseau des jobs (DOCKER-USER)
- Couche 4 : réduction de surface
- config.toml de référence (redacted)
- Restauration / rollback
- Vérifications rapides
Pourquoi durcir ce runner¶
Le runner est un SPOF : s'il tombe, toute la CI tombe. Et il exécute du code arbitraire à chaque job (ton code, tes dépendances, des images publiques), sur une VM posée au milieu du réseau maison, à cÎté du NAS et de l'hyperviseur.
| Risque | Conséquence | Parade |
|---|---|---|
| Le runner est attaqué de l'extérieur | compromission de l'hÎte | durcir l'accÚs (SSH, pare-feu) |
| Un job malveillant s'échappe | pivot vers le homelab, abus de privilÚges | isoler l'executor (privilÚges, capabilities, réseau) |
Approche : défense en profondeur, chaque couche limitant le rayon d'explosion.
L'idĂ©e en une image¶
INTERNET
â (SSH durci + pare-feu inbound)
âŒ
ââââââââââââââââââââââââââââââââââââââââââââ
â VM runner (Ubuntu) â
â SSH clĂ©-only, port custom, fail2ban â â couche 1 : accĂšs
â UFW : deny inbound, allow outbound â
â ââââââââââââââââââââââââââââââââââââââââ â
â â conteneur de job (executor docker) â â â couche 2 : isolation
â â privileged=false, caps rĂ©duites â â
â â mĂ©moire bornĂ©e â â
â ââââââââââââââââââââââââââââââââââââââââ â
â DOCKER-USER : bloque le LAN homelab â â couche 3 : rĂ©seau
ââââââââââââââââââââââââââââââââââââââââââââ
â (autorisĂ©) â (bloquĂ©)
⌠â
gitlab.com / AWS NAS, ESXi, autres VMs
Couche 1 : accĂšs (OS / rĂ©seau admin)¶
SSH (clĂ© only, port custom, pas de root)¶
On passe par un drop-in lu en premier (00-...) pour gagner sur le
50-cloud-init.conf qui réactive le mot de passe.
# Crée le drop-in prioritaire
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<EOF
Port <PORT_SSH>
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
# Valide la syntaxe et la config EFFECTIVE avant de recharger
sudo sshd -t && sudo sshd -T | grep -iE '^(port|permitrootlogin|passwordauthentication)'
# Recharge (ne coupe pas les sessions établies)
sudo systemctl reload ssh
PiĂšge verrouillage.
PasswordAuthentication not'enferme dehors si aucune clé n'est installée. Garde ta session ouverte, ouvre-en une 2e sur le nouveau port AVANT de fermer la 1re :ssh -p <PORT_SSH> user@runner. Console hyperviseur en secours.KbdInteractiveAuthentication noferme la porte « clavier-interactif » que PAM laisserait sinon.
Pare-feu UFW (inbound only)¶
sudo ufw default deny incoming
sudo ufw default allow outgoing # NE JAMAIS fermer : pulls d'images + gitlab.com
sudo ufw allow from <LAN> to any port <PORT_SSH> proto tcp
sudo ufw show added # relire AVANT d'activer
sudo ufw enable # répondre y ; les sessions établies survivent
sudo ufw status verbose
Le runner se connecte vers gitlab.com (aucun port entrant à exposer) et tire ses images vers les registres : seul le sortant compte, on le garde ouvert. UFW filtre l'inbound de l'hÎte (chaßne INPUT) ; le trafic des conteneurs passe par FORWARD, traité à la couche 3.
fail2ban (jail SSH)¶
sudo apt-get update && sudo apt-get install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<EOF
[sshd]
enabled = true
port = <PORT_SSH>
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
EOF
sudo systemctl enable --now fail2ban && sudo systemctl restart fail2ban
sudo fail2ban-client status sshd
Deux réglages cruciaux :
port = <PORT_SSH>(les bans visent la bonne porte) etbackend = systemd(lit le journal systemd, pas/var/log/auth.logsouvent absent).
journald persistant + mises Ă jour de sĂ©curitĂ©¶
# Journal persistant (audit qui survit aux reboots), plafonné
sudo mkdir -p /etc/systemd/journald.conf.d /var/log/journal
sudo tee /etc/systemd/journald.conf.d/00-persistent.conf >/dev/null <<EOF
[Journal]
Storage=persistent
SystemMaxUse=500M
SystemMaxFileSize=50M
MaxRetentionSec=1month
EOF
sudo systemctl restart systemd-journald && journalctl --disk-usage
# Mises Ă jour automatiques de SĂCURITĂ OS uniquement
sudo apt-get install -y unattended-upgrades
sudo tee /etc/apt/apt.conf.d/20auto-upgrades >/dev/null <<EOF
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
gitlab-runneret Docker viennent d'origines (packages.gitlab.com,download.docker.com) hors de la liste-security: ils ne sont jamais mis Ă jour automatiquement, ce qui Ă©vite un restart du runner en plein job. Leurs bumps se font en fenĂȘtre manuelle. VĂ©rifier avec :sudo unattended-upgrade --dry-run --debug 2>&1 | grep -i 'allowed origins'.
Couche 2 : isolation de l'executor Docker¶
Modifs dans config.toml, section [runners.docker]. On sauvegarde avant chaque
changement (rollback fichier en plus du snapshot).
sudo cp /etc/gitlab-runner/config.toml /etc/gitlab-runner/config.toml.bak
# privileged=false : kaniko build en userspace, aucun docker.sock monté -> pas de dind
sudo sed -i 's/privileged = true/privileged = false/' /etc/gitlab-runner/config.toml
# cap_drop ALL + le strict minimum (ops fichiers + identité pendant le build kaniko/pip)
sudo sed -i '/\[runners.docker\]/a\ cap_drop = ["ALL"]\n cap_add = ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID"]' /etc/gitlab-runner/config.toml
# Limites : plafond mémoire dur (un job emballé est OOM-kill, l'hÎte survit)
sudo sed -i '/\[runners.docker\]/a\ cpus = "2"\n memory = "2g"\n memory_swap = "3g"\n memory_reservation = "1g"' /etc/gitlab-runner/config.toml
sudo gitlab-runner restart && sudo gitlab-runner status
| Réglage | Valeur | Pourquoi |
|---|---|---|
privileged |
false |
kaniko n'a pas besoin de privilĂšges ni du daemon Docker |
cap_drop / cap_add |
ALL puis 6 caps |
on retire tout, on rend le strict minimum |
memory |
2g |
OOM-kill par cgroup d'un job emballé, l'hÎte (= SPOF) survit |
memory_swap / memory_reservation |
3g / 1g |
débordement amorti + limite douce sous pression |
cpus |
2 |
l'oversubscription CPU ralentit, ne plante pas |
Ces réglages s'appliquent à tous les jobs (pipelines app ET infra). Toute modif se valide sur les deux configs. Le service
gitlab-runnerreste enroot: le passer non-root imposerait le groupedocker, Ă©quivalent-root de fait, sans gain rĂ©el face Ăprivileged=false+ caps.
Couche 3 : cloisonnement rĂ©seau des jobs (DOCKER-USER)¶
Le trafic sortant d'un conteneur vers le LAN est filtré dans la chaßne iptables
DOCKER-USER.
# Insertion en position 1 (l'ordre s'inverse) -> ordre final : ESTABLISHED, .254, DROP
sudo iptables -I DOCKER-USER 1 -d <LAN> -j DROP
sudo iptables -I DOCKER-USER 1 -d <GW_DNS> -j RETURN
sudo iptables -I DOCKER-USER 1 -m state --state RELATED,ESTABLISHED -j RETURN
sudo iptables -L DOCKER-USER -n --line-numbers -v
| Ordre | RĂšgle | Effet |
|---|---|---|
| 1 | RELATED,ESTABLISHED â RETURN |
laisse passer les réponses |
| 2 | dst <GW_DNS> â RETURN |
autorise la passerelle/DNS (sinon plus de résolution) |
| 3 | dst <LAN> â DROP |
bloque le reste du homelab |
Tout ce qui ne matche rien (IP publiques : gitlab.com, AWS) continue â autorisĂ©.
Tester (le trafic depuis un conteneur traverse bien DOCKER-USER) :
# POSITIF : résolution + internet OK
docker run --rm alpine sh -c "nslookup gitlab.com && wget -qO- -T5 https://gitlab.com | head -c 60; echo"
# NĂGATIF : un autre hĂŽte homelab est injoignable
docker run --rm alpine sh -c "ping -c1 -W2 <HOTE_TEST> && echo JOIGNABLE || echo BLOQUE"
PiĂšges : whitelister le DNS (rĂšgle 2) car il est dans le LAN ; ne jamais bloquer
172.16.0.0/12(plage des bridges Docker, pas le homelab).
Persistance (DOCKER-USER est vidée au reboot / restart Docker) :
# Script idempotent (delete-then-insert)
sudo tee /usr/local/sbin/docker-user-firewall.sh >/dev/null <<'EOF'
#!/bin/sh
set -e
LAN="<LAN>"; GW="<GW_DNS>"
iptables -D DOCKER-USER -m state --state RELATED,ESTABLISHED -j RETURN 2>/dev/null || true
iptables -D DOCKER-USER -d "$GW" -j RETURN 2>/dev/null || true
iptables -D DOCKER-USER -d "$LAN" -j DROP 2>/dev/null || true
iptables -I DOCKER-USER 1 -d "$LAN" -j DROP
iptables -I DOCKER-USER 1 -d "$GW" -j RETURN
iptables -I DOCKER-USER 1 -m state --state RELATED,ESTABLISHED -j RETURN
EOF
sudo chmod +x /usr/local/sbin/docker-user-firewall.sh
# Service systemd lié à Docker (suit boot ET restart)
sudo tee /etc/systemd/system/docker-user-firewall.service >/dev/null <<'EOF'
[Unit]
Description=Reapply DOCKER-USER LAN isolation rules
After=docker.service
Requires=docker.service
PartOf=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-user-firewall.sh
[Install]
WantedBy=docker.service
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now docker-user-firewall.service
# Preuve : on redémarre Docker (vide la chaßne) et les rÚgles reviennent seules
sudo systemctl restart docker && sleep 3 && sudo iptables -L DOCKER-USER -n --line-numbers
Couche 4 : rĂ©duction de surface¶
Rotation du token d'authentification¶
Ce que c'est. Le runner s'authentifie avec un runner authentication token
(préfixe glrt-), en clair dans config.toml. Il autorise à récupérer et exécuter
les jobs du projet : quiconque le possĂšde peut enregistrer un faux runner et
intercepter des jobs (donc des secrets de CI).
Pourquoi le tourner. Par hygiĂšne, pas parce qu'il est compromis : un secret de longue durĂ©e voit son risque grandir (fuite via backup, snapshot, copie...). Le tourner rĂ©duit la fenĂȘtre d'exploitation.
Il y a deux façons de le tourner.
Façon retenue : rotation MANUELLE (dĂ©clenchĂ©e Ă la main)¶
C'est ce qu'on a fait : on appelle l'API en présentant le token courant, GitLab en émet un nouveau et invalide l'ancien. Tout se passe sur la VM, le token ne transite par aucun tiers.
# 1. Backup du fichier (rollback)
sudo cp /etc/gitlab-runner/config.toml /etc/gitlab-runner/config.toml.pre-token
# 2. RécupÚre le token courant depuis le fichier
CUR=$(sudo awk -F'"' '/token =/{print $2; exit}' /etc/gitlab-runner/config.toml)
# 3. Demande un nouveau token (prĂ©sente l'actuel) -> l'ancien est invalidĂ© AUSSITĂT
NEW=$(curl -s --request POST "https://gitlab.com/api/v4/runners/reset_authentication_token" \
--form "token=${CUR}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
echo "longueur nouveau token : ${#NEW}" # ~77 -> OK ; 0 -> échec, l'ancien reste valide
# 4. Ăcrit le nouveau token dans config.toml
sudo sed -i "s|token = \"${CUR}\"|token = \"${NEW}\"|" /etc/gitlab-runner/config.toml
# 5. Redémarre + vérifie (le runner doit repasser "online" et exécuter un job)
sudo gitlab-runner restart && sudo gitlab-runner status
PrĂ©caution clĂ©. DĂšs l'Ă©tape 3, l'ancien token est mort : enchaĂźne 3 â 5 sans traĂźner. Si le runner reste
offline, restaureconfig.toml.pre-tokenpuis relance une rotation propre. L'id du runner ne change pas (historique et tags préservés).Pourquoi l'endpoint reset et pas un ré-enregistrement : ré-enregistrer créerait un nouveau runner (nouvel id, perte d'historique). Le reset par id (
/runners/:id/reset_authentication_token) exige un accĂšs admin d'instance, indisponible sur gitlab.com SaaS.
Façon alternative : rotation AUTOMATIQUE (expiration cĂŽtĂ© serveur)¶
GitLab peut imposer une expiration des tokens de runner ; le gitlab-runner se
rotationne alors tout seul avant l'Ă©chĂ©ance, sans intervention. Ăa se rĂšgle cĂŽtĂ©
serveur, pas sur la VM :
- Instance self-managed : Admin Area â Settings â CI/CD â Runners â Runner token expiration.
- Groupe : Group â Settings â CI/CD â Runners.
On y fixe un intervalle ; le runner dĂ©tecte l'Ă©chĂ©ance et appelle lui-mĂȘme
reset_authentication_token.
Pourquoi le manuel ici¶
| CritĂšre | Manuel (retenu) | Automatique |
|---|---|---|
| ContrĂŽle | â on choisit la fenĂȘtre (SPOF : maĂźtriser quand le runner redĂ©marre) | â ïž rotation Ă un moment subi |
| Mise en place | â rien Ă configurer, une commande | â exige une politique d'expiration |
| Contexte projet | â runner unique sur SaaS (pas d'Admin Area), pas de flotte | â pensĂ© pour flottes / instances self-managed |
| Effort rĂ©current | â ïž Ă refaire Ă la main pĂ©riodiquement | â zĂ©ro intervention |
Pour un runner unique sur SaaS, le manuel est plus simple et plus sûr. L'auto deviendra pertinent si le projet passe sous un groupe (on pourra y fixer une expiration) ou sur une instance self-managed.
slapd (OpenLDAP) dĂ©sactivĂ©¶
Service inutilisĂ© sur ce runner â une surface d'attaque en moins.
sudo systemctl disable --now slapd # coupe le boot
sudo service slapd stop # arrĂȘt immĂ©diat (service sysv)
sudo systemctl is-active slapd || echo "slapd arrĂȘtĂ©"
Horloge : synchronisation garantie aprĂšs un resume ESXi¶
Ce n'est pas du durcissement au sens strict, mais c'est la panne la plus fréquente de cette VM : six occurrences entre le 2026-06-23 et le 2026-07-26. Elle appartient à cette couche parce que le correctif est un réglage d'hÎte, comme le reste.
Le symptÎme ne parle jamais d'heure. Quand la VM sort d'une pause ou d'un shutdown de l'hyperviseur, l'horloge invitée peut avoir des heures ou des jours de retard. Conséquence en CI :
build-candidateéchoue en 401 au push ECR ;- ou
aws stsrenvoieSignatureDoesNotMatch(« Signature expired ») ; - et les jobs non-AWS passent.
C'est cette asymĂ©trie qui identifie la panne : SigV4 rejette au-delĂ de 5 minutes d'Ă©cart, alors que rien d'autre dans le pipeline ne regarde l'heure. Un pipeline oĂč seuls les jobs AWS tombent est une horloge fausse jusqu'Ă preuve du contraire.
Les deux commandes qui mentent¶
C'est ce qui a fait perdre le plus de temps, et aucune des deux n'est un mauvais outil : les deux rapportent fidÚlement un état de croyance, pas l'heure réelle.
| Commande | Ce qu'elle a affiché | La réalité |
|---|---|---|
timedatectl |
System clock synchronized: yes |
horloge fausse de 22 h â systemd-timesyncd se croyait synchronisĂ© |
chronyc tracking |
offset â 0s |
horloge fausse de 17 h, référence NTP périmée, makestep inopérant |
Le seul arbitre est la comparaison croisée : heure systÚme contre RTC
(sudo hwclock --show) contre l'heure réelle.
Le correctif durable, posĂ© en deux temps¶
# 1. Un seul démon NTP. systemd-timesyncd ressuscite et reprend la main sur chrony.
sudo systemctl mask --now systemd-timesyncd
# 2. Autoriser chrony à corriger un écart arbitraire, à tout moment.
# Le défaut `makestep 1.0 3` ne rattrape que les 3 premiÚres mises à jour :
# au-delĂ , chrony *slew* (corrige en douceur) un retard d'un jour, donc jamais.
printf '%s\n' 'makestep 1 -1' | sudo tee /etc/chrony/conf.d/99-runner.conf
sudo systemctl restart chrony
Ces deux réglages ont éteint les récidives de fond. Le 2026-07-11, un décalage transitoire au démarrage a été rattrapé seul : le correctif tient.
DĂ©bloquer une VM dĂ©jĂ dĂ©calĂ©e¶
Dans cet ordre, en s'arrĂȘtant dĂšs que l'heure est juste :
sudo chronyc makestep # cas courant
sudo hwclock --hctosys # si le RTC est juste et que chrony s'entĂȘte
sudo systemctl restart chrony # purge l'historique de confiance de chrony
sudo hwclock --systohc # recaler le RTC, UNIQUEMENT quand l'heure systĂšme est juste
Le dernier geste est le plus facile Ă faire au mauvais moment : recaler le RTC sur une heure systĂšme fausse propage l'erreur au lieu de la corriger.
Ce qui reste ouvert : une course au dĂ©marrage¶
Le 2026-07-26, une nouvelle occurrence est survenue avec la configuration chrony correcte. Ce n'était donc pas un défaut de réglage mais une course : le runner a pris un job avant que chrony n'ait fini de corriger. Le décalage s'est résorbé seul en deux minutes, le job avait déjà échoué.
Deux pistes, aucune appliquée à ce jour :
chrony-wait.serviceen dĂ©pendance degitlab-runnerâ l'unitĂ© existe pour ça : elle bloque jusqu'Ă ce que l'horloge soit synchronisĂ©e. Un drop-inAfter=chrony-wait.service/Wants=chrony-wait.servicesurgitlab-runnerferme la fenĂȘtre au boot. VĂ©rifier d'abord le comportement si la synchronisation n'aboutit pas : un runner qui ne dĂ©marre jamais est une autre panne.vmware-toolbox-cmd timesync enableâ traite le resume plutĂŽt que le boot, en laissant les VMware Tools resynchroniser Ă la reprise. Ă arbitrer avec chrony : deux correcteurs de temps simultanĂ©s sont exactement le problĂšme quemask systemd-timesyncda rĂ©solu.
VĂ©rifier¶
timedatectl # Ă recouper, ne pas croire sur parole
chronyc tracking # idem
sudo hwclock --show # l'arbitre
systemctl is-enabled systemd-timesyncd # doit répondre "masked"
grep -r makestep /etc/chrony/conf.d/ # doit répondre "makestep 1 -1"
config.toml de rĂ©fĂ©rence (redacted)¶
concurrent = 2
[[runners]]
name = "runner1"
url = "https://gitlab.com"
executor = "docker"
# token = "<NE JAMAIS COMMITER>"
[runners.docker]
image = "ruby:3.3"
privileged = false
cap_drop = ["ALL"]
cap_add = ["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID"]
cpus = "2"
memory = "2g"
memory_swap = "3g"
memory_reservation = "1g"
volumes = ["/cache", "/cert/client"]
Restauration / rollback¶
Du plus fin au plus large :
- Backups de fichiers (pris avant chaque modif) :
sudo cp /etc/gitlab-runner/config.toml.<suffixe> /etc/gitlab-runner/config.tomlpuissudo gitlab-runner restart. - RÚgles iptables (réversibles à chaud) :
sudo iptables -F DOCKER-USERou suppression cibléesudo iptables -D DOCKER-USER .... - Snapshots de l'hyperviseur : restauration complÚte de la VM. à privilégier dÚs qu'une CI rouge est inexplicable.
VĂ©rifications rapides¶
sudo sshd -T | grep -iE '^(port|permitrootlogin|passwordauthentication)' # accĂšs
sudo ufw status verbose # pare-feu
sudo fail2ban-client status sshd # bans SSH
sudo grep -E 'privileged|cap_|memory|cpus' /etc/gitlab-runner/config.toml # executor
sudo iptables -L DOCKER-USER -n --line-numbers # cloisonnement
sudo hwclock --show # horloge (l'arbitre)
Réflexe avant de creuser une CI rouge : si seuls les jobs AWS échouent, vérifier l'horloge en premier (voir couche 4).
La validation qui compte : un pipeline app complet vert (les jobs tirent les images et joignent gitlab.com/ECR) et un pipeline infra-status vert (l'executor durci n'a pas cassé les jobs terraform/aws-cli).