Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Forgejo : exploiter le runner de CI en sécurité

Par mis à jour le

Améliorer cette page

Pour exploiter un runner Forgejo en sécurité, on vérifie le binaire 13.2.0 par empreinte et par signature, on l'enregistre hors ligne avec forgejo-cli actions register, on lui donne deux labels (docker et host), puis on constate ce que chaque exécuteur isole vraiment. Le runner est le programme qui exécute les workflows de la forge, l'exécuteur est l'endroit où un job tourne et le label est le nom que le workflow demande (la première section les définit). Un job host tourne sur la machine, un job docker tourne en root dans un conteneur dont le réseau reste ouvert, et le mode éphémère limite un runner à un seul job. La leçon s'adresse à l'administrateur dont la forge tourne déjà et qui veut y brancher de la CI sans ouvrir une porte dérobée.

Vous repartez avec cinq preuves : un binaire vérifié par deux méthodes, un runner en ligne vu par l'API, un job docker vert dont on a mesuré les limites, un job host qui lit le jeton du runner, et un runner éphémère qui disparaît après un job. Les commandes ont été exécutées avec Forgejo 15.0.10 et le runner 13.2.0, sur AlmaLinux 10 avec SELinux en mode enforcing, le runner étant sur la même machine virtuelle que la forge.

  • Vérifier le binaire du runner par sha256sum et par signature GPG.
  • Enregistrer un runner sans les commandes dépréciées, avec un secret passé par l'entrée standard.
  • Configurer les labels, la confiance envers la CA de la forge et les volumes autorisés.
  • Comparer les exécuteurs docker et host en mesurant ce qu'un job peut atteindre.
  • Repérer les écarts de Forgejo Actions avec GitHub Actions qui font échouer une migration.
  • Limiter un runner à un seul job avec le mode éphémère.

Il vous faut la forge des leçons précédentes : Forgejo 15 LTS derrière Caddy dans /opt/forgejo-lab, le nom forge.lab.test et le compte fjadmin (installation), avec les fichiers Compose de surcharge expliqués dans Configuration initiale. La notion de jeton à portée limitée est celle de Sécuriser l'instance, et la révocation d'un jeton celle de Assister les utilisateurs.

Vous devez savoir lire un code HTTP avec curl, écrire un fichier en root avec sudo, et lire un workflow (un fichier YAML qui décrit les étapes d'une intégration continue). Le principe qui guide les choix de la leçon est celui du moindre privilège. Si vous connaissez déjà GitHub Actions, vous reconnaîtrez beaucoup de choses, et c'est un piège : la section sur les écarts montre où l'analogie casse.

La VM doit d'abord faire confiance à la CA de Caddy (l'autorité de certification qui a signé le certificat de forge.lab.test), sinon toute connexion HTTPS échoue. Avant l'ajout, un curl depuis la VM répond 000 avec une erreur de certificat ; après, HTTP 200.

Fenêtre de terminal
sudo docker cp forgejo-proxy:/data/caddy/pki/authorities/local/root.crt /tmp/forge-lab-ca.crt
sudo install -m 0644 /tmp/forge-lab-ca.crt /etc/pki/ca-trust/source/anchors/forge-lab-ca.crt
sudo update-ca-trust
curl -fsS -m 10 -o /dev/null -w 'HTTP %{http_code}\n' https://forge.lab.test/api/v1/version

La dernière commande affiche HTTP 200. Préparez ensuite le shell de la VM : le nom de la forge, l'adresse de l'hôte (celle du lab, à remplacer par la vôtre), un jeton d'administrateur obtenu avec le mot de passe de fjadmin (le .env du lab le conserve sous ADMIN_PASSWORD), et la fonction api. Le jeton de portée all ne convient qu'à un lab jetable.

Fenêtre de terminal
D=forge.lab.test
API=https://$D/api/v1
PW=$(sudo grep '^ADMIN_PASSWORD=' /opt/forgejo-lab/.env | cut -d= -f2-)
T=$(curl -fsS -u "fjadmin:$PW" -H 'Content-Type: application/json' -X POST "$API/users/fjadmin/tokens" -d '{"name":"l8tok","scopes":["all"]}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["sha1"])')
unset PW
api() { curl -sS -H "Authorization: token $T" -H 'Content-Type: application/json' "$@"; }

La variable T contient un secret : ne l'affichez jamais, et révoquez le jeton en fin de leçon (section « Comment défaire cette configuration ? »).

Qu'est-ce qu'un runner Forgejo, un exécuteur et un label ?

Section intitulée « Qu'est-ce qu'un runner Forgejo, un exécuteur et un label ? »

Un runner est un programme, forgejo-runner, qui interroge la forge, récupère les jobs d'un workflow et les exécute ; l'exécuteur décide où le job tourne, et le label relie le runs-on d'un workflow à cet exécuteur. Forgejo Actions est le moteur de CI intégré à la forge. Il ne lance rien lui-même : sans runner, un workflow reste en attente. Le runner se connecte à la forge, jamais l'inverse, et il le fait en interrogeant l'API à intervalle régulier (fetch_interval, 2 secondes par défaut dans la configuration générée).

Deux exécuteurs servent dans la leçon. L'exécuteur docker lance le job dans un conteneur créé à partir d'une image. L'exécuteur host lance les commandes directement sur la machine, avec le compte système du runner. Un troisième, lxc, existe : il n'est pas disponible sur AlmaLinux 10 sans installation tierce (which lxc lxc-ls incus ne trouve rien), et nous ne l'avons pas essayé.

