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

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

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).