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
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Ă©"
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
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).