Un label est un nom, comme docker, suivi après deux-points de l'exécuteur et de l'image : docker:docker://<image> ou host:host. Un workflow écrit runs-on: docker ; la forge confie le job à un runner qui déclare ce label. Le runner n'est pas un détail de plomberie : c'est le composant qui exécute du code fourni par les utilisateurs de la forge. Les principes généraux de sécurité des runners auto-hébergés sont ceux de Sécuriser les runners self-hosted : cette leçon les applique au runner Forgejo sans les redire.

Comment installer le runner en vérifiant le binaire ?

Section intitulée « Comment installer le runner en vérifiant le binaire ? »

On télécharge le binaire, son fichier .sha256 et sa signature .asc, on contrôle l'empreinte avec sha256sum -c, puis la signature avec gpg --verify en comparant l'empreinte de la clé primaire à celle de la documentation. L'empreinte seule prouve que le fichier n'a pas été corrompu ; la signature prouve qu'il a été publié par les mainteneurs. Les deux contrôles se complètent. La version 13.2.0 date du 2026-09-18 et la liste des publications est celle de l'API de code.forgejo.org.

Fenêtre de terminal
V=13.2.0
B=https://code.forgejo.org/forgejo/runner/releases/download/v$V
W=$(mktemp -d); cd "$W"
for f in forgejo-runner-$V-linux-amd64 forgejo-runner-$V-linux-amd64.sha256 forgejo-runner-$V-linux-amd64.asc; do curl -fsSL -O "$B/$f"; done
sha256sum -c forgejo-runner-$V-linux-amd64.sha256

La sortie est forgejo-runner-13.2.0-linux-amd64: OK. Passez à la signature, avec un trousseau jetable pour ne pas mêler la clé à celui de votre compte.

Fenêtre de terminal
export GNUPGHOME=$W/g; mkdir -m700 $GNUPGHOME
gpg --keyserver hkps://keys.openpgp.org --recv EB114F5E6C0DC2BCDD183550A4B61A2DC5923710
gpg --verify forgejo-runner-$V-linux-amd64.asc forgejo-runner-$V-linux-amd64

La sortie contient Good signature from "Forgejo <contact@forgejo.org>", puis l'empreinte de la clé primaire, Primary key fingerprint: EB11 4F5E 6C0D C2BC DD18 3550 A4B6 1A2D C592 3710. La signature est faite par une sous-clé (3BF4 E813 … 6B67 50) de cette clé primaire. Le message WARNING: This key is not certified with a trusted signature est normal : il dit que votre trousseau ne connaît pas la clé par une chaîne de confiance. Ce qui vous protège est la comparaison de l'empreinte de la clé primaire avec celle de la documentation du runner.

Le piège que nous avons rencontré : l'adresse code.forgejo.org/forgejo.gpg ne contenait aucune clé (This user hasn't uploaded any GPG keys), et gpg --verify répondait Can't check signature: No public key. Allez chercher la clé primaire par son empreinte sur keys.openpgp.org. Installez ensuite le binaire, créez le compte système et ajoutez-le au groupe docker.

Fenêtre de terminal
sudo install -m 0755 -o root -g root forgejo-runner-$V-linux-amd64 /usr/local/bin/forgejo-runner
forgejo-runner -v
id runner 2>/dev/null || sudo useradd --create-home runner
sudo usermod -aG docker runner
id runner
cd /; rm -rf "$W"; unset GNUPGHOME

Les sorties sont forgejo-runner version v13.2.0 et groups=…,994(docker) pour runner (le numéro du groupe varie d'une machine à l'autre). Retenez cette dernière ligne : appartenir au groupe docker revient à pouvoir contrôler le démon Docker de la machine, ce que la section sur l'exécuteur host met en évidence.

Comment enregistrer le runner sans commande dépréciée ?

Section intitulée « Comment enregistrer le runner sans commande dépréciée ? »

Dans la version 13.2, forgejo-runner register et create-runner-file sont dépréciés (l'aide l'indique) ; la voie jouée ici est l'enregistrement hors ligne : l'instance génère un secret, le déclare avec forgejo forgejo-cli actions register, et le runner s'en sert comme jeton. Ce secret est le jeton d'enregistrement du runner : une chaîne hexadécimale de 40 caractères qui prouve à la forge que le runner est celui qu'on a déclaré. Il se passe par l'entrée standard avec --secret-stdin, pas en argument, car un argument est visible dans la liste des processus.

Le schéma suit l'enregistrement de haut en bas, entre quatre acteurs. L'administrateur fait générer le secret par l'instance, puis le déclare avec ses labels ; l'instance répond par l'UUID du runner (son identifiant unique, dérivé du secret). Ces valeurs vont dans config.yml, que le démon (le processus forgejo-runner daemon, qui tourne en continu) lit au démarrage pour se déclarer, puis il attend un job dont le runs-on correspond à l'un de ses labels.

Enregistrement hors ligne d'un runner Forgejo, en séquence : l'administrateur lance forgejo-cli actions generate-secret sur l'instance, qui rend un secret de 40 caractères gardé dans une variable du shell ; il lance forgejo-cli actions register avec --secret-stdin et les labels, l'instance rend l'UUID du runner dérivé du secret ; l'administrateur écrit url, uuid, token et labels dans server.connections de config.yml ; le démon forgejo-runner lit cette connexion au démarrage, se déclare à l'instance avec ses labels et sa version 13.2.0, apparaît idle dans l'API, puis demande en boucle un job dont le runs-on correspond à un de ses labels.Démonforgejo-runnerconfig.yml durunnerForgejo(instance)Administrateur(VM)loop[en attente de travail]forgejo-cli actionsgenerate-secretsecret de 40caractères (variabledu shell)forgejo-cli actionsregister--secret-stdin, labelsUUID du runner,dérivé du secretserver.connections : url, uuid, token,labelslit la connexion audémarragese déclare : labels, version 13.2.0runner en ligne (idle dans l'API)demande un job dont le runs-oncorrespond à un label
Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Avant de jouer ces étapes, deux préparations. La CA de Caddy est déjà dans le magasin de confiance de la VM (voir la section des prérequis). Il faut maintenant une copie dédiée dans /etc/forgejo-runner/ca.crt, destinée à être montée dans les conteneurs de job : on ne monte pas un fichier du magasin de confiance de l'hôte.

Fenêtre de terminal
sudo install -d -m 0755 /etc/forgejo-runner
sudo install -m 0644 /tmp/forge-lab-ca.crt /etc/forgejo-runner/ca.crt

Pour l'image du job, relevez ensuite le digest (l'empreinte du contenu) de l'image Node du miroir data.forgejo.org, afin de l'épingler dans le label. Le label prend la forme canonique image:tag@sha256:… : le tag dit ce que l'on croit utiliser, le digest dit ce que l'on utilise vraiment.

