- Shell 76.9%
- HTML 19.5%
- JavaScript 2.9%
- Dockerfile 0.7%
Le next build d'ATLAS culmine à 2,8 Go seul (mesuré aux limites du runner) ; lancé depuis un test d'intégration, à côté de Vitest et d'autres fichiers, il dépassait 3 Go et le noyau le tuait (« next build a échoué (code null) », EQS/ATLAS#8). Le jour prévu par le commentaire de config.yml : 4 Go et 3 584 Mo de tas, ensemble. dind à 5 Go pour un job, dockerd et le Postgres de l'intégration. Un seul job à la fois ; l'hôte a 4 Go de swap depuis ce matin pour protéger Forgejo. Harnais e2e : 29/29, NODE_OPTIONS reçu, services plafonnés à 4 Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .githooks | ||
| dashboard | ||
| scripts | ||
| spinoff | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| config.yml | ||
| docker-compose.yaml | ||
| README.md | ||
forgejo-runner
Runner Forgejo Actions pour l'instance EQS, déployé comme ressource Docker Compose dans Coolify, sur le même serveur et dans le même environnement que Forgejo — mais en ressource séparée.
Ce dépôt ne contient que le runner. Les workflows qui l'utilisent (Renovate, scans de sécurité) vivent dans leurs propres dépôts : voir Découpage.
Fichiers
| Fichier | Rôle |
|---|---|
docker-compose.yaml |
Runner + dind isolé + ménage disque, pour Coolify |
config.yml |
Config du runner (label node-22, cache, capacité, limites de job) |
.env.example |
Variables à renseigner dans l'UI Coolify |
scripts/verify.sh |
Harnais : prouve que les protections échouent quand elles doivent |
scripts/e2e/run.sh |
Test de bout en bout contre une instance Forgejo jetable |
scripts/e2e/*-workflow.yml |
Les workflows hostiles que ce test exécute |
scripts/e2e/lab.sh |
Banc d'essai manuel, pour investiguer (pas pour valider) |
.githooks/pre-push |
Barrière de push : refuse d'envoyer ce qui ne passe pas les contrôles |
scripts/e2e/endurance.sh |
Banc d'endurance : la vraie pile sous charge répétée, on regarde les pentes |
scripts/page-hors-nominal.js |
Fait tourner le JS de la page sans navigateur, sur les relevés dégradés |
scripts/e2e/coolify/ |
Recette : Coolify jetable qui déploie ce dépôt comme en production |
.gitattributes |
Fins de ligne LF : un shebang CRLF fait sortir le conteneur en 127 |
dashboard/index.html |
Page de supervision (statique, sans dépendance) |
dashboard/nginx.conf |
Serveur : authentification, en-têtes, page d'erreur |
dashboard/40-auth.sh |
Rotation du mot de passe au démarrage |
dashboard/401.html |
Page « accès restreint » (celle de nginx est illisible) |
scripts/snapshot.sh |
Collecte de l'état, exécutée dans dind |
scripts/runner-heartbeat.sh |
Battement de cœur du runner |
Modèle de sécurité
Un runner exécute du code arbitraire : c'est sa raison d'être. Tout ici consiste à borner ce que ce code peut atteindre.
Le point à comprendre : les conteneurs de job tournent DANS dind. Ils sortent par l'interface réseau de dind et peuvent donc joindre son IP. Retirer l'accès Docker côté runner ne retire que la commodité, pas la capacité.
Les barrières, par ordre d'importance
-
TLS mutuel sur le daemon dind (
DOCKER_TLS_CERTDIR: /certs). Sans elle, le point d'entrée de l'image écoute en clair et sans authentification sur 2375, et n'importe quel job peut créer un conteneur privilégié montant/— root sur le VPS, donc Forgejo et les secrets de toutes les autres ressources Coolify. Ne jamais remettreDOCKER_TLS_CERTDIR: "". -
Pare-feu des conteneurs de job, posé dans dind. Un job sort par la passerelle de dind et atteignait donc l'hôte, donc tout port publié par les autres ressources Coolify — mesuré : HTTP 200 depuis un job vers une autre appli. Ce n'est pas de l'accès Docker mais du routage IP : ni le TLS ni
docker_hostn'y changent rien. Les règles refusent les plages privées, les métadonnées de l'hébergeur (169.254.0.0/16) et l'IPv6 en transit ; elles n'ouvrent que le proxy de cache (8089), pas le serveur derrière.Ce pare-feu a un point de rupture connu, et il a été mesuré. Les règles vivent dans
DOCKER-USER, branchée sur la chaîneFORWARD: elle ne voit que le trafic qui transite. Un paquet émis depuis la pile réseau de dind elle-même passe parOUTPUTet n'est jamais examiné. Or la documentation de sécurité de Forgejo indique quecontainer.networkde la configuration ne s'applique pas aux conteneurs créés par les workflows, et que ceux-ci « peuvent définir--network=host». Un job qui y parviendrait atterrirait dans la pile de dind et contournerait donc l'intégralité du filtrage.Mesuré sur le runner v13.1.0 : l'option est ignorée, le conteneur reste sur son réseau par workflow (
WORKFLOW-<hash>). La barrière tient. Mais c'est un comportement de version, contraire à ce que la documentation laisse attendre : le harnais le rejoue à chaque exécution, et une mise à jour du runner qui le changerait ferait rougir le test au lieu d'ouvrir une brèche en silence.Elles vivent dans dind et non dans un conteneur annexe : un sidecar en
network_mode: service:dinda été essayé puis rejeté. Au redémarrage de dind la pile réseau est recréée, le sidecar continue d'écrire dans l'ancienne, affiche des règles parfaites — et les jobs atteignent l'hôte. Un pare-feu qui ment. -
Plafonds CPU, mémoire et PID. Une limite mémoire seule n'arrête pas une fork bomb : les PID sont une ressource de l'hôte, et Docker n'en plafonne aucune par défaut.
-
Aucun accès Docker monté dans les jobs (
container.docker_host: "-"). Défense en profondeur. Conséquence assumée : pas dedocker builden CI. -
Portée instance, pour le jeton d'enregistrement (2026-10-15) : une file pour toute la forge. La porte d'entrée se tient côté forge — inscription fermée, approbation des premiers contributeurs extérieurs — parce qu'un runner d'instance exécute les workflows de n'importe quel dépôt.
-
Capabilities retirées partout où elles ne servent pas —
runner,pruneetdashboardtournent aveccap_drop: ALLetno-new-privileges. -
Aucun socket Docker de l'hôte n'est monté nulle part.
Ce qu'un workflow hostile ne peut pas faire
Rien de cette liste n'est déduit de la documentation : un workflow hostile le
tente réellement à chaque sh scripts/e2e/run.sh, contre une instance Forgejo
jetable.
| Tentative | Résultat mesuré |
|---|---|
| Joindre le daemon Docker | 2375 inexistant, 2376 rejeté faute de certificat client |
| Atteindre l'hôte ou une autre ressource Coolify | refusé par le pare-feu |
options: "--privileged" |
ignoré — le job constate qu'il n'a aucune capability de plus |
--volume /:/hostfs, socket Docker |
ignorés — aucun montage |
options: "--network=host" |
ignoré — le job reste sur son réseau par workflow |
--memory=8g |
refusé par le runner, qui abandonne le job |
| Fork bomb | contenue par le cgroup (511 processus sur 512) |
| Lire le job voisin (fichiers, réseau) | rien d'accessible |
| Empoisonner le cache d'un autre dépôt | même clé, aucun contenu restitué |
Lire l'environnement du runner, ses certificats ou son fichier .runner |
rien d'atteignable |
| Se servir du jeton du job au-delà de son dépôt | autre dépôt privé 404, API admin 403, aucune énumération |
| Atteindre le serveur de cache (8088) | expire ; seul le proxy est joignable |
Se donner un conteneur sans plafond via services: |
il hérite des mêmes plafonds que le job (4 Go sans swap, 512 PID, non privilégié) |
Le test du cache s'accompagne d'un témoin positif : le dépôt qui a écrit relit bien sa propre entrée. Sans lui, un cache simplement cassé se lirait comme un cloisonnement réussi.
Ce qu'un job reçoit, et rien de plus
Vérifié depuis l'intérieur d'un vrai job, à chaque exécution du harnais :
- Aucune variable d'environnement du runner ne descend dans le job — ni
DOCKER_HOST, niDOCKER_CERT_PATH, ni le jeton d'enregistrement. - Aucun certificat, relevé de supervision ni fichier
.runnern'est atteignable depuis un job. /run/act— le répertoire où vivent les scripts d'étape,event.jsonetenvs.txt— est propre à chaque job, bien qu'il soit inscriptible. Deux jobs simultanés n'y voient jamais les fichiers l'un de l'autre. S'il était partagé, un dépôt pourrait réécrire le script d'un autre : exécution de code croisée.- L'espace de travail est un volume par job, supprimé en fin de job : ni partage, ni accumulation sur le disque.
- Le jeton remis au job ne porte que sur son propre dépôt : un autre dépôt
privé répond 404 en lecture comme en écriture, l'API d'administration 403, et
/user/reposrenvoie une liste vide — aucune énumération des dépôts de l'instance n'est possible.
Ce qui reste exposé, sciemment
-
Internet sortant complet depuis un job — il en a besoin (
npm ci,apt, téléchargement des outils). Un workflow hostile peut donc exfiltrer ce qu'il a en main : le code du dépôt et les secrets que TU passes à ce job. Ne mets aucun secret d'organisation dans Forgejo si un seul dépôt est moins fiable. -
Nombre de conteneurs de service non borné par le runner — leur taille, elle, l'est : mesuré, chacun hérite exactement des plafonds du job (4 Go sans swap, 512 PID, non privilégié), même quand le workflow ne demande rien. Le garde-fou sur le total reste donc celui de dind : 5 Go de mémoire, sans swap non plus, et 4096 PID, qui plafonnent l'ensemble quoi qu'on déclare.
-
Le jeton d'enregistrement reste valide après usage : le révoquer une fois le runner
Idle. Il est aussi visible dansdocker inspectet dans Coolify. -
DASHBOARD_PASSWORDest une variable d'environnement, donc lisible pardocker inspectet dans l'interface Coolify, comme tout secret passé de cette façon. Qui a cet accès a déjà le socket Docker de l'hôte, c'est-à-dire root : ce n'est pas une aggravation, mais c'est à savoir avant de réutiliser ce mot de passe ailleurs. Ne pas le réutiliser. -
Le volume
runner-datacontient le jeton permanent du runner : qui le lit peut se faire passer pour lui et recevoir de vrais jobs, avec leurs secrets. -
Le volume
docker-certscontient la clé cliente du daemon privilégié : qui la lit obtient root sur le VPS. Montée en lecture seule, et jamais dans le serveur web. -
Rien ne borne le disque qu'un job consomme. C'est le principal risque de disponibilité restant. Mesuré : un job voit tout le système de fichiers de dind (744 Go libres sur le VPS de test) et y écrit à 1,9 Go/s. Un seul job suffirait donc à remplir le disque de l'hôte, ce qui emporte Forgejo avant la CI.
Le quota par conteneur (
--storage-opt size=) n'est pas utilisable : il exige overlay sur XFS monté enpquota, et dind tourne sur extfs. Ni le plafond mémoire, ni--pids-limitne couvrent le disque.Ce qui existe à la place, faute de prévention : le ménage quotidien de
prune(rétention 72 h), l'alarme à 80 % dans les journaux de dind, et la jauge du tableau de bord qui passe à l'ambre à 80 % et au rouge à 90 %. C'est de la détection, pas une barrière — à surveiller si des jobs manipulent de gros artefacts. -
L'accès aux artefacts d'un autre dépôt n'est pas testé. La sonde écrite pour cela a été retirée : tous les chemins de l'API d'artefacts répondent 404 depuis un job, et rien ne permet de distinguer « accès refusé » de « mauvais chemin ». Un test incapable de faire cette différence vaut moins que pas de test — il rassurerait à tort. Le mécanisme est le même que celui du jeton de dépôt, dont le cloisonnement est prouvé, mais ce n'est pas une preuve.
-
Le tableau de bord est joignable depuis Internet. C'est un choix, pas un oubli : sans accès SSH au serveur, il n'existe aucune autre façon de lire l'état de la pile. Ce qui le rend acceptable — TLS terminé par Traefik, mot de passe d'au moins 12 caractères imposé par le conteneur et renouvelé à chaque démarrage tant qu'il n'est pas fixé, 60 requêtes par minute, et une page qui ne détient ni jeton, ni certificat, ni accès au daemon. Ce qu'il révèle malgré tout à qui franchit l'authentification : la topologie interne, les règles de pare-feu et les noms des jobs en cours.
-
Les images de job sont des tags flottants. Épingler par empreinte n'apporterait guère : un workflow peut de toute façon désigner l'image de son choix, et le runner n'offre pas de liste blanche de registres.
Le fond du sujet
Quiconque peut pousser sur un dépôt servi par ce runner peut y exécuter ce qu'il veut. Le modèle tient parce que les dépôts EQS sont internes ; il ne tiendrait pas tel quel pour des dépôts publics acceptant des contributions externes.
Ne pas gêner Forgejo
Forgejo partage le VPS. Trois ressources peuvent l'emporter, et chacune a son garde-fou — tous vérifiés réellement appliqués, pas seulement déclarés :
| Ressource | Garde-fou | Pourquoi ça compte |
|---|---|---|
| CPU / RAM | dind plafonné à 2,5 CPU / 5 Go, et chaque job à 2,5 / 4 Go — swap compris, un seul job à la fois (capacity: 1), avec un tas V8 de 3 584 Mo (NODE_OPTIONS) |
Relevé le 2026-10-14 : à 1 Go, pnpm install d'un monorepo Next mourait en 137 ; à 2 Go, next build une fois sur deux. Le 2026-10-15 : à 3 Go, le lint typé d'ATLAS mourait en SIGABRT (tas V8 plafonné à la moitié du conteneur) et son next build, qui culmine à 2,8 Go seul, était tué quand un test d'intégration le lançait. Un seul job à la fois laisse la marge à Forgejo sur un VPS de 8 Go. Les jobs tournent dans dind, pas dans le service runner : c'est là que la limite doit être. « Swap compris » n'est pas décoratif : par défaut le noyau accorde autant de swap que de mémoire, et ces plafonds valaient donc le double (voir plus bas). |
| PID | pids: 4096 sur dind, --pids-limit=512 par job |
Une limite mémoire n'arrête pas une fork bomb. Les PID sont une ressource d'hôte, et Docker n'en plafonne aucune par défaut. |
| Disque | prune quotidien (rétention 72 h) + alarme à 80 % |
Détection seulement. Aucun quota par job n'est possible ici — voir les risques résiduels. Le volume dind vit sur le disque de l'hôte : s'il le remplit, ce n'est pas la CI qui tombe, c'est Forgejo. |
Le nombre de conteneurs de service qu'un job démarre n'est pas borné par le runner (la doc Forgejo l'admet) : c'est le plafond de dind qui fait rempart.
Le réseau est le quatrième garde-fou, et le plus facile à croire acquis.
Le pare-feu des jobs ferme tout le registre IANA des adresses à usage
spécial — pas seulement les plages « privées » — et, si VPS_PUBLIC_IP est
renseignée, toute l'adresse publique du serveur hors 80 et 443. dind lui-même
n'accepte plus rien de ses propres ponts. Sans cette variable, un workflow atteint sshd et tous les ports
publiés par les autres applications de ce VPS : c'est le premier réglage à
faire, et dind le rappelle dans ses journaux tant qu'il manque.
Installation
-
Coolify : + New → Docker Compose → dépôt
Infrastructure/forgejo-runner, fichierdocker-compose.yaml.Ne pas activer « Connect to Predefined Network » : cette option placerait
dindet lerunnersur le réseau partagé de Coolify, aux côtés de toutes les autres ressources du serveur. Voir « Tableau de bord » pour ce que cela coûte exactement. -
Variables (Coolify → Environment Variables) :
Variable Valeur FORGEJO_INSTANCE_URLhttps://git.equilibre-structures.fr— enhttps://, le démarrage refuse une URL en clairRUNNER_REGISTRATION_TOKENGET /api/v1/admin/runners/registration-tokenavec un jeton d'accès à la portée admin — voir ci-dessousDASHBOARD_PASSWORDun vrai mot de passe, long. Sans lui, il est tiré au hasard à chaque démarrage et il faut le relire dans les journaux après chaque redéploiement RUNNER_CACHE_MAX_MBfacultatif, 4096 par défaut. Plafond du cache des actions, purgé au démarrage du conteneur Le jeton est celui de l'instance. Forgejo 15 ne l'affiche plus : son bouton « Create new runner » donne un UUID et un token, faits pour
server.connectionsdeconfig.yml, queforgejo-runner registerrefuse (« runner registration token not found », mesuré le 2026-10-14). Le demander à l'API, sans le faire transiter par un tiers :read -rsp "Jeton d'accès (portée admin) : " PAT && echo && curl -s -H "Authorization: token $PAT" https://git.equilibre-structures.fr/api/v1/admin/runners/registration-token; echo; unset PATPour réenregistrer un runner déjà enregistré :
rm /data/.runnerdans le conteneurrunner, nouveau jeton dans Coolify, Redeploy — puis supprimer l'ancien runner, hors ligne, dans Forgejo. -
Activer « Preserve Repository During Deployment ». C'est le réglage sans lequel ce dépôt ne peut pas se déployer, et il est désactivé par défaut.
Vérifié en montant un Coolify jetable et en y déployant cette pile : sans ce réglage, Coolify ne dépose sur le serveur que le fichier compose et un
.env— aucun fichier du dépôt. Docker, à qui l'on demande alors de monter./dashboard/nginx.conf, ne trouve rien à ce chemin et crée un répertoire. Mesuré sur le serveur cible :config.yml,dashboard/nginx.conf,dashboard/index.html,dashboard/401.htmletdashboard/40-auth.shétaient tous des répertoires. Le déploiement échoue alors sur :mount src=.../dashboard/nginx.conf ... not a directory: Are you trying to mount a directory onto a file (or vice-versa)?L'échec est bruyant, et c'est une chance : il vient du tableau de bord. Si celui-ci n'était pas dans la pile, le runner démarrerait avec un
config.ymlqui est un répertoire, donc sur ses valeurs par défaut — sans plafond mémoire, sans plafond de PID, et surtout sansdocker_hostneutralisé, ce qui monte un accès Docker dans chaque job. Le tableau de bord le signalerait (config.yml n'est pas un fichier), mais après coup.Avec le réglage actif, les fichiers arrivent réellement — et
40-auth.shavec son bit exécutable, sans lequel nginx l'ignorerait. -
Attribuer un domaine au service
dashboard. Le service écoute sur le port 80 : le domaine seul suffit, Coolify pose la route Traefik et termine le TLS. C'est le seul accès à la supervision quand on n'a pas de shell. -
Deploy.
-
Ouvrir le tableau de bord — c'est le test de recette. Le mot de passe est dans les journaux Coolify du service
dashboardsi tu ne l'as pas fixé.La bannière doit afficher « Tout est en ordre ». Si elle affiche des anomalies, elle nomme chacune et le geste correspondant. Les trois qui comptent au premier démarrage :
- « config.yml n'est pas un fichier / est vide » → Coolify a transformé le bind-mount de fichier en entrée « Persistent Storage » vide. C'est le piège le plus courant de ce déploiement, et le seul dont l'échec serait autrement silencieux : le runner démarre sur ses valeurs par défaut, donc sans plafond mémoire ni plafond de PID sur les jobs.
- « le daemon isolé tourne SANS plafond » → la version de Docker Compose de
l'hôte a ignoré
deploy.resources.limits. Rien ne l'aurait signalé. - « le pare-feu est incomplet » → les règles manquantes sont nommées ;
redémarrer le service
dind.
Les autres montages échouent d'eux-mêmes et se voient sans la page : sans
nginx.confrien n'écoute, sans40-auth.shle conteneurdashboardest déclaré en échec par Coolify (mesuré : nginx démarre mais refuse tout le monde par un 403, y compris avec le bon mot de passe — d'où le contrôle du fichier d'authentification dans la sonde). -
Vérifier côté Forgejo que le runner apparaît Idle avec les labels
node-22etalpine. -
Révoquer le jeton d'enregistrement. Il reste valide après usage et permettrait d'enregistrer des runners pirates, qui recevraient de vrais jobs avec leurs secrets.
-
Lancer un premier vrai workflow, puis regarder la colonne « plafonds » du tableau des jobs : elle doit afficher
4 Go · 512 proc.. Cette valeur est lue sur le conteneur, pas recopiée de la configuration — c'est la preuve que les limites sont réellement appliquées, pas seulement déclarées.
Vérifier
Deux harnais, complémentaires. Le premier teste la pile isolément :
sh scripts/verify.sh # tout, y compris de vraies attaques
sh scripts/verify.sh --quick # contrôles statiques seulement
Le second monte une instance Forgejo jetable et lui fait distribuer de vrais jobs, dont plusieurs workflows hostiles :
sh scripts/e2e/run.sh
Le banc d'endurance
Les trois harnais ci-dessus durent quelques minutes. Ils prouvent qu'une chose marche ; ils ne peuvent rien dire de ce qui dérive. Or cette pile est faite pour tourner des mois sans shell : les pannes qui la guettent réellement ne sont pas des refus nets, ce sont des pentes — de la mémoire qui monte, des volumes orphelins qui s'accumulent, des descripteurs qui fuient, un relevé qui met de plus en plus de temps à s'écrire.
sh scripts/e2e/endurance.sh 35 # minutes, défaut 30
Il monte la vraie pile contre une Forgejo jetable et lui envoie des vagues de trois profils mélangés — un job léger, un job qui écrit et relit le cache, un job avec conteneur de service — puis prélève une ligne de mesures toutes les 30 secondes. À la fin il compare le premier tiers au dernier tiers : pas le premier point au dernier, qui seraient l'un artificiellement bas et l'autre pris au hasard d'un pic.
Premier passage réel, 35 minutes, 39 runs, 20/20 : dockerd +4 %, runner
0 %, descripteurs −1 %, volumes et réseaux plats, mémoire du cgroup de dind
−11 %. Aucune dérive. Une seule grandeur monte, et c'est celle qui doit monter :
le cache des actions, 1,48 Mo/min sous cette charge — le plafond de 4 Go
serait atteint après 45 h de charge continue. C'est ce chiffre-là qui permet
de dimensionner RUNNER_CACHE_MAX_MB au lieu de le deviner.
Le ménage quotidien y tourne enfin sous observation. Son sleep 86400 était
écrit en dur : c'était la seule partie de la pile dont personne n'avait jamais
vu le code s'exécuter, parce que personne ne fait tourner un banc vingt-quatre
heures. RUNNER_PRUNE_INTERVAL est devenue une variable — défaut inchangé, le
banc la descend à 120 s — et le harnais exige d'avoir vu au moins deux cycles
réels avec leur compte rendu de récupération. Une valeur en dur qui rend une
fonction inobservable est un défaut à part entière.
Et la mesure qui compte le plus est l'âge des relevés sous charge. La page considère un relevé périmé au-delà de 45 s. Si la charge fait dépasser ce seuil, l'exploitant lit « le collecteur ne tourne plus » pendant que tout va bien — une porte qui ment, déclenchée par le succès. Pire cas mesuré sur 35 minutes : 11 s et 15 s.
Le banc a d'abord menti, et c'est instructif
Premier passage : 19 assertions vertes, dont quatre sur les fuites. Toutes
fausses. Je lisais /proc/1 — or dans dind le PID 1 est docker-init
(576 ko, immuable) et dans le runner c'est dumb-init, dont le VmRSS vaut
littéralement 0. Le banc rapportait « mémoire stable : 576 → 576 » et
« 0 → 0 » : des assertions qui ne pouvaient pas échouer, sur exactement les
grandeurs qu'il existe pour surveiller. Un test comme celui-là rassure pendant
des mois. Les sondes cherchent maintenant le processus par son nom, sans
jamais supposer de PID.
Un garde-fou en est sorti : une colonne dont la valeur est identique sur tous
les points est presque toujours une sonde cassée, pas une grandeur stable. Le
banc refuse de conclure « stable » dessus et le dit. Ce garde-fou a lui-même eu
un défaut — print "non"; exit au milieu d'une règle awk, alors que exit
exécute quand même le bloc END, qui imprimait le verdict contraire derrière.
Le contrôle écrit contre les fausses mesures en produisait une.
La barrière de push
Ce dépôt part en production directement depuis sa branche : rien ne sépare
push de déployé. Les harnais ci-dessus ne valaient donc que par la
discipline de les lancer — exactement l'écart que ce dépôt poursuit partout
ailleurs. Un crochet le ferme :
git config core.hooksPath .githooks
À chaque push, verify.sh --quick s'exécute : 112 assertions en une
minute, sans monter la pile. Il attrape ce qui casse un déploiement — CRLF sur
un shebang, config.yml amputé, labels qui divergent, plafonds perdus, branches
du tableau de bord. Le harnais complet reste accessible d'un mot :
GATE=complet git push # verify.sh entier + e2e, une trentaine de minutes
git push --no-verify # passer outre, en connaissance de cause
Le contournement est nommé dans le message de refus, délibérément : une barrière qu'on ne sait pas ouvrir se fait désinstaller au premier cas urgent, et on perd tout. Un push qui ne fait que supprimer une branche distante ne publie rien et n'est pas retenu ; Docker à l'arrêt est signalé comme tel et non comme un échec de test, sans quoi on irait chercher une panne inexistante dans le dépôt.
Deux assertions gardent la barrière elle-même. Le mode du crochet est lu dans
l'index git : un crochet sans bit exécutable est ignoré en silence —
mot pour mot la panne déjà vécue ici avec 40-auth.sh. Et core.hooksPath est
vérifié : une barrière versionnée que personne n'a installée rassure sans
protéger, ce qui est pire que pas de barrière du tout.
C'est ce second harnais qui a révélé que le runner crée un réseau par
workflow : un pare-feu cadré sur docker0 affichait des règles impeccables,
des compteurs à zéro, et laissait passer 100 % du trafic réel. Aucun test
hors-ligne ne pouvait le montrer.
Les tests de comportement démarrent la vraie pile compose, pas une réplique :
une réplique dérive, et on finirait par tester autre chose que ce qu'on déploie.
Ils utilisent le même subnet que la production et ne peuvent donc pas tourner
pendant que le runner sert — --quick est fait pour ça.
scripts/verify.sh |
Ce qu'il prouve |
|---|---|
| T1 | Le compose et la config sont valides |
| T2 | Les labels ne dérivent pas entre config.yml et le register |
| T3 | Le plan d'adressage est cohérent et libre |
| T4 | config.yml se charge, et ses clés critiques portent |
| T5 | Le pare-feu des jobs est déclaré au bon endroit et bien cadré |
| T6 | Le tableau de bord est inerte, et joignable |
| T7 | Les relevés ne peuvent pas être invalidés par une valeur hostile |
| T8 | Les workflows hostiles se déclenchent bien |
| T9 | La CI partagée n'installe rien d'inépinglé |
| T10 | Un conteneur de job ne peut pas atteindre le daemon |
| T11 | Le chemin légitime fonctionne encore, en uid 1000 |
| T12 | Un job est confiné : ni hôte, ni cache interne, mais internet OK |
| T13 | Le pare-feu survit à un redémarrage de dind |
| T14 | Les garde-fous de ressources sont réellement posés |
| T15 | Un redéploiement de dind ne coupe pas le runner |
| T16 | Le tableau de bord ne peut rien joindre, et ne rapporte que du mesuré |
scripts/e2e/run.sh |
Ce qu'il prouve |
|---|---|
| 1 | Instance Forgejo jetable |
| 2 | La vraie pile du dépôt, branchée dessus |
| 3 | Un job ordinaire doit s'exécuter |
| 4 | Un workflow HOSTILE ne doit rien obtenir |
| 5 | Un workflow ne peut pas s'octroyer de privilèges |
| 6 | Deux jobs simultanés restent cloisonnés |
| 7 | Rien n'est tombé sous l'attaque |
| 8 | Le tableau de bord dit la vérité |
| 9 | Le cache est cloisonné entre dépôts |
| 10 | Un job ne reçoit que ce qui le concerne |
| 11 | Les conteneurs de service sont bornés comme les jobs |
Les deux tableaux sont tirés des scripts eux-mêmes ; s'ils divergent, c'est que cette page est périmée.
Le harnais a attrapé plusieurs régressions introduites pendant sa propre
écriture — un pare-feu qui filtrait les paquets de retour, un dockerd démarré
sans TLS, un CA qui tournait à chaque redéploiement. Cent mutants lui
ont été soumis, tous détectés.
Recette de déploiement dans un vrai Coolify
Les deux harnais précédents lancent la pile avec docker compose en direct. En
production c'est Coolify qui déploie, et il ne se contente pas d'appliquer le
fichier : il le transforme. Un troisième banc a donc été monté — Coolify jetable,
serveur cible jetable, forge jetable — pour éprouver ce chemin-là.
sh scripts/e2e/coolify/run.sh up # amorce la recette
sh scripts/e2e/coolify/run.sh down # démonte tout, données comprises
Le script fait tout : Coolify isolé, serveur cible, forge, dépôt poussé, application, déploiement, exécution d'un vrai workflow, et vérification du tableau de bord sur la pile déployée. 32 assertions, comptez une vingtaine de minutes au premier passage — les images sont volumineuses.
Isolement. Une installation Coolify occupe des noms globaux : conteneurs
coolify*, volumes coolify-db et coolify-redis, réseau coolify, et le
répertoire /data/coolify qui porte l'APP_KEY chiffrant tous ses secrets. Tout
est préfixé recette-, les données vont dans un répertoire jetable, et Coolify
déploie sur un serveur qui est lui-même un conteneur (Docker-in-Docker + sshd) :
rien ne touche au daemon de la machine. Le script refuse de démarrer si on
pointe son répertoire de données vers /data/coolify.
Ce que la recette a établi
| Question | Réponse mesurée |
|---|---|
Coolify préserve-t-il deploy.resources ? |
Oui, cpus, memory et pids — relu dans le cgroup : pids.max = 4096 |
| Que fait-il des réseaux ? | Il ajoute le sien à tous les services, en plus de ceux déclarés ; le réseau runner et son subnet sont conservés |
| Des montages de fichiers du dépôt ? | Réécrits en chemins absolus sous /data/coolify/applications/<uuid>/ |
| Les volumes nommés ? | Préfixés par l'uuid de la ressource |
| Les noms de conteneurs ? | Remplacés (dind-<uuid>-…), mais les alias de service subsistent : tcp://dind:2376 résout toujours |
| Un job reçoit-il ses plafonds ? | Oui : memory.max = 1073741824, pids.max = 512, mesurés dans le job |
| Le pare-feu tient-il ? | Oui : « aucun accès au daemon », « hôte injoignable », « cache interne hors de portée », et le compteur DROP 10.0.0.0/8 monte réellement |
| Le tableau de bord ? | 401 sans identifiants, 200 avec, config: ok, manquantes: "" |
| Un redéploiement crée-t-il un runner fantôme ? | Non : toujours un seul runner, même UUID — le volume sur .runner fait son office, et Coolify le préserve |
| Le cache survit-il à un redéploiement ? | Oui, et la purge au démarrage se journalise : [cache] 1 Mo (plafond 4096 Mo) |
| Attribuer un domaine au tableau de bord suffit-il ? | Oui : Coolify génère les labels Traefik, détecte le port 80, et le routage répond 401/200 à travers le proxy |
| Où se retrouve le proxy de Coolify ? | Sur tous les réseaux de la ressource, runner compris — le TLS mutuel refuse quand même 2376 sans certificat client |
Les pièges de Coolify, tous silencieux
Chacun a coûté un passage complet, et chacun est désormais commenté à l'endroit du script qui le contourne :
| Symptôme | Cause |
|---|---|
couldn't find env file |
Compose résout env_file côté client ; sous Docker Desktop, client et daemon ne partagent pas le système de fichiers |
getaddrinfo for coolify-redis failed |
Coolify code en dur les noms d'hôtes de ses dépendances ; renommer les conteneurs les casse |
| utilisateur root jamais créé | le seeder valide les enregistrements MX de l'adresse, et impose majuscule + minuscule + chiffre au mot de passe |
API is disabled |
l'API est désactivée par défaut |
| jeton refusé à la création | les jetons exigent un team_id, que createToken() ne pose pas |
| serveur « injoignable » sans motif | le multiplexage SSH a besoin d'un vrai système de fichiers Linux pour ses sockets, et de la clé lisible par l'uid 9999 |
sh: bash: not found |
Coolify lance toutes ses commandes distantes via bash |
Failed to read Git source |
git doit être présent sur le serveur lui-même, pas seulement dans le conteneur de build |
dumb http transport does not support shallow capabilities |
Coolify clone en --depth=1 : il faut un vrai serveur git |
The git repository cannot use IP addresses |
l'URL du dépôt doit être un nom, résolvable côté serveur et côté conteneur de build |
| runner sans URL ni jeton | loadComposeFile efface les variables d'environnement posées avant lui : les poser après |
La trouvaille qui bloque un déploiement
Sans « Preserve Repository During Deployment », cette pile ne démarre pas.
Le réglage est désactivé par défaut. Sans lui, Coolify ne dépose sur le serveur
que le compose et un .env : aucun fichier du dépôt. Docker, à qui l'on demande
de monter ./dashboard/nginx.conf, ne trouve rien et crée un répertoire.
Relevé sur le serveur cible — config.yml, nginx.conf, index.html,
401.html, 40-auth.sh : tous des répertoires. Le déploiement échoue alors sur
Are you trying to mount a directory onto a file?.
L'échec est bruyant, et c'est un coup de chance : il vient du tableau de bord.
Sans lui dans la pile, le runner démarrerait avec un config.yml qui est un
répertoire — donc sur ses valeurs par défaut, sans plafond mémoire, sans
plafond de PID, et sans docker_host neutralisé, ce qui monte un accès Docker
dans chaque job.
Réglage activé, tout arrive correctement — y compris 40-auth.sh avec son
bit exécutable, sans lequel nginx l'ignore et refuse tout le monde par 403.
memswap_limit survit-il à Coolify ?
Question posée par la correction des plafonds mémoire, et que seule cette
recette peut trancher. memswap_limit n'a aucun équivalent sous deploy:, et
Coolify réécrit le compose avant de l'appliquer : une clé qu'il ne connaîtrait
pas disparaîtrait en silence, et le plafond redeviendrait le double de
l'annoncé sans que rien ne le dise.
Mesuré sur le déploiement réel : dind sort avec Memory=3221225472 et
MemorySwap=3221225472, et le cgroup du conteneur porte
memory.swap.max=0. Le config.yml arrivé jusqu'au runner garde bien
--memory-swap=1g, et un conteneur lancé avec ces options-là obtient
memory.swap.max=0. Coolify conserve la clé ; la correction atteint la
production. Deux assertions le vérifient désormais à chaque passage, en lisant
les options dans le conteneur déployé et non dans le dépôt.
Ce que cette recette ne prouve pas
Elle tourne sur un noyau et une version de Docker Compose qui ne sont pas
forcément les tiens. Que deploy.resources.limits.pids soit appliqué ici ne
garantit pas qu'il le soit là-bas : c'est précisément pourquoi le tableau de
bord lit ces valeurs dans le cgroup et lève une anomalie quand elles manquent.
La recette valide les transformations de Coolify, qui sont les mêmes partout ;
elle ne valide pas l'hôte.
Comment ces tests ont été écrits
Neuf assertions de ce dépôt ont d'abord échoué pour une raison étrangère à ce qu'elles prétendaient mesurer. Elles sont notées ici parce que la même erreur se reproduira sinon :
- une recherche de fuite trouvait son propre motif, écrit en clair dans le script du chercheur ;
- un
exit 0dans une règleawkétait écrasé par leexit 1du blocEND: le test ne rougissait jamais ; - un test de fins de ligne CRLF portait lui-même un retour chariot littéral ;
- une assertion lisait les journaux de Forgejo en croyant qu'ils ne sont conservés que pour les jobs en échec. C'est faux — un job réussi peut avoir le sien, un job en échec peut ne pas l'avoir — et le test échouait par intermittence ;
- un
grepcherchait--pids-limitdans toutconfig.ymlet trouvait le commentaire qui explique l'option, validant une configuration qui ne l'avait plus ; - un test de limitation de débit lançait 200
curlsuccessifs. Chacun rouvrant une connexion, la rafale s'étalait au-delà d'une minute et restait sous le seuil de 60/min : le test échouait alors que la protection fonctionnait. Il mesurait sa propre lenteur. Une seule invocation decurlavec 200 URL, sur une connexion réutilisée, rend la rafale franche — 170 refus sur 200 ; - un test réussissait pour n'importe quelle configuration. Il concluait
« toutes les clés acceptées » sur la présence du message
0 server connections configureddans la sortie du binaire. Or ce message apparaît pour toute config qui se charge : le témoin sain le produit aussi. Mesuré : uneconfig.ymldont la sectioncontainerétait mal orthographiée — donc des jobs sans aucun plafond — passait le test. C'est la découverte du témoin qui l'a révélé, pas la relecture ; - le contrôle d'intégrité de
config.ymlcherchait--pids-limitdans tout le fichier, et le trouvait dans le commentaire qui explique l'option. Unoptions:amputé était donc rapporté « ok » par le tableau de bord. C'est la répétition exacte d'une erreur déjà listée ci-dessus, dans un autre fichier : la leçon n'avait été appliquée qu'à l'endroit où elle avait été apprise ; - un test observait le conteneur d'un job pendant une fenêtre fixe de 90 s.
Les jobs d'un même workflow passant à
capacity: 2, celui qu'on observait attendait derrière les autres : le test concluait « aucun conteneur créé » alors qu'il mesurait la longueur de la file. Il surveille désormais l'état du run plutôt que l'horloge, et relit une fois après l'état terminal pour fermer la course entre les deux lectures.
Deux règles en découlent, appliquées partout :
- Observer l'objet, pas un artefact intermédiaire. Le plafond mémoire d'un job se lit dans dind sur le conteneur réel, pas dans un journal.
- Un constat négatif exige un témoin positif. « La victime n'obtient rien » ne prouve le cloisonnement que si le dépôt d'origine, lui, retrouve bien son entrée. Sinon on mesure une panne et on la lit comme une protection.
- Lire la valeur d'une clé, jamais le fichier entier. Un commentaire qui
documente une option contient le texte de cette option ; un
grepglobal le trouve et valide une configuration qui ne l'a plus. - Ce qui est permissif sur le poste ne l'est pas sur le serveur. Les permissions de fichier, les fins de ligne et la casse des chemins se comportent différemment sous Windows : ce qui doit voyager se vérifie dans l'index git, pas dans le répertoire de travail.
- Un test doit pouvoir dire « je n'ai rien mesuré ». Le test de force brute compte d'abord combien de réponses lui sont revenues, et refuse de conclure en dessous de la moitié. Sans ce garde-fou, une pile injoignable produirait zéro refus et se lirait comme une absence de protection — ou l'inverse, selon le sens de l'assertion.
Vérifier que la porte ne ment pas
Un runner absent laisse les workflows en attente pour toujours pendant que les dépôts affichent une CI « active ». Après installation, casser volontairement une commande dans un workflow de test : si le run reste vert, aucun runner ne l'a pris.
Le même souci vaut pour le tableau de bord : sans accès au serveur, il est le seul témoin, et un témoin qui se trompe coûte plus cher que pas de témoin. Cinq façons dont il aurait menti ont été trouvées en l'attaquant, toutes corrigées et toutes verrouillées par un test :
| Ce qui se passait | Ce que la page affichait | Ce qu'elle affiche maintenant |
|---|---|---|
Le pare-feu dépasse 100 000 paquets bloqués : iptables abrège le compteur en « 12M », qui partait comme nombre JSON |
« Le collecteur ne tourne pas » — la page s'éteignait toute seule, le jour où le pare-feu avait le plus servi | compteurs exacts, relevé valide |
| L'horloge du serveur est en avance sur celle du navigateur | « Relevé il y a 0 s », éternellement frais : un collecteur mort restait au vert | « Horloges désaccordées », écart chiffré, fraîcheur déclarée invérifiable |
| Relevé valide mais amputé d'une section | bannière verte figée sur du vide, en permanence | « Relevé illisible », avec le service à inspecter |
| Relevé présent mais illisible | « Le fichier d'état est introuvable » — envoyait chercher une panne inexistante | la cause exacte : le contenu, pas la présence |
| Le collecteur ne sait pas lire le certificat du daemon | date vide et « en service » : rassurait sur un contrôle non fait | anomalie explicite |
Combinaisons de pannes, et deux fenêtres refermées
Casser un composant à la fois finit par rendre. La tournée suivante a donc provoqué des combinaisons — et une mise à jour de Docker Desktop en cours de route a offert un cas qu'on ne peut pas fabriquer : toute la pile sur une version majeure différente.
| Éprouvé | Résultat |
|---|---|
| Docker 29.1.3 au lieu de 28.5.2 | deploy.resources toujours appliqué, pare-feu complet, compteurs numériques — la pile passe d'une version majeure à l'autre |
| dind redémarre pendant un job | le job meurt avec lui, aucun conteneur ne tourne dans l'intervalle, les règles reviennent en ~6 s |
Saturation : 4 jobs pour capacity: 2 |
jamais plus de 2 conteneurs, la page reflète exactement ce qui tourne, la file se vide |
| La forge tombe puis revient | le runner reprend les jobs sans redémarrage |
| Un job qui alloue sans fin | mort lisible (Killed, exitcode 137) — mais à 2 048 Mo sous un plafond de 1 Go : voir plus bas, tous les plafonds mémoire valaient le double |
| La page sans relevé, dans un navigateur | elle accusait le collecteur d'écrire un relevé illisible alors qu'il n'en écrivait aucun — voir plus bas |
Un plafond de cache mal saisi (4G, abc) |
la purge ne se déclenchait plus jamais pendant que la page annonçait « 4096 Mo » — voir plus bas |
| Chronométrage des collecteurs | docker system df appelé deux fois par tour, 6–7 s l'appel, sans borne — voir plus bas |
Première fenêtre refermée — le pare-feu au redémarrage. Mesuré : après un
redémarrage de dind, dockerd acceptait des connexions avant que la boucle
n'ait reposé les règles. Le runner interroge Forgejo toutes les deux secondes :
un job créé dans cet intervalle aurait tourné sans filtrage. Les règles sont
désormais posées avant de lancer le daemon — vérifié que dockerd préserve
une chaîne DOCKER-USER existante et se contente d'y ajouter le saut depuis
FORWARD. La garantie est structurelle, plus une question de cadence, et le
journal de dind s'ouvre sur [pare-feu] regles posees avant le demarrage du daemon.
Seconde fenêtre — dans la sonde que je venais d'ajouter. La sonde de la forge
partageait le compteur qui espace la mesure du cache : elle ne tournait donc
qu'une fois toutes les cinq minutes. Mesuré : forge arrêtée 80 secondes, la
page a affiché « ok » du début à la fin. Un angle mort de cinq minutes sur le
signal le plus important du relevé, créé par un couplage sans raison. Une requête
HTTP toutes les quinze secondes ne coûte rien ; du sur plusieurs gigaoctets,
si. Après correction : ok → injoignable en 45 s, et retour à ok au rétablissement.
Cinq pannes de plus, provoquées et observées
Deuxième tournée de pannes volontaires. Quatre comportements se sont révélés corrects — c'est un résultat aussi, et il vaut mieux l'avoir mesuré :
| Panne provoquée | Ce qui se passe réellement |
|---|---|
| Certificat client malformé | le runner refuse de démarrer, healthcheck unhealthy |
| Certificat client valide mais d'un autre CA | tls: certificate required, refus au démarrage, unhealthy — le runner pingue donc le daemon avant de servir |
| Rotation complète du CA, runner laissé en marche | le job suivant réussit : le certificat client vit sur un volume partagé que dind réécrit, le runner reprend le nouveau |
| Arrêt propre du runner en plein job (ce que fait Coolify) | le job est marqué failure dans Forgejo — pas bloqué en « running » — et il ne reste ni conteneur ni volume orphelin |
| Jeton d'enregistrement révoqué | le runner redémarre et exécute des jobs : .runner suffit, register est sauté |
| Certificat proche de l'expiration | seuil de 30 jours correct : 825 j → non, 20 j → oui, 2 j → oui |
Cette tournée a surtout corrigé une affirmation de plus. Le dépôt annonçait
qu'une rotation du CA laisserait un runner en marche avec un certificat périmé,
échouant en silence derrière un healthcheck vert. Mesuré : cela n'arrive pas. Le
volume docker-certs-ca garde son intérêt — des certificats stables d'un
déploiement à l'autre, ce que vérifie T15 — mais pas celui qu'on lui prêtait.
Deux pannes réelles, provoquées sur le banc
Les cinq défauts ci-dessus ont été trouvés en lisant. Ces deux-là ont été trouvés en cassant volontairement la pile :
- Forgejo devient injoignable. Le runner journalise l'erreur toutes les trois
secondes — parfaitement diagnosticable — mais son healthcheck reste vert (le
processus tourne,
/data/.runnerexiste) et la page annonçait « enregistré ». Le tableau de bord affichait donc que tout allait bien pendant que la CI était morte.registeredne dit qu'une chose : l'enregistrement a réussi un jour. Le battement de cœur interroge désormais la forge pour de vrai, et la page affiche « forge injoignable » en rouge avec le geste à faire. - dind meurt. Le collecteur meurt avec lui, l'horodatage gèle, et la page bascule bien en « Données périmées » au-delà de 45 s. Vérifié sur une vraie panne cette fois, pas sur un relevé fabriqué.
Le remplissage de disque, de bout en bout
Le seul risque documenté et non corrigé du dépôt. Éprouvé sur un système de
fichiers borné à 600 Mo, rempli par un conteneur de job : df voit la
montée, l'alarme sort à 88 % avec son message exact, le collecteur rapporte le
pourcentage, et la page passe ambre à 88 % puis rouge à 96 % en nommant
la conséquence — « Forgejo est menacé avant la CI ». La chaîne complète tient.
Le ménage ne nettoyait pas ce qu'il fallait
Trouvé en observant prune plutôt qu'en le relisant. Depuis Docker 23,
docker volume prune ne retire par défaut que les volumes anonymes. Or le
runner nomme ceux de ses jobs. Mesuré : à la fin normale d'un job il fait le
ménage lui-même — mais s'il est tué en cours de job, ce qui arrive à chaque
redéploiement Coolify pendant qu'un job tourne, ses volumes restent orphelins
pour toujours, sur le disque que Forgejo partage. La commande passe à -af.
Vérifié aussi que -af n'emporte pas un job en cours : prune ignore les volumes
attachés à un conteneur vivant, et le job concerné a bien abouti.
Le point commun de ces cinq premiers défauts : aucun ne se voit en regardant la page un jour où tout va bien. Trois d'entre eux se déclenchent avec le temps, pas avec une erreur — un compteur qui grossit, une horloge qui dérive. C'est la raison pour laquelle ils sont testés plutôt que relus.
Un processus qui ne se lançait pas du tout
Le plus dur à voir de tous, parce qu'il ne se manifestait que sur le serveur.
dashboard/40-auth.sh était stocké dans git en mode 100644, non exécutable.
Or le point d'entrée de l'image nginx teste [ -x "$f" ] sur chaque fichier de
/docker-entrypoint.d/ et journalise Ignoring …, not executable sinon. Mesuré :
sans ce bit, nginx démarre normalement, aucun fichier d'authentification n'est
écrit, et le bon mot de passe reçoit 403. Le tableau de bord aurait été
inutilisable dès le premier déploiement.
Pourquoi rien ne l'avait signalé : Docker Desktop sous Windows monte les fichiers avec des permissions permissives. Toute la mise au point s'est donc faite sur une machine où le bit n'avait aucune importance, pour un déploiement sur une machine où il décide de tout. Le harnais vérifie désormais le mode dans l'index git — c'est lui qui voyage, pas celui du poste.
Ce que la page affirmait sans le vérifier
Une seconde famille de mensonges, plus discrète : la page rapportait ce que le dépôt déclare plutôt que ce que l'hôte applique. Trois écarts trouvés :
- Les plafonds du daemon. Le compose les déclare sous
deploy.resources. Selon la version de Docker Compose de l'hôte, une clé non reconnue —pidsen particulier — est ignorée sans message. On croirait le garde-fou posé parce qu'il figure dans le fichier. Ces valeurs sont désormais lues dans le cgroup de dind, donc dans le noyau ; « aucune limite » devient une anomalie. Le harnais le vérifie de bout en bout : si ton hôte ignoraitpids, T16 le dirait avant le déploiement. - La complétude du pare-feu. L'indicateur « actif » ne testait qu'une règle, la même que le healthcheck. Quatre des cinq auraient pu manquer sans que la carte cesse d'être verte — or chaque règle absente rouvre une plage entière. Les règles réellement en place sont maintenant comparées à celles attendues, et les manquantes sont nommées.
- Les plafonds par job. La page écrivait, en texte figé, « chacun est
plafonné à 1 Go de mémoire et 512 processus ». C'était une promesse, pas un
constat : elle serait restée affichée mot pour mot si le runner avait démarré
sur ses valeurs par défaut — exactement le cas d'un
config.ymlmal monté. La colonne « plafonds » du tableau des jobs est désormais lue sur chaque conteneur, et un job sans plafond devient une anomalie qui nomme le geste.
La règle qui en découle, appliquée partout : la page ne répète jamais le
dépôt. Tout ce qu'elle affiche vient d'une lecture du système — cgroup,
iptables, docker inspect, df. Quand les deux divergent, c'est le système
qui a raison, et c'est lui qu'on affiche.
Tous les plafonds mémoire valaient le double de l'annoncé
Trouvé en faisant échouer un job exprès : un workflow qui alloue sans fin, sous
--memory=1g, pour voir si la mort du job est lisible dans les journaux.
Elle l'est — Killed, exitcode '137', Job failed, et le témoin d'après ne
s'exécute pas. Mais le compteur du job disait autre chose : il avait écrit
2 048 Mo avant de mourir.
C'est le comportement documenté de Docker, et il prend tout le monde à revers :
--memory=1g sans --memory-swap accorde au conteneur 1 Gio de RAM plus
1 Gio de swap. Le cgroup le confirmait sans ambiguïté — memory.max et
memory.swap.max valaient tous deux 1073741824. Et la même chose valait pour
dind (3 Go + 3 Go) et pour le service runner (512 Mo + 512 Mo) : chaque
plafond de ce dépôt était le double de ce que le README, la page et les
commentaires annonçaient.
Ce n'est pas qu'une erreur de comptabilité. Le gigaoctet supplémentaire est du disque, sur le même disque que Forgejo. Un job qui fuit ne mourait pas : il ramait en remplissant le swap, pendant tout le temps qu'il mettait à le faire. La panne ressemblait à « la CI est lente » et frappait Forgejo au passage.
Corrigé sur les trois étages — --memory-swap=1g dans config.yml,
memswap_limit sur dind et sur runner (cette clé n'a pas d'équivalent sous
deploy:, et le banc a servi à vérifier que les deux cohabitent). Le même
workflow meurt maintenant à 1 024 Mo. Le collecteur remonte memory.swap.max
au même titre que pids, et la page en fait une anomalie.
L'assertion écrite pour empêcher ce défaut a d'abord été fausse. Première
rédaction : elle n'alarmait que si le swap valait max, c'est-à-dire illimité.
Or l'état défectueux qu'on venait de corriger était 3221225472 — fini, borné,
et pourtant le double de l'annoncé. Le test serait passé sur le bug qu'il
existait pour attraper. Il exige maintenant zéro. C'est la deuxième fois dans ce
dépôt qu'un contrôle de sécurité est écrit assez lâche pour valider l'état qu'il
surveille ; la parade est la même que la première fois — l'éprouver sur l'état
défectueux avant de le déclarer bon.
Trois trous dans le pare-feu des jobs, dont un très large
Passe de sécurité à froid. Le pare-feu bloque les plages privées — et c'est exactement là qu'était l'erreur de raisonnement : « privé » n'est pas la bonne frontière.
L'adresse publique du VPS lui-même n'est dans aucune plage privée. Un job
l'atteignait donc librement. Mesuré, pas déduit : une adresse publique posée sur
l'hôte du banc, un service qui écoute dessus, et un conteneur de job sur un
réseau WORKFLOW-* s'y connecte. En production cela vise sshd, le proxy
Coolify, le port de l'interface Coolify, et tout port qu'une autre application
du serveur y publie. Les paquets arrivent à l'hôte SNATés en 10.89.7.20 : une
restriction par IP source côté hôte les prendrait pour du trafic local.
La variable VPS_PUBLIC_IP referme cela — seuls 80 et 443 passent vers cette
adresse, ce dont Forgejo a besoin. Elle ne peut pas être devinée depuis le
conteneur, donc elle est vide par défaut ; non renseignée, dind l'annonce
dans ses journaux au démarrage, en nommant ce qui reste joignable. Une alarme
permanente sur la page pour un réglage optionnel apprendrait à ignorer la page,
ce qui coûte plus cher que ça ne rapporte ; les journaux sont de toute façon la
surface de l'exploitant.
100.64.0.0/10 manquait — la plage CGNAT (RFC 6598), c'est-à-dire celle de
Tailscale. Elle n'est pas « privée » au sens RFC 1918, elle ne figurait donc
nulle part. Sur un VPS raccordé à un tailnet, ou chez un hébergeur qui adresse
en CGNAT, tout le réseau interne était ouvert aux workflows. Vérifié atteignable
avant, jeté après.
Et un piège de configuration qui annulait la correction. FORGEJO_ALLOW_IP
pose un RETURN sans port, et il finit en tête de chaîne. Réglé sur
l'adresse publique du VPS — un réglage qui a l'air prudent, on nomme sa forge —
il rouvrait sshd et tous les ports publiés. Le démarrage refuse maintenant ce
cas et dit pourquoi.
Les trois sont éprouvés par les compteurs du pare-feu, pas par « nc a
échoué » : nc ne distingue pas un port fermé d'un paquet jeté. Avec un
contrôle apparié — le 443 doit toujours passer, sans quoi le checkout de tous
les jobs serait cassé par une règle qui aurait l'air d'une protection.
La page se vantait d'un isolement qu'elle n'a pas
Le compose l'affirmait et le pied de page le répétait au lecteur : le conteneur
web n'a « aucune route vers le réseau du runner ». C'est vrai avec docker compose seul — T6 et T16 le vérifient — et faux une fois déployé.
Mesuré dans la recette Coolify, sur la pile réellement déployée : Coolify
raccorde son réseau applicatif à tous les services de la ressource,
tableau de bord compris. Depuis le conteneur web, dind:2376 répond, et les
deux ports du cache aussi. Déclarer un réseau dans le compose n'y changerait
rien : celui de Coolify s'ajoute quoi qu'on fasse. Le harnais validait donc
une topologie que Coolify ne déploie pas.
Ce qui tient réellement n'est pas l'absence de route, c'est l'authentification
— et elle tient : depuis ce conteneur, 2376 refuse faute de certificat client,
et le serveur de cache répond 403 faute de jeton. Le conteneur web n'a ni l'un
ni l'autre. Le danger était donc dans la phrase, pas dans la pile : un
exploitant qui lit « aucune route » ne va pas chercher ailleurs le jour où il
raisonne sur un incident.
Corrigé sur les trois supports — le commentaire du compose, le pied de page, et la recette, qui rejoue désormais les deux refus sur la pile déployée. C'est le bon endroit : la seule mesure qui compte pour cette question ne peut se faire que là où la topologie est la vraie.
Le pare-feu ne protégeait pas dind de lui-même
DOCKER-USER ne filtre que le FORWARD. Or un paquet qu'un job envoie à la
passerelle de son réseau — 172.18.0.1, c'est-à-dire dind — n'est pas
forwardé : il est délivré localement, donc il passe par INPUT, où un seul port
était fermé. Mesuré : un service mis à l'écoute dans dind sur le port 9999 a été
joint depuis un conteneur de job du premier coup.
La règle sur 172.16.0.0/12 donnait en plus une fausse impression de
couverture : 172.18.0.1 y figure bien, mais cette règle est dans la mauvaise
chaîne pour ce trafic. Aujourd'hui seul dockerd écoute dans dind, et il est
protégé par le TLS mutuel ; mais tout ce qui y écouterait un jour serait
joignable depuis n'importe quel workflow, sans le moindre signal.
INPUT refuse désormais tout ce qui vient des ponts, en gardant le retour des
connexions que dind a lui-même ouvertes.
Et le cadrage a dû changer, ce qui a refermé une fragilité plus ancienne.
Première rédaction : ! -i <interface de sortie>, soit « tout sauf la route par
défaut ». Dans Coolify, dind a deux interfaces d'infrastructure — eth0 sur
le réseau applicatif, eth1 sur celui du compose — et ce complément englobe la
seconde. Si le DNS de Docker rendait l'adresse eth1 pour le nom dind, la
connexion du runner vers le daemon serait jetée par notre propre pare-feu.
Et ce n'est pas une inquiétude théorique : deux passages consécutifs de la
recette, sur le même dépôt, ont donné des noms d'interface inversés.
| Passage | eth0 |
eth1 |
route par défaut |
|---|---|---|---|
| 1 | 172.31.55.4 (Coolify) |
10.89.7.20 (compose) |
eth0 |
| 2 | 10.89.7.20 (compose) |
172.31.55.4 (Coolify) |
eth1 |
Le complément ! -i <sortie> désigne donc un réseau différent à chaque
déploiement. La pile ne tenait que parce que le nom dind résout vers
l'adresse du réseau applicatif, qui se trouve porter la route par défaut — deux
faits corrélés, pas une garantie. L'ancienne règle 2376 avait exactement le
même défaut, depuis le début.
Les règles nomment donc les ponts : docker0, et le joker br+ d'iptables
qui couvre les ponts par workflow créés plus tard sans aucune course. Les
interfaces d'infrastructure ne sont plus jamais concernées. C'est à la fois plus
précis pour la menace visée — ce qui vient d'un conteneur tournant dans
dind — et cela referme la fragilité qui dormait.
La liste noire des plages est devenue un ensemble clos
Le cadrage précédent listait les trois plages RFC 1918 plus le lien-local : une
liste noire ad hoc, donc incomplète par construction. Elle avait déjà laissé
passer 100.64.0.0/10. En allonger la queue après chaque trouvaille garantit
seulement la trouvaille suivante.
Les règles reprennent maintenant le registre IANA des adresses à usage
spécial, fermé en entier — douze plages, dont le multicast, 0.0.0.0/8, la
loopback, 192.88.99.0/24 et 198.18.0.0/15. Ce qui n'y figure pas est, par
définition, une adresse publique d'Internet, dont les jobs ont besoin. Seules
les plages TEST-NET restent ouvertes : aucun service réel n'y vit, et les
harnais s'en servent pour simuler une adresse publique.
Ce que la même passe n'a PAS trouvé
Consigné parce qu'un résultat négatif mesuré vaut mieux qu'une zone d'ombre :
- Le proxy de cache n'est pas un relais. C'est le seul service interne qu'un
job a le droit de joindre, et le runner, lui, atteint l'hôte : un relais
ouvert aurait été un pivot direct. Éprouvé depuis un vrai job, avec son vrai
jeton de cache : forme absolue,
CONNECT,Hostdétourné, traversées de chemin,curl -x,X-Forwarded-Host. Tout répond 404 ou ignore, et le piège posé sur l'hôte n'a jamais reçu la moindre connexion. - Aucune injection HTML dans la page. Les noms de jobs et d'images viennent
de workflows tiers ; la page ne possède aucun
innerHTML, tout passe partextContentetcreateTextNode. - Un mode de fichier hérité plutôt que choisi, en revanche, a été corrigé :
snapshot.shposait explicitement0644, le battement de cœur non. Il l'est aujourd'hui par le seul umask de l'image. Le jour où cette image change, le relevé devient illisible pour nginx et la page annonce « le runner n'écrit plus son état » pendant qu'il l'écrit. L'asymétrie entre deux fichiers qui font le même travail était le signe.
Le collecteur appelait deux fois la commande la plus lente de Docker
docker system df parcourt toutes les images, tous les conteneurs et tous les
volumes. Mesuré sur un hôte à 183 images : 6 à 7 secondes par appel. Le
collecteur en faisait deux par tour, pour le même travail, sans aucune
borne — treize secondes ajoutées à une boucle qui doit écrire son relevé plus
souvent que le seuil de péremption de la page, qui est de 45 secondes.
Aujourd'hui ça passe, sur un dind quasi vide. Sur le VPS, dind accumule les
images de chaque job pendant 72 heures avant le ménage, et rien ne garantit
qu'il restera aussi rapide. Le jour où la boucle dépasse 45 secondes, la page
annonce « le collecteur ne tourne plus » alors qu'il tourne — la panne exacte
déjà rencontrée avec du dans le battement de cœur, retrouvée au même endroit
du raisonnement dans l'autre collecteur.
Un seul appel désormais, borné par timeout, avec conservation des valeurs
précédentes en cas de dépassement : une jauge disque figée une minute vaut mieux
qu'un relevé en retard, qui éteint toute la page. Et l'assertion vérifie la
règle plutôt que les deux cas connus — toute commande lente de ces deux
boucles doit être précédée de timeout.
La page mentait sur la panne, dès qu'il n'y avait plus rien à afficher
Trouvé en regardant la page pour de vrai, dans un navigateur, avec un relevé absent — un cas qu'on ne voit jamais en développant, puisqu'on la regarde toujours avec un collecteur qui tourne.
Un seul bloc de render() déréférençait s sans garde. Tous les autres testent
!s ou écrivent s && … ; celui-là non. Dès que le relevé manquait — collecteur
arrêté, volume absent, 401 sur /data/ — une TypeError partait au milieu du
rendu. Le filet de tick() la rattrapait, donc pas de page verte sur du vide,
mais il affichait « La page n'a pas pu exploiter ce relevé : Cannot read
properties of null » : un collecteur accusé d'écrire un relevé illisible alors
qu'il n'en écrivait aucun. Les messages soigneusement rédigés pour ces
cas — « le collecteur ne tourne pas », « authentification perdue » — n'étaient
jamais atteints. Le diagnostic envoyait chercher la panne au mauvais endroit,
ce qui est exactement le reproche que ce dépôt fait aux tableaux de bord.
Le harnais fait désormais tourner le JavaScript de la page dans node, avec un
DOM factice (scripts/page-hors-nominal.js), sur dix-neuf situations :
relevé absent, illisible, amputé, nominal, horloges désaccordées, plafond doublé
par le swap, forge injoignable ou inconnue, config.yml vide ou amputé, runner
non enregistré, cache au-dessus du plafond, certificat bientôt expiré ou
illisible, pare-feu absent ou incomplet, job sans plafond, plafonds du compose
ignorés par l'hôte, disque à 93 %. Chaque cas casse une chose dans un relevé
sain — partir de l'état nominal, plutôt que fabriquer un relevé sur mesure qui
donnerait raison au test par construction.
Le contrôle porte sur le titre et le motif. Le titre seul ne suffirait pas : « 2 anomalies » serait vrai avec deux mauvaises anomalies, et l'exploitant ne lit pas le compte, il lit la phrase. C'est le premier passage où ces dix-neuf branches de la page sont réellement exécutées ; elles tiennent toutes. node vient d'un conteneur — un test qui se saute quand l'outil manque ne prouve rien.
Une dernière chausse-trappe a failli tout annuler : la boucle était écrite
printf … | while read, donc dans un sous-shell. Les OK et KO se
seraient affichés, mais PASS et FAIL ne seraient jamais remontés — dix-neuf
contrôles absents du décompte, et un échec incapable de faire rougir la suite. Un
harnais qui sous-déclare ses résultats est le pire cas de figure : il rassure.
La boucle est alimentée par un document en ligne, et la correction est vérifiée
en cassant un motif exprès.
Deux corrections sont sorties de ce banc. La garde manquante, d'abord. Puis une phrase fausse : « son contenu n'est pas du JSON valide » servait aussi au relevé qui est du JSON parfaitement valide mais amputé de ses sections. Elle dit maintenant « pas exploitable : JSON invalide, ou sections manquantes ».
Et le harnais lui-même a d'abord fabriqué un faux défaut : son bouchon de
fetch levait de façon synchrone sur du JSON invalide, là où une vraie
Response.json() rend une promesse rejetée. La page paraissait confondre
« illisible » et « absent ». Un bouchon doit imiter le contrat, pas le
comportement le plus simple à écrire.
Deux lectures d'une même variable, et l'une des deux se taisait
RUNNER_CACHE_MAX_MB est interprété à deux endroits : la commande du
service runner décide s'il faut purger, le battement de cœur décide de ce que
la page affiche. Les deux ne lisaient pas la variable de la même façon.
Mesuré, en saisissant dans Coolify ce qu'on y saisit vraiment : 4G au lieu de
4096. Côté purge, [ "$taille" -gt "4G" ] échoue — ce n'est pas un nombre —
et le test part dans la branche « pas de purge », définitivement. Côté page,
seuls les chiffres étaient conservés : 4G devenait 4. Avec abc, pire
encore : la purge ne se déclenchait plus jamais, et la page annonçait sereinement
« plafond 4096 Mo ». Aucun signal nulle part, pour la seule croissance de disque
continue de cette pile.
Les deux lectures sont maintenant identiques au caractère près, et le conteneur
runner journalise le plafond qu'il a réellement retenu. Le test ne compare pas
les deux bouts de code : il les extrait de leur fichier et les fait tourner
côte à côte sur neuf valeurs, valides et non valides. Écrit d'abord autrement —
lire le journal de la pile de test — il passait à vide, parce que ce runner ne
s'enregistre pas et n'atteint donc jamais la purge. Un test qui ne peut pas
échouer ne prouve rien ; celui-ci détecte les deux états d'avant.
Un test qui ne lisait rien, et le disait comme s'il avait lu
Le même passage a révélé une fragilité du harnais lui-même. Git Bash sous
Windows convertit les chemins absolus des arguments : docker exec X cat /snapshot/status.json devient cat C:/Users/…/Git/snapshot/status.json, le
conteneur répond « No such file », et le test lit une chaîne vide. La forme
docker exec X sh -c '…' y échappe — l'argument ne commence pas par un slash —
et les scripts mélangeaient les deux. lab.sh log rendait donc un journal
vide au lieu d'une erreur, ce qui se lit exactement comme « rien à
signaler ». Les quatre harnais posent maintenant MSYS_NO_PATHCONV=1 ; sous
Linux la variable n'existe pas et ne coûte rien.
Et ce correctif était lui-même une demi-vérité. Sur Windows la variable
n'est pas gratuite : la conversion était portante pour les chemins de
l'hôte que le binaire Windows lit lui-même. La recette Coolify l'a montré au
coup suivant — --env-file /tmp/recette-coolify/source/.env résolu en
C:\tmp\…, et le même écart sur les sources de montage : le .env écrit à un
endroit, cherché à un autre. La règle exacte : un chemin destiné à un
conteneur ne doit pas être converti ; un chemin lu sur l'hôte doit l'être. On
désarme donc globalement — le premier cas est le plus nombreux, et surtout le
seul qui échoue en silence — et les rares chemins hôte passent par une fonction
hote() qui les reconvertit, sans effet sous Linux.
Cet échec a révélé un troisième défaut, dans la recette : son compose up
jetait sa sortie (>/dev/null 2>&1 || true). Le démarrage échouait en une
ligne, le script attendait ensuite sept minutes et demie un conteneur jamais
créé, puis annonçait « Coolify n'est jamais devenu healthy » — un diagnostic
faux, cher, et qui pointait le mauvais coupable. La sortie est désormais
conservée et affichée dès qu'aucun conteneur n'apparaît.
Décisions à connaître
-
Les images de la pile portent une version, pas une empreinte — et c'est une asymétrie assumée avec le reste du dépôt, qui exige l'épinglage par empreinte dans la CI partagée (T9) et pour Renovate. Ici,
docker:28-dindsuit les correctifs de la branche 28 ; l'épingler par empreinte figerait le conteneur le plus privilégié de tout le montage sur une version qui ne recevrait plus aucun correctif tant que personne ne la bumpe à la main. Sur un VPS sans mise à jour automatique, le risque « Docker non patché » est plus concret que le risque « tag Docker Hub compromis ». Ce qui reste interdit, et vérifié ::latestou une image sans tag, dans le compose comme dans les labels du runner. Le même commit doit redéployer la même pile. Si tu branches Renovate sur ce dépôt, passer aux empreintes devient praticable — c'est le seul cas où ce compromis mérite d'être revu. -
Plafond sur le cache des actions (
RUNNER_CACHE_MAX_MB, 4 Go par défaut). Le runner ne purge jamais/data/cache, et le ménage quotidien ne le voit pas : celui-ci n'agit que sur les données du daemon, dans dind. Or les deux volumes vivent sur le même système de fichiers — vérifié, même device — que celui de Forgejo. C'est la seule croissance de disque réellement continue de cette pile, et ce n'est pas théorique : l'infrastructure de Forgejo elle-même a connu une partition racine pleine à cause de ce cache.La purge a lieu au démarrage du conteneur, avant que le daemon du runner ne s'exécute. C'est le seul instant où rien ne tient
bolt.dbouvert, donc le seul purge qui ne puisse pas corrompre un cache en cours d'usage. Conséquence utile ici : elle se déclenche par un « Redeploy » dans Coolify, sans shell. La taille courante s'affiche sur la carte « Runner », et le dépassement y devient une anomalie qui nomme le geste à faire. -
Volume sur
.runner: sans lui, chaque redéploiement Coolify ré-enregistre un runner fantôme. L'image tourne enuser 1000:1000; garder un volume nommé (un bind-mount hôte serait root, et l'écriture de/data/.runneréchouerait). -
IP fixes :
10.89.7.10(runner) et10.89.7.20(dind). Le runner doit avoir une IP fixe parce que les conteneurs de job tournent dans dind, sur un autre réseau : sanscache.host, ils ne savent pas joindre le serveur de cache.Si le subnet
10.89.7.0/24est déjà pris sur le VPS, le déploiement Coolify échoue avecPool overlaps with other one on this address space. Sans SSH, ce message du journal de déploiement est le seul signal — il est suffisant. Le corriger demande quatre endroits, tous dans le dépôt : lesubnet:du réseaurunner, les deuxipv4_address:, leDOCKER_TLS_SANdedind(sinon le certificat serveur ne couvre plus l'IP et le runner ne peut plus parler au daemon), etcache.hostdansconfig.yml. Le test T3 vérifie ces correspondances, mais il tourne sur le poste : il ne peut pas connaître les réseaux du VPS. -
Image pinnée à l'exact (
13.1.0). Le bump passera par Renovate plutôt que par un tag flottant. -
Un label = une image explicite (
node-22,alpine). Ajouter un label dansconfig.ymlet dans la commanderegisterdu compose : un label présent d'un seul côté laisse les jobs en attente pour toujours. Le test T2 interdit cette dérive. -
registerest déprécié côté Forgejo (remplacé parforgejo forgejo-cli actions register, à lancer côté serveur, qui fournit un UUID + jeton à mettre dans le blocserver.connectionsdeconfig.yml). On le garde parce qu'il permet un déploiement Coolify en un clic sans aller chercher un UUID à la main. Il fonctionne toujours ; à migrer le jour où il disparaît. -
Runners éphémères non retenus.
--ephemeralexiste et la doc sécurité Forgejo le recommande : le runner est supprimé après un job, ce qui borne les dégâts d'un jeton compromis. Incompatible avec un service Compose toujours allumé — il faudrait un orchestrateur qui réenregistre à chaque job. À reconsidérer si des dépôts publics acceptant des PR externes arrivent. -
Arrêt propre :
shutdown_timeout(60 s) <stop_grace_period(90 s). Sinon un redéploiement Coolify tue le runner en 10 s et Forgejo affiche les jobs « running » jusqu'à expiration de leur propre timeout.
Tableau de bord
C'est un service web ordinaire : un nginx qui écoute sur le port 80 et sert une page statique plus les relevés JSON déposés sur un volume. Rien de plus exotique que n'importe quelle application déployée dans Coolify.
Accès à la page
Attribuer un domaine au service dashboard dans Coolify. C'est tout. Le
service écoute sur 80, donc le domaine seul suffit : Coolify génère les règles
Traefik, termine le TLS, et la page est joignable en HTTPS. Aucun accès SSH,
aucun terminal, aucune ligne à ajouter au dépôt.
Le TLS n'est pas un détail de confort : il corrige le seul vrai défaut de l'authentification Basic, qui sans lui ferait circuler le mot de passe en clair.
Le compose publie aussi 127.0.0.1:8090 sur le serveur. Ce port n'est pas le
moyen d'accès — c'est une commodité pour les harnais de test et pour un éventuel
tunnel ssh -L 8090:127.0.0.1:8090 <vps>. Traefik, lui, joint le conteneur par
le réseau Docker et se moque des ports publiés.
Pourquoi le service ne déclare aucun réseau
Le point qui décide de tout, et qui n'est visible nulle part une fois déployé :
dind, runner et prune déclarent tous explicitement le réseau runner. Le
service dashboard, lui, ne déclare rien — il rejoint donc le réseau default
de la pile, où il se retrouve seul. L'isolement est le même que celui d'un
réseau dédié : aucune route vers dind, aucune route vers le runner, le seul lien
entre les deux mondes reste un fichier sur un volume.
Correction, établie par la recette. J'ai d'abord écrit ici qu'un réseau nommé par nous ne serait jamais rejoint par Traefik, et que la page serait donc injoignable. C'est faux. Mesuré dans un vrai Coolify : il ajoute son réseau d'application à tous les services, y compris à ceux qui en déclarent un, et il raccorde son proxy à tous les réseaux de la ressource. La page aurait été joignable dans les deux cas.
Ne pas déclarer de réseau ici reste préférable — un réseau de moins pour le même
isolement, et rien qui dépende d'un comportement non documenté — mais c'est une
simplification, pas la correction d'une panne. verify.sh (T6) maintient la
règle avec cette justification-là.
Deux réglages Coolify à ne pas manquer
- Ne pas activer « Connect to Predefined Network » sur cette ressource. Cette
option place tous les services de la pile sur le réseau partagé de Coolify —
donc
dindet lerunneraux côtés de toutes les autres ressources du serveur. Le pare-feu des jobs n'est pas contourné pour autant (il filtre par destination, pas par réseau d'origine), mais deux choses changent : le daemon privilégié devient joignable depuis n'importe quelle autre ressource — seul le TLS mutuel l'arrête alors — et le conteneurrunner, qui détient à la fois le jeton du runner et la clé cliente du daemon, se retrouve exposé au voisinage. Le cloisonnement par réseau est la barrière la moins chère de cette pile ; autant la garder. - Le proxy de Coolify se retrouve sur le réseau
runner. Mesuré : il raccorde son proxy à tous les réseaux de la ressource, pas seulement au sien. Le composant exposé à Internet partage donc un réseau avec le daemon privilégié. Ce qui tient malgré cela, vérifié depuis le proxy lui-même : 2375 n'existe pas, et 2376 refuse sans certificat client — le TLS mutuel est bien la barrière. En revanche le serveur de cache interne du runner (8088) lui répond : c'est pourquoi ce port reste fermé côté jobs et pourquoi le cache signe ses requêtes. Rien à changer, mais à savoir. - Garder un mot de passe long. Une fois la page joignable depuis Internet, elle n'est plus protégée que par lui. Le minimum de 12 caractères est imposé par le conteneur, mais viser plus.
Ce qui freine la force brute, et ce qu'il a fallu corriger
Trois compteurs, parce qu'un seul ne suffisait pas :
| Compteur | Clé | Rôle |
|---|---|---|
| 60 req/min | adresse du client (via X-Forwarded-For) |
l'usage normal (~13 req/min) passe sans gêne |
| 300 req/min | pair réel de la connexion | plafond absolu, qu'aucun en-tête ne peut contourner |
| 600 req/min | pair réel | compteur séparé pour /healthz |
Chacun vient d'une mesure, pas d'une intuition :
- La limitation vivait dans
location /et ne couvrait donc que la page. Les relevés/data/*.json, authentifiés eux aussi, passaient 80 essais de mot de passe sur 80 contre 37 sur la page. Un 401 se distinguant d'un 200, c'était un oracle de force brute sans aucun frein. Elle est désormais au niveauserver, dont toutes les routes héritent. - Le compteur par client se fonde sur un en-tête, donc sur une valeur que le
client choisit :
X-Forwarded-For: 127.0.0.1passait 50 essais sur 50. D'où le plafond fondé sur$realip_remote_addr, le pair réel de la connexion, qu'aucun en-tête ne modifie. Vérifié : la rotation d'adresses dans l'en-tête ne donne plus que 232 essais sur 300 au lieu de 300. - Sans compteur séparé, une attaque depuis Internet aurait épuisé le quota
partagé,
/healthzaurait répondu 503, et Coolify aurait redémarré en boucle un conteneur parfaitement sain. La sonde tient maintenant 100 sur 100 pendant l'attaque.
Autre piège nginx corrigé au passage : dès qu'une location déclare un
add_header, elle n'hérite plus d'aucun en-tête du bloc parent. Les relevés
/data/*.json — le seul contenu influencé par un tiers, puisqu'il porte des noms
de jobs — étaient donc servis sans nosniff ni CSP. Chaque route les redéclare,
et un test refuse toute route qui les oublierait.
Enfin, HSTS n'est envoyé que si la requête est arrivée en HTTPS. Sans cette
condition, il épinglerait aussi l'origine http://127.0.0.1:8090 du tunnel
local, que le navigateur refuserait ensuite de charger en clair, durablement.
Question tranchée par la recette : attribuer le domaine suffit. Sur Coolify
4.3.18, poser un domaine sur le service dashboard génère automatiquement les
labels Traefik complets — traefik.enable=true, un routeur avec
Host(`ton-domaine`), et loadbalancer.server.port=80 détecté tout seul. Le
routage a été vérifié de bout en bout à travers le proxy : 401 sans
identifiants, 200 avec. Aucun label à ajouter au dépôt.
Si malgré tout le domaine répondait 404 page not found (Traefik n'a aucune
route) ou no available server (route posée, mais le proxy n'atteint pas le
conteneur), le symptôme se lit sans shell et le repli serait d'écrire les labels
à la main. Ce n'a pas été nécessaire ici.
Ce qui se vérifie sans shell
Le tableau de bord a été conçu pour être la surface d'exploitation, pas un gadget. Sans accès au serveur :
| Question | Où |
|---|---|
| Le runner est-il enregistré, avec quels labels ? | page, carte « Runner » |
config.yml est-il bien monté ? |
page, anomalie explicite si absent, vide ou incomplet |
| Le pare-feu est-il actif, et complet ? | page, carte : les règles manquantes sont nommées |
| Les plafonds déclarés sont-ils appliqués par l'hôte ? | page, anomalie si le noyau ne les a pas posés |
| Un job tourne-t-il sans plafond ? | page, colonne « plafonds » du tableau des jobs |
| Un plafond mémoire est-il doublé par le swap ? | page : anomalie sur le daemon, « + swap » dans la colonne « plafonds » |
| Le plafond du cache est-il celui que j'ai saisi ? | journaux Coolify du service runner, ligne [cache] — la page affiche exactement la même valeur |
| Le cache des actions grossit-il ? | page, carte « Runner » ; anomalie au-delà du plafond |
| Le disque se remplit-il ? | page, jauge ; alarme à 80 % dans les journaux dind |
| Le certificat du daemon expire-t-il ? | page, carte « Daemon isolé » |
| Quels jobs tournent ? | page, tableau des jobs |
| Le mot de passe du tableau de bord | journaux Coolify du service dashboard |
| Le ménage disque passe-t-il ? | journaux Coolify du service prune |
| La CI fonctionne-t-elle vraiment ? | Forgejo, onglet Actions d'un dépôt |
Chacune de ces réponses est une branche de la page, et les dix-neuf sont exécutées à chaque passage du harnais — pas seulement écrites. Voir « La page mentait sur la panne » : celle qu'on croyait la mieux rédigée était justement celle qui ne s'affichait jamais.
Le contrôle de config.yml mérite une mention : c'est la panne la plus
dangereuse et la plus silencieuse de ce déploiement. Un fichier mal monté par
Coolify ne fait pas échouer le runner — il démarre sur ses valeurs par défaut, et
les jobs tournent alors sans plafond mémoire ni plafond de PID. Rien d'autre ne
le signalerait.
Ce que le tableau de bord ne remplace pas : les harnais verify.sh et
e2e/run.sh exigent un shell et l'accès à Docker. Ils se lancent depuis un poste
qui a le dépôt, avant de déployer — pas sur le serveur.
Identifiants
| Identifiant | informatique@equilibre-structures (variable DASHBOARD_USER) |
| Mot de passe | variable DASHBOARD_PASSWORD |
Le mot de passe de démonstration ne survit pas au déploiement. Si
DASHBOARD_PASSWORD est vide ou vaut encore change_me, le conteneur en tire
un au hasard à chaque démarrage et l'affiche dans ses journaux :
docker compose logs dashboard | grep -A6 "NOUVEAU MOT DE PASSE"
(dans Coolify : onglet Logs du service dashboard.)
C'est volontairement pénible : un identifiant par défaut laissé en place sur une
page qui décrit l'état des défenses est exactement ce qu'on cherche à éviter.
Pour un mot de passe stable, renseigner DASHBOARD_PASSWORD dans Coolify avec
autre chose que change_me — la rotation cesse alors, et le journal l'indique.
DASHBOARD_PORT change le port publié (défaut 8090). Les deux harnais de test
utilisent 8790 et 8791 pour pouvoir tourner pendant que la pile sert.
Elle affiche un verdict global, l'état du runner et du daemon, les jobs en cours, l'occupation disque de l'hôte, et les règles de filtrage avec leurs compteurs de paquets refusés.
Un mot de passe de moins de 12 caractères est refusé et remplacé par un
mot de passe aléatoire : le hachage utilisé ({SHA}, seul format que l'image
alpine sait produire sans openssl ni htpasswd) n'est pas salé, et ne
protège donc qu'un secret long. Un identifiant contenant : fait refuser le
démarrage — il couperait la ligne htpasswd en deux.
Tout est authentifié, la page comme les relevés JSON. Seule /healthz reste
ouverte : sans elle, le healthcheck recevrait un 401 et Coolify afficherait le
conteneur en panne alors qu'il fonctionne. Elle ne renvoie que ok.
Ce que coûte le fait de l'exposer. La page décrit la topologie interne et l'état des défenses : exactement ce qu'un attaquant veut lire avant d'agir. La mettre derrière un domaine, c'est offrir un formulaire à Internet — un coût accepté sciemment, parce que sans accès SSH il n'existe aucune autre façon de lire l'état de la pile. Ce qui rend le compte acceptable : TLS terminé par Traefik (l'authentification Basic ne voyage donc pas en clair), un mot de passe d'au moins 12 caractères imposé par le conteneur, une limitation à 60 requêtes par minute, et une page qui ne détient aucun secret — ni jeton, ni certificat, ni accès au daemon.
Elle ne peut rien casser. Le serveur web n'a aucun accès au daemon Docker,
aucun certificat, et n'est même pas sur le réseau du runner : il sert un fichier
déposé par dind, qui est le seul à voir à la fois le daemon, iptables et le
système de fichiers. Aucun composant privilégié n'a été ajouté pour afficher
une page. Les tests T6 et T13 verrouillent ces trois propriétés.
Ce qu'elle ne peut pas garantir, en revanche. Elle affiche ce que dind et le runner déposent sur le volume de supervision. Sa fidélité ne va donc pas plus loin que celle de ces deux conteneurs : qui obtiendrait l'exécution de code dans le runner pourrait y écrire un relevé mensonger. Ce n'est pas une faille séparée — ce même accès donne déjà la clé cliente du daemon, donc root sur le VPS — mais c'est la limite exacte de ce que la page démontre. Elle atteste de l'état du système tant que le système n'est pas compromis ; elle ne le détecte pas une fois qu'il l'est.
Elle ne ment pas. Au-delà de 45 secondes sans nouveau relevé, elle bascule en « données périmées » et le dit en tête, au lieu d'afficher un vert rassurant sur un état mort. C'est le même principe que pour les healthchecks.
Observabilité
Ce qu'on peut savoir, et par quel moyen :
| Question | Réponse | Où |
|---|---|---|
| Le daemon tourne-t-il ? | healthcheck dind |
Coolify |
| Le runner est-il enregistré ? | healthcheck runner (teste /data/.runner, pas juste le process) |
Coolify |
| Le runner prend-il vraiment des jobs ? | canary toutes les 30 min → healthchecks.io | alerte push |
| Le disque se remplit-il ? | docker system df avant/après chaque ménage |
logs prune |
| Le pare-feu est-il actif ? | compteurs de paquets refusés | tableau de bord, ou iptables -n -L DOCKER-USER -v dans dind |
| Vue d'ensemble en un coup d'œil | verdict global + 6 indicateurs | tableau de bord |
| Le certificat du daemon expire-t-il ? | date d'expiration, alerte sous 30 j | tableau de bord |
| Le disque de l'hôte se remplit-il ? | ligne [disque] ALERTE au-delà de 80 % |
logs dind |
| Les journaux saturent-ils le disque ? | non : rotation 10 Mo × 5 par service | — |
| Que fait un job ? | logs du job | Forgejo, onglet Actions |
Tout ce tableau est accessible sans shell sur le serveur : interface Coolify,
tableau de bord, ou Forgejo. La seule entrée qui demande un terminal
(iptables -n -L DOCKER-USER -v) est donnée en second recours — le tableau de
bord affiche les mêmes règles et les mêmes compteurs.
Le seul avertissement actif est le canary (dans Infrastructure/ci) : les healthchecks
ne sont visibles que si quelqu'un ouvre Coolify. Sans canary, un runner mort
est indétectable — les workflows restent simplement en attente, et les dépôts
affichent une CI « active » qui ne s'exécute jamais.
Trou connu : rien ne remonte de métrique CPU/RAM par job. Les limites sont
posées (container.options), mais leur atteinte ne produit qu'un OOM kill
visible dans les logs du job.
Découpage
| Dépôt | Contenu | Pourquoi séparé |
|---|---|---|
Infrastructure/forgejo-runner |
ce dépôt | Infra. Ne détient aucun secret applicatif. |
Infrastructure/renovate |
workflow + config Renovate | Détient le PAT du bot. La politique de dépendances n'a pas le cycle de vie de l'infra. |
L'image de Renovate est épinglée par empreinte, comme le reste de la CI
partagée. Elle ne l'était pas : elle tournait sur le tag flottant :44, alors
que ce conteneur détient un PAT en écriture sur tous les dépôts découverts.
Une republication sous ce tag — compromission amont ou simple erreur — se serait
exécutée avec ces droits au passage nocturne suivant, sans que personne n'ait
rien changé. C'était le workflow le plus privilégié et le seul non épinglé ; le
test T9 refuse désormais toute image de conteneur sans empreinte.
| Infrastructure/ci | security.yml réutilisable, canary | CI par dépôt, consommée via uses: — pas copiée-collée. |
Le contenu prêt à pousser pour les deux autres dépôts est dans spinoff/,
à supprimer une fois les dépôts créés.
Licences
Runner (GPL-3.0-or-later), Docker Engine (Apache 2.0), Renovate CLI (AGPL, payant seulement en édition Enterprise), OSV.dev (service public). Aucune clé, aucune limite, usage interne sans obligation.