Skip to content

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 no t'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 no ferme 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) et backend = systemd (lit le journal systemd, pas /var/log/auth.log souvent 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-runner et 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-runner reste en root : le passer non-root imposerait le groupe docker, é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, restaure config.toml.pre-token puis 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 sts renvoie SignatureDoesNotMatch (« 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.service en dĂ©pendance de gitlab-runner — l'unitĂ© existe pour ça : elle bloque jusqu'Ă  ce que l'horloge soit synchronisĂ©e. Un drop-in After=chrony-wait.service / Wants=chrony-wait.service sur gitlab-runner ferme 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 que mask systemd-timesyncd a 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 :

  1. Backups de fichiers (pris avant chaque modif) : sudo cp /etc/gitlab-runner/config.toml.<suffixe> /etc/gitlab-runner/config.toml puis sudo gitlab-runner restart.
  2. RÚgles iptables (réversibles à chaud) : sudo iptables -F DOCKER-USER ou suppression ciblée sudo iptables -D DOCKER-USER ....
  3. 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).