Fenêtre de terminal
IMG=data.forgejo.org/oci/node:22-bookworm
sudo docker pull "$IMG"
sudo docker image inspect "$IMG" --format '{{index .RepoDigests 0}}'
NODE=$IMG@$(sudo docker image inspect "$IMG" --format '{{index .RepoDigests 0}}' | cut -d@ -f2)
echo "$NODE"

La variable IMG porte le tag une seule fois. La commande inspect affiche data.forgejo.org/oci/node@sha256:…. La ligne suivante garde le seul digest de cette sortie (cut -d@ -f2) et le recolle derrière le tag dans NODE, que la dernière commande affiche : data.forgejo.org/oci/node:22-bookworm@sha256:0e5f…a2a8 dans notre rejeu. Votre digest peut différer si l'image a été republiée. Un digest ne reçoit aucun correctif : relevez-le de nouveau à chaque rafraîchissement de l'image.

Il reste à générer le secret, à le garder dans un fichier lisible du seul compte runner, et à déclarer le runner à l'instance.

Fenêtre de terminal
SECRET=$(sudo docker exec -u git forgejo forgejo forgejo-cli actions generate-secret | tr -d '\r\n')
printf "%s" "$SECRET" | sudo tee /home/runner/.secret >/dev/null; sudo chown runner:runner /home/runner/.secret; sudo chmod 600 /home/runner/.secret
echo "secret: ${#SECRET} caracteres (masque)"
UUID=$(printf '%s' "$SECRET" | sudo docker exec -i -u git forgejo forgejo forgejo-cli actions register --secret-stdin --name lab-runner --labels docker,host)
unset SECRET

La première ligne affichée est secret: 40 caracteres (masque). La commande register répond par l'UUID du runner, rangé ici dans UUID. Comme il dérive du secret, ne le publiez pas. La commande est idempotente (la rejouer avec le même secret met à jour le même runner), et sans option --scope le runner est global à l'instance ; --scope propriétaire/dépôt le limiterait à un propriétaire ou à un dépôt, ce que nous n'avons pas essayé. Le --name lab-runner n'a pas été retenu : l'API affichera runner, le nom venant du démon.

Comment configurer le runner et le lancer en service ?

Section intitulée « Comment configurer le runner et le lancer en service ? »

Le fichier config.yml porte une connexion nommée avec l'URL de la forge, l'UUID, le secret et les labels ; l'unité systemd lance forgejo-runner daemon -c /home/runner/config.yml sous le compte runner. Il n'y a plus de fichier .runner en mode démon : la section server.connections le remplace. Le fichier contient un secret, donc il appartient à runner en mode 600.

Fenêtre de terminal
TOKEN=$(sudo cat /home/runner/.secret)
sudo tee /home/runner/config.yml >/dev/null <<EOF
log:
level: info
runner:
capacity: 1
envs:
NODE_EXTRA_CA_CERTS: /etc/ssl/forge-lab-ca.crt
GIT_SSL_CAINFO: /etc/ssl/forge-lab-ca.crt
container:
docker_host: "-"
options: "--add-host=forge.lab.test:10.10.110.11 -v /etc/forgejo-runner/ca.crt:/etc/ssl/forge-lab-ca.crt:ro,z"
valid_volumes:
- /etc/forgejo-runner/ca.crt
host:
workdir_parent: /home/runner/.cache/act
server:
connections:
forge:
url: https://forge.lab.test/
uuid: $UUID
token: $TOKEN
labels:
- docker:docker://$NODE
- host:host
EOF
sudo chown runner:runner /home/runner/config.yml; sudo chmod 600 /home/runner/config.yml

Le heredoc de ce bloc n'est pas quoté (<<EOF) : le shell y injecte $UUID, $TOKEN et $NODE. capacity: 1 limite le runner à un job à la fois, et host.workdir_parent fixe le dossier parent des dossiers de travail des jobs host. Pour le reste, chaque bloc a une raison d'être. runner.envs pose deux variables dans les jobs, pour que Node et Git fassent confiance à la CA montée. docker_host: "-" est la valeur par défaut : elle signifie qu'aucun socket Docker n'est donné aux jobs (le socket /var/run/docker.sock est le point d'entrée du démon Docker, qui donne le contrôle de la machine à qui l'atteint). container.options passe des options au conteneur de job : --add-host fait pointer forge.lab.test vers l'adresse de la VM (dans un conteneur, 127.0.0.1 désigne le conteneur lui-même, alors que la VM le résout vers son propre bouclage), et -v …:ro,z monte la CA en lecture seule avec une étiquette SELinux adaptée.

Le piège est valid_volumes. Sans cette liste (elle est vide par défaut), le montage de la CA est refusé, et le journal du job contient [/etc/forgejo-runner/ca.crt] is not a valid volume, will be ignored. Au premier essai, nous n'avions pas la liste : le montage était ignoré et le job a échoué. Déclarez donc chaque chemin monté dans valid_volumes. Créez l'unité systemd et démarrez.

Fenêtre de terminal
sudo tee /etc/systemd/system/forgejo-runner.service >/dev/null <<'EOF'
[Unit]
Description=Forgejo Runner
Documentation=https://forgejo.org/docs/latest/admin/actions/
After=docker.service network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/forgejo-runner daemon -c /home/runner/config.yml
ExecReload=/bin/kill -s HUP $MAINPID
User=runner
WorkingDirectory=/home/runner
Restart=on-failure
TimeoutSec=0
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now forgejo-runner.service
sleep 8
systemctl is-active forgejo-runner
sudo journalctl -u forgejo-runner --no-pager -n 15 | sed "s/$TOKEN/***/g"
sudo ausearch -m avc -ts recent

La commande is-active répond active. Le journal contient Starting runner daemon, puis runner: runner, with version: v13.2.0, with labels: [docker host], ephemeral: false, declared successfully et [poller] launched. Le filtre sed masque le secret au cas où il apparaîtrait. La recherche ausearch -m avc -ts recent affiche <no matches> : SELinux n'a rien refusé. La preuve qui compte est celle de la forge, pas celle du service.

Fenêtre de terminal
api "$API/admin/actions/runners" | python3 -c '
import sys,json
d=json.load(sys.stdin)
r=d["runners"] if isinstance(d,dict) else d
for x in r: print({k:x.get(k) for k in ("id","name","status","ephemeral","labels","version")})'

L'API répond {'id': 4, 'name': 'runner', 'status': 'idle', 'ephemeral': False, 'labels': ['docker', 'host'], 'version': 'v13.2.0'}. L'id dépend des runners déjà enregistrés sur l'instance (4 dans notre rejeu, 1 sur une instance neuve). Le statut idle signifie « en ligne, sans job ». C'est la preuve que le runner est enregistré. Pour changer la configuration, éditez le fichier puis sudo systemctl restart forgejo-runner : l'unité prévoit aussi un ExecReload, mais nous n'avons rejoué que le redémarrage.

Comment exécuter un premier job dans un conteneur ?

Section intitulée « Comment exécuter un premier job dans un conteneur ? »

On crée un dépôt, on y pousse un workflow dans .forgejo/workflows/ avec runs-on: docker, et le journal de la forge montre l'image épinglée, le checkout et Job succeeded. Créez d'abord le dépôt de test et un secret, que la section sur les secrets exploite. Cherchez aussi le SHA du commit de l'action actions/checkout : les actions se résolvent sur data.forgejo.org, et l'épinglage par SHA vaut ici comme ailleurs (voir Épingler les actions par SHA).

Fenêtre de terminal
api -X POST "$API/user/repos" -d '{"name":"l8test","auto_init":true,"default_branch":"main"}' -o /dev/null -w 'create repo HTTP %{http_code}\n'
api -X PUT "$API/repos/fjadmin/l8test/actions/secrets/MYSECRET" -d '{"data":"topsecret-value-123"}' -o /dev/null -w 'secret HTTP %{http_code}\n'
curl -fsS "https://data.forgejo.org/api/v1/repos/actions/checkout/tags?limit=50" | python3 -c '
import sys,json
for t in json.load(sys.stdin):
if t["name"] in ("v4","v5","v6"): print(t["name"], t["commit"]["sha"])'

Les deux créations répondent HTTP 201 (la valeur du secret du lab est factice : le vôtre ne doit jamais figurer dans une commande). La liste des étiquettes donne notamment v6 d23441a48e516b6c34aea4fa41551a30e30af803. Créez le dossier /tmp/wf (mkdir -p /tmp/wf) et écrivez-y le workflow ci-dessous ; la fonction put, définie après lui, le poussera par l'API.

name: docker-job
on: [push]
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- run: |
echo "--- os"; head -2 /etc/os-release; id
echo "--- contexte"; echo "server_url=${{ github.server_url }} actor=${{ github.actor }} runner_os=${{ runner.os }}"
echo "--- noms de variables"; env | cut -d= -f1 | grep -E '^(GITHUB_|FORGEJO_|GITEA_|ACTIONS_|CI$)' | sort | tr '\n' ' '; echo
echo "--- socket docker dans le job ?"; ls -l /var/run/docker.sock 2>&1 || true
echo "--- reseau"
curl -sS -m 8 -o /dev/null -w "forge.lab.test:%{http_code}\n" https://forge.lab.test/api/v1/version || true
curl -sS -m 8 -o /dev/null -w "hote 10.10.110.11 https:%{http_code}\n" -k https://10.10.110.11/api/v1/version || true
timeout 5 bash -c 'echo > /dev/tcp/10.10.110.11/22' && echo "tcp 10.10.110.11:22 OUVERT" || echo "tcp 10.10.110.11:22 ferme"
echo "--- secret"; echo "valeur=${{ secrets.MYSECRET }}"
echo "--- fin des mesures"

Le workflow sert de sonde : l'étape checkout prouve que le runner joint la forge et l'action, et les echo mesurent ce que le job voit. Enregistrez-le sous /tmp/wf/docker.yml, puis poussez-le et attendez une minute.

Fenêtre de terminal
put() { b=$(base64 -w0 "$2"); api -X POST "$API/repos/fjadmin/l8test/contents/$1" -d "{\"content\":\"$b\",\"message\":\"add $1\"}" -o /dev/null -w "push $1 HTTP %{http_code}\n"; }
put .forgejo/workflows/docker.yml /tmp/wf/docker.yml
sleep 60
api "$API/repos/fjadmin/l8test/actions/tasks" | python3 -c '
import sys,json
for t in json.load(sys.stdin).get("workflow_runs",[]): print(t["id"], t["name"], t["status"])'

La poussée répond HTTP 201, et la liste affiche 8 build success dans notre rejeu (le numéro dépend des exécutions déjà faites sur l'instance). Un statut failure se diagnostique dans le journal du job, décrit juste après.

Le journal d'un job est stocké côté instance en .log.zst, un fichier compressé en zstd, dans /data/gitea/actions_log/<propriétaire>/<dépôt>/. L'interface web l'affiche aussi, mais le fichier sert quand on veut chercher dans le texte ou conserver une preuve. La VM AlmaLinux 10 du lab n'avait pas zstd (son historique dnf en montre l'installation) : la première commande l'installe, et ne fait rien s'il est déjà présent. Les suivantes copient le fichier hors du conteneur forgejo, la variable F recevant le journal le plus récent du dépôt.

Fenêtre de terminal
sudo dnf -y -q install zstd
sudo docker exec forgejo find /data/gitea/actions_log -type f -name '*.zst' | sort
F=$(sudo docker exec forgejo find /data/gitea/actions_log/fjadmin/l8test -type f -name '*.zst' | sort | tail -1)
sudo docker cp forgejo:$F /tmp/l2.zst
zstd -dc /tmp/l2.zst | sed 's/^[0-9T:.Z+-]* //' | grep -v -E '^hint:|^\[command\]|##\[' | cut -c1-210

La commande find liste les chemins du type …/l8test/08/8.log.zst, où 8 est le numéro de la tâche et 08 ce numéro en hexadécimal sur deux chiffres (0a/10.log.zst pour la tâche 10). Le sed retire l'horodatage de chaque ligne et le grep écarte le bruit du checkout. Voici les lignes utiles de la tâche réussie ; l'extrait omet les lignes de variables et de curl, commentées dans les sections qui suivent.

🚀 Start image=data.forgejo.org/oci/node:22-bookworm@sha256:0e5f906573693feaa1e21057ebdcfdb5bd5021f050b2dc7c9deceb629c7da2a8
⭐ Run Main actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
--- os
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
uid=0(root) gid=0(root) groups=0(root)
--- contexte
server_url=https://forge.lab.test actor=fjadmin runner_os=Linux
--- socket docker dans le job ?
ls: cannot access '/var/run/docker.sock': No such file or directory
tcp 10.10.110.11:22 OUVERT
--- secret
valeur=***
🏁 Job succeeded

Chaque ligne répond à une question : l'image est bien celle du digest épinglé, l'action a été prise sur son SHA, le job tourne dans un Debian 12 (le système de l'image, non celui de la VM), et il y est root. Les sections suivantes exploitent ces mesures.

Pourquoi refuser l'exécuteur host hors d'un lab ?

Section intitulée « Pourquoi refuser l'exécuteur host hors d'un lab ? »

Un job host tourne sur la VM avec le compte runner : il écrit hors de son répertoire de travail, lit le config.yml du runner, donc son jeton, et liste les conteneurs de la forge parce que runner est dans le groupe docker. Un utilisateur qui peut pousser un workflow dans un dépôt servi par ce runner contrôle donc la forge. La documentation du runner (pages d'installation et de configuration) le dit pour cet exécuteur : « There is no isolation at all and a single job can permanently destroy the host ». La page de sécurité ajoute que le jeton du runner est alors accessible au job. Nous avons mesuré cet accès avec un workflow sur le label host.

name: host-job
on: [push]
jobs:
h:
runs-on: host
steps:
- run: |
whoami; hostname; head -1 /etc/almalinux-release; pwd
echo "ecrit-par-le-job" > /tmp/host-proof.txt && echo "ecrit /tmp/host-proof.txt"
test -r /home/runner/config.yml && echo "config.yml du runner LISIBLE ($(grep -c token /home/runner/config.yml) ligne token)"
docker ps --format '{{.Names}}' 2>&1 | head -3

Enregistrez ce fichier sous /tmp/wf/host.yml, poussez-le avec put .forgejo/workflows/host.yml /tmp/wf/host.yml, lisez le journal comme ci-dessus (le tail -1 désigne le journal le plus récent, donc celui du job host), puis contrôlez sur la VM avec ls -l /tmp/host-proof.txt. La sortie du job, telle que nous l'avons obtenue :

runner
forgejo-alma.lab.lab
AlmaLinux release 10.2 (Lavender Lion)
/home/runner/.cache/act/687a17811eb1ee6c/hostexecutor
ecrit /tmp/host-proof.txt
config.yml du runner LISIBLE (1 ligne token)
forgejo
forgejo-proxy
forgejo-db
🏁 Job succeeded

Lisez-la de haut en bas : le job est runner sur la VM elle-même (AlmaLinux, pas un conteneur), le fichier /tmp/host-proof.txt écrit par le job existe ensuite sur l'hôte (ls -l le montre, propriétaire runner), le config.yml est lisible et contient la ligne du secret, et docker ps nomme les trois conteneurs de la forge. Un attaquant qui lit ce secret peut se faire passer pour le runner ; avec l'accès à docker, il peut agir sur la base de données de la forge.

La règle est simple : pas d'exécuteur host hors d'un lab jetable. Si vous devez le garder, le compte qui l'exécute ne doit pas être dans le groupe docker et ne doit pas pouvoir lire de secret, ce que nous n'avons pas essayé de configurer. Le host reste utile pour apprendre exactement ce que l'on ne veut pas en production.

L'exécuteur docker sépare le système de fichiers du job de celui de l'hôte et ne donne aucun socket Docker, mais le job est root dans son conteneur et son réseau n'est pas cloisonné : il joint l'IP privée de la VM, port 22 compris. C'est un progrès net sur host, pas une isolation complète. Trois mesures, toutes dans le journal du premier job (celui du label docker), le démontrent.

D'abord, l'identité : uid=0(root) dans le conteneur. C'est un root confiné par le moteur de conteneurs, pas celui de la VM, mais toute faille d'évasion le rapprocherait de l'hôte. Ensuite, le socket : ls -l /var/run/docker.sock répond No such file or directory, parce que docker_host: "-" ne monte rien. Donner le socket à un job (une valeur d'URL dans docker_host) reviendrait à lui donner le contrôle du démon Docker, le même risque que l'exécuteur host. La documentation renvoie à une page dédiée à l'accès Docker, que nous n'avons pas rejouée.

Enfin le réseau : la ligne tcp 10.10.110.11:22 OUVERT montre qu'un job atteint un port de la VM. L'isolation du réseau d'un job n'est donc pas automatique ; elle se construit par un filtrage de la sortie (le principe d'egress filtré décrit dans Hardening de l'environnement de build). Nous n'avons pas posé de règle de pare-feu pour la leçon.

Un dernier détail, utile pour le diagnostic. Dans le job, curl https://forge.lab.test échoue (curl: (60) SSL certificate problem, affichage forge.lab.test:000) alors que le checkout par git réussit : curl ignore GIT_SSL_CAINFO, la variable que Git lit. Un step qui appelle la forge avec curl demande donc sa propre configuration de confiance, que nous n'avons pas essayée. L'appel https://10.10.110.11 en -k échoue de son côté sur tlsv1 alert internal error : le serveur a répondu, c'est donc une erreur de négociation TLS et non un blocage réseau.

Les secrets de dépôt sont-ils masqués dans les journaux ?

Section intitulée « Les secrets de dépôt sont-ils masqués dans les journaux ? »

Un secret de dépôt s'affiche *** dans le journal quand le job écrit sa valeur exacte, mais cette protection se limite au masquage de la valeur : une valeur transformée n'a pas été testée. Le workflow du premier job écrit echo "valeur=${{ secrets.MYSECRET }}" ; le journal contient valeur=***. Sur le même principe, le journal du checkout masque l'en-tête d'authentification qu'il configure (AUTHORIZATION: basic ***).

Conséquence pratique : le masquage n'est pas un coffre-fort. Un secret passé dans une commande reste lisible par le job lui-même, donc par quiconque peut modifier le workflow. Écrire le secret transformé (encodé, coupé en morceaux) dans un journal est un contournement classique que nous n'avons pas rejoué ; ne comptez pas sur *** pour protéger un secret contre un auteur de workflow malveillant. La parade est de ne confier à un job que le secret dont il a besoin, et de ne pas exécuter de workflow venu d'un dépôt qu'on ne maîtrise pas.

En quoi Forgejo Actions diffère-t-il de GitHub Actions ?

Section intitulée « En quoi Forgejo Actions diffère-t-il de GitHub Actions ? »

Forgejo Actions est conçu pour être familier à qui connaît GitHub Actions, mais il n'est pas conçu pour être compatible : quatre écarts ont été prouvés dans notre essai. Ne portez donc pas un workflow en le copiant, et ne présumez pas qu'une action du Marketplace se comporte pareil. Le tableau sert de liste de contrôle à une migration.

SujetGitHub ActionsForgejo Actions (essai)
Dossier des workflows.github/workflows/.forgejo/workflows/ ; .github/workflows/ est ignoré quand .forgejo/workflows/ existe
Résolution de uses:Marketplace de GitHubdata.forgejo.org par défaut (réglage DEFAULT_ACTIONS_URL de l'instance)
runs-on: ubuntu-latestrunner fourni par GitHubattente sans fin si aucun runner ne porte ce label
Variables d'environnementGITHUB_*GITHUB_* et FORGEJO_*, contexte github.* émulé

Reprenons les écarts un à un. Pour le dossier, nous avons poussé un .github/workflows/gh.yml dans un dépôt qui avait déjà .forgejo/workflows/ : la poussée a relancé les workflows .forgejo déjà présents (build et h), et aucune exécution du fichier .github n'est apparue. Le cas d'un dépôt qui n'a que .github/workflows/ n'a pas été rejoué : ne dites pas qu'il fonctionne, essayez-le.

Pour uses:, la ligne du journal est git clone 'https://data.forgejo.org/actions/checkout'. Une action de GitHub n'est pas trouvée par défaut ; Forgejo peut aussi appeler une action par son adresse complète, que nous n'avons pas essayée. Pour runs-on, nous avons poussé un workflow ubuntu-latest : aucun runner ne porte ce label, et le job n'est jamais exécuté. Aucun message d'erreur n'apparaît, la tâche ne figure même pas dans la liste de l'API. C'est le piège qu'on rencontre en migrant : ajoutez le label voulu au runner ou changez le runs-on. Pour les variables, le journal liste FORGEJO_ACTION, FORGEJO_ACTIONS et FORGEJO_ACTIONS_RUNNER_VERSION, en plus des ACTIONS_* et de CI.

L'épinglage ne change pas : une action s'épingle sur le SHA du commit, comme dans le workflow du premier job.

Comment limiter un runner à un seul job avec le mode éphémère ?

Section intitulée « Comment limiter un runner à un seul job avec le mode éphémère ? »

Un runner éphémère se déclare avec --ephemeral, se lance avec forgejo-runner one-job et non daemon, prend un seul job, puis la forge le supprime : un job compromis ne trouve pas de runner réutilisable. La page de sécurité de Forgejo le recommande aux administrateurs qui créent des runners à la demande. Le principe est celui des runners éphémères de GitHub Actions, avec des commandes qui lui sont propres.

L'enregistrement ajoute --ephemeral. Le runner permanent est arrêté pendant l'essai, pour qu'il ne prenne pas le job à la place de l'éphémère. Le fichier config-eph.yml est une copie de config.yml sans la section server : l'identité du runner (UUID, adresse de la forge, label) passe par les options de one-job, et le secret y est lu dans un fichier (--token-url file:…) plutôt que passé en argument. L'option -w (--wait) fait attendre one-job jusqu'à ce qu'une tâche lui soit attribuée.

Fenêtre de terminal
SECRET=$(sudo docker exec -u git forgejo forgejo forgejo-cli actions generate-secret | tr -d '\r\n')
printf '%s' "$SECRET" | sudo -u runner tee /home/runner/eph.tok >/dev/null; sudo chmod 600 /home/runner/eph.tok
UUID=$(printf '%s' "$SECRET" | sudo docker exec -i -u git forgejo forgejo forgejo-cli actions register --secret-stdin --name eph --ephemeral --labels docker)
unset SECRET
api "$API/admin/actions/runners" | python3 -c '
import sys,json
d=json.load(sys.stdin); r=d["runners"] if isinstance(d,dict) else d
for x in r: print(" ", {k:x.get(k) for k in ("id","name","status","ephemeral")})'
sudo systemctl stop forgejo-runner
sudo sed '/^server:/,$d' /home/runner/config.yml | sudo -u runner tee /home/runner/config-eph.yml >/dev/null

L'API liste maintenant deux runners : le permanent (idle, ephemeral: False) et un second offline, ephemeral: True, enregistré mais pas encore connecté. Créez un dépôt avec deux workflows docker, pour avoir deux jobs en attente.

Fenêtre de terminal
api -X POST "$API/user/repos" -d '{"name":"l8eph","auto_init":true,"default_branch":"main"}' -o /dev/null -w 'create repo HTTP %{http_code}\n'
for i in 1 2; do
printf 'name: eph%s\non: [push]\njobs:\n j:\n runs-on: docker\n steps:\n - run: echo job-ephemere-ok-%s\n' $i $i > /tmp/e.yml
api -X POST "$API/repos/fjadmin/l8eph/contents/.forgejo/workflows/e$i.yml" -d "{\"content\":\"$(base64 -w0 /tmp/e.yml)\",\"message\":\"e$i\"}" -o /dev/null -w "push e$i HTTP %{http_code}\n"
done
sudo -u runner bash -c "cd /home/runner && timeout 120 /usr/local/bin/forgejo-runner one-job -c /home/runner/config-eph.yml --url https://forge.lab.test/ --uuid $UUID --token-url file:/home/runner/eph.tok --label 'docker:docker://$NODE' -w"

La sortie contient, dans cet ordre : ephemeral: true, declared successfully, single task poller successfully fetched one task, puis single task poller is shutting down, et le code de retour est 0. Une seule tâche a été prise (la tâche 15 dans notre rejeu). L'API ne liste plus que le runner permanent : l'éphémère a disparu. Relancez le runner permanent.

Fenêtre de terminal
sudo systemctl start forgejo-runner; sleep 35
api "$API/repos/fjadmin/l8eph/actions/tasks" | python3 -c '
import sys,json
for t in json.load(sys.stdin).get("workflow_runs",[]): print(" ", t["id"], t["name"], t["status"])'

La liste affiche les tâches 16 j success et 15 j success (les numéros dépendent de l'instance) : la plus récente est celle que le runner permanent a prise après son redémarrage. Chaque job a donc bien eu son exécution, et le runner éphémère n'en a pas pris deux.

Deux pièges à connaître. Premier piège : one-job lancé depuis un autre répertoire que /home/runner s'arrête sur Error: invalid configuration: failed to load registration file: open .runner: permission denied (nous le lancions d'abord depuis un dossier que runner ne peut pas lire) ; lancez la commande après un cd /home/runner, comme ci-dessus. Second piège : un runner éphémère qui ne reçoit jamais de job reste enregistré. Le nôtre s'était attardé dans l'API (offline, ephemeral: True) après un premier essai manqué, et il se supprime à la main. Ne lancez la commande que si la liste montre un tel runner, et remplacez <id> par son identifiant : un numéro copié d'ici pourrait désigner votre runner permanent.

Fenêtre de terminal
api -X DELETE "$API/admin/actions/runners/<id>" -o /dev/null -w 'suppression runner HTTP %{http_code}\n'

La réponse est HTTP 204 (dans notre essai, le runner oublié portait l'identifiant 2). Ce que la leçon ne montre pas : créer un runner éphémère à la demande, pour chaque job, par un orchestrateur. Le mode éphémère est la brique ; l'automatisation qui lance un one-job par tâche n'a pas été rejouée.

Que faire quand le runner ne se comporte pas comme prévu ?

Section intitulée « Que faire quand le runner ne se comporte pas comme prévu ? »

Les échecs de cette leçon se lisent dans le journal du job, dans le journal systemd du runner ou dans la liste des runners de l'API. Le tableau relie chaque symptôme à sa cause.

SymptômeCauseCorrection
[…/ca.crt] is not a valid volume, will be ignored dans le journal du joble chemin monté n'est pas dans container.valid_volumesdéclarer le chemin, redémarrer le runner
curl: (60) SSL certificate problem dans un jobcurl ne lit pas GIT_SSL_CAINFOdonner à curl sa propre CA ; git passe déjà
curl sur forge.lab.test répond 000 depuis la VM, avant l'enregistrementla CA de Caddy n'est pas dans le magasin de confianceupdate-ca-trust après avoir copié la CA
Can't check signature: No public keyla clé n'a pas été importée (l'adresse forgejo.gpg ne contenait aucune clé)importer l'empreinte EB114F5E… depuis keys.openpgp.org
Un workflow runs-on: ubuntu-latest ne démarre jamais, sans messageaucun runner ne porte ce labelajouter le label au runner ou changer runs-on
.github/workflows/… n'est pas exécuté.forgejo/workflows/ existe et prend le pastout mettre dans .forgejo/workflows/
open .runner: permission denied au lancement de one-jobcommande lancée hors de /home/runnercd /home/runner avant la commande
Runner éphémère offline qui reste dans la listeil n'a jamais reçu de jobDELETE /api/v1/admin/actions/runners/<id>
zstd introuvable en lisant un journall'outil n'est pas installé sur la VMsudo dnf -y -q install zstd

Le diagnostic commence toujours par la même question : le runner est-il en ligne ? Si GET /api/v1/admin/actions/runners ne le montre pas idle, regardez journalctl -u forgejo-runner. S'il est idle et que le job ne démarre pas, comparez le runs-on du workflow aux labels de la liste.

On supprime les dépôts de test, on arrête et retire l'unité, on retire le runner de l'API, on révoque le jeton, puis on retire le compte, le binaire et la CA : ce sont les gestes inverses de l'installation. Le runner se retire de l'API avant la révocation du jeton, car c'est ce jeton qui authentifie l'appel. Le jeton se révoque par un DELETE en authentification Basic comme dans la leçon Assister les utilisateurs.

Fenêtre de terminal
api -X DELETE "$API/repos/fjadmin/l8test" -o /dev/null -w 'delete repo HTTP %{http_code}\n'
api -X DELETE "$API/repos/fjadmin/l8eph" -o /dev/null -w 'delete repo HTTP %{http_code}\n'
sudo systemctl disable --now forgejo-runner.service
sudo rm -f /etc/systemd/system/forgejo-runner.service
sudo systemctl daemon-reload
for ID in $(api "$API/admin/actions/runners" | python3 -c '
import sys,json
d=json.load(sys.stdin); r=d["runners"] if isinstance(d,dict) else d
print(" ".join(str(x["id"]) for x in r))'); do
api -X DELETE "$API/admin/actions/runners/$ID" -o /dev/null -w "delete runner $ID HTTP %{http_code}\n"
done
PW=$(sudo grep '^ADMIN_PASSWORD=' /opt/forgejo-lab/.env | cut -d= -f2-)
curl -sS -u "fjadmin:$PW" -X DELETE "$API/users/fjadmin/tokens/l8tok" -o /dev/null -w 'delete token HTTP %{http_code}\n'; unset PW
sudo rm -f /tmp/l2.zst /tmp/e.yml /tmp/host-proof.txt /tmp/forge-lab-ca.crt
sudo rm -rf /tmp/wf
sudo userdel -r runner
sudo rm -f /usr/local/bin/forgejo-runner
sudo rm -rf /etc/forgejo-runner
sudo rm -f /etc/pki/ca-trust/source/anchors/forge-lab-ca.crt
sudo update-ca-trust

Les quatre suppressions par l'API (deux dépôts, le runner, le jeton) répondent HTTP 204. La boucle retire tous les runners de l'instance : sur une forge qui en héberge d'autres, filtrez sur le nom ou l'id lu dans la liste. userdel -r runner supprime le compte et son répertoire, donc le config.yml et le secret. Vérifiez : id runner répond no such user, systemctl is-active forgejo-runner répond inactive, l'API ne liste plus aucun runner, aucun dépôt, aucun jeton, et un curl HTTPS sur la forge répond de nouveau 000 puisque la CA n'est plus dans le magasin. Ne retirez la CA que si vous ne gardez pas la forge sur cette VM.

  • Le binaire du runner se vérifie par sha256sum -c et par gpg --verify ; le message not certified with a trusted signature est normal, la clé primaire (EB114F5E…) se compare à la documentation, et se récupère sur keys.openpgp.org.
  • forgejo-runner register et create-runner-file sont dépréciés en 13.2 : on génère un secret avec forgejo forgejo-cli actions generate-secret et on le déclare avec actions register --secret-stdin. L'UUID dérive du secret, le --name n'est pas retenu.
  • En mode démon, la configuration porte server.connections.<nom> avec url, uuid, token et labels ; il n'y a plus de fichier .runner. Le runner doit faire confiance à la CA de la forge.
  • Un chemin monté par container.options doit figurer dans container.valid_volumes, sinon is not a valid volume, will be ignored.
  • L'exécuteur host n'isole rien : le job lit le config.yml du runner et liste les conteneurs de la forge. Hors lab, on le refuse.
  • L'exécuteur docker ne donne pas de socket (docker_host: "-"), mais le job est root dans son conteneur et son réseau joint la VM. Donner le socket équivaut à donner host.
  • Le masquage *** ne vaut que pour la valeur exacte d'un secret.
  • Forgejo Actions n'est pas compatible GitHub Actions : .forgejo/workflows/ prime sur .github/workflows/, uses: se résout sur data.forgejo.org, un runs-on sans runner attend sans message, et les variables FORGEJO_* s'ajoutent.
  • Le mode éphémère (register --ephemeral, puis forgejo-runner one-job lancé depuis /home/runner) prend un seul job puis disparaît de l'API ; un éphémère jamais utilisé reste enregistré et se supprime par l'API.

Six questions tirées sur huit : l'enregistrement sans commande dépréciée, les volumes autorisés, ce que peut un job host, le cloisonnement d'un job docker, les écarts avec GitHub Actions et le mode éphémère.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn