Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Qu’est-ce que gitrust ?

gitrust est une forge Git self-hosted écrite en Rust, conçue pour les équipes de 3 à 20 personnes qui souhaitent maîtriser leur infrastructure de développement. Elle offre un serveur HTTP (:4000) et SSH (:2222), une CI intégrée via Dagger, la génération de SBOM avec Syft et l’intégration Dependency-Track, une gestion fine des permissions (4 rôles), la double authentification, des labels hiérarchiques et une conformité ANSSI PA-074.

Une instance publique de démonstration est accessible sur demo.gitrust.eu.

À qui s’adresse cette documentation ?

Cette documentation est organisée en quatre parties selon leurs publics respectifs :

  • Manuel utilisateur — pour les développeurs qui utilisent une instance gitrust existante : push/pull, pull requests, issues, CI, notifications. Prérequis : savoir utiliser Git en ligne de commande.
  • Manuel d’administration — pour les ops et self-hosters qui déploient, configurent et maintiennent une instance en production. Prérequis : administration Linux, notions de réseau et TLS.
  • Manuel développeur — pour les contributeurs au code gitrust et les développeurs qui construisent des intégrations sur l’API publique. Prérequis : Rust (pour contribuer) ou un langage client (pour l’API).
  • Bibliothèque de templates — pour tous les profils, lorsqu’ils cherchent un point de départ prêt à l’emploi (fichiers .env, docker-compose.yml, configs reverse-proxy, scripts de backup, exemples de clients API).

Comment cette documentation est-elle structurée ?

Cette documentation suit le cadre Diátaxis, qui distingue quatre types de contenu selon le besoin du lecteur :

TypeQuand l’utiliserExemples
TutorielsQuand vous apprenezInstaller gitrust, créer votre premier dépôt
Guides pratiquesQuand vous résolvez un problème précisConfigurer SMTP, gérer les clés SSH
RéférenceQuand vous cherchez une information techniqueVariables d’environnement, schéma API REST
ExplicationQuand vous voulez comprendre le pourquoiArchitecture des crates, modèle RBAC

Chaque manuel (utilisateur, administration, développeur) contient ces quatre types de contenu organisés de manière indépendante.

Manuel utilisateur

Ce manuel t’accompagne depuis ta première connexion à une instance gitrust jusqu’à l’autonomie complète : pousser du code, collaborer via les pull requests, gérer les issues et automatiser tes workflows avec la CI intégrée. Tu n’as pas besoin d’administrer ni de comprendre l’architecture interne — ce manuel suppose seulement que tu sais utiliser Git en ligne de commande.

Parcours d’apprentissage

Le manuel est structuré comme un parcours progressif de 4 tutoriels. Chaque tutoriel te laisse dans un état stable, vérifié, avant de passer au suivant. L’aide fournie recule d’un cran à chaque étape (du copier-coller intégral vers la composition autonome).

graph LR
    A[Compte créé] --> B[01-premier-pas<br/>20 min]
    B --> C[02-cloner-pousser<br/>25 min]
    C --> D[03-collaborer<br/>25 min]
    D --> E[04-automatiser-ci<br/>20 min]
    E --> F[Utilisateur autonome]

Checkpoint de parcours : avant de passer au tutoriel 02, tu dois pouvoir te connecter à gitrust, voir ton dépôt dans l’interface et l’accès SSH configuré. Si ce n’est pas le cas, relis le tutoriel 01.

Checkpoint de parcours : avant le tutoriel 03, tu dois avoir poussé au moins un commit sur ta branche via SSH. Si ce n’est pas le cas, relis le tutoriel 02.

Checkpoint de parcours : avant le tutoriel 04, tu dois avoir ouvert et mergé une pull request. Si ce n’est pas le cas, relis le tutoriel 03.


Tutoriels — apprendre en faisant

Les tutoriels sont guidés pas-à-pas avec des sorties verbatim attendues. Suis-les dans l’ordre — chacun suppose le précédent complété.


How-to — recettes pour les tâches courantes

Les how-to répondent à « comment faire X » sans explication de fond. Tu peux les consulter dans n’importe quel ordre selon ton besoin immédiat.

Ces guides supposent que tu as suivi le parcours tutoriels. Ils ne répètent pas les étapes de base.


Référence — informations techniques exactes

La référence est destinée à la consultation ponctuelle, pas à la lecture linéaire. Elle documente les formats, schémas et comportements exacts.


Explication — comprendre le pourquoi

Les explanations t’aident à construire des modèles mentaux durables. Lis-les quand tu veux comprendre pourquoi gitrust fonctionne ainsi, pas seulement comment.

Tutoriels — Apprendre gitrust pas à pas

Les tutoriels sont des parcours d’apprentissage guidés. Leur rôle est de te faire acquérir des compétences en te faisant réaliser quelque chose de concret — pas de t’informer sur des fonctionnalités. Chaque tutoriel te laisse dans un état précis, avec quelque chose qui fonctionne réellement.

Mode Diátaxis : Tutorial. Si tu cherches à accomplir une tâche précise que tu sais déjà faire, consulte les guides pratiques. Si tu veux comprendre pourquoi gitrust fonctionne ainsi, consulte les explications.


Parcours utilisateur (~90 min)

graph LR
    A[Compte créé] --> B["01 — Premiers pas<br/>~20 min"]
    B --> C["02 — Cloner et pousser<br/>~25 min"]
    C --> D["03 — Collaborer<br/>~25 min"]
    D --> E["04 — Automatiser CI<br/>~20 min"]
    E --> F[Utilisateur autonome]

    style A fill:#e8f4e8
    style F fill:#e8f4e8

État initial : tu as un compte gitrust actif et Git installé sur ta machine. État final : tu pousses du code, collabores en équipe via issues et PRs, et bénéficies d’une CI automatique.

Le niveau d’aide diminue progressivement d’un tutoriel à l’autre — c’est volontaire :

TutorielNiveau d’aideNature
01 — Premiers pas100 % fourniCopier-coller intégral
02 — Cloner et pousser100 % fourniCopier-coller intégral
03 — Collaborer80 % fourniQuelques trous à compléter
04 — Automatiser CI80 % fourniConfiguration à adapter

Les quatre tutoriels

01 — Premiers pas : active la 2FA, ajoute ta clé SSH et crée ton premier dépôt

Active l’authentification à deux facteurs, enregistre ta première clé SSH et crée un dépôt vide. Point d’entrée obligatoire du parcours.

Bloom : O1 Activer (2FA) · O2 Enregistrer (clé SSH) · O3 Créer (dépôt)


02 — Cloner et pousser du code

Génère une clé SSH ed25519, clone ton dépôt en local et pousse ton premier commit. Tu vérifies ensuite le résultat dans l’interface web.

Bloom : O1 Générer (clé SSH) · O2 Cloner (dépôt via SSH) · O3 Pousser (commit)

Avant de commencer : le tutoriel 01 doit être complété — tu dois avoir un dépôt créé et un compte actif.


03 — Collaborer : équipes, issues et pull requests

Crée une équipe, invite un coéquipier, ouvre une issue, travaille sur une branche et soumets ta première pull request. Tu la fusionnes et vérifies que l’issue se ferme automatiquement.

Bloom : O1 Créer (équipe) · O2 Ouvrir (issue) · O3 Ouvrir et fusionner (PR)

Avant de commencer : le tutoriel 02 doit être complété — tu dois avoir un dépôt cloné en local.


04 — Automatiser les tests avec la CI intégrée

Écris un fichier .gitrust-ci.yml minimal, pousse-le et observe le pipeline s’exécuter en temps réel. Tu lis le statut et les logs sur /{owner}/{repo}/ci.

Bloom : O1 Écrire (config CI) · O2 Déclencher (pipeline) · O3 Lire (résultats)

Avant de commencer : le tutoriel 03 doit être complété — tu dois avoir collaboré sur au moins une PR.


Checkpoint de parcours

Tu es prêt pour le tutoriel 03 si : tu peux exécuter git push origin main depuis ton dépôt local sans erreur d’authentification. Sinon, relis le tutoriel 02.

Tu es prêt pour le tutoriel 04 si : tu peux ouvrir une PR, la faire reviewer et la fusionner. Sinon, relis le tutoriel 03.


Après les tutoriels

Une fois les quatre tutoriels complétés, tu n’as plus besoin d’un guide pas-à-pas. Consulte :

  • Les guides pratiques pour des recettes ciblées (gérer les clés SSH, configurer la 2FA, importer un dépôt…)
  • La référence pour les tableaux de permissions, le schéma CI, l’API REST
  • Les explications pour comprendre le modèle de collaboration, la sécurité et le cycle de vie d’une PR

Premiers pas : active la 2FA, ajoute ta clé SSH et crée ton premier dépôt

Objectifs

À la fin de ce tutoriel, tu sauras :

  • O1. Activer l’authentification à deux facteurs (2FA) sur ton compte gitrust
  • O2. Enregistrer une clé SSH publique dans ton profil pour te connecter sans mot de passe
  • O3. Créer un dépôt vide et vérifier qu’il est accessible à l’URL attendue

Pré-requis

  • Technique : un compte gitrust existant (e-mail + mot de passe), Git installé (git --version répond), une paire de clés SSH générée ou à générer (ssh-keygen)
  • Pédagogique : aucun tutoriel préalable requis — c’est le point de départ du parcours
  • Temps estimé : ~20 minutes

Vue d’ensemble

Avant de taper la première commande, prenons 2 minutes pour comprendre ce que tu vas faire et pourquoi.

Un dépôt (repository), c’est comme un dossier de projet avec tout l’historique de ses modifications — une boîte d’archives vivante que Git surveille. Sur gitrust, ce dossier vit sur le serveur et peut être partagé avec ton équipe.

Une clé SSH fonctionne comme une serrure et une clé : tu gardes la clé privée sur ta machine (ne la partage jamais), et tu déposes la clé publique dans ton profil gitrust. Ensuite, Git se connecte automatiquement sans te redemander de mot de passe — gitrust reconnaît ta machine.

La 2FA (authentification à deux facteurs) ajoute un second verrou à ton compte : même si quelqu’un vole ton mot de passe, il ne peut pas se connecter sans le code de ton téléphone. C’est une protection essentielle pour tout compte qui héberge du code.

Voici le flux que tu vas configurer :

sequenceDiagram
    participant T as Ton navigateur
    participant G as gitrust (HTTPS :4000)
    participant S as Ton terminal (SSH :2222)

    T->>G: Connexion avec e-mail + mdp + code 2FA
    T->>G: Dépôt de la clé SSH publique
    S->>G: git push via SSH (authentifié par clé)
    G-->>T: Dépôt visible dans l'interface web

Étape 1 : Connecte-toi et accède aux paramètres de sécurité

Ouvre ton navigateur et va sur l’URL de ton instance gitrust (par exemple https://demo.gitrust.eu). Connecte-toi avec ton e-mail et ton mot de passe.

Une fois connecté, clique sur ton avatar en haut à droite, puis sur Paramètres (/settings). Dans le menu de gauche, sélectionne Sécurité.

Checkpoint : tu dois voir la page Sécurité avec une section « Authentification à deux facteurs » et le statut « Désactivée ». Si tu ne la vois pas, vérifie que tu es bien connecté avec le bon compte.


Étape 2 : Active la 2FA avec une application d’authentification

Sur la page Sécurité, clique sur Activer la 2FA. gitrust affiche un QR code.

  1. Ouvre ton application d’authentification (Google Authenticator, Authy, ou tout équivalent compatible TOTP).
  2. Scanne le QR code avec l’application.
  3. L’application affiche un code à 6 chiffres qui change toutes les 30 secondes.
  4. Saisis ce code dans le champ Code de vérification et clique Confirmer.

Sortie attendue (message dans l’interface) :

La 2FA a été activée avec succès.
Conservez vos codes de secours dans un endroit sûr.

gitrust affiche également 10 codes de secours (format XXXX-XXXX). Copie-les et conserve-les hors ligne — ils te permettent de récupérer l’accès si tu perds ton téléphone.

Checkpoint : retourne sur Paramètres → Sécurité. La section 2FA doit maintenant afficher « Activée » en vert. Si elle affiche toujours « Désactivée », recommence l’étape en t’assurant que le code à 6 chiffres est saisi avant qu’il expire.


Étape 3 : Génère ou localise ta clé SSH

Si tu n’as pas encore de paire de clés SSH, génère-en une maintenant dans ton terminal :

ssh-keygen -t ed25519 -C "mon-compte@example.com"

Sortie attendue :

Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/toi/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/toi/.ssh/id_ed25519
Your public key has been saved in /home/toi/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:xXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx mon-compte@example.com

Appuie sur Entrée pour accepter le chemin par défaut. Tu peux choisir une passphrase ou laisser vide (appuie deux fois sur Entrée). Une passphrase ajoute une couche de sécurité.

Affiche maintenant ta clé publique (le fichier .pub — celui-ci est fait pour être partagé) :

cat ~/.ssh/id_ed25519.pub

Sortie attendue (une seule ligne) :

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx mon-compte@example.com

Copie cette ligne entière dans le presse-papier.

Checkpoint : la commande cat doit afficher une ligne commençant par ssh-ed25519 (ou ssh-rsa si tu as une clé plus ancienne). Si elle affiche « No such file or directory », la génération n’a pas abouti — relance ssh-keygen.


Étape 4 : Enregistre la clé SSH dans ton profil gitrust

Dans ton navigateur, retourne sur Paramètres et clique sur Clés SSH dans le menu de gauche.

Clique sur Ajouter une clé SSH. Un formulaire apparaît :

  • Titre : donne un nom descriptif à cette clé (ex. laptop-perso-2026, poste-bureau)
  • Clé : colle la ligne copiée à l’étape précédente

Clique Enregistrer la clé.

Sortie attendue (message dans l’interface) :

Clé SSH ajoutée avec succès.

Teste maintenant la connexion SSH depuis ton terminal :

ssh -T git@demo.gitrust.eu -p 2222

Sortie attendue :

Bonjour tonpseudo ! Vous êtes authentifié, mais gitrust ne fournit pas d'accès shell.

Checkpoint : si tu obtiens ce message, ta clé SSH est correctement enregistrée. Si tu obtiens Permission denied (publickey), vérifie que tu as bien copié la clé publique (.pub) et non la clé privée. Consulte la section « Et si ça ne marche pas » ci-dessous.


Étape 5 : Crée ton premier dépôt vide

Dans ton navigateur, clique sur le + en haut à droite (ou navigue vers /repo/new).

Remplis le formulaire :

  • Propriétaire : ton pseudo
  • Nom du dépôt : mon-premier-depot (lettres, chiffres, tirets uniquement)
  • Visibilité : Privé (ou Public selon ta préférence)
  • Initialiser le dépôt : coche « Ajouter un README »

Clique sur Créer le dépôt.

Sortie attendue : gitrust te redirige vers la page du dépôt à l’URL :

https://demo.gitrust.eu/tonpseudo/mon-premier-depot

La page affiche un fichier README.md avec le nom du dépôt, et la barre latérale montre 1 commit, 1 branche (main).

Checkpoint : note l’URL affichée dans la barre d’adresse de ton navigateur. Elle doit correspondre au patron https://<instance>/<pseudo>/<nom-depot>. Si tu vois une erreur 404 ou 500, relis le formulaire de création.


Récapitulatif

  • O1 accompli : la 2FA est activée sur ton compte — tu l’as vérifiée sur la page Sécurité qui affiche « Activée »
  • O2 accompli : ta clé SSH publique est enregistrée — tu l’as confirmée avec ssh -T git@... -p 2222 qui a répondu « Vous êtes authentifié »
  • O3 accompli : ton premier dépôt est créé et visible à l’URL /<pseudo>/mon-premier-depot

Et si ça ne marche pas

SymptômeCause probableCorrection
Permission denied (publickey) lors du test SSHTu as collé la clé privée au lieu de la clé publique, ou le fichier .pub ne correspond pas à la clé activeDans gitrust, supprime la clé ajoutée. Retourne au terminal et exécute ssh-add -l pour voir les clés actives. Recopie la bonne clé .pub avec cat ~/.ssh/id_ed25519.pub
Connection refused lors du test SSHLe port SSH de l’instance est différent de 2222, ou l’instance est inaccessibleVérifie avec l’administrateur le port SSH réel. Essaie ssh -T git@<instance> -p 22 en alternative
Le code 2FA est refusé alors qu’il semble correctL’horloge de ton téléphone est désynchronisée (décalage > 30 s)Active la synchronisation automatique de l’heure sur ton téléphone (Paramètres → Date et heure → Automatique). Réessaie immédiatement après
Le dépôt redirige vers une erreur 422 lors de la créationLe nom contient des caractères interdits (espaces, accents, /)Utilise uniquement des lettres non accentuées, chiffres et tirets. Évite les points en début ou fin de nom

Prochaine étape

02 — Cloner et pousser du code : clone ton dépôt en local avec SSH, fais ton premier commit et pousse-le sur gitrust (~25 min)

Cloner un dépôt et pousser ton premier commit

Objectifs

À la fin de ce tutoriel, tu sauras :

  • O1. Générer une clé SSH ed25519 et la déclarer dans ton profil gitrust (/settings/keys)
  • O2. Cloner un dépôt gitrust en local via SSH (git clone git@…)
  • O3. Pousser un commit vers gitrust et vérifier le résultat dans l’interface web

Pré-requis

  • Technique : Git installé (git --version répond), un terminal, ton instance gitrust accessible
  • Pédagogique : tutoriel 01 — Premiers pas complété (compte actif, 2FA configurée, un dépôt créé)
  • Temps estimé : ~25 minutes

Vue d’ensemble

Avant de taper la première commande, voici ce que tu vas mettre en place et pourquoi.

SSH (Secure Shell) est le protocole que Git utilise pour envoyer et recevoir du code de manière sécurisée. La différence avec HTTPS : au lieu de saisir un mot de passe à chaque fois, tu déposes une clé publique sur le serveur gitrust, et ta machine prouve son identité grâce à la clé privée correspondante que tu gardes chez toi. C’est à la fois plus sûr et plus pratique.

Voici les trois acteurs de ce tutoriel :

sequenceDiagram
    participant M as Ta machine (terminal)
    participant A as Agent SSH (~/.ssh/)
    participant G as gitrust (:2222 SSH)
    participant W as gitrust (navigateur)

    M->>M: ssh-keygen → génère id_ed25519 + id_ed25519.pub
    M->>W: dépôt de id_ed25519.pub dans /settings/keys
    M->>A: ssh-add id_ed25519 (optionnel)
    M->>G: git clone git@gitrust.example.com:owner/repo.git
    G-->>M: copie locale du dépôt
    M->>M: édite un fichier, git add, git commit
    M->>G: git push origin main
    G-->>W: commit visible dans l'interface

Étape 1 : Génère une clé SSH ed25519

Note : si tu as déjà généré une clé ed25519 pendant le tutoriel 01, passe directement à l’étape 2.

Dans ton terminal, lance la commande suivante (remplace l’adresse e-mail par la tienne) :

ssh-keygen -t ed25519 -C "ton@email.com"

Sortie attendue :

Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/toi/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/toi/.ssh/id_ed25519
Your public key has been saved in /home/toi/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:xXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx ton@email.com
The key's randomart image is:
+--[ED25519 256]--+
|        .o+      |
|        ...      |
+----[SHA256]-----+

Appuie sur Entrée pour accepter le chemin par défaut. Une passphrase est recommandée — elle chiffre la clé privée sur disque.

Affiche ta clé publique (le fichier .pub — celui-là est fait pour être partagé) :

cat ~/.ssh/id_ed25519.pub

Sortie attendue :

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ton@email.com

Copie cette ligne complète dans le presse-papier.

Checkpoint : la ligne commence obligatoirement par ssh-ed25519. Si elle commence par -----BEGIN tu affiches la clé privée par erreur — arrête-toi et relis la commande.


Étape 2 : Déclare la clé dans ton profil gitrust

Dans ton navigateur, va sur Paramètres → Clés SSH (/settings/keys).

Page Clés SSH dans les paramètres

Clique Ajouter une clé SSH. Remplis le formulaire :

  • Titre : un nom qui identifie la machine (ex. laptop-maison-2026)
  • Clé : colle la ligne ssh-ed25519 … copiée à l’étape 1

Clique Enregistrer.

Sortie attendue (bannière verte dans l’interface) :

Clé SSH ajoutée avec succès.

Vérifie la connexion depuis le terminal :

ssh -T git@gitrust.example.com -p 2222

Sortie attendue :

Bonjour tonpseudo ! Vous êtes authentifié, mais gitrust ne fournit pas d'accès shell.

Checkpoint : tu dois obtenir ce message de bienvenue. Si tu obtiens Permission denied (publickey), relis l’encart « Et si ça ne marche pas » en bas de ce tutoriel.


Étape 3 : Récupère l’URL SSH de ton dépôt

Dans ton navigateur, ouvre la page de ton dépôt (/<tonpseudo>/mon-premier-depot).

Sur la page d’accueil du dépôt, clique sur le bouton Cloner (ou Code). Sélectionne l’onglet SSH et copie l’URL affichée.

Elle a la forme :

git@gitrust.example.com:tonpseudo/mon-premier-depot.git

Bouton Cloner avec l’URL SSH sélectionnée

Checkpoint : l’URL SSH commence par git@ (pas par https://). Si tu ne vois pas d’onglet SSH, l’instance n’a pas de serveur SSH configuré — demande à ton administrateur.


Étape 4 : Clone le dépôt en local

Dans ton terminal, place-toi dans le dossier où tu veux stocker tes projets, puis clone :

cd ~/projets
git clone git@gitrust.example.com:tonpseudo/mon-premier-depot.git

Sortie attendue :

Cloning into 'mon-premier-depot'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (2/2), done.
Receiving objects: 100% (3/3), done.

Entre dans le dossier cloné :

cd mon-premier-depot
ls

Sortie attendue :

README.md

Checkpoint : le fichier README.md est présent — ton dépôt est bien cloné. Si Git affiche fatal: repository not found, vérifie l’URL SSH copiée à l’étape précédente.


Étape 5 : Crée un fichier, commite et pousse

Crée un nouveau fichier dans le dépôt :

echo "# Notes de développement" > NOTES.md

Ajoute-le à l’index Git et crée un commit :

git add NOTES.md
git commit -m "ajoute NOTES.md"

Sortie attendue :

[main 3a8c21f] ajoute NOTES.md
 1 file changed, 1 insertion(+)
 create mode 100644 NOTES.md

Pousse vers gitrust :

git push origin main

Sortie attendue :

Enumerating objects: 4, done.
Counting objects: 100% (4/4), done.
Delta compression using up to 8 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 298 bytes | 298.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To git@gitrust.example.com:tonpseudo/mon-premier-depot.git
   a1b2c3d..3a8c21f  main -> main

Checkpoint : la dernière ligne montre main -> main avec deux SHA différents, ce qui confirme que les objets ont bien été envoyés.


Étape 6 : Vérifie dans l’interface web

Retourne dans ton navigateur sur la page du dépôt (/<tonpseudo>/mon-premier-depot). Rafraîchis la page.

Tu dois voir :

  • Le fichier NOTES.md apparaît dans la liste des fichiers
  • La barre de commits affiche 2 commits (le README initial + ton nouveau commit)
  • Le message de commit ajoute NOTES.md est visible à côté du fichier

Page du dépôt après le push, NOTES.md visible

Checkpoint : si le fichier NOTES.md n’est pas visible après rafraîchissement, assure-toi que le git push a bien terminé sans erreur (relis la sortie de l’étape 5).


Récapitulatif

  • O1 accompli : tu as généré une clé SSH ed25519 et vérifié son empreinte SHA256 — elle est enregistrée dans /settings/keys
  • O2 accompli : tu as cloné le dépôt en local avec git clone git@… et obtenu une copie fonctionnelle
  • O3 accompli : tu as poussé un commit avec git push origin main et vérifié sa présence dans l’interface web

Et si ça ne marche pas

SymptômeCause probableCorrection
Permission denied (publickey) au git clone ou git pushLa clé privée locale ne correspond pas à la clé publique enregistrée sur gitrust, ou l’agent SSH ne la connaît pasExécute ssh-add ~/.ssh/id_ed25519 pour ajouter la clé à l’agent, puis réessaie. Vérifie aussi que la bonne clé .pub a été copiée dans gitrust
Host key verification failedL’empreinte SSH du serveur gitrust a changé ou est inconnueExécute ssh-keyscan -p 2222 gitrust.example.com >> ~/.ssh/known_hosts, puis réessaie. En environnement de test, tu peux vérifier l’empreinte avec l’administrateur
fatal: repository 'git@…' not foundL’URL SSH est incorrecte, ou le dépôt n’existe pas (typo dans le nom), ou tu n’as pas les droitsVérifie l’URL sur la page web du dépôt. Assure-toi que le dépôt est bien accessible avec ton compte
error: failed to push some refs / rejectedLa branche distante contient des commits que tu n’as pas en localExécute git pull origin main --rebase d’abord, résous les conflits éventuels, puis git push à nouveau

Prochaine étape

03 — Collaborer : équipes, issues et pull requests : crée une équipe, invite un coéquipier, ouvre une issue et ta première pull request (~25 min)

Collaborer : équipes, issues et pull requests

Objectifs

À la fin de ce tutoriel, tu sauras :

  • O1. Créer une équipe et y inviter un coéquipier depuis /teams/new
  • O2. Ouvrir une issue pour décrire un problème ou une tâche à réaliser
  • O3. Ouvrir une pull request depuis une branche et la fusionner après review

Pré-requis

  • Technique : dépôt cloné en local (tutoriel 02 complété), accès à un second compte gitrust (ou un coéquipier disponible)
  • Pédagogique : tutoriel 02 — Cloner et pousser complété
  • Temps estimé : ~25 minutes

Vue d’ensemble

gitrust organise la collaboration autour de trois concepts qui s’enchaînent naturellement :

  • Une équipe (team) regroupe des personnes et définit leur niveau d’accès à un ou plusieurs dépôts. C’est le point d’entrée pour inviter quelqu’un sur un projet.
  • Une issue est une unité de travail : un bug à corriger, une feature à ajouter, une question à trancher. Elle porte un numéro (#N) qui sert de référence partout.
  • Une pull request (PR) est une demande de fusion d’une branche vers une autre. Elle lie le code proposé à l’issue qu’il résout et déclenche la review de l’équipe.
flowchart LR
    T[Équipe créée<br/>/teams/new] --> R[Dépôt assigné<br/>à l'équipe]
    R --> I[Issue ouverte<br/>&#35;1]
    I --> B[Branche créée<br/>feat/fix-readme]
    B --> PR[Pull Request<br/>ouverte]
    PR --> Rev[Review approuvée]
    Rev --> M[Fusion dans main]
    M --> C[Issue fermée<br/>automatiquement]

Scaffolding niveau 2 : dans ce tutoriel, certaines commandes comportent un trou ___ à compléter. L’aide est donnée juste après le bloc de code — essaie d’abord sans regarder.


Étape 1 : Crée une équipe

Dans ton navigateur, navigue vers /teams/new.

Remplis le formulaire :

  • Nom de l’équipe : equipe-alpha (lettres minuscules, chiffres, tirets)
  • Description : Équipe de développement principale (optionnel)

Clique Créer l’équipe.

Sortie attendue : gitrust te redirige vers la page de l’équipe /teams/equipe-alpha. La page affiche l’équipe avec toi comme premier membre avec le rôle Owner.

Page de l’équipe nouvellement créée

Checkpoint : tu vois la page /teams/equipe-alpha avec ton pseudo listé en tant que membre. Si tu obtiens une erreur 422, le nom contient probablement des caractères interdits — utilise uniquement des minuscules et des tirets.


Étape 2 : Invite un coéquipier et assigne le dépôt

Sur la page de l’équipe, dans la section Membres, saisis le pseudo de ton coéquipier et clique Inviter.

Sortie attendue :

Membre ajouté à l'équipe.

Dans la section Dépôts, clique Ajouter un dépôt. Sélectionne mon-premier-depot dans la liste et choisis le niveau d’accès Developer. Clique Confirmer.

Sortie attendue :

Accès au dépôt accordé.

Checkpoint : ton coéquipier doit maintenant voir mon-premier-depot dans son tableau de bord. Demande-lui de vérifier. Le niveau Developer lui permet de cloner, pousser sur les branches non-protégées et ouvrir des PRs.


Étape 3 : Ouvre une issue

Dans ton navigateur, navigue vers /<tonpseudo>/mon-premier-depot/issues/new.

Remplis le formulaire :

  • Titre : Améliorer le README avec une description du projet
  • Description :
    Le fichier README.md est vide. Il faudrait ajouter :
    - Une description du projet
    - Les instructions d'installation
    

Clique Ouvrir l’issue.

Sortie attendue : gitrust crée l’issue et te redirige vers /<tonpseudo>/mon-premier-depot/issues/1. La page affiche l’issue avec le statut Ouverte et le numéro #1.

Issue ouverte avec statut « Ouverte »

Checkpoint : l’URL dans ton navigateur doit se terminer par /issues/1. Note ce numéro — tu l’utiliseras dans le message de commit pour fermer l’issue automatiquement.


Étape 4 : Crée une branche pour travailler

Dans ton terminal, depuis le dossier mon-premier-depot, crée une nouvelle branche :

git checkout -b ___

Complète ___ avec le nom de branche feat/ameliorer-readme. La convention type/description-courte rend les branches lisibles dans la liste.

Réponse :

git checkout -b feat/ameliorer-readme

Sortie attendue :

Switched to a new branch 'feat/ameliorer-readme'

Modifie le fichier README.md :

cat > README.md << 'EOF'
# mon-premier-depot

Un dépôt de démonstration gitrust.

## Installation

Cloner le dépôt :

```bash
git clone git@gitrust.example.com:tonpseudo/mon-premier-depot.git

EOF


Commite et pousse la branche :

```bash
git add README.md
git commit -m "améliore le README - closes #1"
git push origin feat/ameliorer-readme

Sortie attendue :

[feat/ameliorer-readme 7c3d4e5] améliore le README - closes #1
 1 file changed, 10 insertions(+), 1 deletion(-)
...
To git@gitrust.example.com:tonpseudo/mon-premier-depot.git
 * [new branch]      feat/ameliorer-readme -> feat/ameliorer-readme

Checkpoint : la sortie de git push montre [new branch] feat/ameliorer-readme. Si elle montre rejected, exécute d’abord git pull origin main --rebase.


Étape 5 : Ouvre une pull request

Dans ton navigateur, navigue vers /<tonpseudo>/mon-premier-depot/pulls/new.

gitrust peut avoir détecté ta branche et proposer une bannière « Ouvrir une PR pour feat/ameliorer-readme » — clique dessus si elle apparaît.

Remplis le formulaire :

  • Titre : Améliore le README avec description et installation
  • Branche source : feat/ameliorer-readme
  • Branche cible : main
  • Description : Closes #1 (gitrust fermera l’issue automatiquement à la fusion)
  • Reviewers : sélectionne ton coéquipier (s’il est disponible)

Clique Ouvrir la pull request.

Sortie attendue : gitrust crée la PR et te redirige vers /<tonpseudo>/mon-premier-depot/pulls/1.

Pull request ouverte avec diff visible

Checkpoint : la PR est bien à l’état Ouverte et l’onglet Fichiers modifiés montre les changements dans README.md. Si la PR est vide (pas de diff), vérifie que tu as bien sélectionné feat/ameliorer-readme comme branche source.


Étape 6 : Fusionne la pull request

Si ton coéquipier peut review, demande-lui d’approuver la PR avant de fusionner. Dans ce tutoriel, tu peux fusionner directement si tu es seul.

Sur la page de la PR, fais défiler jusqu’à la section Fusion. Sélectionne la stratégie Merge commit (par défaut). Clique Fusionner la pull request.

Sortie attendue (message dans l’interface) :

Pull request fusionnée avec succès.

La PR passe au statut Fusionnée (violet). Navigue vers /<tonpseudo>/mon-premier-depot/issues/1 — l’issue doit être Fermée automatiquement grâce au closes #1 dans le message de commit.

Issue fermée automatiquement après fusion

Checkpoint : l’issue #1 est au statut Fermée. La page principale du dépôt affiche le nouveau contenu du README.md. Si l’issue est encore ouverte, vérifie que le message de commit contenait exactement closes #1 (respecter la casse n’est pas requis, mais le numéro doit être correct).


Récapitulatif

  • O1 accompli : tu as créé l’équipe equipe-alpha via /teams/new, invité un membre et assigné le dépôt avec le niveau Developer
  • O2 accompli : tu as ouvert l’issue #1 pour décrire la tâche, obtenant une unité de travail traçable et numérotée
  • O3 accompli : tu as ouvert une PR depuis feat/ameliorer-readme, la fusionner a fermé l’issue automatiquement grâce à closes #1

Et si ça ne marche pas

SymptômeCause probableCorrection
Le coéquipier ne voit pas le dépôt dans son tableau de bordL’accès au dépôt n’a pas été correctement accordé à l’équipeSur la page de l’équipe, vérifie que le dépôt apparaît dans la section Dépôts avec un niveau d’accès. Reconfirme si nécessaire
La PR n’a pas de diff (onglet Fichiers modifiés vide)La branche source est identique à la branche cibleVérifie que tu as bien poussé depuis feat/ameliorer-readme et non depuis main. Exécute git log --oneline main..feat/ameliorer-readme pour voir les commits en attente
L’issue #1 reste ouverte après fusionLe message de commit ne contient pas le mot-clé reconnu, ou le numéro est incorrectLes mots-clés acceptés sont closes, fixes, resolves (insensibles à la casse) suivi de #N. Vérifie le message exact avec git log --oneline
git push refusé avec remote: push blockedLa branche main est protégée et le push direct y est interditC’est normal : travaille toujours sur une branche séparée et utilise une PR pour fusionner dans main

Prochaine étape

04 — Automatiser avec la CI : écris ton premier .gitrust-ci.yml, déclenche un pipeline et lis le résultat (~20 min)

Automatiser les tests avec la CI intégrée

Objectifs

À la fin de ce tutoriel, tu sauras :

  • O1. Écrire un fichier .gitrust-ci.yml minimal en mode Easy pour un projet Rust ou Node
  • O2. Déclencher un pipeline CI en poussant un commit
  • O3. Lire le résultat d’un pipeline (statut, logs) sur la page /{owner}/{repo}/ci

Pré-requis

  • Technique : dépôt cloné en local avec au moins un fichier source (tutoriel 02 complété), Git installé
  • Pédagogique : tutoriel 03 — Collaborer complété
  • Temps estimé : ~20 minutes

Vue d’ensemble

La CI (Continuous Integration, intégration continue) automatise l’exécution de tes tests à chaque push. Au lieu de vérifier manuellement que le code compile et que les tests passent, gitrust le fait pour toi — et signale immédiatement si quelque chose est cassé.

Le mode Easy de gitrust CI utilise un fichier .gitrust-ci.yml que tu places à la racine de ton dépôt. Ce fichier décrit en quelques lignes ce que gitrust doit exécuter : compiler, linter, tester. Le serveur de build reçoit le code, l’exécute dans un container Docker isolé, et renvoie les logs en temps réel.

flowchart LR
    subgraph "Ta machine"
        C[Commit +<br/>git push]
    end
    subgraph "gitrust (:4000)"
        W[CiWorker<br/>Tokio task]
        DB[(Pipeline<br/>en base)]
    end
    subgraph "Serveur de build"
        D[Docker<br/>+ Dagger]
        CI[ci-engine]
    end

    C -->|push SSH/HTTP| W
    W --> DB
    W -->|rsync + SSH| D
    D --> CI
    CI -->|logs ligne par ligne| W
    W --> DB
    DB -->|SSE stream| C

Scaffolding niveau 2 : la configuration CI comporte une section incomplète à compléter. Essaie d’abord sans regarder l’indice.


Étape 1 : Vérifie que la CI est activée sur le dépôt

Dans ton navigateur, ouvre ton dépôt et navigue vers Settings (/{owner}/{repo}/settings).

Fais défiler jusqu’à la section CI. Si tu ne vois pas cette section, l’administrateur de l’instance n’a pas encore activé le CI — contacte-le.

Coche CI activé et Déclencher sur push. Clique Enregistrer.

Section CI dans les paramètres du dépôt

Checkpoint : la case CI activé est cochée et sauvegardée. Si la section CI n’apparaît pas dans les paramètres, la fonctionnalité n’est pas activée sur cette instance — consulte la section « Et si ça ne marche pas ».


Étape 2 : Crée le fichier .gitrust-ci.yml

Dans ton terminal, depuis la racine du dépôt cloné, crée le fichier de configuration CI.

Pour un projet Rust :

# .gitrust-ci.yml
language: rust

build:
  command: "cargo build"

checks:
  lint: "cargo clippy -- -D warnings"
  format: "cargo fmt -- --check"

tests:
  command: "___"

Complète ___ avec la commande de test Rust standard. Réponse : cargo test.

Pour un projet Node.js :

# .gitrust-ci.yml
language: node

build:
  command: "npm install"

checks:
  lint: "npm run lint"

tests:
  command: "npm test"

Pour ce tutoriel, utilise le fichier le plus adapté à ton projet, ou crée un exemple minimaliste avec Node si tu n’as pas de projet existant :

# Exemple minimal Node — crée les fichiers nécessaires
echo '{"name":"demo","scripts":{"test":"node -e \"console.log(42===42)\"","lint":"echo ok"}}' > package.json

Puis crée .gitrust-ci.yml avec le contenu Node ci-dessus.

Checkpoint : exécute ls -la dans le dépôt — tu dois voir .gitrust-ci.yml dans la liste. La taille du fichier doit être supérieure à 0 octet.


Étape 3 : Commite et pousse pour déclencher la CI

Ajoute le fichier et crée un commit :

git add .gitrust-ci.yml
git commit -m "ajoute configuration CI gitrust"
git push origin main

Sortie attendue :

[main a4f7b8c] ajoute configuration CI gitrust
 1 file changed, 10 insertions(+)
 create mode 100644 .gitrust-ci.yml
...
To git@gitrust.example.com:tonpseudo/mon-premier-depot.git
   3a8c21f..a4f7b8c  main -> main

Immédiatement après le push, dans ton navigateur, navigue vers /{owner}/{repo}/ci.

Sortie attendue : une entrée de pipeline apparaît avec le statut En attente ou En cours, associée au commit a4f7b8c et au message ajoute configuration CI gitrust.

Page CI du dépôt avec un pipeline en cours

Checkpoint : le pipeline apparaît dans la liste. S’il n’apparaît pas après 10 secondes, rafraîchis la page. S’il n’apparaît toujours pas, vérifie que la CI est bien activée (étape 1) et que le fichier .gitrust-ci.yml est bien à la racine du dépôt (pas dans un sous-dossier).


Étape 4 : Lis les logs du pipeline

Clique sur le pipeline dans la liste pour ouvrir la page de détail.

La page affiche :

  • Le statut en cours (Pending → Running → Success ou Failure)
  • Le commit et la branche associés
  • Les logs en temps réel, mis à jour ligne par ligne

Attends la fin de l’exécution. Pour un projet minimal, cela prend 1 à 3 minutes selon la rapidité du serveur de build.

Sortie attendue pour un pipeline réussi (dernières lignes des logs) :

[build] cargo build: ok
[checks] cargo clippy: ok
[tests] cargo test: ok
Pipeline terminé avec succès.

Pour un pipeline en échec, les logs montrent la commande qui a échoué et le code de retour :

[tests] cargo test: FAILED (exit code 1)
error[E0277]: the trait bound `...` is not satisfied
Pipeline terminé en échec.

Page de détail d’un pipeline réussi

Checkpoint : le pipeline passe au statut Réussi (vert) ou Échoué (rouge). Dans les deux cas, tu as atteint l’objectif O3 — tu sais lire le résultat. Si le statut reste bloqué sur En cours plus de 10 minutes, consulte la section « Et si ça ne marche pas ».


Récapitulatif

  • O1 accompli : tu as écrit un fichier .gitrust-ci.yml avec les sections language, build, checks et tests, adapté à ton langage
  • O2 accompli : tu as déclenché le pipeline en poussant un commit — gitrust l’a automatiquement détecté et lancé l’exécution
  • O3 accompli : tu as lu le statut et les logs sur /{owner}/{repo}/ci, identifiant si les étapes build/checks/tests ont réussi ou échoué

Et si ça ne marche pas

SymptômeCause probableCorrection
Aucun pipeline n’apparaît après le pushLa CI n’est pas activée sur le dépôt, ou le fichier .gitrust-ci.yml est absent/mal placéVérifie Settings → CI (case CI activé cochée). Vérifie que .gitrust-ci.yml est à la racine avec git ls-files .gitrust-ci.yml
Le pipeline reste en statut En attente indéfinimentLe serveur de build n’est pas accessible depuis gitrust, ou CI_MAX_CONCURRENT est atteintDemande à l’administrateur de vérifier la connectivité SSH vers le builder et les logs journalctl -u gitrust
Le pipeline échoue à l’étape build avec command not foundLe langage sélectionné n’a pas les outils installés sur le serveur de buildVérifie avec l’administrateur que le profil du langage (rust, node) est disponible. Consulte la référence des schémas CI
Les logs s’affichent puis le pipeline reste en statut En coursTimeout atteint (CI_DEFAULT_TIMEOUT) ou processus bloquéVérifie si le test boucle indéfiniment. Contacte l’administrateur pour ajuster CI_DEFAULT_TIMEOUT

Prochaine étape

Tu as maintenant complété le parcours utilisateur de base : compte sécurisé → code versionné → collaboration en équipe → CI automatisée.

Pour aller plus loin :

Guides pratiques — Recettes orientées tâche

Les guides pratiques (how-to guides) sont des recettes : ils répondent à la question « comment faire X ? » quand tu sais déjà ce que tu veux accomplir. Ils supposent que tu es déjà opérationnel — pas besoin d’explication du contexte ou de prise en main.

Mode Diátaxis : How-to. Si tu apprends gitrust pour la première fois, commence par les tutoriels. Si tu veux comprendre pourquoi gitrust fonctionne ainsi, consulte les explications. Pour les tableaux de référence exhaustifs, consulte la référence.


Les neuf guides pratiques

Sécurité et accès

Gérer ses clés SSH Générer une clé ed25519 ou RSA-4096, l’ajouter ou la supprimer via /settings/keys, vérifier l’empreinte SHA256, diagnostiquer ssh -T git@….

Configurer la double authentification (2FA) Activer le TOTP via /settings/security/2fa/setup, scanner le QR code, sauvegarder les codes de secours, désactiver ou régénérer.

Créer un Personal Access Token (PAT) Créer un token via /settings/tokens, choisir les scopes repo:read/repo:write, l’utiliser avec git clone https:// ou le header Authorization: Bearer, révoquer.


Collaboration et code review

Gérer les équipes et les permissions Créer une équipe, inviter des membres, attribuer des rôles (Owner/Maintainer/Member), assigner un dépôt avec un niveau d’accès (Reader/Developer/Maintainer/Owner).

Ouvrir une pull request Depuis une branche, ouvrir une PR, rédiger la description avec Closes #N, assigner des reviewers, suivre les statuts CI.

Choisir une stratégie de fusion Comparer fast-forward, squash et merge commit — quand utiliser chacune, permissions requises, équivalent API.

Utiliser les labels et les issues Labels à deux niveaux (classification + sujet), filtrer les issues, fermer automatiquement via closes #N dans un commit ou une PR.


Gestion des dépôts

Importer un dépôt externe Importer via l’UI /import, URL HTTPS publique, suivre la progression SSE, gérer les erreurs courantes (timeout, taille, auth).

Consulter le SBOM d’un dépôt Page Security d’un dépôt, vulnérabilités via Dependency-Track, télécharger le fichier CycloneDX.


Après les guides pratiques

Pour les tableaux de référence complets (permissions, schéma CI, API REST, notifications) : consulte la référence.

Comment gérer ses clés SSH

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Ajouter une clé SSH depuis une nouvelle machine
  • Générer une clé plus sécurisée (ed25519 au lieu de RSA)
  • Vérifier l’empreinte d’une clé avant de l’ajouter
  • Supprimer une clé d’une machine perdue ou compromise
  • Diagnostiquer une erreur Permission denied (publickey)

Pré-requis

  • Un compte gitrust actif
  • Un terminal avec ssh-keygen disponible (ssh-keygen --version doit répondre)

Étapes

1. Générer une clé ed25519 (recommandé)

ed25519 est l’algorithme recommandé : clés courtes, très rapides, résistantes aux attaques modernes.

ssh-keygen -t ed25519 -C "description-de-la-machine"

Exemple de description : laptop-maison-2026, serveur-ci, poste-bureau.

Sortie attendue :

Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/toi/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Your identification has been saved in /home/toi/.ssh/id_ed25519
Your public key has been saved in /home/toi/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:xXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx description-de-la-machine

Une passphrase est fortement recommandée : elle chiffre la clé privée sur le disque. Si ta machine est volée, la clé reste inutilisable sans la passphrase.

2. Générer une clé RSA-4096 (alternative)

Si un outil ou un service impose RSA :

ssh-keygen -t rsa -b 4096 -C "description-de-la-machine"

La clé sera dans ~/.ssh/id_rsa et ~/.ssh/id_rsa.pub. RSA-4096 est sûr mais plus lent qu’ed25519.

3. Afficher la clé publique à copier

La clé publique (fichier .pub) est faite pour être partagée :

cat ~/.ssh/id_ed25519.pub

Sortie (une ligne) :

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx description-de-la-machine

Copie cette ligne entière.

4. Ajouter la clé dans gitrust (/settings/keys)

Dans ton navigateur, navigue vers Paramètres → Clés SSH (/settings/keys).

Clique Ajouter une clé SSH :

  • Titre : le même que la description (ex. laptop-maison-2026)
  • Clé : colle la ligne ssh-ed25519 … copiée

Clique Enregistrer.

Page /settings/keys avec une clé ajoutée

5. Lister les clés enregistrées

La page /settings/keys affiche toutes tes clés avec :

  • Leur titre
  • Leur empreinte SHA256 (pour vérification)
  • La date d’ajout
  • Un bouton Supprimer

6. Vérifier l’empreinte SHA256 d’une clé locale

Pour confirmer qu’une clé locale correspond bien à une clé enregistrée dans gitrust :

ssh-keygen -lf ~/.ssh/id_ed25519.pub

Sortie :

256 SHA256:xXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx description-de-la-machine (ED25519)

Compare ce SHA256 avec celui affiché dans /settings/keys. Ils doivent être identiques.

7. Tester la connexion SSH

ssh -T git@gitrust.example.com -p 2222

Sortie attendue :

Bonjour tonpseudo ! Vous êtes authentifié, mais gitrust ne fournit pas d'accès shell.

Si tu as plusieurs clés, spécifie laquelle utiliser :

ssh -T -i ~/.ssh/id_ed25519 git@gitrust.example.com -p 2222

8. Supprimer une clé

Dans /settings/keys, clique Supprimer à côté de la clé concernée. La suppression est immédiate — toute connexion utilisant cette clé sera refusée dès la prochaine tentative.


Variantes

Plusieurs machines, plusieurs clés

Tu peux enregistrer autant de clés que nécessaire dans gitrust. Chaque machine a sa propre paire de clés — tu ne copies jamais une clé privée d’une machine à l’autre.

Pour avoir plusieurs clés sur la même machine et choisir laquelle utiliser selon le serveur, crée un fichier ~/.ssh/config :

Host gitrust.example.com
    HostName gitrust.example.com
    User git
    Port 2222
    IdentityFile ~/.ssh/id_ed25519_gitrust

Ensuite git clone git@gitrust.example.com:owner/repo.git utilisera automatiquement cette clé.

Ajouter la clé à l’agent SSH (évite de saisir la passphrase à chaque push)

ssh-add ~/.ssh/id_ed25519

Sur macOS, ajoute --apple-use-keychain pour que la passphrase soit mémorisée dans le trousseau système.


Dépannage ssh -T

SymptômeCause probableCorrection
Permission denied (publickey)La clé privée locale ne correspond pas à une clé publique enregistrée, ou l’agent ne la connaît pasExécute ssh-add ~/.ssh/id_ed25519, puis réessaie. Vérifie que la bonne clé .pub est dans /settings/keys
Connection refusedLe port 2222 est bloqué ou le service SSH de gitrust est arrêtéEssaie le port 22 : ssh -T git@gitrust.example.com -p 22. Contacte l’administrateur
Host key verification failedL’empreinte du serveur a changé ou est inconnueExécute ssh-keyscan -p 2222 gitrust.example.com >> ~/.ssh/known_hosts. Vérifie l’empreinte avec l’administrateur avant d’accepter
Too many authentication failuresL’agent SSH propose trop de clésSpécifie explicitement : ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -T git@… -p 2222

Voir aussi

Comment configurer la double authentification (2FA)

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Activer la 2FA TOTP sur ton compte gitrust
  • Scanner le QR code avec une nouvelle application
  • Sauvegarder tes codes de secours
  • Désactiver la 2FA (par exemple pour changer d’application)
  • Régénérer tes codes de secours après les avoir utilisés

Pré-requis

  • Un compte gitrust actif
  • Une application d’authentification TOTP installée sur ton téléphone : Google Authenticator, Authy, Bitwarden Authenticator, ou tout équivalent compatible RFC 6238

Étapes

1. Accéder à la page de sécurité

Dans ton navigateur, navigue vers Paramètres → Sécurité (/settings/security).

La section Authentification à deux facteurs affiche l’état actuel : « Désactivée » ou « Activée ».

Page /settings/security avec la section 2FA

2. Lancer la configuration TOTP

Clique Activer la 2FA. gitrust génère un secret TOTP et affiche un QR code.

3. Scanner le QR code

Dans ton application d’authentification :

  1. Ouvre l’application
  2. Ajoute un nouveau compte (icône + ou « Ajouter un compte »)
  3. Choisis Scanner un QR code
  4. Pointe l’appareil photo vers le QR code affiché dans gitrust

L’application ajoute un compte nommé gitrust (tonpseudo) et affiche un code à 6 chiffres qui se renouvelle toutes les 30 secondes.

Si tu ne peux pas scanner le QR code (appareil photo indisponible) : clique sur « Afficher la clé secrète ». Copie la chaîne de 32 caractères et saisis-la manuellement dans ton application (option « Saisir une clé »).

4. Vérifier le code et valider

Dans gitrust, saisis le code à 6 chiffres affiché par ton application dans le champ Code de vérification. Clique Confirmer.

Le code est valide pendant 30 secondes. Si l’horloge de ton téléphone n’est pas synchronisée, le code peut être rejeté — active la synchronisation automatique de l’heure dans les paramètres de ton téléphone.

Sortie attendue :

La 2FA a été activée avec succès.
Conservez vos codes de secours dans un endroit sûr.

5. Sauvegarder les codes de secours

gitrust affiche 10 codes de secours au format XXXX-XXXX. Ces codes permettent de te connecter si tu perds accès à ton application d’authentification.

Actions à effectuer immédiatement :

  • Copie les 10 codes
  • Stocke-les dans un gestionnaire de mots de passe (Bitwarden, KeePass…) ou sur papier dans un endroit sécurisé
  • Ne les stocke pas dans le même appareil que ton application 2FA

Chaque code ne peut être utilisé qu’une seule fois.


Variantes

Désactiver la 2FA

Sur /settings/security, dans la section 2FA, clique Désactiver la 2FA. gitrust te demande de confirmer avec ton mot de passe.

Après désactivation, ton compte n’est protégé que par ton mot de passe. Si ton instance force la 2FA globalement (décision administrateur), tu ne pourras pas la désactiver.

Régénérer les codes de secours

Si tu as utilisé tous tes codes ou crains qu’ils soient compromis :

Sur /settings/security, clique Régénérer les codes de secours. Les anciens codes sont immédiatement invalidés. gitrust affiche 10 nouveaux codes — sauvegarde-les comme à l’étape 5.

Changer d’application d’authentification

  1. Désactive la 2FA (voir ci-dessus)
  2. Installe la nouvelle application
  3. Réactive la 2FA depuis le début (étape 1)

Si tu as encore accès à l’ancienne application, tu peux aussi simplement ajouter le même compte dans la nouvelle application en rescannant le QR code pendant l’activation.


Voir aussi

Comment créer un Personal Access Token (PAT)

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Accéder à l’API gitrust depuis un script ou un outil CI sans exposer ton mot de passe
  • Cloner un dépôt via HTTPS depuis un environnement où SSH n’est pas disponible
  • Révoquer un accès accordé à un outil tiers sans changer ton mot de passe
  • Comprendre quels scopes attribuer à un token selon l’usage

Pré-requis

  • Un compte gitrust actif

Étapes

1. Accéder à la gestion des tokens

Dans ton navigateur, navigue vers Paramètres → Tokens d’accès (/settings/tokens).

La page liste tes tokens existants avec leur nom, leur date de création et leur date d’expiration.

Page /settings/tokens

2. Créer un nouveau token

Clique Créer un token. Remplis le formulaire :

  • Nom : un nom descriptif qui rappelle l’usage (ex. script-backup-2026, ci-github-actions, cli-perso)
  • Expiration : choisis une durée (30 jours, 90 jours, 1 an, ou sans expiration). Pour les scripts de CI, préfère une expiration explicite.
  • Scopes : sélectionne uniquement les permissions nécessaires (principe du moindre privilège)
ScopePermet
repo:readLire les dépôts, les issues, les PRs, les commits
repo:writeCréer des issues, commenter, pousser du code, ouvrir des PRs
user:readLire les informations de ton profil

Clique Générer le token.

3. Copier le token immédiatement

gitrust affiche le token une seule fois :

gr_pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Copie-le immédiatement dans ton gestionnaire de mots de passe. Cette valeur ne sera plus jamais affichée — si tu la perds, tu devras révoquer le token et en créer un nouveau.


Utiliser le token

Avec git clone via HTTPS

git clone https://tonpseudo:gr_pat_xxxx@gitrust.example.com/owner/repo.git

Pour éviter que le token apparaisse dans l’historique du shell, stocke-le dans une variable d’environnement :

export GITRUST_TOKEN="gr_pat_xxxx"
git clone https://tonpseudo:${GITRUST_TOKEN}@gitrust.example.com/owner/repo.git

Avec l’API REST (header Authorization)

curl -H "Authorization: Bearer gr_pat_xxxx" \
     https://gitrust.example.com/api/v1/user

Réponse attendue :

{
  "id": 42,
  "username": "tonpseudo",
  "email": "ton@email.com"
}

Dans un script Python

import urllib.request
import json

token = "gr_pat_xxxx"
req = urllib.request.Request(
    "https://gitrust.example.com/api/v1/user",
    headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req) as resp:
    user = json.load(resp)
    print(user["username"])

Révoquer un token

Dans /settings/tokens, clique Révoquer à côté du token concerné. La révocation est immédiate — toute requête utilisant ce token recevra une réponse 401 Unauthorized.

Cas où révoquer immédiatement :

  • Token exposé accidentellement dans un dépôt public ou des logs
  • Machine ou outil auquel le token était destiné est compromise
  • Token inutilisé depuis longtemps

Variantes

Token sans expiration

Acceptable pour les outils personnels sur une machine de confiance. À déconseiller pour les environnements partagés (CI d’entreprise, scripts déployés sur un serveur). Préfère dans ces cas un token avec expiration de 90 jours renouvelé via un secret manager.

Plusieurs tokens pour un même outil

Crée un token par environnement (local, staging, prod) avec des scopes distincts. Ainsi, compromettre le token de staging n’expose pas la production.


Voir aussi

Comment gérer les équipes et les permissions

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Créer une équipe et y regrouper des collaborateurs
  • Inviter ou retirer un membre d’une équipe
  • Assigner une équipe à un dépôt avec un niveau d’accès précis
  • Choisir le bon rôle (Owner / Maintainer / Member) pour chaque membre
  • Comprendre la différence entre les rôles d’équipe et les niveaux d’accès aux dépôts

Pré-requis

  • Un compte gitrust actif
  • Être propriétaire (Owner) de l’équipe ou de l’organisation pour gérer les membres et accès

Étapes

1. Créer une équipe

Navigue vers Équipes → Nouvelle équipe (/teams/new).

Remplis le formulaire :

  • Nom : identifiant de l’équipe (lettres minuscules, chiffres, tirets — ex. backend-core, devops-ops)
  • Description : optionnel, visible par les membres

Clique Créer l’équipe. gitrust te redirige vers /teams/{slug}.

Page de l’équipe nouvellement créée

2. Ajouter des membres

Sur la page de l’équipe (/teams/{slug}), dans la section Membres :

  1. Saisis le pseudo du collaborateur dans le champ de recherche
  2. Sélectionne son compte dans la liste déroulante
  3. Choisis son rôle dans l’équipe (voir tableau ci-dessous)
  4. Clique Inviter
Rôle dans l’équipeCe que le membre peut faire
MemberUtiliser les dépôts assignés selon le niveau d’accès
MaintainerGérer les dépôts assignés à l’équipe
OwnerGérer les membres, les dépôts et les paramètres de l’équipe

Un seul Owner par équipe est requis. Désigne un Owner de secours pour éviter le blocage en cas d’absence.

3. Assigner un dépôt à l’équipe

Sur la page de l’équipe, dans la section Dépôts :

  1. Clique Ajouter un dépôt
  2. Sélectionne le dépôt dans la liste
  3. Choisis le niveau d’accès au dépôt (voir tableau ci-dessous)
  4. Clique Confirmer
Niveau d’accès dépôtLirePousserOuvrir/merger PRGérer le dépôt
Reader
Developer✓ (branches)
Maintainerpartiel
Owner

Pour la référence complète des permissions par action, consulte le modèle de permissions.

4. Retirer un membre

Sur la page de l’équipe, dans la section Membres, clique Retirer à côté du membre concerné. Le retrait est immédiat — le membre perd l’accès aux dépôts de l’équipe.

5. Modifier le niveau d’accès d’un dépôt

Pour changer le niveau d’accès d’un dépôt déjà assigné à une équipe : retire le dépôt (bouton Retirer dans la section Dépôts), puis réassigne-le avec le nouveau niveau.


Variantes

Accès direct à un dépôt (sans équipe)

Il est possible d’ajouter un collaborateur directement sur un dépôt via /{owner}/{repo}/collaborators. Cette approche est adaptée aux cas ponctuels (ex. un prestataire externe sur un seul dépôt). Pour des équipes permanentes, l’approche par équipe est préférable : un seul point de gestion pour plusieurs dépôts.

Un utilisateur appartient à plusieurs équipes

Si un utilisateur appartient à plusieurs équipes qui ont des niveaux d’accès différents sur un même dépôt, c’est le niveau le plus élevé qui s’applique. Par exemple : Team A avec Reader + Team B avec Developer → l’utilisateur a Developer sur ce dépôt.


Voir aussi

Comment ouvrir une pull request

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Proposer des modifications de code pour review avant fusion
  • Lier une PR à une issue pour la fermer automatiquement à la fusion
  • Assigner des reviewers à une PR
  • Vérifier que la CI est verte avant de fusionner

Pré-requis

  • Un dépôt avec au moins deux branches (ta branche de travail + main ou autre branche cible)
  • Le niveau d’accès Developer minimum sur le dépôt

Étapes

1. Pousser ta branche de travail

Avant d’ouvrir une PR, ta branche doit être poussée sur gitrust :

git push origin ma-branche

Sortie attendue :

To git@gitrust.example.com:owner/repo.git
 * [new branch]      ma-branche -> ma-branche

2. Ouvrir le formulaire de nouvelle PR

Navigue vers /{owner}/{repo}/pulls/new.

gitrust peut aussi afficher une bannière de suggestion au sommet de la page du dépôt juste après un push récent : « Ouvrir une pull request pour ma-branche ». Clique dessus si elle apparaît.

Formulaire de création d’une pull request

3. Remplir le formulaire

ChampValeur recommandée
TitrePhrase courte à l’impératif : Ajoute la validation email, Corrige le bug #42
Branche sourceTa branche de travail (ma-branche)
Branche ciblemain (ou la branche de destination souhaitée)
DescriptionContexte, lien vers l’issue (Closes #N), captures d’écran si pertinent
ReviewersPseudo(s) des coéquipiers à notifier
LabelsOptionnel — pour catégoriser (voir Utiliser les labels)

Mot-clé Closes #N : si tu écris Closes #3 dans la description ou dans un message de commit sur cette branche, gitrust fermera automatiquement l’issue #3 lors de la fusion.

Clique Ouvrir la pull request.

4. Vérifier les onglets de la PR

Une fois créée, la PR s’affiche sur /{owner}/{repo}/pulls/{num} avec plusieurs onglets :

  • Conversation : fil de discussion, commentaires généraux
  • Commits : liste des commits inclus dans la PR
  • Fichiers modifiés : diff complet, ligne par ligne — c’est ici que les reviewers commentent
  • CI : statut du pipeline CI (si configuré)

Onglets d’une pull request ouverte

5. Suivre le statut CI

Si le dépôt a une configuration CI (.gitrust-ci.yml), gitrust exécute automatiquement le pipeline sur la branche de la PR. Le statut apparaît en bas de la PR :

  • En attente / En cours : le pipeline tourne
  • Réussi (vert) : tous les checks passent
  • Échoué (rouge) : au moins un check a échoué — clique sur « Voir les détails » pour lire les logs

6. Répondre à une review

Quand un reviewer laisse des commentaires :

  1. Pousse de nouveaux commits sur la même branche pour adresser les remarques
  2. Réponds aux commentaires dans l’interface pour indiquer ce qui a été fait
  3. Le reviewer peut alors approuver la PR

Variantes

PR en mode brouillon

Si ton code n’est pas encore prêt pour review, ouvre la PR en cochant Brouillon lors de la création. Une PR en brouillon ne peut pas être fusionnée. Elle est visible par l’équipe pour discussion préalable. Passe-la en « Prête pour review » quand tu es prêt.

Mettre à jour la branche avant fusion

Si des commits ont été ajoutés sur main depuis que tu as créé ta branche, rebaser avant de fusionner :

git fetch origin
git rebase origin/main
git push origin ma-branche --force-with-lease

Voir aussi

Comment choisir une stratégie de fusion

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Comprendre la différence entre fast-forward, squash et merge commit
  • Choisir la stratégie adaptée à ton projet ou à ta PR
  • Savoir quelles permissions sont requises pour chaque stratégie

Pré-requis

  • Une pull request ouverte sur le dépôt
  • Le niveau d’accès Developer minimum (pour fusionner une PR dont tu es l’auteur) ou Maintainer (pour fusionner les PRs des autres)

Les trois stratégies

Fast-forward (--ff-only)

Ce que ça fait : déplace simplement le pointeur de la branche cible vers le dernier commit de la branche source. Aucun commit de merge n’est créé. L’historique reste parfaitement linéaire.

Avant :   main: A → B
          feat:       B → C → D

Après :   main: A → B → C → D

Quand l’utiliser :

  • Branches courtes avec peu de commits
  • Tu veux un historique linéaire lisible avec git log
  • La branche n’a pas divergé de main (pas de commits sur main pendant que tu travaillais)

Limitation : impossible si la branche cible a avancé depuis la création de la branche source — il faut d’abord rebaser.


Squash

Ce que ça fait : regroupe tous les commits de la branche en un seul commit sur la branche cible. L’historique de la branche est condensé.

Avant :   main: A → B
          feat:       B → C → D → E (3 commits)

Après :   main: A → B → F
          (F = C+D+E fusionnés en un seul commit)

Quand l’utiliser :

  • Branche avec de nombreux micro-commits de type fix typo, wip, encore un fix
  • Tu veux que main ne contienne que des commits propres et signifiants
  • La PR correspond à une seule unité logique de travail

Limitation : les commits individuels sont perdus pour main — difficile de retrouver quel commit précis a introduit un changement avec git bisect.


Merge commit (--no-ff)

Ce que ça fait : crée un commit de merge explicite qui unit les deux branches. L’historique conserve la trace de la branche et de quand elle a été intégrée.

Avant :   main: A → B
          feat:       B → C → D

Après :   main: A → B → C → D → M
                          ↑___↑
                    (M = commit de merge)

Quand l’utiliser :

  • Branches longues avec historique riche qu’on veut conserver
  • Tu veux pouvoir git revert l’ensemble de la feature d’un seul coup (revert du commit M)
  • Les conventions de l’équipe exigent un historique non-rebasé

Limitation : l’historique du projet devient un graphe orienté (DAG) moins lisible avec git log --oneline.


Tableau de comparaison

CritèreFast-forwardSquashMerge commit
Historique linéaire
Conserve les commits individuels
Commit de merge visible
git revert d’une feature entièredifficilepar commit✓ (revert M)
git bisect précisdifficile
Idéal pourpetites branches propresbranches bruyantesfeatures longues

Étapes pour fusionner dans gitrust

Sur la page de la PR (/{owner}/{repo}/pulls/{num}), fais défiler jusqu’à la section Fusion :

  1. Sélectionne la stratégie dans le menu déroulant (Merge commit, Squash, ou Fast-forward)
  2. Si fast-forward est choisi mais impossible (branche divergente), gitrust indique l’erreur — rebaser d’abord
  3. Clique Fusionner la pull request
  4. Confirme si demandé

Sortie attendue :

Pull request fusionnée avec succès.

Variantes

Fusionner via l’API

curl -X POST \
  -H "Authorization: Bearer gr_pat_xxxx" \
  -H "Content-Type: application/json" \
  -d '{"merge_method": "squash"}' \
  https://gitrust.example.com/api/v1/repos/owner/repo/pulls/1/merge

Valeurs valides pour merge_method : merge, squash, rebase (fast-forward).

Stratégie par défaut de l’équipe

Conventionner une stratégie par dépôt réduit les décisions ad hoc. Les équipes optant pour trunk-based development préfèrent le squash. Les équipes avec des branches longues (GitFlow) préfèrent le merge commit.


Voir aussi

Comment utiliser les labels et les issues

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Créer et organiser des labels sur un dépôt
  • Assigner des labels à une issue pour la catégoriser
  • Filtrer les issues par label
  • Fermer automatiquement une issue depuis un message de commit ou une PR
  • Comprendre le système de labels à deux niveaux de gitrust

Pré-requis

  • Un dépôt gitrust avec le niveau d’accès Developer minimum

Comprendre les labels à deux niveaux

gitrust organise les labels en deux niveaux : classification et sujet.

  • Niveau 1 — Classification : la nature du ticket (bug, feature, docs, chore)
  • Niveau 2 — Sujet : la zone fonctionnelle concernée (auth, ci, ui, api)

Combiner les deux niveaux donne une issue catégorisée précisément : une issue avec bug + auth est un bug dans le module d’authentification. Le filtrage par combinaison de labels permet de retrouver rapidement les issues pertinentes.


Étapes

1. Accéder à la gestion des labels

Navigue vers /{owner}/{repo}/labels.

La page liste les labels existants avec leur couleur et leur description.

Page de gestion des labels

2. Créer un label

Sur la page des labels, remplis le formulaire en haut :

  • Nom : court, en minuscules, sans espaces (ex. bug, feature, auth, ci)
  • Couleur : choisis une couleur hexadécimale ou utilise le sélecteur
  • Description : optionnel — explique en une phrase quand appliquer ce label

Clique Créer le label.

Labels recommandés pour démarrer :

LabelNiveauCouleur suggéréeUsage
bugClassification#d73a4a (rouge)Comportement inattendu
featureClassification#0075ca (bleu)Nouvelle fonctionnalité
docsClassification#0052cc (bleu foncé)Documentation uniquement
choreClassification#e4e669 (jaune)Maintenance, dépendances
authSujet#7057ff (violet)Module authentification
ciSujet#008672 (vert)Pipeline CI
apiSujet#e99695 (rose)API REST

3. Assigner des labels à une issue

Sur la page d’une issue (/{owner}/{repo}/issues/{num}), dans la barre latérale droite, clique sur Labels.

Sélectionne un ou plusieurs labels dans la liste déroulante. La sélection est appliquée immédiatement.

Ou depuis l’API :

curl -X POST \
  -H "Authorization: Bearer gr_pat_xxxx" \
  -H "Content-Type: application/json" \
  -d '{"label_ids": [1, 3]}' \
  https://gitrust.example.com/api/v1/repos/owner/repo/issues/5/labels

4. Filtrer les issues par label

Sur la page des issues (/{owner}/{repo}/issues), clique sur le menu Labels dans la barre de filtres. Sélectionne un ou plusieurs labels — seules les issues portant ces labels s’affichent.

Pour combiner des filtres (ex. toutes les issues bug dans la zone auth) : sélectionne les deux labels dans le même filtre.

5. Fermer une issue automatiquement

Pour fermer automatiquement une issue lors de la fusion d’une PR ou d’un push sur main, utilise un mot-clé de fermeture dans :

  • Le message de commit (si poussé directement sur main)
  • La description de la PR (fermeture effective à la fusion)
  • Un commentaire de commit dans la branche

Mots-clés acceptés (insensibles à la casse) :

Mot-cléExemple
closescloses #5
fixesfixes #5
resolvesresolves #5

Exemple de message de commit :

git commit -m "corrige la validation email - fixes #5"

Après fusion de la PR contenant ce commit, l’issue #5 passe automatiquement au statut Fermée.


Variantes

Modifier ou supprimer un label

Sur /{owner}/{repo}/labels, chaque label a un bouton Modifier (pour changer le nom, la couleur ou la description) et un bouton Supprimer. La suppression retire le label de toutes les issues auxquelles il était assigné.

Labels suggérés automatiquement

gitrust peut suggérer des labels en fonction du contenu d’une issue. Cette suggestion s’affiche lors de la création d’une issue. Accepte ou ignore selon le contexte.


Voir aussi

Comment importer un dépôt externe

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Migrer un dépôt depuis GitHub, GitLab ou tout autre forge Git vers gitrust
  • Copier un dépôt public accessible en HTTPS sans avoir à le cloner/pousser manuellement
  • Suivre la progression d’un import en cours
  • Diagnostiquer un import qui échoue (timeout, dépôt trop volumineux)

Pré-requis

  • Un compte gitrust actif
  • L’URL HTTPS publique du dépôt source (ex. https://github.com/owner/repo.git)
  • Le dépôt source accessible sans authentification (ou les credentials si privé)

Étapes

1. Ouvrir le formulaire d’import

Navigue vers /import dans ton navigateur.

Le formulaire d’import apparaît.

Formulaire d’import de dépôt

2. Remplir le formulaire

  • URL du dépôt source : l’URL HTTPS du dépôt à importer (ex. https://github.com/torvalds/linux.git)
  • Nom du dépôt cible : le nom que le dépôt aura dans gitrust (rempli automatiquement depuis l’URL, modifiable)
  • Visibilité : Public ou Privé
  • Credentials (si dépôt privé) : nom d’utilisateur + mot de passe ou token du service source

Clique Lancer l’import.

3. Suivre la progression

gitrust crée un job d’import asynchrone et te redirige vers /imports/{id}.

La page affiche la progression en temps réel via SSE (Server-Sent Events) :

Connexion au dépôt source…
Récupération des objets : 100% (1234/1234)
Résolution des deltas : 100% (456/456)
Import terminé avec succès.

Les statuts possibles :

StatutSignification
En attenteJob en file d’attente
En coursClonage en cours, logs en direct
RéussiDépôt disponible sur gitrust
ÉchouéVoir les logs pour la cause
AnnuléImport annulé manuellement

4. Accéder au dépôt importé

Une fois le statut Réussi, clique sur le lien vers le dépôt dans l’interface. Le dépôt est disponible à /{tonpseudo}/{nom-depot} avec tout l’historique Git préservé.

5. Annuler un import en cours

Si l’import prend trop longtemps ou a été lancé par erreur, clique Annuler sur la page /imports/{id}.


Variantes

Dépôt privé avec token

Pour importer un dépôt privé depuis GitHub, utilise un token GitHub dans le champ Credentials :

  • Nom d’utilisateur : ton pseudo GitHub
  • Mot de passe : ton Personal Access Token GitHub (pas ton mot de passe GitHub)

Le token n’est jamais stocké par gitrust après la fin de l’import.

Importer un dépôt volumineux

gitrust impose un timeout par défaut sur les imports. Pour les dépôts très volumineux (plusieurs Go), l’administrateur peut ajuster IMPORT_TIMEOUT dans la configuration de l’instance. Si l’import échoue systématiquement par timeout, cloner manuellement et pousser directement sur gitrust est une alternative :

git clone --mirror https://github.com/owner/repo.git repo.git
cd repo.git
git remote add gitrust git@gitrust.example.com:tonpseudo/repo.git
git push gitrust --mirror

Erreurs courantes

SymptômeCause probableCorrection
Connexion refusée ou timeoutURL inaccessible depuis le serveur gitrust (firewall, dépôt supprimé)Vérifie que l’URL est accessible depuis un navigateur. Signale à l’administrateur si le serveur gitrust est derrière un proxy
Authentification échouéeCredentials incorrects ou token expiréVérifie le token source. Pour GitHub, génère un nouveau PAT avec le scope repo
Dépôt trop volumineuxDépassement de la taille limite configuréeContacte l’administrateur pour ajuster la limite, ou utilise l’import miroir manuel décrit ci-dessus
L’import reste en statut En attente longtempsFile d’attente saturée ou worker d’import non démarréRafraîchis la page. Si le problème persiste, contacte l’administrateur

Voir aussi

Comment consulter le SBOM d’un dépôt

Quand utiliser ce guide

Utilise ce guide quand tu veux :

  • Voir les composants logiciels détectés dans un dépôt (SBOM)
  • Consulter les vulnérabilités identifiées par Dependency-Track
  • Télécharger le fichier SBOM au format SPDX ou CycloneDX
  • Comprendre ce qu’affiche l’onglet Security d’un dépôt

Pré-requis

  • Un dépôt gitrust avec au moins un push récent
  • La fonctionnalité SBOM activée par l’administrateur de l’instance (CI_SBOM_ENABLED=true)
  • Le niveau d’accès Reader minimum sur le dépôt

Étapes

1. Accéder à l’onglet Security du dépôt

Navigue vers /{owner}/{repo}/security.

La page Security s’affiche avec l’encart SBOM.

Onglet Security d’un dépôt avec les données SBOM

2. Lire les informations du SBOM

L’encart SBOM affiche pour le dernier push analysé :

InformationSignification
Statutsuccess, pending, processing, failed
Composants détectésNombre de dépendances identifiées par Syft
Commit analyséSHA du dernier commit scanné
Hash SHA256 du BOMEmpreinte du fichier SBOM généré (traçabilité)
Dernière analyseDate et heure du scan

3. Consulter les vulnérabilités (si Dependency-Track est configuré)

Si l’administrateur a configuré Dependency-Track, l’encart affiche en plus les compteurs par sévérité :

SévéritéSignification
CriticalVulnérabilité critique, exploitation active connue
HighVulnérabilité haute, correctif disponible
MediumImpact modéré
LowImpact faible

Un lien Voir dans Dependency-Track permet d’accéder au détail de chaque vulnérabilité (CVE, description, composant affecté, version corrigée).

4. Télécharger le fichier SBOM

Si le SBOM a été généré avec succès, un bouton Télécharger le SBOM est disponible dans l’encart. Le fichier téléchargé est au format CycloneDX JSON — le format standard utilisé par gitrust pour générer et transmettre les SBOM.


Variantes

Statut processing qui ne se résout pas

Si le statut reste processing longtemps, cela signifie que Dependency-Track n’a pas terminé son analyse dans le délai imparti (30 secondes). L’analyse continue en arrière-plan sur Dependency-Track. Pour voir les résultats définitifs, consulte directement l’interface Dependency-Track en cliquant sur le lien de l’encart.

Pas de données SBOM sur un dépôt

Deux raisons possibles :

  1. Aucun push depuis l’activation du SBOM : le scan ne se déclenche qu’à chaque push. Pousse un commit pour déclencher le premier scan.
  2. Fonctionnalité non activée : l’administrateur n’a pas activé CI_SBOM_ENABLED. Contacte-le.

Voir aussi

Référence — Tableaux et spécifications

La section référence contient des informations factuelles précises destinées à la consultation. Elle ne t’apprend pas à faire quelque chose — pour ça, consulte les tutoriels ou les guides pratiques.

Mode Diátaxis : Reference. Ces pages sont conçues pour être parcourues rapidement, pas lues de bout en bout. Utilise la recherche ou les tableaux pour trouver ce que tu cherches.


Les cinq références

Modèle de permissions Matrice complète des 4 rôles dépôt (Reader / Developer / Maintainer / Owner) croisés avec toutes les actions possibles. Règles d’héritage utilisateur × équipe. À consulter quand tu ne sais pas si un rôle permet une action précise.

API REST v1 Tous les endpoints publics groupés par domaine (auth, user, repos, issues, pulls, CI). Authentification JWT vs PAT, pagination, codes d’erreur. Lien vers l’interface Swagger interactive /api/docs.

Schéma .gitrust-ci.yml Référence complète du fichier de configuration CI en mode Easy : champs language, build, checks, tests, deploy, artifacts, env. Profils disponibles (Rust, Node, Python, Go) avec exemples complets.

Markdown et syntaxe supportée Toute la syntaxe Markdown acceptée dans les issues, PRs et README : titres, code, tableaux, listes de tâches, mentions @, références #N, mots-clés de fermeture d’issue, emojis, Mermaid.

Notifications Matrice des événements (PR assignée, issue mentionnée, review demandée, CI échouée…) × canaux (SSE in-app, email) × préférences via /settings/notifications. Endpoint du stream SSE.

Modèle de permissions — 4 rôles par dépôt

Cette page est une référence factuelle. Pour comprendre comment assigner ces rôles, consulte Gérer les équipes et les permissions.


Les 4 rôles de dépôt

RôleDescription
ReaderLecture seule — consulte le code, les issues et les PRs
DeveloperContribue — pousse sur les branches non protégées, ouvre et commente des PRs
MaintainerAdministre partiellement — fusionne les PRs, gère les branches protégées, les labels
OwnerContrôle total — paramètres du dépôt, visibilité, suppression, gestion des accès

Matrice des actions × rôles

ActionReaderDeveloperMaintainerOwner
Lire le code (branches, commits, blobs)
Lire les issues et les PRs
Cloner via SSH ou HTTPS
Ouvrir une issue
Commenter une issue / une PR
Fermer / rouvrir une issue
Pousser sur une branche non protégée
Ouvrir une pull request
Fusionner une pull request (propre)
Fusionner la PR d’un autre contributeur
Pousser sur une branche protégée (main)
Créer / supprimer des labels
Créer / supprimer des branches
Forcer le push (--force)
Modifier les paramètres du dépôt
Changer la visibilité (public/privé)
Gérer les collaborateurs et les équipes
Supprimer le dépôt
Activer / désactiver la CI
Lire les pipelines CI
Déclencher un pipeline manuellement
Lire le SBOM (onglet Security)

Héritage utilisateur × équipe

Un utilisateur peut avoir un accès à un dépôt par deux voies :

  1. Accès direct : ajouté comme collaborateur sur /{owner}/{repo}/collaborators avec un niveau explicite
  2. Via une équipe : membre d’une équipe à laquelle le dépôt a été assigné avec un niveau

Règle de résolution : le niveau d’accès effectif est le plus élevé parmi tous les accès (directs + équipes). Il n’y a pas de rôle négatif (interdit explicite) dans gitrust.

Exemple :

SourceNiveau
Équipe frontendDeveloper
Équipe leadsMaintainer
Accès directReader
EffectifMaintainer

Rôles d’équipe vs niveaux d’accès dépôt

Ne pas confondre les deux systèmes :

SystèmeValeursPortée
Rôle dans l’équipeMember / Maintainer / OwnerQui peut gérer l’équipe elle-même
Niveau d’accès dépôtReader / Developer / Maintainer / OwnerCe que les membres de l’équipe peuvent faire sur un dépôt

Un membre avec le rôle Member dans une équipe peut avoir le niveau Owner sur un dépôt — ces deux dimensions sont indépendantes.


Voir aussi

API REST v1

Référence des endpoints publics de l’API gitrust v1. Pour tester interactivement, accède à l’interface Swagger de ton instance sur /api/docs.


Authentification

L’API accepte deux méthodes d’authentification :

JWT Bearer (session web)

Obtenu via POST /api/v1/auth/login. Durée de vie courte, renouvelable via /api/v1/auth/refresh.

Authorization: Bearer <jwt_token>

Personal Access Token (PAT)

Créé dans /settings/tokens. Durée de vie configurable, révocable à tout moment.

Authorization: Bearer gr_pat_xxxx...

Les deux méthodes utilisent le même header Authorization: Bearer. gitrust détecte automatiquement le type par le préfixe du token.


Pagination

Tous les endpoints de liste supportent la pagination par curseur de page :

ParamètreTypeDéfautDescription
pageint1Numéro de page (commence à 1)
limitint20Résultats par page (max 100)

Exemple :

GET /api/v1/repos/owner/repo/issues?page=2&limit=50

Les réponses paginées incluent les headers :

X-Total-Count: 142
X-Page: 2
X-Per-Page: 50

Codes de réponse

CodeSignification
200 OKSuccès
201 CreatedRessource créée
204 No ContentSuccès sans corps de réponse
400 Bad RequestParamètres invalides
401 UnauthorizedToken absent ou expiré
403 ForbiddenAuthentifié mais permissions insuffisantes
404 Not FoundRessource introuvable ou non accessible
422 Unprocessable EntityValidation échouée (corps JSON avec détails)
429 Too Many RequestsRate limit atteint

Les erreurs retournent un corps JSON :

{
  "error": "repository not found",
  "code": "REPO_NOT_FOUND"
}

Endpoints — Authentification

MéthodeEndpointDescription
POST/api/v1/auth/loginConnexion email + password (+ code 2FA si activé)
POST/api/v1/auth/registerCréer un compte
POST/api/v1/auth/refreshRenouveler le JWT
POST/api/v1/auth/logoutInvalider la session
GET/api/v1/auth/meInfos de l’utilisateur connecté (alias de /api/v1/user)
POST/api/v1/auth/forgot-passwordEnvoyer le lien de réinitialisation
POST/api/v1/auth/reset-passwordChanger le mot de passe via token email
POST/api/v1/auth/2fa/verifyVérifier le code TOTP lors de la connexion

Endpoints — Utilisateur

MéthodeEndpointDescription
GET/api/v1/userProfil de l’utilisateur authentifié
GET/api/v1/user/reposDépôts accessibles par l’utilisateur authentifié

Exemple de réponse GET /api/v1/user :

{
  "id": 42,
  "username": "tonpseudo",
  "email": "ton@email.com",
  "avatar_url": "https://gitrust.example.com/avatars/42.png",
  "created_at": "2026-01-15T10:30:00Z"
}

Endpoints — Dépôts

MéthodeEndpointDescription
GET/api/v1/repos/{owner}/{repo}Détail d’un dépôt
GET/api/v1/repos/{owner}/{repo}/branchesListe des branches
GET/api/v1/repos/{owner}/{repo}/tagsListe des tags
GET/api/v1/repos/{owner}/{repo}/commitsListe des commits (paginée)

Exemple de réponse GET /api/v1/repos/owner/repo :

{
  "id": 7,
  "owner": "owner",
  "name": "repo",
  "full_name": "owner/repo",
  "description": "Mon dépôt",
  "private": false,
  "default_branch": "main",
  "stars_count": 3,
  "forks_count": 0,
  "created_at": "2026-02-01T09:00:00Z",
  "updated_at": "2026-04-15T14:22:00Z"
}

Endpoints — Issues

MéthodeEndpointDescription
GET/api/v1/repos/{owner}/{repo}/issuesLister les issues (filtres : state, labels, page, limit)
POST/api/v1/repos/{owner}/{repo}/issuesCréer une issue
GET/api/v1/repos/{owner}/{repo}/issues/{num}Détail d’une issue
PATCH/api/v1/repos/{owner}/{repo}/issues/{num}Modifier une issue
POST/api/v1/repos/{owner}/{repo}/issues/{num}/commentsAjouter un commentaire

Exemple POST /api/v1/repos/owner/repo/issues :

{
  "title": "Bug: validation email incorrecte",
  "body": "La validation rejette les adresses avec un + dans le local-part."
}

Endpoints — Pull Requests

MéthodeEndpointDescription
GET/api/v1/repos/{owner}/{repo}/pullsLister les PRs
POST/api/v1/repos/{owner}/{repo}/pullsCréer une PR
GET/api/v1/repos/{owner}/{repo}/pulls/{num}Détail d’une PR
POST/api/v1/repos/{owner}/{repo}/pulls/{num}/mergeFusionner une PR
POST/api/v1/repos/{owner}/{repo}/pulls/{num}/commentsCommenter une PR

Exemple POST /api/v1/repos/owner/repo/pulls/{num}/merge :

{
  "merge_method": "squash",
  "commit_title": "feat: améliore la validation email (#5)"
}

Valeurs merge_method : merge (merge commit), squash, rebase (fast-forward).


Endpoints — CI

MéthodeEndpointDescription
GET/api/v1/repos/{owner}/{repo}/ci/pipelinesLister les pipelines
POST/api/v1/repos/{owner}/{repo}/ci/pipelines/triggerDéclencher un pipeline manuellement
GET/api/v1/repos/{owner}/{repo}/ci/pipelines/{id}Détail d’un pipeline
GET/api/v1/repos/{owner}/{repo}/ci/pipelines/{id}/logsLogs d’un pipeline
GET/api/v1/repos/{owner}/{repo}/ci/configLire la config CI
PUT/api/v1/repos/{owner}/{repo}/ci/configMettre à jour la config CI
GET/api/v1/repos/{owner}/{repo}/ci/variablesLister les variables CI
POST/api/v1/repos/{owner}/{repo}/ci/variablesCréer une variable CI
DELETE/api/v1/repos/{owner}/{repo}/ci/variables/{id}Supprimer une variable CI

Documentation interactive

L’interface Swagger complète est disponible sur ton instance à /api/docs. Elle permet de tester chaque endpoint directement depuis le navigateur avec ton token d’authentification.

La spécification OpenAPI brute (YAML) est disponible sur /api/docs/openapi.yaml.


Voir aussi

Schéma du fichier .gitrust-ci.yml (mode Easy)

Référence complète du fichier de configuration CI pour le mode Easy de gitrust. Ce fichier doit se trouver à la racine du dépôt.

Pour une introduction pratique, consulte le tutoriel 04 — Automatiser avec la CI.


Structure générale

language: <string>       # Profil de langage (requis)

build:
  command: <string>      # Commande de build

checks:
  <nom>: <string>        # Une ou plusieurs commandes de vérification
  <nom>: <string>

tests:
  command: <string>      # Commande de test principale

deploy:                  # Optionnel
  command: <string>
  only_on: <string>      # Branche déclenchant le déploiement (ex. "main")

artifacts:               # Optionnel
  paths:
    - <chemin>

env:                     # Variables d'environnement pour toutes les étapes
  <NOM>: <valeur>

Champ language

Type : string — Requis

Charge un profil préconfigué avec les outils et l’image Docker adaptés au langage.

ValeurImage Docker de baseOutils pré-installés
rustrust:latestcargo, rustfmt, clippy
nodenode:ltsnpm, npx
pythonpython:3-slimpip, pytest
gogolang:latestgo, gofmt, golangci-lint

Si ton langage n’est pas listé, utilise language: node ou language: python comme base générique et surcharge les commandes manuellement. Les profils sont définis sur le serveur de build dans deployment/ci-engine/profiles/.


Section build

Optionnel

Exécutée en premier. Arrête le pipeline en cas d’échec (code de sortie non nul).

build:
  command: "cargo build --release"

Section checks

Optionnel

Dictionnaire de vérifications statiques (lint, format, sécurité). Chaque clé est un nom arbitraire affiché dans les logs. Les checks sont exécutés en parallèle si le serveur de build le supporte.

checks:
  lint: "cargo clippy -- -D warnings"
  format: "cargo fmt -- --check"
  audit: "cargo audit"

Section tests

Optionnel

Commande de test principale. Exécutée après build et checks.

tests:
  command: "cargo test --release"

Section deploy

Optionnel

Déploiement conditionnel. Exécuté uniquement sur la branche spécifiée dans only_on.

deploy:
  command: "./scripts/deploy.sh production"
  only_on: "main"

Si only_on est omis, le déploiement s’exécute sur toutes les branches — généralement non souhaitable.


Section artifacts

Optionnel

Chemins des fichiers ou dossiers à conserver après le pipeline. Les artefacts sont accessibles depuis la page de détail du pipeline dans l’UI.

artifacts:
  paths:
    - target/release/mon-binaire
    - dist/
    - coverage/

Section env

Optionnel

Variables d’environnement disponibles dans toutes les commandes du pipeline. Ne jamais placer de secrets ici — utilise les variables CI chiffrées via l’UI (/{owner}/{repo}/ci/variables).

env:
  RUST_BACKTRACE: "1"
  NODE_ENV: "test"

Les variables définies dans l’UI ont priorité sur celles du fichier YAML en cas de conflit de nom.


Exemples complets par langage

Rust

language: rust

build:
  command: "cargo build --release"

checks:
  lint: "cargo clippy -- -D warnings"
  format: "cargo fmt -- --check"

tests:
  command: "cargo test --release"

artifacts:
  paths:
    - target/release/mon-binaire

Node.js

language: node

build:
  command: "npm ci"

checks:
  lint: "npm run lint"
  typecheck: "npm run typecheck"

tests:
  command: "npm test"

artifacts:
  paths:
    - dist/

Python

language: python

build:
  command: "pip install -r requirements.txt"

checks:
  lint: "flake8 src/"
  format: "black --check src/"

tests:
  command: "pytest --tb=short"

Go

language: go

build:
  command: "go build ./..."

checks:
  lint: "golangci-lint run ./..."
  vet: "go vet ./..."

tests:
  command: "go test ./..."

Détection automatique du mode

gitrust détecte le mode à exécuter lors d’un push selon la règle suivante :

Contenu de l’arbre du commitMode
.gitrust-ci.yml présentEasy — ce fichier
.dagger/ présentPower — module Dagger complet
Aucun des deuxPas de pipeline

Si les deux sont présents, le mode Power a la priorité.


Voir aussi

Markdown et syntaxe supportée

Référence de la syntaxe Markdown acceptée dans les issues, les pull requests, les commentaires et les fichiers README des dépôts gitrust.


Titres

# Titre niveau 1
## Titre niveau 2
### Titre niveau 3
#### Titre niveau 4

Les titres génèrent des ancres de navigation (ex. #titre-niveau-2).


Mise en forme du texte

SyntaxeRendu
**gras**gras
*italique*italique
~~barré~~barré
`code inline`code inline

Blocs de code

Délimiter par trois backticks. Spécifier le langage pour la coloration syntaxique :

```rust
fn main() {
    println!("Hello, gitrust!");
}
```

Langages supportés pour la coloration : rust, go, python, javascript, typescript, json, yaml, toml, bash, sql, html, css, markdown, et la plupart des langages courants.


Listes

Liste non ordonnée

- Premier élément
- Deuxième élément
  - Élément imbriqué

Liste ordonnée

1. Première étape
2. Deuxième étape
3. Troisième étape

Liste de tâches (cases à cocher)

- [x] Tâche accomplie
- [ ] Tâche en attente
- [ ] Autre tâche

Rendu : cases cliquables dans les issues et les PRs. Cocher une case met à jour le texte en temps réel.


Tableaux

| Colonne A | Colonne B | Colonne C |
|-----------|-----------|-----------|
| Valeur 1  | Valeur 2  | Valeur 3  |
| Valeur 4  | Valeur 5  | Valeur 6  |

Alignement des colonnes :

| Gauche | Centre | Droite |
|:-------|:------:|-------:|
| texte  | texte  |  texte |

Liens et images

[Texte du lien](https://example.com)
[Lien relatif](../how-to/gerer-cles-ssh.md)
![Texte alternatif](https://example.com/image.png)

Citations

> Ceci est une citation.
> Elle peut s'étendre sur plusieurs lignes.

Ligne de séparation horizontale

---

Références aux issues et aux PRs

gitrust transforme automatiquement les références en liens cliquables :

SyntaxeRendu
#5Lien vers l’issue ou PR numéro 5 du dépôt courant
owner/repo#5Lien vers l’issue 5 d’un autre dépôt

Mentions d’utilisateurs

@tonpseudo

gitrust envoie une notification à l’utilisateur mentionné et crée un lien vers son profil.


Mots-clés de fermeture d’issue

Utilisés dans les messages de commit ou les descriptions de PR pour fermer automatiquement une issue lors de la fusion :

Closes #5
Fixes #12
Resolves #8

Insensibles à la casse. Plusieurs issues peuvent être référencées :

Closes #5, fixes #7

Emojis

La syntaxe :nom_emoji: est supportée :

SyntaxeRendu
:rocket:🚀
:bug:🐛
:white_check_mark:
:warning:⚠️
:information_source:ℹ️

Labels dans les descriptions

Les labels assignés à une issue sont affichés dans la barre latérale et non directement dans le corps Markdown. Pour référencer un label dans le texte, utilise son nom entre backticks : `bug`.


Diagrammes Mermaid

gitrust supporte les blocs Mermaid dans les README et les wikis (pas dans les commentaires d’issues) :

```mermaid
graph LR
    A[Début] --> B[Étape 1]
    B --> C[Fin]
```

Types supportés : graph, sequenceDiagram, flowchart, stateDiagram-v2, classDiagram, gantt, pie.


Voir aussi

Notifications

Référence des événements déclenchant des notifications, des canaux disponibles et des préférences configurables via /settings/notifications.


Canaux de notification

gitrust envoie les notifications par deux canaux :

CanalDescriptionConfiguration
SSE in-appNotifications en temps réel dans l’interface web, via Server-Sent Events. Icône cloche en haut à droite.Toujours actif quand tu es connecté
EmailEmails envoyés à l’adresse de ton compteConfigurable par type d’événement dans /settings/notifications

Matrice des événements × canaux

ÉvénementSSE in-appEmailDéclencheur
PR assignée (tu es reviewer)Quelqu’un t’assigne comme reviewer d’une PR
Issue mentionnée (tu es mentionné par @pseudo)Ton pseudo apparaît dans une issue ou un commentaire
Review demandéeL’auteur d’une PR te demande explicitement une review
CI échouée (sur ton push)Un pipeline CI lancé par ton push se termine en échec
CI réussie (sur ton push)Un pipeline CI lancé par ton push se termine en succès
Commentaire sur ta PRQuelqu’un commente une PR dont tu es l’auteur
Commentaire sur ton issueQuelqu’un commente une issue que tu as ouverte
PR fusionnée (tu es auteur)Ta PR est fusionnée
PR fermée (tu es auteur)Ta PR est fermée sans fusion
Issue fermée (tu es assigné)Une issue qui t’est assignée est fermée
Membre ajouté à une équipeTu es ajouté à une équipe
Accès dépôt accordéUn dépôt t’est rendu accessible (équipe ou direct)

= activé par défaut — = non envoyé par défaut (peut être activé dans les préférences)


Configurer les préférences

Navigue vers Paramètres → Notifications (/settings/notifications).

La page affiche un tableau de cases à cocher pour chaque type d’événement × canal. Décoche les emails que tu ne veux pas recevoir et sauvegarde.

Page /settings/notifications avec les préférences


Stream SSE en temps réel

Les notifications SSE sont disponibles sur le endpoint :

GET /notifications/stream

Connexion en text/event-stream. Chaque notification est un événement JSON :

data: {"id": 42, "type": "pr_assigned", "repo": "owner/repo", "pr_num": 7, "created_at": "2026-04-17T10:15:00Z"}

Ce stream alimente l’icône cloche de l’interface. Il peut aussi être consommé par des outils externes (scripts, bots) via un PAT.


Marquer les notifications comme lues

  • Une notification : POST /notifications/{id}/read
  • Toutes les notifications : POST /notifications/read-all
  • Via l’interface : clique sur une notification dans la liste /notifications, ou utilise le bouton Tout marquer comme lu

Voir aussi

  • Créer un Personal Access Token : accéder au stream SSE par script
  • API REST v1 : les endpoints de notifications ne sont pas listés dans l’API v1 publique — le stream SSE est le seul canal programmatique exposé

Explications — Comprendre gitrust en profondeur

Les explications sont des textes de compréhension : elles répondent à la question « pourquoi gitrust fonctionne-t-il ainsi ? » et construisent des modèles mentaux durables. Elles ne te font pas réaliser quelque chose — pour ça, consulte les tutoriels ou les guides pratiques.

Mode Diátaxis : Explanation. Les explications s’adressent à quelqu’un qui maîtrise déjà les opérations de base et veut comprendre les principes sous-jacents pour mieux décider, diagnostiquer et argumenter.


Les trois explications

Modèle de collaboration sur gitrust Philosophie des petites PRs, review obligatoire, auto-merge refusé. Analogie avec la relecture scientifique. Diagramme du cycle de travail. Comparaison avec les alternatives (trunk-based sans PR, GitFlow).

Bloom ciblé : expliquer la philosophie (2) · analyser les compromis PR courtes vs PR longues (4) · évaluer l’adéquation au contexte (5)


Sécurité côté utilisateur Pourquoi la 2FA protège même si le mot de passe est volé. Comment gitrust stocke les clés SSH (clé publique uniquement), les PAT (hash SHA-256) et les secrets TOTP. Analogie coffre-fort bancaire. Matrice vecteurs d’attaque × protections.

Bloom ciblé : expliquer le rôle de la 2FA (2) · analyser la surface d’attaque de chaque mécanisme (4) · évaluer la combinaison de protections adaptée (5)


Cycle de vie d’une pull request Diagramme Mermaid complet des états (Draft → Open → ReviewRequested → ChangesRequested → Approved → Merged / Closed) avec toutes les transitions manuelles et automatiques. Pourquoi une approbation est invalidée après un nouveau push. Comparaison review obligatoire vs optionnelle.

Bloom ciblé : identifier les états et transitions (2) · analyser les conditions de fusion (4) · évaluer les stratégies de review selon le contexte (5)

Comprendre le modèle de collaboration sur gitrust

Ce que tu vas comprendre

  • Expliquer pourquoi gitrust impose une review avant toute fusion sur main (Bloom 2)
  • Analyser les compromis entre petites PRs fréquentes et grosses PRs rares (Bloom 4)
  • Évaluer si le modèle de review obligatoire convient à un contexte donné (Bloom 5)

Le problème concret

Tu travailles seul sur une feature pendant deux semaines. La branche diverge, s’accumule. Quand tu ouvres enfin une PR, elle contient 800 lignes de diff. Personne dans l’équipe ne veut (ni ne peut) la reviewer sérieusement — le review se réduit à « ça a l’air bien, LGTM ». Le code est fusionné avec des bugs qui auraient été détectés si quelqu’un avait lu attentivement 50 lignes.

gitrust est conçu pour rendre ce scénario difficile et le modèle opposé — petites PRs fréquentes, review courte et sincère — naturel et peu coûteux.


L’analogie

Imagine un journal scientifique avec comité de lecture. Chaque article soumis est lu par deux relecteurs indépendants avant publication. Les articles courts et bien ciblés obtiennent des retours rapides et précis. Les articles de 80 pages avec 200 références prennent six mois et reviennent avec des commentaires superficiels.

La code review dans gitrust fonctionne exactement comme le comité de lecture : plus le changement proposé est petit et focalisé, plus la review est rapide, sincère et utile. L’auto-merge (fusionner sa propre PR sans review externe) est interdit pour la même raison qu’un auteur ne peut pas valider lui-même son propre article : le biais de l’auteur rend les erreurs invisibles.


Le modèle

flowchart TD
    subgraph "Philosophie gitrust"
        A[Issue tracée<br/>avant tout travail]
        B[Branche courte<br/>1 issue = 1 branche]
        C[PR petite<br/>≤200 lignes idéalement]
        D[Review sincère<br/>par au moins 1 pair]
        E[CI verte<br/>avant fusion]
        F[Fusion dans main<br/>historique propre]
    end

    A --> B --> C --> D --> E --> F
    F -->|Nouvelle issue| A

Trois principes structurants :

1. Toute modification passe par une PR

gitrust n’interdit pas le push direct sur main au niveau du protocole Git, mais le modèle de collaboration encourage systématiquement la PR : c’est le seul endroit où la CI est déclenchée, où la review est documentée, où l’issue est liée et fermée automatiquement.

Les équipes qui veulent appliquer cela strictement protègent la branche main dans les paramètres du dépôt (niveau Maintainer requis pour le push direct).

2. L’auto-merge est refusé par convention

gitrust ne force pas techniquement la review externe — un Owner peut fusionner sa propre PR. Mais la convention du projet, renforcée par l’outillage (assignation de reviewers, statut « En attente de review »), crée une pression sociale pour attendre une approbation externe.

L’exception acceptable : un dépôt solo où l’on est seul contributeur. Dans ce cas, la CI joue le rôle du reviewer.

3. La taille d’une PR est un signal de qualité

Taille du diffSignalAction recommandée
< 100 lignesExcellentReview en 5-10 min, commentaires précis
100-300 lignesAcceptableReview en 30 min, décomposer si possible
300-600 lignesAttentionDécomposer en sous-tâches
> 600 lignesProblèmePresque impossible à reviewer sérieusement

Alternatives et compromis

Trunk-based development sans PR

Certaines équipes poussent directement sur main (trunk) avec feature flags pour isoler les fonctionnalités en développement. Plus rapide, moins de friction, mais nécessite une discipline de test très forte et une culture de confiance élevée. Adapté aux petites équipes seniors avec une couverture de tests élevée.

GitFlow avec longues branches de release

GitFlow maintient des branches develop, release/*, hotfix/* avec des cycles de merge longs. Adapté aux logiciels avec releases versionnées (ex. bibliothèques), inadapté aux services web déployés en continu.

Le choix de gitrust

gitrust adopte une variante de trunk-based development avec PR courtes : pas de develop séparée, pas de longues branches de feature, mais une PR obligatoire même pour les petits changements. C’est le compromis entre la vitesse du trunk-based et la traçabilité des PRs.


Vérifier ta compréhension

  1. Un développeur propose une PR de 900 lignes modifiant le système d’authentification. Quelles questions lui poser pour l’aider à la découper ? (Bloom 2 — expliquer)

  2. Ton équipe travaille sur un projet open-source avec des contributeurs externes que tu ne connais pas. Quels mécanismes de gitrust utiliserais-tu pour maintenir la qualité du code sans bloquer les contributions ? (Bloom 4 — analyser)


Pour aller plus loin

Comprendre la sécurité côté utilisateur

Ce que tu vas comprendre

  • Expliquer pourquoi la 2FA protège même si ton mot de passe est volé (Bloom 2)
  • Analyser les différences entre clé SSH, mot de passe et PAT en termes de surface d’attaque (Bloom 4)
  • Évaluer quelle combinaison de protections adopter selon ton niveau d’exposition (Bloom 5)

Le problème concret

Ton mot de passe a été compromis. Peut-être lors d’une fuite de données d’un autre service où tu utilisais le même mot de passe. L’attaquant se connecte à ton compte gitrust, clone tous tes dépôts privés, y dépose un backdoor et efface l’historique. Tu ne t’en aperçois que trois jours plus tard.

gitrust fournit quatre mécanismes de protection indépendants pour rendre ce scénario soit impossible, soit détectable immédiatement. Ce document explique comment ils s’articulent.


L’analogie

Imagine un coffre-fort bancaire avec plusieurs couches de sécurité :

  • La porte principale (mot de passe) : nécessaire mais insuffisante seule
  • Le code secret temporaire (2FA TOTP) : change toutes les 30 secondes, connu uniquement de toi
  • La clé physique nominative (clé SSH) : associée à une machine précise, inopérante sans la clé privée
  • Le badge d’accès limité (PAT) : n’ouvre que certaines salles, révocable instantanément

Chaque couche est indépendante. Compromettre l’une n’ouvre pas les autres.


Le modèle

graph TD
    subgraph "Vecteurs d'attaque"
        V1[Mot de passe volé<br/>phishing / fuite DB]
        V2[Machine compromise<br/>malware / vol]
        V3[Token exposé<br/>commit accidentel / log]
    end

    subgraph "Protections gitrust"
        P1[2FA TOTP<br/>code valide 30s]
        P2[Clé SSH + passphrase<br/>liée à la machine]
        P3[PAT à durée limitée<br/>scope minimal]
        P4[Audit log<br/>connexions tracées]
    end

    V1 -->|bloqué par| P1
    V2 -->|atténué par| P2
    V3 -->|atténué par| P3
    V1 & V2 & V3 -->|détectable via| P4

2FA — pourquoi le mot de passe seul ne suffit plus

Le mot de passe est un secret statique : une fois volé, il est valable indéfiniment jusqu’à ce que tu le changes. Un code TOTP (Time-based One-Time Password) est un secret dynamique : il expire en 30 secondes et ne peut être utilisé qu’une seule fois. Même si un attaquant intercepte ton code TOTP, il est déjà expiré au moment où il essaie de l’utiliser.

gitrust stocke uniquement le secret TOTP (une chaîne de 32 caractères) qui permet de générer des codes — jamais les codes eux-mêmes. Ce secret est chiffré en base.

Clés SSH — une identité par machine

Une clé SSH ed25519 est une paire mathématique (clé privée + clé publique) liée à une machine. gitrust ne stocke que la clé publique — celle-ci ne permet pas de reconstruire la clé privée. Pour s’authentifier, ta machine doit prouver qu’elle possède la clé privée en signant un challenge.

Ce que gitrust stocke : clé publique + empreinte SHA256 (pour identification) + date d’ajout.

Ce que gitrust ne stocke jamais : la clé privée (elle ne quitte jamais ta machine).

Si ta machine est volée, supprimer la clé dans /settings/keys révoque immédiatement l’accès — même si le voleur a copié les fichiers ~/.ssh/.

PAT — accès minimal et révocable

Un Personal Access Token est haché (SHA-256) dès sa création. gitrust ne stocke que le hash — si un attaquant accède à la base de données, les tokens en clair ne sont pas exposés. La valeur réelle du token (gr_pat_xxxx) n’est affichée qu’une seule fois à la création.

Le scope minimal (repo:read uniquement pour un script de lecture) limite les dégâts si un token est exposé. La date d’expiration garantit qu’un token oublié dans un vieux script ne reste pas valide indéfiniment.

Audit log — tout est tracé

Gitrust enregistre les connexions réussies et les tentatives échouées dans un journal d’audit accessible via /settings/activity. Si tu constates des connexions depuis des IP inconnues ou à des heures inhabituelles, c’est le signal d’une compromission.


Alternatives et compromis

Mot de passe fort sans 2FA

Un mot de passe long et unique (généré par un gestionnaire de mots de passe) réduit considérablement le risque de compromission par brute-force ou réutilisation. Mais il ne protège pas contre le phishing (tu es trompé pour le saisir sur un faux site) ni contre les fuites de base de données. La 2FA couvre ces deux vecteurs.

Clé SSH sans passphrase

Plus pratique (pas de saisie à chaque push), mais si ta machine est volée ou compromise par un malware, la clé privée est directement utilisable. Une passphrase chiffre la clé sur le disque — seule une combinaison vol physique + connaissance de la passphrase permet l’accès.

PAT sans expiration

Acceptable pour les outils personnels sur une machine de confiance. Inacceptable pour un token partagé dans un environnement CI, un script de serveur, ou un dépôt accessible à plusieurs personnes.


Vérifier ta compréhension

  1. Un collègue dit « je n’ai pas besoin de la 2FA, mon mot de passe fait 30 caractères aléatoires ». Quels scénarios d’attaque son mot de passe fort ne couvre-t-il pas ? (Bloom 2 — expliquer)

  2. Tu découvres que ton token PAT a été accidentellement commité dans un dépôt public il y a 6 heures. Quelles actions effectues-tu dans quel ordre, et pourquoi ? (Bloom 4 — analyser)


Pour aller plus loin

Comprendre le cycle de vie d’une pull request

Ce que tu vas comprendre

  • Identifier les états possibles d’une PR et les transitions entre eux (Bloom 2)
  • Analyser pourquoi certaines transitions sont automatiques et d’autres manuelles (Bloom 4)
  • Évaluer à quel moment une PR est prête à être fusionnée (Bloom 5)

Le problème concret

Tu as ouvert une PR il y a trois jours. Un reviewer a laissé des commentaires. Tu as poussé des corrections. La CI a repassé au vert. Mais tu ne sais pas si tu dois re-demander une review, si tu peux fusionner maintenant, ou si quelque chose bloque encore. L’état de la PR n’est pas clair.

Comprendre le cycle de vie d’une PR te permet de lire son état actuel d’un coup d’œil et de savoir exactement quelle action effectuer ensuite.


L’analogie

Une pull request ressemble à un dossier de demande de permis de construire :

  • Ouvert : le dossier est déposé, les voisins (reviewers) peuvent faire des remarques
  • En cours de review : l’architecte (reviewer) examine les plans et demande des modifications
  • Approuvé : l’architecte signe les plans modifiés
  • CI verte : les normes techniques sont vérifiées automatiquement (sécurité incendie, etc.)
  • Fusionné : le permis est accordé, les travaux peuvent commencer sur main
  • Refusé / fermé : le dossier est archivé sans suite

Comme pour un permis, une PR peut être rouverte si elle a été fermée par erreur ou prématurément.


Le modèle — diagramme de séquence complet

stateDiagram-v2
    [*] --> Draft : créer en brouillon
    [*] --> Open : créer directement

    Draft --> Open : marquer "Prête pour review"
    Open --> Draft : repasser en brouillon

    Open --> ReviewRequested : assigner un reviewer
    ReviewRequested --> ChangesRequested : reviewer demande modifications
    ReviewRequested --> Approved : reviewer approuve

    ChangesRequested --> ReviewRequested : auteur pousse corrections<br/>+ re-demande review
    Approved --> Open : nouveau push (invalidation auto)

    Open --> Closed : fermer sans fusionner
    Closed --> Open : rouvrir

    Approved --> Merged : fusionner (CI verte requise si configurée)
    Open --> Merged : fusionner (si permissions suffisantes)

    Merged --> [*]
    Closed --> [*]

Les états en détail

Draft (Brouillon)

La PR est visible par l’équipe mais ne peut pas être fusionnée. Utilise cet état pour :

  • Partager du code en cours de travail pour des commentaires précoces
  • Réserver un numéro de PR pour une issue
  • Travailler en collaboration sur la même branche

Transition manuelle : l’auteur clique Marquer comme prête pour review.

Open (Ouverte)

État par défaut à la création. La PR peut être commentée, reviewée et fusionnée (selon les permissions). C’est l’état de travail normal.

ReviewRequested (Review demandée)

Un reviewer a été assigné. gitrust lui envoie une notification. La PR attend son verdict. L’auteur peut continuer à pousser des commits pendant ce temps.

ChangesRequested (Modifications demandées)

Le reviewer a explicitement demandé des changements. La PR est bloquée — elle ne peut pas être fusionnée tant que le reviewer n’a pas approuvé ou n’a pas retiré son refus.

Transition de sortie : l’auteur pousse les corrections et re-demande une review (bouton Re-request review).

Approved (Approuvée)

Au moins un reviewer a approuvé. La PR peut être fusionnée si la CI est verte.

Invalidation automatique : si l’auteur pousse un nouveau commit après l’approbation, le statut repasse à Open — le reviewer doit approuver à nouveau. Ce comportement garantit que ce qui est fusionné correspond exactement à ce qui a été approuvé.

Merged (Fusionnée)

État terminal. Le code est dans la branche cible. La PR est archivée en lecture seule. Si la description contenait closes #N, l’issue N est fermée automatiquement à ce moment.

Closed (Fermée)

Fermée sans fusion — le travail a été abandonné ou la PR était incorrecte. Peut être rouverte si nécessaire. Les commits de la branche ne sont pas supprimés.


Transitions automatiques

DéclencheurTransition
Nouveau commit poussé après approbationApproved → Open (invalidation de l’approbation)
Fusion dans la branche cibleOpen/Approved → Merged
Fusion + closes #N dans le commit ou la descriptionIssue N → Closed
CI configurée et pipeline échouéBouton Fusionner désactivé (blocage soft)

Alternatives et compromis

Review obligatoire vs optionnelle

gitrust permet de fusionner une PR sans review externe si l’on a les permissions Owner ou Maintainer. Certaines équipes configurent une branche protégée exigeant au moins une approbation — ce qui rend la review techniquement obligatoire. D’autres s’appuient sur la convention sans protection forcée. La protection forcée convient aux projets critiques ou réglementés ; la convention suffit pour les petites équipes de confiance.

Invalidation automatique après push

Certaines forges (GitHub) ont rendu ce comportement optionnel. gitrust invalide systématiquement l’approbation après un nouveau push — c’est plus strict mais garantit que le reviewer a vu exactement le code fusionné. Inconvénient : un correctif de typo force une re-review complète.


Vérifier ta compréhension

  1. Une PR est à l’état ChangesRequested. L’auteur pousse ses corrections mais ne re-demande pas de review. Que se passe-t-il ? Peut-on fusionner ? (Bloom 2 — identifier l’état)

  2. Tu es Maintainer d’un dépôt. Une PR approuvée contient un conflit de merge introduit par un commit récent sur main. Quelles options as-tu, et quels sont les avantages et inconvénients de chacune ? (Bloom 4 — analyser les compromis)


Pour aller plus loin

Manuel d’administration

Ce manuel s’adresse aux opérateurs et self-hosters qui déploient, configurent et maintiennent une instance gitrust en production. Il couvre l’intégralité du cycle de vie d’une instance : premier démarrage, mise en production, sauvegarde, mise à jour et dépannage. Il suppose des connaissances en administration Linux, en réseau et en TLS — il n’explique pas ces prérequis.

Parcours d’apprentissage

Le parcours administrateur est structuré en 4 tutoriels qui représentent environ 3 heures au total. L’étayage est dégressif : le premier tutoriel fournit tout clé en main, le dernier demande de composer soi-même à partir des recettes disponibles.

graph LR
    A[VM prête<br/>Docker installé] --> B[01-installation-docker<br/>30 min]
    B --> C[02-installation-systemd<br/>45 min]
    C --> D[03-premier-demarrage<br/>20 min]
    D --> E[04-mise-en-production<br/>90 min]
    E --> F[Admin opérationnel]

Checkpoint de parcours : avant de passer au tutoriel 02, vous devez avoir une instance gitrust accessible sur http://localhost:4000 avec un compte administrateur fonctionnel. Si ce n’est pas le cas, relisez le tutoriel 01.

Checkpoint de parcours : avant le tutoriel 04, vous devez avoir une instance démarrée avec une configuration SMTP et un reverse-proxy TLS opérationnel. Si ce n’est pas le cas, relisez le tutoriel 03.


Tutoriels — apprendre en faisant

Les tutoriels sont guidés pas-à-pas avec des sorties verbatim attendues. Suivez-les dans l’ordre — chacun suppose le précédent complété.


How-to — recettes pour les tâches courantes

Les how-to répondent à « comment faire X » sans explication de fond. Consultez-les dans n’importe quel ordre selon votre besoin.


Référence — informations techniques exactes

La référence documente de façon exhaustive les paramètres, schémas et comportements. Elle est destinée à la consultation ponctuelle.


Explication — comprendre le pourquoi

Les explanations construisent les modèles mentaux nécessaires pour prendre de bonnes décisions d’exploitation. Lisez-les quand vous voulez comprendre les choix d’architecture, pas seulement les appliquer.

Tutoriels — Manuel d’administration

Les tutoriels vous guident pas à pas, du premier démarrage jusqu’à une instance gitrust en production. Chaque tutoriel part d’un état précis et vous mène à un résultat observable et vérifiable.

Avant de commencer : lisez l’encart de pré-requis en tête de chaque tutoriel. Le parcours est conçu pour être suivi dans l’ordre — chaque étape suppose la précédente réalisée.


Parcours d’apprentissage

graph LR
    A[VM prête<br/>Linux + Docker<br/>ou Rust toolchain] --> B[01-installation-docker<br/>~30 min]
    B --> C[02-installation-systemd<br/>~45 min]
    C --> D[03-premier-demarrage<br/>~20 min]
    D --> E[04-mise-en-production<br/>~90 min]
    E --> F[Admin opérationnel<br/>Instance sécurisée]

    style A fill:#f5f5f5
    style F fill:#d4edda

Scaffolding dégressif — le niveau d’aide recule d’un cran à chaque tutoriel :

TutorielNiveauNature de l’aide
01 — Installation Docker1 — Copier-collerToutes les commandes fournies intégralement
02 — Installation systemd1-2 — Copier-coller + explicationCommandes fournies avec contexte
03 — Premier démarrage2 — CompléterQuelques valeurs à adapter (domaine, mots de passe)
04 — Mise en production3 — CombinerCombine les recettes des how-to en autonomie

Checkpoint de parcours : avant de passer au tutoriel suivant, vous devez savoir faire :

  • Après 01 ou 02 : curl http://localhost:4000/ retourne un code HTTP ; ssh-keyscan -p 2222 localhost retourne un fingerprint
  • Après 03 : connexion réussie à /admin avec le compte bootstrap
  • Après 04 : accès HTTPS depuis l’extérieur, SMTP configuré, première sauvegarde réalisée

Liste des tutoriels

01 — Installation avec Docker Compose

Déployer gitrust + PostgreSQL en quelques minutes avec Docker Compose. Point d’entrée recommandé pour découvrir la plateforme.

État initial : VM Linux avec Docker installé
État final : gitrust accessible sur http://localhost:4000, SSH sur :2222

02 — Installation avec systemd (binaire natif)

Installer gitrust comme un binaire natif sur Debian/Ubuntu, créer l’utilisateur système gitrust, configurer PostgreSQL, écrire le fichier gitrust.service avec durcissement systemd.

État initial : VM Debian 12 / Ubuntu 22.04 LTS
État final : systemctl status gitrust affiche active (running), ports 4000 et 2222 répondent

03 — Premier démarrage

Bootstrap du compte administrateur, vérification de la clé SSH hôte générée automatiquement, confirmation que les trois services (HTTP, SSH, PostgreSQL) sont sains, premier accès à /admin.

État initial : tutoriel 01 ou 02 complété
État final : connecté à /admin, fingerprint SSH noté, migrations confirmées dans les logs

04 — Mise en production

Reverse-proxy Nginx avec TLS, configuration SMTP, première sauvegarde, monitoring de base. Généralisation à partir d’un exemple de déploiement réseau local.

État initial : tutoriel 03 complété, domaine/DNS prêt
État final : accès HTTPS depuis l’extérieur, e-mails fonctionnels, sauvegarde planifiée


Après les tutoriels

Une fois le parcours complété, vous pouvez utiliser les guides pratiques (how-to) de manière autonome pour des tâches spécifiques : configurer OAuth, régler le rate limiting, dépanner SSH, etc.

Guides pratiques (how-to)

Installation Docker : démarrez gitrust en 30 minutes avec docker-compose

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Déployer la stack gitrust minimale (application + PostgreSQL) avec docker-compose
  • O2. Vérifier que les trois services démarrent correctement et sont en bonne santé
  • O3. Créer le premier compte administrateur et accéder à l’interface web

Pré-requis

  • Technique : une VM ou machine Linux (Debian 12 / Ubuntu 22.04 ou supérieur), Docker Engine ≥ 24 installé (docker --version répond), Docker Compose plugin installé (docker compose version répond), ports 4000 et 2222 libres sur la machine
  • Pédagogique : aucun tutoriel gitrust préalable requis — c’est le point de départ du parcours administrateur
  • Temps estimé : ~30 minutes

Vue d’ensemble

Avant de lancer la moindre commande, comprenons ce que docker-compose va orchestrer et pourquoi.

gitrust est composé de trois services principaux dans sa configuration minimale. Pensez-y comme trois pièces d’un appartement qui s’appellent entre elles via un réseau interne :

graph TB
    subgraph internet ["Internet / votre navigateur"]
        B[Navigateur :4000]
        S[Client git/SSH :2222]
    end

    subgraph docker ["Réseau Docker gitrust_net"]
        A["gitrust-app<br/>(Rust, port 4000 + 2222)<br/>Lit la config depuis .env"]
        P["postgres<br/>(PostgreSQL 16, port 5432)<br/>Stocke comptes, dépôts, issues"]
        R["redis (optionnel)<br/>(Redis 7, port 6379)<br/>Sessions web + cache"]
    end

    B -->|HTTP| A
    S -->|SSH| A
    A -->|SQL| P
    A -.->|sessions| R

gitrust-app est le cœur : serveur HTTP (:4000) + serveur SSH (:2222) compilé en Rust. Il lit sa configuration depuis un fichier .env et écrit les dépôts Git bare sur un volume Docker. PostgreSQL stocke toutes les données relationnelles (comptes, organisations, dépôts, issues, PRs). Redis est optionnel dans la configuration minimale — il gère les sessions web et le cache quand il est présent.

Dans ce tutoriel, nous utilisons la stack minimale (app + PostgreSQL) suffisante pour évaluer gitrust et démarrer une petite instance. La stack production avec Redis et SMTP se trouve dans template/docker/docker-compose.production.yml.


Étape 1 : Créez le répertoire de travail et le fichier de configuration

Créez un répertoire dédié à votre instance gitrust et positionnez-vous dedans :

mkdir -p /opt/gitrust && cd /opt/gitrust

Créez le fichier de configuration .env avec les valeurs minimales. Changez impérativement les valeurs marquées CHANGE_ME :

cat > .env << 'EOF'
# === gitrust — configuration minimale ===
# Changez toutes les valeurs CHANGE_ME avant de démarrer

# Clé secrète de l'application (générez avec : openssl rand -hex 32)
SECRET_KEY=CHANGE_ME_openssl_rand_hex_32

# URL publique de l'instance (sans slash final)
APP_URL=http://localhost:4000

# Base de données PostgreSQL
DATABASE_URL=postgres://gitrust:CHANGE_ME_db_password@postgres:5432/gitrust
POSTGRES_USER=gitrust
POSTGRES_PASSWORD=CHANGE_ME_db_password
POSTGRES_DB=gitrust

# Serveur SSH (port exposé à l'extérieur)
SSH_PORT=2222

# Mode de l'application
RUST_LOG=info
APP_ENV=production
EOF

Générez maintenant une clé secrète robuste et remplacez la valeur dans .env :

SECRET=$(openssl rand -hex 32)
sed -i "s/CHANGE_ME_openssl_rand_hex_32/$SECRET/" .env

Choisissez également un mot de passe pour PostgreSQL et remplacez les deux occurrences :

DB_PASS=$(openssl rand -hex 16)
sed -i "s/CHANGE_ME_db_password/$DB_PASS/g" .env

Sortie attendue (vérification) :

grep SECRET_KEY .env
# → SECRET_KEY=4a7f2c9e1b8d3f6a0e5c2b9d7f4a1e8c3b6d9f2a5e8c1b4d7f0a3e6c9b2d5f8a

Checkpoint : exécutez grep CHANGE_ME .env — la commande doit ne rien retourner. Si elle retourne des lignes, ces valeurs n’ont pas été remplacées et le démarrage échouera.


Étape 2 : Créez le fichier docker-compose.yml

Créez le fichier docker-compose.yml suivant dans /opt/gitrust/. Ce contenu correspond à la stack minimale gitrust — il est reproduit ici intégralement pour que vous puissiez démarrer sans dépendance externe :

cat > docker-compose.yml << 'EOF'
# docker-compose.minimal.yml — Stack gitrust minimale (app + PostgreSQL)
# Cas d'usage : évaluation, développement, petite instance (<10 utilisateurs)
# Pour la production : utilisez docker-compose.production.yml (+ Redis + SMTP)
#
# Usage :
#   docker compose up -d
#   docker compose logs -f gitrust
#   docker compose down

version: "3.9"

services:
  # ── Base de données ───────────────────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: gitrust_postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      # Données persistantes — ne supprimez pas ce volume sans sauvegarde
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - gitrust_net

  # ── Application gitrust ───────────────────────────────────────────────────
  gitrust:
    image: ghcr.io/gitrust/gitrust:latest
    container_name: gitrust_app
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    env_file:
      # Lit toutes les variables depuis .env — ne commitez jamais ce fichier
      - .env
    ports:
      # Interface web — exposée sur toutes les interfaces de la machine hôte
      - "4000:4000"
      # Serveur SSH Git — exposé sur le port configuré dans .env
      - "${SSH_PORT:-2222}:2222"
    volumes:
      # Dépôts Git bare — données critiques, sauvegardez ce volume
      - git_repos:/var/lib/gitrust/repositories
      # Clés SSH du serveur — générées au premier démarrage, persistez-les
      - ssh_host_keys:/etc/gitrust/ssh
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
    networks:
      - gitrust_net

volumes:
  # postgres_data : données relationnelles (comptes, dépôts, issues, PRs)
  postgres_data:
  # git_repos : dépôts Git bare (le contenu réel des commits)
  git_repos:
  # ssh_host_keys : clés SSH hôte (fingerprint de votre serveur)
  ssh_host_keys:

networks:
  gitrust_net:
    driver: bridge
EOF

Checkpoint : vérifiez que les deux fichiers sont présents dans /opt/gitrust/ :

ls -la /opt/gitrust/

Sortie attendue :

total 16
drwxr-xr-x 2 root root 4096 avr 17 10:00 .
drwxr-xr-x 8 root root 4096 avr 17 09:55 ..
-rw------- 1 root root  612 avr 17 10:00 .env
-rw-r--r-- 1 root root 2048 avr 17 10:00 docker-compose.yml

Étape 3 : Démarrez la stack et suivez les logs

Démarrez tous les services en arrière-plan :

docker compose up -d

Sortie attendue :

[+] Running 4/4
 ✔ Network gitrust_gitrust_net    Created                          0.1s
 ✔ Volume "gitrust_postgres_data" Created                          0.0s
 ✔ Container gitrust_postgres     Started                          0.8s
 ✔ Container gitrust_app          Started                          1.2s

Suivez les logs de l’application pour observer le démarrage (les migrations s’exécutent automatiquement) :

docker compose logs -f gitrust

Sortie attendue (les 15-20 premières secondes) :

gitrust_app  | 2026-04-17T10:00:15Z INFO  gitrust > Starting gitrust v0.9.0
gitrust_app  | 2026-04-17T10:00:15Z INFO  gitrust > Connecting to database...
gitrust_app  | 2026-04-17T10:00:16Z INFO  gitrust > Running database migrations...
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > Applied 42 migrations successfully
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > Generating SSH host keys...
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > SSH host key fingerprint: SHA256:xxxxxxxxxxxxxxxxxxxx
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > HTTP server listening on 0.0.0.0:4000
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > SSH server listening on 0.0.0.0:2222
gitrust_app  | 2026-04-17T10:00:17Z INFO  gitrust > gitrust is ready

Appuyez sur Ctrl+C pour quitter le suivi des logs (les containers continuent de tourner).

Checkpoint : vérifiez l’état de santé des services :

docker compose ps

Sortie attendue :

NAME              IMAGE                          COMMAND                  SERVICE    CREATED         STATUS                   PORTS
gitrust_app       ghcr.io/gitrust/gitrust:latest "/usr/local/bin/gitr…"  gitrust    2 minutes ago   Up 2 minutes (healthy)   0.0.0.0:4000->4000/tcp, 0.0.0.0:2222->2222/tcp
gitrust_postgres  postgres:16-alpine             "docker-entrypoint.s…"  postgres   2 minutes ago   Up 2 minutes (healthy)   5432/tcp

Les deux containers doivent afficher (healthy). Si l’un affiche (starting), attendez 30 secondes et relancez la commande. S’il affiche (unhealthy), consultez les logs avec docker compose logs gitrust ou docker compose logs postgres.


Étape 4 : Créez le premier compte administrateur

Ouvrez votre navigateur et rendez-vous sur http://localhost:4000.

Vous devriez voir la page d’accueil gitrust avec un formulaire d’inscription. Comme l’instance est vierge, le premier compte créé devient automatiquement administrateur.

Remplissez le formulaire :

  • Pseudo : votre identifiant administrateur (ex. admin)
  • E-mail : votre adresse e-mail
  • Mot de passe : choisissez un mot de passe robuste (≥ 12 caractères)

Cliquez sur Créer mon compte.

Sortie attendue dans les logs (visible avec docker compose logs -f gitrust) :

gitrust_app  | 2026-04-17T10:05:00Z INFO  gitrust::auth > New user registered: admin (id=1)
gitrust_app  | 2026-04-17T10:05:00Z INFO  gitrust::admin > First user promoted to administrator: admin

Checkpoint final : connectez-vous avec vos identifiants. Après la connexion, vérifiez que le menu en haut à droite affiche un lien Administration (ou naviguez vers http://localhost:4000/admin). Cette page n’est accessible qu’aux administrateurs — sa présence confirme que votre compte a bien le rôle admin.

L’URL de votre instance est http://localhost:4000 et votre compte administrateur est opérationnel.


Récapitulatif

  • O1 accompli : la stack docker-compose est déployée — docker compose ps affiche deux containers (healthy)
  • O2 accompli : les trois services (app, PostgreSQL, réseau interne) démarrent correctement — les logs montrent « gitrust is ready » et les migrations sont appliquées
  • O3 accompli : le premier compte administrateur est créé et la page /admin est accessible

Et si ça ne marche pas

SymptômeCause probableCorrection
Error: port 4000 is already in use au démarrageUn autre service occupe le port 4000 sur la machine hôteIdentifiez le service avec ss -tlnp | grep 4000 ou lsof -i :4000. Arrêtez-le ou changez le port dans docker-compose.yml : remplacez "4000:4000" par "4001:4000" et accédez sur :4001
Le container gitrust_app affiche (unhealthy) ou redémarre en boucleLa variable DATABASE_URL est incorrecte ou SECRET_KEY contient des caractères spéciaux non échappésVérifiez avec docker compose logs gitrust | grep ERROR. Assurez-vous que SECRET_KEY est bien une chaîne hexadécimale sans guillemets. Rechargez avec docker compose down && docker compose up -d
La page http://localhost:4000 affiche « Connection refused »Le container n’est pas encore en (healthy) ou le port n’est pas exposéAttendez 30 secondes et rechargez. Si le problème persiste, vérifiez que votre pare-feu local (ufw status) autorise le port 4000
FATAL: password authentication failed for user "gitrust" dans les logs postgresLe mot de passe dans DATABASE_URL ne correspond pas à POSTGRES_PASSWORDSupprimez le volume PostgreSQL (docker compose down -v) et relancez — attention : supprime toutes les données

Prochaine étape

02 — Installation systemd : binaire natif + service : découvrez l’installation sans Docker pour un déploiement production avec hardening AppArmor (~45 min)

Ou si vous souhaitez rendre cette instance accessible depuis Internet :

04 — Mise en production : reverse-proxy TLS, SMTP, sauvegarde automatique (~90 min)

Installer gitrust comme service systemd (binaire natif)

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Créer un utilisateur système gitrust dédié et structurer l’arborescence /opt/gitrust
  • O2. Configurer PostgreSQL pour gitrust et vérifier la connexion
  • O3. Écrire et activer un fichier gitrust.service avec durcissement systemd
  • O4. Démarrer gitrust et confirmer que le serveur HTTP et SSH répondent

Pré-requis

  • Technique : Debian 12 ou Ubuntu 22.04 LTS (ou dérivé) ; accès root ou sudo ; curl, rsync, psql installés ; Docker non requis (c’est précisément l’alternative binaire natif)
  • Pédagogique : Tutoriel 01-installation-docker lu ou notions équivalentes d’administration Linux
  • Temps estimé : ~45 minutes

Vue d’ensemble

L’installation binaire natif place gitrust comme un service systemd classique, au même titre que nginx ou PostgreSQL. Il n’y a aucun conteneur : le binaire Rust s’exécute directement sous un utilisateur système dédié, avec un durcissement systemd (PrivateTmp, ProtectSystem, NoNewPrivileges).

graph LR
    A[Binaire gitrust<br/>/opt/gitrust/gitrust] -->|ExecStart| B[systemd unit<br/>gitrust.service]
    B -->|User=gitrust| C[Utilisateur système<br/>gitrust]
    C -->|R/W| D[/opt/gitrust/data<br/>repos + clé SSH]
    A -->|DATABASE_URL| E[PostgreSQL :5432]
    A -->|:4000| F[Navigateur / nginx]
    A -->|:2222| G[Client Git SSH]

Modèle mental : pensez à gitrust comme à nginx — un processus unique, lancé par systemd, propriétaire de ses données, sans jamais tourner en root.

Étape 1 : Créer l’utilisateur et l’arborescence

Créez un utilisateur système sans shell de login ni répertoire home personnel :

sudo useradd \
  --system \
  --home-dir /opt/gitrust \
  --shell /usr/sbin/nologin \
  --comment "Gitrust service" \
  gitrust

Créez l’arborescence de travail :

sudo mkdir -p /opt/gitrust/data/repos
sudo mkdir -p /opt/gitrust/static
sudo chown -R gitrust:gitrust /opt/gitrust
sudo chmod 750 /opt/gitrust

Sortie attendue (vérification) :

$ ls -la /opt/gitrust/
total 20
drwxr-x--- 4 gitrust gitrust 4096 ...
drwxr-xr-x 3 gitrust gitrust 4096 ...  data/
drwxr-xr-x 2 gitrust gitrust 4096 ...  static/

Checkpoint O1 : id gitrust doit retourner une ligne avec uid=NNN(gitrust) gid=NNN(gitrust). Si la commande échoue, relisez l’étape 1.

Étape 2 : Installer PostgreSQL et créer la base gitrust

Installez PostgreSQL si ce n’est pas fait :

sudo apt-get update
sudo apt-get install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql

Créez l’utilisateur et la base de données :

sudo -u postgres psql <<'SQL'
CREATE USER gitrust WITH PASSWORD 'CHOISIR_UN_MOT_DE_PASSE_FORT';
CREATE DATABASE gitrust OWNER gitrust;
GRANT ALL PRIVILEGES ON DATABASE gitrust TO gitrust;
SQL

Vérifiez la connexion :

psql "postgres://gitrust:CHOISIR_UN_MOT_DE_PASSE_FORT@localhost:5432/gitrust" \
  -c "SELECT version();"

Sortie attendue :

                                                  version
------------------------------------------------------------------------------------------------------------
 PostgreSQL 15.x on x86_64-pc-linux-gnu, ...
(1 row)

Checkpoint O2 : Si psql retourne version(), la base est accessible. Conservez le mot de passe PostgreSQL pour l’étape suivante.

Étape 3 : Déployer le binaire gitrust

Sur votre machine de développement (ou sur le serveur si vous compilez localement) :

# Depuis la racine du dépôt gitrust
cargo build --release

Puis transférez vers le serveur :

# Depuis la machine de développement
rsync -avz target/release/gitrust  user@VOTRE_SERVEUR:/opt/gitrust/gitrust
rsync -avz static/                 user@VOTRE_SERVEUR:/opt/gitrust/static/

Sur le serveur, rendez le binaire exécutable et attribuez-le à l’utilisateur gitrust :

sudo chown gitrust:gitrust /opt/gitrust/gitrust
sudo chmod 750 /opt/gitrust/gitrust
sudo chown -R gitrust:gitrust /opt/gitrust/static/

Alternative : si vous utilisez deployment/deploy.sh (fourni dans le dépôt source), il effectue les étapes 1 à 5 automatiquement. Ce tutoriel décompose les étapes pour que vous compreniez ce que le script fait.

Étape 4 : Créer le fichier de configuration .env

Créez /opt/gitrust/.env avec les valeurs minimales de production :

sudo -u gitrust tee /opt/gitrust/.env <<'EOF'
# === BASE DE DONNÉES ===
DATABASE_URL=postgres://gitrust:CHOISIR_UN_MOT_DE_PASSE_FORT@localhost:5432/gitrust

# === SERVEUR HTTP ===
SERVER_HOST=127.0.0.1
SERVER_PORT=4000
RUST_LOG=info

# === SERVEUR SSH ===
SSH_PORT=2222
SSH_LISTEN_ADDR=0.0.0.0
SSH_HOST_KEY_PATH=/opt/gitrust/data/ssh_host_ed25519_key
SSH_PUBLIC_HOST=VOTRE_DOMAINE_OU_IP

# === DÉPÔTS GIT ===
GIT_REPOS_BASE_PATH=/opt/gitrust/data/repos

# === FICHIERS STATIQUES ===
STATIC_FILES_PATH=/opt/gitrust/static

# === JWT (OBLIGATOIRE — générer avec : openssl rand -hex 64) ===
JWT_SECRET=REMPLACER_PAR_OPENSSL_RAND_HEX_64
JWT_EXPIRATION_MINUTES=15
JWT_ISSUER=gitrust

# === SESSIONS ===
SESSION_TIMEOUT_MINUTES=30
SESSION_BACKEND=seaorm

# === COOKIES ===
COOKIE_SECURE=true
COOKIE_SAME_SITE=Lax
APP_DEBUG=false

# === ADMIN INITIAL ===
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@VOTRE_DOMAINE
ADMIN_PASSWORD=REMPLACER_PAR_MOT_DE_PASSE_FORT

# === EMAIL (optionnel au démarrage initial, configurer ensuite) ===
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
EMAIL_BASE_URL=https://VOTRE_DOMAINE
EOF

Sécurisez le fichier (il contient des secrets) :

sudo chmod 600 /opt/gitrust/.env
sudo chown gitrust:gitrust /opt/gitrust/.env

Générez le vrai secret JWT :

openssl rand -hex 64

Copiez la sortie et remplacez REMPLACER_PAR_OPENSSL_RAND_HEX_64 dans /opt/gitrust/.env.

Checkpoint : sudo -u gitrust stat /opt/gitrust/.env doit afficher Mode: 0600 et Uid: gitrust.

Étape 5 : Créer le fichier gitrust.service

Créez le fichier d’unité systemd :

sudo tee /etc/systemd/system/gitrust.service <<'EOF'
[Unit]
Description=Gitrust - Self-hosted Git platform
Documentation=https://gitrust.eu/es/docs/
After=network.target postgresql.service
Requires=postgresql.service

[Service]
Type=simple
User=gitrust
Group=gitrust
WorkingDirectory=/opt/gitrust
EnvironmentFile=/opt/gitrust/.env
ExecStart=/opt/gitrust/gitrust
Restart=on-failure
RestartSec=5
TimeoutStartSec=30
TimeoutStopSec=30

# --- Durcissement systemd ---
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true

# Répertoires en écriture autorisés
ReadWritePaths=/opt/gitrust/data

# Si gitrust écoute sur un port < 1024 (ex: 80) :
# AmbientCapabilities=CAP_NET_BIND_SERVICE

[Install]
WantedBy=multi-user.target
EOF

Rechargez systemd et activez le service :

sudo systemctl daemon-reload
sudo systemctl enable gitrust

Sortie attendue :

Created symlink /etc/systemd/system/multi-user.target.wants/gitrust.service → /etc/systemd/system/gitrust.service.

Checkpoint O3 : systemctl is-enabled gitrust doit afficher enabled.

Étape 6 : Premier démarrage et vérification

Démarrez le service :

sudo systemctl start gitrust

Attendez 3 secondes, puis vérifiez le statut :

sudo systemctl status gitrust

Sortie attendue :

● gitrust.service - Gitrust - Self-hosted Git platform
     Loaded: loaded (/etc/systemd/system/gitrust.service; enabled; vendor preset: enabled)
     Active: active (running) since ... ago
   Main PID: XXXX (gitrust)
      Tasks: N (limit: NNNN)
     Memory: NNM
        CPU: NNNms
     CGroup: /system.slice/gitrust.service
             └─XXXX /opt/gitrust/gitrust

Vérifiez que les ports répondent :

# HTTP
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/
# Attendu : 200 ou 302

# SSH (vérification du fingerprint)
ssh-keyscan -p 2222 -H localhost 2>/dev/null | head -1
# Attendu : [localhost]:2222 ssh-ed25519 AAAA...

Consultez les logs pour les migrations automatiques :

sudo journalctl -u gitrust -n 50 --no-pager

Cherchez les lignes :

INFO gitrust_core::migrations: Running migrations...
INFO gitrust_core::migrations: Applied migration m20260325_000001_create_repositories
...
INFO gitrust_core::migrations: All migrations applied successfully
INFO rustwarden_core::services::default_admin_service: Admin user 'admin' created

Checkpoint O4 : curl http://localhost:4000/ retourne du HTML (code 200 ou 302). Le service est opérationnel.

Récapitulatif

  • ✓ O1 accompli en créant l’utilisateur système gitrust et l’arborescence /opt/gitrust
  • ✓ O2 accompli en configurant PostgreSQL et en vérifiant la connexion avec psql
  • ✓ O3 accompli en rédigeant gitrust.service avec les directives de durcissement systemd
  • ✓ O4 accompli en démarrant le service et en confirmant la réponse HTTP et SSH

Et si ça ne marche pas

SymptômeCause probableCorrection
Active: failed immédiatement après start.env manquant ou mal formé ; DATABASE_URL incorrectejournalctl -u gitrust -n 30 --no-pager pour voir l’erreur exacte ; vérifier que /opt/gitrust/.env existe et que DATABASE_URL est correct
Permission denied dans les logsRépertoire data/ non accessible par l’utilisateur gitrustsudo chown -R gitrust:gitrust /opt/gitrust/data
Address already in use sur le port 4000Un autre processus utilise le port`sudo ss -tlnp
JWT_SECRET trop court / refus de démarrageLe secret JWT est la valeur d’exemple ou fait moins de 32 bytesGénérer avec openssl rand -hex 64 et mettre à jour .env
Les migrations échouent (migration error)Base de données inaccessible ou droits insuffisantsVérifier DATABASE_URL et que l’utilisateur PG a GRANT ALL ON DATABASE gitrust
curl: (7) Failed to connectLe service n’est pas encore démarré ou écoute sur 127.0.0.1 uniquementTester depuis le serveur (pas depuis l’extérieur) ; vérifier SERVER_HOST=127.0.0.1

Prochaine étape

03 — Premier démarrage : bootstrap admin, clé SSH hôte, vérification santé

Premier démarrage : bootstrap admin, clé SSH hôte et vérification santé

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Expliquer comment gitrust crée automatiquement le compte administrateur initial à partir des variables ADMIN_*
  • O2. Identifier et vérifier la clé SSH hôte Ed25519 générée automatiquement au premier démarrage
  • O3. Confirmer que les trois services (HTTP :4000, SSH :2222, PostgreSQL :5432) sont sains via des commandes de vérification
  • O4. Vous connecter à l’interface d’administration /admin pour la première fois

Pré-requis

  • Technique : gitrust installé et démarré via 01-installation-docker ou 02-installation-systemd ; curl, ssh-keyscan, psql disponibles
  • Pédagogique : Tutoriel 01 ou 02 complété — gitrust doit être active (running)
  • Temps estimé : ~20 minutes

Vue d’ensemble

Le premier démarrage de gitrust effectue automatiquement trois opérations critiques, dans cet ordre :

sequenceDiagram
    participant S as systemd / Docker
    participant G as gitrust (binaire)
    participant PG as PostgreSQL
    participant FS as Système de fichiers

    S->>G: ExecStart / docker run
    G->>PG: Connexion DATABASE_URL
    G->>PG: AppMigrator::run_migrations()
    PG-->>G: Migrations OK (18 tables créées)
    G->>FS: SSH_HOST_KEY_PATH absent ?
    FS-->>G: Absent
    G->>FS: Génère Ed25519 host key
    G->>PG: ensure_default_admin() — users vide ?
    PG-->>G: Vide
    G->>PG: INSERT admin (ADMIN_USERNAME, ADMIN_EMAIL, hash(ADMIN_PASSWORD))
    G-->>S: Listening on :4000 (HTTP) et :2222 (SSH)

Modèle mental : c’est le même principe qu’un routeur domestique — à la première mise sous tension, il crée un mot de passe admin par défaut (ici fourni par vous via .env) et ne recommence jamais s’il existe déjà des données.

Étape 1 : Vérifier les migrations automatiques

Les migrations sont appliquées à chaque démarrage, mais seulement si elles n’ont pas encore été jouées. Elles sont idempotentes.

Consultez les logs :

# Installation systemd
sudo journalctl -u gitrust --since "10 minutes ago" --no-pager | grep -E "migrat|admin|listen"

# Installation Docker
docker compose logs gitrust 2>&1 | grep -E "migrat|admin|listen"

Sortie attendue (premier démarrage) :

INFO gitrust_core::migrations: Running migrations...
INFO gitrust_core::migrations: Applied migration m20260305_000001_initial_schema
INFO gitrust_core::migrations: Applied migration m20260306_000002_create_app_settings_table
...
INFO gitrust_core::migrations: Applied migration m20260327_000013_create_pr_comments
INFO gitrust_core::migrations: All migrations applied successfully (18 applied)
INFO rustwarden_core::services::default_admin_service: Admin user 'admin' created
INFO gitrust_web: HTTP server listening on 127.0.0.1:4000
INFO gitrust_ssh: SSH server listening on 0.0.0.0:2222

Sortie attendue (démarrage suivant) :

INFO gitrust_core::migrations: Running migrations...
INFO gitrust_core::migrations: No pending migrations

Checkpoint O1 : si vous voyez Admin user 'admin' created, le bootstrap admin a fonctionné. Si vous voyez Admin user already exists, skipping, un compte admin existait déjà — c’est normal si ce n’est pas le tout premier démarrage.

Étape 2 : Vérifier la clé SSH hôte

gitrust génère une clé Ed25519 à SSH_HOST_KEY_PATH si elle n’existe pas. Cette clé identifie votre instance auprès des clients Git.

Vérifiez que la clé existe :

# Systemd
sudo -u gitrust ls -la /opt/gitrust/data/ssh_host_ed25519_key*

# Docker (le volume est monté dans /data dans le conteneur)
docker compose exec gitrust ls -la /data/ssh_host_ed25519_key*

Sortie attendue :

-rw------- 1 gitrust gitrust  399 ...  /opt/gitrust/data/ssh_host_ed25519_key
-rw-r--r-- 1 gitrust gitrust   93 ...  /opt/gitrust/data/ssh_host_ed25519_key.pub

Affichez le fingerprint SHA256 (à conserver précieusement — vos utilisateurs vérifieront ce fingerprint lors du premier git clone) :

# Systemd
sudo -u gitrust ssh-keygen -l -f /opt/gitrust/data/ssh_host_ed25519_key.pub

# Docker
docker compose exec gitrust ssh-keygen -l -f /data/ssh_host_ed25519_key.pub

Sortie attendue :

256 SHA256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX gitrust-host-key (ED25519)

Important : notez ce fingerprint SHA256 dans votre documentation interne. Vos utilisateurs devront le comparer lors du premier ssh-keyscan ou lors du message The authenticity of host ... can't be established. Si vous régénérez cette clé après que des utilisateurs ont cloné des dépôts, leur ~/.ssh/known_hosts sera en conflit et tous les push/pull échoueront.

Checkpoint O2 : les deux fichiers existent avec les bonnes permissions (clé privée 600, clé publique 644).

Étape 3 : Vérifier la santé des trois services

HTTP :4000

curl -v http://localhost:4000/ 2>&1 | grep -E "^< HTTP|Location"

Sortie attendue :

< HTTP/1.1 302 Found
< location: /login

Une redirection vers /login signifie que gitrust répond et que le routeur fonctionne.

SSH :2222

ssh-keyscan -p 2222 -H localhost 2>/dev/null

Sortie attendue :

[localhost]:2222 ssh-ed25519 AAAA... (votre clé hôte)

Si aucune sortie, le serveur SSH n’écoute pas encore — consultez les logs.

PostgreSQL :5432

# Depuis l'hôte
psql "postgres://gitrust:VOTRE_MOT_DE_PASSE@localhost:5432/gitrust" \
  -c "SELECT COUNT(*) AS nb_tables FROM information_schema.tables WHERE table_schema='public';"

Sortie attendue :

 nb_tables
-----------
        18
(1 row)

18 tables = toutes les migrations ont été appliquées.

Checkpoint O3 : les trois commandes répondent comme attendu. Si l’une échoue, consultez la section « Et si ça ne marche pas ».

Étape 4 : Premier accès à l’interface d’administration

Ouvrez votre navigateur sur http://VOTRE_SERVEUR:4000/login (ou https:// si vous avez déjà configuré un reverse-proxy TLS).

Connectez-vous avec :

  • Identifiant : valeur de ADMIN_USERNAME dans votre .env (par défaut : admin)
  • Mot de passe : valeur de ADMIN_PASSWORD dans votre .env

Page de connexion gitrust

Après connexion, accédez au tableau de bord d’administration :

http://VOTRE_SERVEUR:4000/admin

Vous devriez voir :

  • Le nombre d’utilisateurs (1 — l’admin initial)
  • Le nombre de dépôts (0)
  • L’état des services CI

Tableau de bord admin

Sécurité : changez immédiatement le mot de passe admin via /admin/users ou le profil utilisateur si vous avez utilisé un mot de passe faible dans .env. Ensuite, vous pouvez supprimer ou commenter ADMIN_PASSWORD dans .env — gitrust ne recrée le compte admin que si la table users est vide.

Checkpoint O4 : vous êtes connecté à /admin et le tableau de bord s’affiche sans erreur.

Récapitulatif

  • ✓ O1 accompli en lisant les logs de démarrage qui confirment les migrations et la création du compte admin
  • ✓ O2 accompli en vérifiant l’existence et le fingerprint SHA256 de la clé SSH hôte Ed25519
  • ✓ O3 accompli en testant HTTP (:4000 → 302), SSH (:2222 → fingerprint) et PostgreSQL (:5432 → 18 tables)
  • ✓ O4 accompli en se connectant à /admin avec les identifiants ADMIN_* du .env

Et si ça ne marche pas

SymptômeCause probableCorrection
HTTP/1.1 Connection refused sur le port 4000gitrust n’a pas démarré ou écoute sur un autre portsystemctl status gitrust ou docker compose ps ; vérifier SERVER_PORT dans .env
Logs : JWT_SECRET trop court ou invalid JWT secretLe secret JWT est la valeur exemple du templateGénérer avec openssl rand -hex 64 et redémarrer
ssh-keyscan ne retourne rienLe port SSH est bloqué par un firewall ou gitrust n’a pas démarré le serveur SSHsudo ufw allow 2222/tcp (si ufw actif) ; vérifier SSH_PORT dans .env
Admin user already exists mais impossible de se connecterLe mot de passe dans .env n’est pas celui du compte en base (déjà modifié)Réinitialiser via psql : UPDATE users SET password_hash = '...' WHERE username = 'admin' — ou utiliser la procédure de reset
18 tables manquantes (moins de 18 en base)Migrations interrompuesjournalctl -u gitrust -n 100 pour voir l’erreur ; souvent un problème de droits PG ou de connexion interrompue

Prochaine étape

04 — Mise en production : reverse-proxy TLS, SMTP, sauvegarde

Ou, si vous voulez configurer les fonctionnalités avancées maintenant :

Mise en production sur une machine Linux avec HTTPS Let’s Encrypt

Contexte

Procédure de référence pour déployer une instance gitrust sur une machine Linux à IP fixe, avec patte externe routée et HTTPS via Let’s Encrypt. Reproductible et testée en conditions réelles.

Adaptez les valeurs suivantes à votre environnement :

PlaceholderRemplacez par
<your-server-ip>L’IP fixe de votre serveur (ex: 192.168.1.57 sur un LAN, ou une IP publique)
<your-domain>Votre FQDN principal (ex: gitrust.example.com)
<acme-email>L’email pour Let’s Encrypt (ex: contact@example.com)
<admin-user>L’utilisateur SSH admin sur le serveur cible

Les trois services gitrust

#ServiceBind interneExposition publiqueVariable
1Web HTTP (UI SSR + API)127.0.0.1:4000:443 HTTPS via NginxSERVER_PORT
2SSH Git (russh)127.0.0.1:2222:22 via Nginx streamSSH_PORT
3Worker CI/CD (Dagger)tâche Tokio in-process— (déclenché par push)CI_ENABLED

PostgreSQL = dépendance, conteneur Docker bind loopback 127.0.0.1:5432.

Topologie réseau

Internet
    |
    v
<your-server-ip> (patte externe)
    |
    +-- :22   Nginx stream  --> 127.0.0.1:2222 (gitrust SSH)
    +-- :80   Nginx HTTP    --> 301 HTTPS (sauf /.well-known/acme-challenge)
    +-- :443  Nginx HTTPS   --> 127.0.0.1:4000 (gitrust HTTP)
    +-- :2022 sshd système  (accès admin, déplacé depuis :22)

    Loopback uniquement :
    +-- 127.0.0.1:4000  gitrust web
    +-- 127.0.0.1:2222  gitrust SSH
    +-- 127.0.0.1:5432  postgres docker

Risque critique — ordre d’exécution

Le déplacement de sshd système de :22 vers :2022 doit être fait avant la configuration nginx stream, et doit être validé depuis une deuxième session SSH avant fermeture de la première. Sinon : lockout total (besoin d’accès console physique).


Pré-requis

Poste de build (machine locale avec le code source)

rustup show && command -v rsync envsubst npx
# Installer si besoin : sudo apt install rsync gettext-base

Cible <your-server-ip>

  • Debian/Ubuntu récent
  • Accès SSH avec sudo
  • Docker + plugin compose (docker compose version)
  • Snap ou apt pour certbot + plugin nginx
  • DNS <your-domain> → IP publique de la patte externe (vérifier dig +short <your-domain>)
  • Module stream Nginx disponible (paquet libnginx-mod-stream sur Debian)

Phase 0 — Générer .env.production (poste de build, une seule fois)

JWT=$(openssl rand -hex 64)
ADMIN_PWD=$(openssl rand -base64 24)
PG_PWD=$(openssl rand -base64 24)

cat > .env.production <<EOF
# ---- Base de données ----
DATABASE_URL=postgres://gitrust:${PG_PWD}@127.0.0.1:5432/gitrust

# ---- Serveur HTTP (derrière Nginx HTTPS) ----
SERVER_HOST=127.0.0.1
SERVER_PORT=4000

# ---- Serveur SSH (derrière nginx stream :22) ----
SSH_PORT=2222
SSH_LISTEN_ADDR=127.0.0.1
SSH_PUBLIC_HOST=<your-domain>
SSH_HOST_KEY_PATH=/opt/gitrust/data/ssh_host_ed25519_key

# ---- Chemins (réécrits par deploy.sh) ----
GIT_REPOS_BASE_PATH=/opt/gitrust/data/repos
STATIC_FILES_PATH=/opt/gitrust/static

# ---- Sécurité — HTTPS actif ----
JWT_SECRET=${JWT}
JWT_EXPIRATION_MINUTES=15
REFRESH_TOKEN_EXPIRATION_DAYS=7
COOKIE_SECURE=true            # HTTPS via Nginx
COOKIE_SAME_SITE=Lax
APP_DEBUG=false
RUST_LOG=info

# ---- Admin initial ----
ADMIN_USERNAME=admin
ADMIN_EMAIL=<acme-email>
ADMIN_PASSWORD=${ADMIN_PWD}

# ---- Inscription & UI ----
ALLOW_REGISTRATION=false
APP_NAME=Gitrust
DEFAULT_LOCALE=fr

# ---- Email ----
EMAIL_VALIDATION_REQUIRED=false
EMAIL_BASE_URL=https://<your-domain>

# ---- CI/CD Dagger (worker 3) ----
CI_ENABLED=true
CI_MAX_CONCURRENT=4
CI_DEFAULT_TIMEOUT=3600
CI_WORKSPACE_PATH=/tmp/gitrust-ci
CI_REMOTE_HOST=localhost

# ---- Worker import ----
IMPORT_MAX_CONCURRENT=2
IMPORT_TIMEOUT_SECS=1800
DB_WORKER_POOL_SIZE=4
EOF

chmod 600 .env.production
echo "ADMIN_PASSWORD = ${ADMIN_PWD}"
echo "POSTGRES_PASSWORD = ${PG_PWD}"

Critique : noter ADMIN_PASSWORD et POSTGRES_PASSWORD dans un gestionnaire de mots de passe.


Phase 1 — Préparer la cible : déplacer sshd système sur :2022

À faire en gardant deux sessions SSH simultanées.

1.pre. Diagnostic de l’état initial

1.pre.1 — Trouver le port SSH ouvert depuis le poste de build

# Depuis votre poste (sans être connecté) :
for p in 22 2022 2222 2200 22022; do
  timeout 3 bash -c "echo >/dev/tcp/<your-server-ip>/$p" 2>/dev/null \
    && echo "Port $p : OUVERT" \
    || echo "Port $p : fermé/filtré"
done

1.pre.2 — Se connecter sur le port détecté

# Cas le plus probable : sshd est sur :22 par défaut
ssh <admin-user>@<your-server-ip>

1.pre.3 — Une fois connecté, vérifier l’état sshd actuel

# Sur quel(s) port(s) sshd écoute-t-il ?
sudo ss -tlnp | grep sshd

# Contenu de la config
sudo grep -riE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/ 2>/dev/null

# Statut du service
sudo systemctl status ssh --no-pager

1a. Session 1 : modifier la config sshd

ssh <admin-user>@<your-server-ip>

# Éditer /etc/ssh/sshd_config
sudo tee /etc/ssh/sshd_config.d/99-gitrust.conf <<'EOF'
Port 2022
EOF

# Vérifier la syntaxe
sudo sshd -t

# Si OK, reload (ne déconnecte pas la session courante)
sudo systemctl reload ssh
sudo ss -tlnp | grep -E ':22\b|:2022\b'
# Attendu : sshd écoute sur 22 ET 2022

1b. Session 2 : valider le nouveau port

Depuis le poste de build, dans un autre terminal :

ssh -p 2022 <admin-user>@<your-server-ip> 'echo "OK port 2022"'
# Doit afficher : OK port 2022

1c. (Plus tard, après Phase 5) Fermer le port 22 du sshd système

Une fois nginx stream :22 actif et testé :

ssh -p 2022 <admin-user>@<your-server-ip>
sudo sed -i 's/^Port 22$/# Port 22 (déplacé vers 2022, :22 utilisé par nginx stream)/' /etc/ssh/sshd_config
sudo systemctl restart ssh

1d. Firewall

ssh -p 2022 <admin-user>@<your-server-ip> bash <<'EOF'
sudo ufw allow 80/tcp comment 'HTTP ACME + redirect'
sudo ufw allow 443/tcp comment 'HTTPS Gitrust'
sudo ufw allow 22/tcp comment 'SSH Git via nginx stream'
sudo ufw allow 2022/tcp comment 'SSH admin système'
sudo ufw status numbered
EOF

Mettre à jour ~/.ssh/config côté poste dev :

Host gitrust-host
    HostName <your-server-ip>
    User <admin-user>
    Port 2022

Le reste du plan utilise ssh gitrust-host.


Phase 2 — PostgreSQL (Docker, loopback)

scp -P 2022 database/docker-compose.yml <admin-user>@<your-server-ip>:/tmp/pg-compose.yml

ssh gitrust-host bash <<EOF
sudo mkdir -p /opt/gitrust/database
sudo mv /tmp/pg-compose.yml /opt/gitrust/database/docker-compose.yml
echo 'POSTGRES_PASSWORD=${PG_PWD}' | sudo tee /opt/gitrust/database/.env >/dev/null
sudo chmod 600 /opt/gitrust/database/.env
cd /opt/gitrust/database && sudo docker compose up -d
sudo docker compose ps
EOF

Remplacer ${PG_PWD} par la valeur réelle. Vérifier que le port n’est pas exposé publiquement :

ssh gitrust-host 'sudo ss -tlnp | grep 5432'
# Attendu : 127.0.0.1:5432 — PAS 0.0.0.0

Phase 3 — Build + déploiement gitrust via deploy.sh

3a. Préparer deployment/deploy.conf

cp deployment/deploy.conf.example deployment/deploy.conf
# Éditer DEPLOY_TARGET=gitrust-host, DEPLOY_REMOTE_PATH=/opt/gitrust, DEPLOY_FQDN_INITIAL=<your-domain>
$EDITOR deployment/deploy.conf
chmod 600 deployment/deploy.conf

3b. Validation du build seul (optionnel)

BUILD_ONLY=1 ./deployment/deploy.sh

3c. Déploiement

# Sans argument : utilise DEPLOY_TARGET du deploy.conf (gitrust-host via ~/.ssh/config, port 2022)
./deployment/deploy.sh

Ce que fait deploy.sh : cargo build --release, tailwindcss --minify, adapte gitrust.service + .env, rsync, crée user gitrust, installe systemd, restart.

À la fin : gitrust écoute sur 127.0.0.1:4000 (HTTP) et 127.0.0.1:2222 (SSH).

ssh gitrust-host 'sudo systemctl status gitrust --no-pager && sudo ss -tlnp | grep -E "4000|2222"'
# Attendu : 127.0.0.1:4000 et 127.0.0.1:2222 (PAS 0.0.0.0)

Phase 4 — Nginx HTTP + Let’s Encrypt

4a. Installer nginx + certbot

ssh gitrust-host bash <<'EOF'
sudo apt update
sudo apt install -y nginx libnginx-mod-stream certbot python3-certbot-nginx
sudo systemctl enable --now nginx
EOF

4b. Déployer la conf Nginx

scp -P 2022 deployment/nginx-gitrust.conf <admin-user>@<your-server-ip>:/tmp/gitrust-nginx.conf

ssh gitrust-host bash <<EOF
sudo sed -i 's/gitrust.nuage.ebii/<your-domain>/g' /tmp/gitrust-nginx.conf
sudo mv /tmp/gitrust-nginx.conf /etc/nginx/sites-available/gitrust
sudo ln -sf /etc/nginx/sites-available/gitrust /etc/nginx/sites-enabled/gitrust
sudo rm -f /etc/nginx/sites-enabled/default
sudo mkdir -p /var/www/html/.well-known/acme-challenge
sudo nginx -t && sudo systemctl reload nginx
EOF

4c. Émettre le certificat Let’s Encrypt

ssh gitrust-host 'sudo certbot --nginx \
    -d <your-domain> \
    --email <acme-email> \
    --agree-tos \
    --no-eff-email \
    --redirect'

4d. Vérifier le renouvellement automatique

ssh gitrust-host 'sudo systemctl list-timers | grep certbot'
ssh gitrust-host 'sudo certbot renew --dry-run'

4e. Test HTTPS

curl -I https://<your-domain>/
# Attendu : HTTP/2 200 (ou 302 vers /login)

# Vérifier HSTS
curl -sI https://<your-domain>/ | grep -i strict-transport

Phase 5 — Exposer SSH Git sur :22 via Nginx stream

5a. Ajouter le bloc stream

ssh gitrust-host bash <<'EOF'
sudo nginx -V 2>&1 | grep -o with-stream

sudo tee -a /etc/nginx/nginx.conf <<'NGINX'

# --- Gitrust SSH (proxy :22 -> :2222) -----------------------------------------
stream {
    upstream gitrust_ssh {
        server 127.0.0.1:2222;
    }
    server {
        listen 22;
        listen [::]:22;
        proxy_pass gitrust_ssh;
        proxy_timeout 1h;
        proxy_connect_timeout 30s;
        error_log /var/log/nginx/gitrust-ssh.log;
    }
}
NGINX

sudo nginx -t && sudo systemctl restart nginx
sudo ss -tlnp | grep -E ':22\b|:2222\b'
# Attendu : nginx :22, gitrust :2222 (loopback)
EOF

5b. Désactiver sshd système sur :22 (Phase 1c)

ssh -p 2022 <admin-user>@<your-server-ip> bash <<'EOF'
sudo sed -i 's/^Port 22$/# Port 22 déplacé/' /etc/ssh/sshd_config 2>/dev/null || true
sudo systemctl restart ssh
sudo ss -tlnp | grep -E ':22\b|:2022\b'
# Attendu : nginx :22, sshd :2022 — plus de sshd:22
EOF

5c. Test clone Git

# Dans l'UI gitrust : Settings -> SSH keys -> coller ~/.ssh/id_ed25519.pub
# Créer un repo test admin/test-deploy via UI

git clone git@<your-domain>:admin/test-deploy.git /tmp/test-deploy
cd /tmp/test-deploy
echo "# test" > README.md
git -c user.email=<acme-email> -c user.name=admin add . && \
  git -c user.email=<acme-email> -c user.name=admin commit -m "test deploy" && \
  git push origin main

Phase 6 — Vérifications globales

# Service systemd
ssh gitrust-host 'sudo systemctl status gitrust --no-pager'

# Logs (recherche d'erreurs)
ssh gitrust-host 'sudo journalctl -u gitrust -n 100 --no-pager | grep -iE "error|warn|fail"'

# Ports d'écoute (vue d'ensemble)
ssh gitrust-host 'sudo ss -tlnp'
# Attendu :
#   :22    nginx
#   :80    nginx
#   :443   nginx
#   :2022  sshd système
#   127.0.0.1:2222  gitrust
#   127.0.0.1:4000  gitrust
#   127.0.0.1:5432  docker-proxy

# Renouvellement TLS automatique
ssh gitrust-host 'sudo systemctl status certbot.timer --no-pager'

# Test E2E
curl -I https://<your-domain>/login
ssh -T git@<your-domain> || true   # Banner SSH russh attendu

Phase 7 — Durcissement : Fail2ban

Voir administration_manual/how-to/durcir-avec-fail2ban.md pour la configuration complète.

7a. Installation

ssh gitrust-host bash <<'EOF'
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
EOF

7b. Configuration minimale rapide

ssh gitrust-host sudo tee /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
backend  = systemd
bantime  = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8 ::1
banaction          = ufw
banaction_allports = ufw

[sshd]
enabled  = true
port     = 2022
filter   = sshd
backend  = %(sshd_backend)s
logpath  = %(sshd_log)s
maxretry = 3

[nginx-http-auth]
enabled  = true
port     = http,https
filter   = nginx-http-auth
logpath  = /var/log/nginx/gitrust.error.log

[gitrust-login]
enabled  = true
port     = http,https
filter   = gitrust-login
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 5

[gitrust-ssh]
enabled      = true
port         = 22,2222
filter       = gitrust-ssh
backend      = systemd
journalmatch = _SYSTEMD_UNIT=gitrust.service
maxretry     = 5

[recidive]
enabled   = true
logpath   = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime   = 1w
findtime  = 1d
maxretry  = 3
EOF

Créer les filtres gitrust-login et gitrust-ssh, puis :

ssh gitrust-host 'sudo systemctl restart fail2ban && sudo fail2ban-client status'

Phase 8 — Mise à jour vers un nouveau domaine (quand DNS prêt)

8a. Ajouter le SAN au certificat existant

ssh gitrust-host 'sudo certbot --nginx --expand \
    -d <your-domain> \
    -d <new-domain> \
    --email <acme-email> \
    --agree-tos --no-eff-email'

8b. Mettre à jour la conf Nginx

ssh gitrust-host bash <<EOF
sudo sed -i 's/server_name <your-domain>;/server_name <your-domain> <new-domain>;/g' /etc/nginx/sites-available/gitrust
sudo nginx -t && sudo systemctl reload nginx
EOF

8c. Mettre à jour .env.production et redéployer

sed -i 's|SSH_PUBLIC_HOST=.*|SSH_PUBLIC_HOST=<new-domain>|' .env.production
sed -i 's|EMAIL_BASE_URL=.*|EMAIL_BASE_URL=https://<new-domain>|' .env.production
./deployment/deploy.sh gitrust-host

Phase 9 — Mises à jour ultérieures

git pull
SKIP_ENV=1 SKIP_DB=1 ./deployment/deploy.sh

Dépannage

SymptômeDiagnosticFix
Lockout SSH après reload sshdConsole physique / KVM IPMI
Cert Let’s Encrypt échoue (Connection refused)dig +short <your-domain> ≠ IP publiqueCorriger DNS, ou vérifier que :80 traverse bien le NAT
Cert échoue (unauthorized)Nginx :80 ne répond pas sur /.well-known/acme-challenge/curl http://<your-domain>/.well-known/acme-challenge/test doit ne pas rediriger en 301
Login boucle sur /login malgré HTTPSgrep COOKIE_SECURE /opt/gitrust/.envDoit être true ; vérifier que Nginx forward bien X-Forwarded-Proto https
Push SSH Connection closedjournalctl -u gitrust | grep sshVérifier que gitrust écoute bien 127.0.0.1:2222 et que nginx stream forward
nginx -t : unknown directive "stream"Module non chargésudo apt install libnginx-mod-stream puis systemctl restart nginx
Mixed content HTTPSTemplates qui hardcodent http://Vérifier EMAIL_BASE_URL=https://... et headers X-Forwarded-Proto
Push gros repo timeoutLogs nginx client intended to send too large bodyAugmenter client_max_body_size 2G (déjà fait pour .git/)

Récapitulatif des ports

PortProcessExpositionRôle
22nginx (stream)PublicSSH Git → forward vers :2222
80nginxPublicACME challenge + redirect HTTPS
443nginxPublicHTTPS → forward vers :4000
2022sshd systèmePublicAdmin SSH (à restreindre par firewall si possible)
2222gitrustLoopbackBackend SSH russh
4000gitrustLoopbackBackend HTTP axum
5432docker-proxyLoopbackPostgreSQL

Guides pratiques — Manuel d’administration

Les guides pratiques répondent à une question précise : « Comment faire X ? » Chaque guide est une recette orientée tâche, utilisable indépendamment, sans contexte pédagogique. Vous êtes supposé avoir déjà une instance gitrust opérationnelle.

Prérequis : parcours tutoriel complété (tutoriels 01-03 minimum). Si vous démarrez, commencez par les tutoriels.


Exploitation quotidienne

GuideCe qu’il vous permet de faire
Gérer les utilisateursCréer, désactiver, supprimer des comptes ; forcer reset mot de passe ; gérer les droits admin
Auditer l’instanceConsulter /admin/audit, filtrer par utilisateur/action/période, exporter en CSV, corréler avec journalctl

Configuration des services

GuideCe qu’il vous permet de faire
Configurer SMTPActiver l’envoi d’e-mails ; recettes pour Gmail, OVH, Postfix local, Mailpit
Configurer OAuthActiver Google, GitHub, Discord ou Microsoft comme fournisseur de connexion
Régler le rate limitingAjuster les limites par profil (équipe 5 pers., 50 pers., instance publique) ; surveiller les 429

Maintenance et sécurité

GuideCe qu’il vous permet de faire
Sauvegarder et restaurerScript backup.sh complet ; restauration pas-à-pas de PostgreSQL, des dépôts bare et de la clé SSH hôte
Mettre à jour gitrustChecklist upgrade : sauvegarde → arrêt → déploiement → migrations auto → smoke test → rollback si besoin
Durcir avec Fail2banBannir les IPs après tentatives de brute force sur SSH et HTTP
Forcer le 2FA globalementActiver totp_force_enabled pour obliger tous les utilisateurs à configurer le TOTP
Intégrer Dependency-TrackConnecter gitrust à une instance Dependency-Track pour le suivi CVE via SBOM

Dépannage

GuideCe qu’il vous permet de faire
Dépanner SSHDiagnostiquer les erreurs Permission denied, Connection refused, REMOTE HOST IDENTIFICATION HAS CHANGED, timeouts
Dépanner la CIDébloquer un pipeline bloqué, diagnostiquer Dagger, résoudre les erreurs SBOM et Dependency-Track

Runners CI distants

GuideCe qu’il vous permet de faire
Configurer un runner CI distantDéporter l’exécution CI sur une machine dédiée via SSH + rsync

Trouver rapidement la bonne page

Sauvegarder et restaurer une instance gitrust

À qui s’adresse cette page

Administrateurs qui souhaitent mettre en place une sauvegarde fiable et tester la restauration. Pour comprendre la stratégie 3-2-1 adaptée à gitrust, consultez Stratégie de sauvegarde.


Ce qu’il faut sauvegarder

Gitrust repose sur trois sources de vérité distinctes :

SourceChemin / objetCritiqueFréquence recommandée
Base de données PostgreSQLdatabase gitrustOuiQuotidien minimum
Dépôts Git bareGIT_REPOS_BASE_PATH (ex. /opt/gitrust/data/repos)OuiQuotidien minimum
Clé SSH hôteSSH_HOST_KEY_PATH (ex. /opt/gitrust/data/ssh_host_ed25519_key)OuiUne seule fois, puis conserver
Fichier .env/opt/gitrust/.envOuiÀ chaque modification

La clé SSH hôte est immuable après le premier démarrage. Si vous la perdez, tous vos utilisateurs devront supprimer leur entrée known_hosts et re-vérifier le fingerprint.


Sauvegarder

Sauvegarde PostgreSQL (pg_dump)

BACKUP_DIR="/var/backups/gitrust"
DATE=$(date +%Y%m%d-%H%M%S)
mkdir -p "$BACKUP_DIR"

pg_dump \
  -h localhost \
  -U gitrust \
  --format=custom \
  --file="${BACKUP_DIR}/pg-${DATE}.dump" \
  gitrust

echo "Dump : ${BACKUP_DIR}/pg-${DATE}.dump"
# Purger les sauvegardes de plus de 30 jours
find "$BACKUP_DIR" -name "pg-*.dump" -mtime +30 -delete

Le format --format=custom est compressé et restaurable avec pg_restore. Le fichier contient la structure ET les données.

Sauvegarde des dépôts bare (rsync)

REPOS_SRC="/opt/gitrust/data/repos"
REPOS_DST="/var/backups/gitrust/repos"

rsync \
  --archive \
  --delete \
  --hard-links \
  --checksum \
  "${REPOS_SRC}/" "${REPOS_DST}/"

Les dépôts bare Git sont consistants en lecture même pendant un git push actif : Git garantit l’atomicité au niveau du pack.

Sauvegarde de la clé SSH hôte et du .env

BACKUP_DIR="/var/backups/gitrust"
DATE=$(date +%Y%m%d-%H%M%S)

tar czf "${BACKUP_DIR}/config-${DATE}.tar.gz" \
  /opt/gitrust/data/ssh_host_ed25519_key \
  /opt/gitrust/data/ssh_host_ed25519_key.pub \
  /opt/gitrust/.env

chmod 600 "${BACKUP_DIR}/config-${DATE}.tar.gz"

Script complet backup.sh

#!/usr/bin/env bash
# /opt/gitrust/scripts/backup.sh
set -euo pipefail

BACKUP_DIR="${BACKUP_DIR:-/var/backups/gitrust}"
DATE=$(date +%Y%m%d-%H%M%S)
PG_USER="gitrust"
PG_DB="gitrust"
REPOS_SRC="/opt/gitrust/data/repos"
SSH_KEY="/opt/gitrust/data/ssh_host_ed25519_key"
ENV_FILE="/opt/gitrust/.env"

mkdir -p "$BACKUP_DIR"

echo "[1/3] Dump PostgreSQL..."
pg_dump -U "$PG_USER" --format=custom \
  --file="${BACKUP_DIR}/pg-${DATE}.dump" "$PG_DB"

echo "[2/3] Rsync dépôts bare..."
mkdir -p "${BACKUP_DIR}/repos"
rsync --archive --delete --checksum \
  "${REPOS_SRC}/" "${BACKUP_DIR}/repos/"

echo "[3/3] Archive config sensible..."
tar czf "${BACKUP_DIR}/config-${DATE}.tar.gz" \
  "$SSH_KEY" "${SSH_KEY}.pub" "$ENV_FILE"
chmod 600 "${BACKUP_DIR}/config-${DATE}.tar.gz"

echo "Sauvegarde terminée dans $BACKUP_DIR"
find "$BACKUP_DIR" -name "pg-*.dump" -mtime +30 -delete
find "$BACKUP_DIR" -name "config-*.tar.gz" -mtime +90 -delete

Planifier via cron :

# Sauvegarder chaque nuit à 2h00
0 2 * * * /opt/gitrust/scripts/backup.sh >> /var/log/gitrust-backup.log 2>&1

Restaurer

Avant toute restauration

# 1. Arrêter gitrust
sudo systemctl stop gitrust   # ou : docker compose stop gitrust

# 2. Vérifier les fichiers de sauvegarde disponibles
ls -lh /var/backups/gitrust/pg-*.dump | tail -5
ls -lh /var/backups/gitrust/config-*.tar.gz | tail -3
du -sh /var/backups/gitrust/repos/

Restaurer la base PostgreSQL

DUMP="/var/backups/gitrust/pg-20260417-020000.dump"

sudo -u postgres psql -c "DROP DATABASE IF EXISTS gitrust;"
sudo -u postgres psql -c "CREATE DATABASE gitrust OWNER gitrust;"

pg_restore \
  -h localhost \
  -U gitrust \
  --dbname=gitrust \
  --verbose \
  "$DUMP"

Vérification :

psql "postgres://gitrust:MOT_DE_PASSE@localhost:5432/gitrust" \
  -c "SELECT COUNT(*) FROM users; SELECT COUNT(*) FROM repositories;"

Restaurer les dépôts bare

REPOS_SRC="/var/backups/gitrust/repos"
REPOS_DST="/opt/gitrust/data/repos"

rsync --archive --delete "${REPOS_SRC}/" "${REPOS_DST}/"
sudo chown -R gitrust:gitrust "${REPOS_DST}"

Restaurer la clé SSH hôte et le .env

ARCHIVE="/var/backups/gitrust/config-20260417-020000.tar.gz"

sudo tar xzf "$ARCHIVE" -C /
sudo chown gitrust:gitrust /opt/gitrust/data/ssh_host_ed25519_key*
sudo chmod 600 /opt/gitrust/data/ssh_host_ed25519_key
sudo chmod 644 /opt/gitrust/data/ssh_host_ed25519_key.pub
sudo chmod 600 /opt/gitrust/.env

Redémarrer et vérifier

sudo systemctl start gitrust
sleep 3
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/
# Attendu : 302

Tester la restauration (drill trimestriel)

Une sauvegarde non testée n’est pas une sauvegarde. Testez sur une VM distincte :

# Sur une VM de test vierge — restaurer, puis vérifier :
git clone ssh://git@VM_TEST:2222/admin/un-depot-connu.git /tmp/drill-test
ls /tmp/drill-test/
# Les fichiers doivent correspondre à l'état au moment de la sauvegarde

Pour aller plus loin

Mettre à jour gitrust

À qui s’adresse cette page

Administrateurs qui souhaitent appliquer une nouvelle version de gitrust sur une instance en production, en limitant les risques.


Principe général

Les migrations de base de données sont appliquées automatiquement au démarrage par AppMigrator. Vous n’avez jamais à exécuter de commande SQL manuellement. Le binaire contient toujours les migrations cumulatives.

flowchart LR
    A[Sauvegarde] --> B[Arrêt service]
    B --> C[Déploiement<br/>nouveau binaire]
    C --> D[Démarrage]
    D --> E{Migrations<br/>auto ?}
    E -->|OK| F[Smoke test]
    E -->|Erreur| G[Rollback]
    F -->|OK| H[Terminé]
    F -->|Erreur| G

Checklist de mise à jour

1. Sauvegarder avant tout

# Sauvegarde complète avant toute modification
/opt/gitrust/scripts/backup.sh

Vérifiez que le dump PostgreSQL a été créé :

ls -lh /var/backups/gitrust/pg-$(date +%Y%m%d)*.dump

Ne passez à l’étape 2 que si la sauvegarde est confirmée.

2. Arrêter le service

# Systemd
sudo systemctl stop gitrust

# Docker Compose
docker compose stop gitrust

Attendez la confirmation :

sudo systemctl is-active gitrust
# Attendu : inactive

3. Déployer le nouveau binaire

Option A — via deploy.sh (recommandé si vous compilez localement) :

# Sur la machine de développement, depuis la racine du dépôt gitrust
./deployment/deploy.sh user@VOTRE_SERVEUR /opt/gitrust

Le script compile en release, synchronise le binaire, les assets statiques et le service systemd, puis redémarre automatiquement.

Option B — manuellement :

# Compiler le nouveau binaire
cargo build --release

# Compiler le CSS si les templates ont changé
npx tailwindcss -i static/css/input.css -o static/css/style.css --minify

# Transférer
rsync -avz target/release/gitrust  user@VOTRE_SERVEUR:/opt/gitrust/gitrust
rsync -avz static/                 user@VOTRE_SERVEUR:/opt/gitrust/static/

# Sur le serveur
sudo chown gitrust:gitrust /opt/gitrust/gitrust
sudo chmod 750 /opt/gitrust/gitrust

4. Vérifier les éventuels changements de .env

Consultez le CHANGELOG de la nouvelle version pour identifier les nouvelles variables d’environnement. Ajoutez-les à /opt/gitrust/.env si nécessaire, avec des valeurs appropriées.

# Comparer votre .env avec .env.example de la nouvelle version
diff /opt/gitrust/.env .env.example | grep "^>"

5. Démarrer le service

# Systemd
sudo systemctl start gitrust

# Docker Compose
docker compose up -d gitrust

6. Vérifier les migrations automatiques

sudo journalctl -u gitrust --since "2 minutes ago" --no-pager | grep -E "migrat|error|panic"

Sortie attendue si de nouvelles migrations ont été appliquées :

INFO gitrust_core::migrations: Applied migration m20260501_000014_add_webhooks
INFO gitrust_core::migrations: All migrations applied successfully

Sortie attendue si aucune nouvelle migration :

INFO gitrust_core::migrations: No pending migrations

Si vous voyez ERROR ou panic, consultez la section Rollback.

7. Smoke test

# HTTP
curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/
# Attendu : 302

# SSH (fingerprint inchangé — comparer avec la valeur notée)
ssh-keyscan -p 2222 -H localhost 2>/dev/null | ssh-keygen -l -f -

Connectez-vous à l’interface /admin et vérifiez :

  • La liste des utilisateurs est correcte
  • Un dépôt existant est accessible
  • La page /admin/settings s’affiche sans erreur

Rollback

Si le démarrage échoue ou si le smoke test révèle une régression :

1. Arrêter le service

sudo systemctl stop gitrust

2. Restaurer le binaire précédent

Si vous avez conservé l’ancien binaire (bonne pratique : le renommer avant la mise à jour) :

sudo cp /opt/gitrust/gitrust.backup /opt/gitrust/gitrust
sudo chown gitrust:gitrust /opt/gitrust/gitrust

3. Restaurer la base de données si des migrations ont été appliquées

# Identifier la dernière sauvegarde pré-mise-à-jour
DUMP=$(ls -t /var/backups/gitrust/pg-*.dump | head -1)
echo "Restauration depuis : $DUMP"

sudo -u postgres psql -c "DROP DATABASE IF EXISTS gitrust;"
sudo -u postgres psql -c "CREATE DATABASE gitrust OWNER gitrust;"

pg_restore -h localhost -U gitrust --dbname=gitrust "$DUMP"

4. Redémarrer avec l’ancienne version

sudo systemctl start gitrust

Attention : si des nouvelles migrations ont créé ou modifié des tables, le rollback de la base est indispensable avant de démarrer l’ancienne version. Un binaire ancien sur une base migratée peut produire des erreurs imprévisibles.


Conseils

  • Conservez toujours le binaire précédent pendant 24 h avant de le supprimer (mv gitrust gitrust.backup).
  • Planifiez les mises à jour en dehors des heures de pointe (le service est arrêté pendant l’opération).
  • Les migrations sont idempotentes : redémarrer deux fois de suite avec la même version est sans risque.

Pour aller plus loin

Configurer l’envoi d’e-mails (SMTP)

À qui s’adresse cette page

Administrateurs qui souhaitent activer l’envoi d’e-mails (validation de compte, réinitialisation de mot de passe, notifications). Sans configuration SMTP, gitrust démarre normalement mais toutes les fonctionnalités e-mail sont désactivées.


Variables concernées

Toutes ces variables se placent dans /opt/gitrust/.env (ou dans l’EnvironmentFile de votre service systemd / docker-compose.yml). Un redémarrage du service est nécessaire après modification.

VariableObligatoireDéfautDescription
SMTP_HOSTOuiNom d’hôte ou IP du serveur SMTP
SMTP_PORTNon587Port SMTP (25, 465, 587, 1025)
SMTP_USERNonIdentifiant SMTP (souvent = adresse e-mail)
SMTP_PASSWORDNonMot de passe applicatif SMTP
SMTP_FROMNonAdresse expéditeur visible (doit être valide)
SMTP_FROM_NAMENonNom affiché dans le champ « De : »
SMTP_USE_TLSNontrueActiver TLS natif (port 465)
SMTP_USE_STARTTLSNontrueActiver STARTTLS (port 587)
SMTP_ACCEPT_INVALID_CERTSNonfalseAccepter les certificats invalides (dev seulement)
SMTP_CA_CERT_PATHNonChemin vers un CA personnalisé (PEM)
EMAIL_BASE_URLOui (prod)http://localhost:3000URL de base pour les liens dans les e-mails
EMAIL_VALIDATION_REQUIREDNontrueExiger la validation e-mail à l’inscription
EMAIL_QUEUE_BATCH_SIZENon10Nombre d’e-mails traités par tick
EMAIL_QUEUE_RETRY_ATTEMPTSNon5Nombre de tentatives avant abandon
EMAIL_QUEUE_RETRY_DELAY_SECONDSNon300Délai entre deux tentatives (secondes)

Important : Ne jamais activer SMTP_ACCEPT_INVALID_CERTS=true en production — cela ouvre la voie à des attaques MITM sur tous les e-mails sortants.


Recettes par prestataire

Gmail (compte personnel ou Google Workspace)

Google exige un mot de passe applicatif (pas votre mot de passe Google). Générez-le sur myaccount.google.com/apppasswords en activant au préalable la validation en deux étapes.

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=votre.adresse@gmail.com
SMTP_PASSWORD=xxxx_xxxx_xxxx_xxxx   # mot de passe applicatif (16 caractères)
SMTP_FROM=votre.adresse@gmail.com
SMTP_FROM_NAME=Gitrust
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=true
EMAIL_BASE_URL=https://VOTRE_DOMAINE

Limite : Gmail impose un quota de 500 e-mails/jour pour les comptes personnels. Pour les volumes importants, utilisez un service dédié (SendGrid, Brevo, etc.).

OVH / Infomaniak / Gandi

Ces hébergeurs utilisent STARTTLS sur le port 587 ou SMTPS sur le port 465.

# OVH (SSL/TLS natif sur 465)
SMTP_HOST=ssl0.ovh.net
SMTP_PORT=465
SMTP_USER=noreply@VOTRE_DOMAINE.com
SMTP_PASSWORD=MOT_DE_PASSE_EMAIL
SMTP_FROM=noreply@VOTRE_DOMAINE.com
SMTP_FROM_NAME=Gitrust
SMTP_USE_TLS=true
SMTP_USE_STARTTLS=false
EMAIL_BASE_URL=https://VOTRE_DOMAINE
# Gandi (STARTTLS sur 587)
SMTP_HOST=mail.gandi.net
SMTP_PORT=587
SMTP_USER=noreply@VOTRE_DOMAINE.com
SMTP_PASSWORD=MOT_DE_PASSE_EMAIL
SMTP_FROM=noreply@VOTRE_DOMAINE.com
SMTP_FROM_NAME=Gitrust
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=true
EMAIL_BASE_URL=https://VOTRE_DOMAINE

Postfix local (instance interne sans authentification)

Pour les déploiements sur réseau interne où Postfix est installé sur le même serveur :

SMTP_HOST=127.0.0.1
SMTP_PORT=25
# Pas de SMTP_USER ni SMTP_PASSWORD si Postfix accepte sans auth sur loopback
SMTP_FROM=noreply@VOTRE_DOMAINE.com
SMTP_FROM_NAME=Gitrust
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=false
EMAIL_BASE_URL=https://VOTRE_DOMAINE

Vérifiez que Postfix accepte les connexions depuis 127.0.0.1 :

# Dans /etc/postfix/main.cf
mynetworks = 127.0.0.0/8

Mailpit / Mailhog (développement local)

SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_FROM=dev@localhost
SMTP_FROM_NAME=Gitrust-Dev
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=false
SMTP_ACCEPT_INVALID_CERTS=false
EMAIL_BASE_URL=http://localhost:4000
EMAIL_VALIDATION_REQUIRED=false

Démarrez Mailpit : docker run -p 1025:1025 -p 8025:8025 axllent/mailpit
Interface web : http://localhost:8025


Tester la configuration

Après avoir modifié le .env et redémarré gitrust, testez via le panel admin :

  1. Connectez-vous à /admin
  2. Accédez à /admin/email
  3. Saisissez une adresse de test et cliquez Envoyer un e-mail de test

Panel de test SMTP

En cas d’erreur, consultez les logs :

sudo journalctl -u gitrust -n 50 --no-pager | grep -i "smtp\|email\|mail"

Erreurs courantes :

Message dans les logsCauseCorrection
Connection refused sur le port SMTPSMTP_HOST ou SMTP_PORT incorrectVérifier les paramètres du prestataire
Authentication failedSMTP_USER ou SMTP_PASSWORD incorrectPour Gmail : utiliser un mot de passe applicatif, pas le mot de passe du compte
Certificate verification failedCertificat TLS du serveur SMTP invalide ou CA non reconnuSpécifier SMTP_CA_CERT_PATH ou contacter le prestataire
EMAIL_BASE_URL pointe vers localhostLiens dans les e-mails non cliquables pour les destinataires externesDéfinir EMAIL_BASE_URL=https://VOTRE_DOMAINE

Pour aller plus loin

Configurer l’authentification OAuth (Google, GitHub, Discord, Microsoft)

À qui s’adresse cette page

Administrateurs qui souhaitent permettre à leurs utilisateurs de se connecter via un fournisseur OAuth externe plutôt que (ou en plus de) le couple identifiant/mot de passe local.


Principe

OAuth est le seul sous-système hybride de gitrust : la configuration peut venir soit de la base de données (via /admin/settings), soit du fichier .env en fallback. La priorité est toujours à la base de données.

flowchart LR
    A[OAuthConfig::load au démarrage] --> B{Valeur en DB ?}
    B -->|Oui| C[Utiliser valeur DB]
    B -->|Non| D[Fallback .env]
    D --> E[Valeur codée en dur si absent]
    C --> F[OAuthConfig en mémoire]
    E --> F
    F --> G[Arc — non rechargé à chaud]

Conséquence : toute modification de la configuration OAuth (via l’UI ou via .env) nécessite un redémarrage pour être prise en compte.


Méthode recommandée : via l’interface /admin/settings

C’est la méthode la plus simple. Les secrets sont chiffrés en base (AES-256-GCM).

  1. Connectez-vous à /admin/settings
  2. Activez le toggle OAuth / SSO activé
  3. Renseignez l’URL de base des callbacks (ex. https://gitrust.votre-domaine.com)
  4. Pour chaque fournisseur souhaité, activez le toggle et renseignez Client ID et Client Secret
  5. Cliquez Enregistrer
  6. Redémarrez le service : sudo systemctl restart gitrust

Configuration OAuth dans /admin/settings


Méthode alternative : via les variables .env

Utile pour le provisionnement automatisé (Ansible, cloud-init) ou pour définir les valeurs au premier démarrage.

# Activation globale
OAUTH_ENABLED=true
OAUTH_REDIRECT_BASE_URL=https://gitrust.votre-domaine.com

# Clé de chiffrement des secrets OAuth en base (AES-256-GCM)
# Générer avec : openssl rand -hex 32
OAUTH_ENCRYPTION_KEY=REMPLACER_PAR_32_BYTES_HEX

Ces variables ne s’appliquent qu’en l’absence de valeur en base de données.


Configurer Google OAuth

1. Créer les identifiants dans Google Cloud Console

  1. Ouvrez console.cloud.google.comAPIs & ServicesCredentials

  2. Cliquez Create CredentialsOAuth 2.0 Client ID

  3. Type : Web application

  4. Ajoutez l’URI de redirection autorisée :

    https://gitrust.votre-domaine.com/api/v1/auth/oauth/google/callback
    
  5. Notez le Client ID et le Client Secret

2. Configurer dans gitrust

Via .env :

OAUTH_GOOGLE_ENABLED=true
OAUTH_GOOGLE_CLIENT_ID=123456789-xxxxxxxxxxxx.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx

Via /admin/settings : activez « Google » et renseignez les mêmes valeurs.

Scopes utilisés : openid, email, profile (read-only, pas d’accès au compte Google).


Configurer GitHub OAuth

1. Créer l’application OAuth sur GitHub

  1. Ouvrez github.com/settings/developersOAuth AppsNew OAuth App

  2. Authorization callback URL :

    https://gitrust.votre-domaine.com/api/v1/auth/oauth/github/callback
    
  3. Notez le Client ID et générez un Client Secret

2. Configurer dans gitrust

OAUTH_GITHUB_ENABLED=true
OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
OAUTH_GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Scopes utilisés : read:user, user:email.


Configurer Discord OAuth

1. Créer l’application sur Discord Developer Portal

  1. Ouvrez discord.com/developers/applicationsNew Application

  2. Onglet OAuth2Redirects → ajoutez :

    https://gitrust.votre-domaine.com/api/v1/auth/oauth/discord/callback
    
  3. Notez le Client ID et le Client Secret

2. Configurer dans gitrust

OAUTH_DISCORD_ENABLED=true
OAUTH_DISCORD_CLIENT_ID=123456789012345678
OAUTH_DISCORD_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Configurer Microsoft / Azure AD

1. Créer l’application dans Azure

  1. Ouvrez portal.azure.comAzure Active DirectoryApp registrationsNew registration

  2. Redirect URI (Web) :

    https://gitrust.votre-domaine.com/api/v1/auth/oauth/microsoft/callback
    
  3. Notez le Application (client) ID et créez un Client Secret dans Certificates & Secrets

2. Configurer dans gitrust

OAUTH_MICROSOFT_ENABLED=true
OAUTH_MICROSOFT_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
OAUTH_MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# "common" = comptes personnels et professionnels
# Remplacer par le GUID du tenant pour restreindre à votre organisation
OAUTH_MICROSOFT_TENANT=common

Comportement lors de la connexion OAuth

ScénarioComportement par défaut
Premier login OAuth, adresse e-mail inconnueCompte créé automatiquement (oauth_auto_register=true)
Premier login OAuth, adresse e-mail déjà en baseCompte lié au compte existant (oauth_link_existing_account=true)
Login OAuth, compte désactivé par l’adminConnexion refusée

Ces comportements sont configurables dans /admin/settings (paramètres oauth_auto_register et oauth_link_existing_account).


Vérification après activation

# Redémarrer le service
sudo systemctl restart gitrust

# Vérifier les logs de démarrage
sudo journalctl -u gitrust -n 30 --no-pager | grep -i oauth

Ouvrez une fenêtre de navigation privée et accédez à /login : les boutons OAuth activés doivent apparaître sous le formulaire classique.


Pour aller plus loin

Configurer le runner CI distant

Guide d’usage du script qui provisionne une machine distante pour exécuter les pipelines CI/CD de gitrust via SSH + rsync + Dagger.


1. Rôle du script

deployment/setup-remote-ci.sh (disponible dans le dépôt source gitrust) prépare un serveur de build distant utilisé par le worker CI de gitrust. Il automatise 6 étapes :

  1. Vérifie la connectivité SSH vers le runner
  2. Installe Docker (curl https://get.docker.com | sh) s’il est absent
  3. Installe Dagger CLI (curl https://dl.dagger.io/dagger/install.sh | sh) s’il est absent
  4. Crée le répertoire de travail distant (CI_REMOTE_PATH)
  5. Synchronise le module deployment/ci-engine/ (mode CI Easy) via rsync -az --delete
  6. Smoke test : affiche docker --version et dagger version

Le script est idempotent : rejoue sans dommage, skip ce qui existe déjà.


2. Quand l’utiliser

ScénarioBesoin de setup-remote-ci.sh ?
Dev local, CI sur la même machine que gitrust (CI_REMOTE_HOST=localhost)Non — Docker et Dagger déjà installés localement, le ci-engine/ est lu depuis deployment/ci-engine/ directement
Prod on-premise, un seul serveur (gitrust + CI colocalisés)Non — idem, un simple curl get.docker.com | sh suffit sur le serveur gitrust
Prod avec runner CI dédié (machine séparée)OUI — c’est le cas cible de ce script
Prod avec plusieurs instances gitrust partageant un runnerOUI — le runner est provisionné une fois, chaque instance pointe dessus
CI dans Kubernetes / Nomad / runner managé cloudNon — ce script est pensé pour une VM Debian/Ubuntu classique

3. Architecture cible

+---------------------+        SSH + rsync        +-----------------------+
|  Gitrust (web+SSH)  |---------------------------|  CI Runner (distant)  |
|  <your-server-ip>   |  CI_REMOTE_HOST/USER/KEY  |  <ci-runner-ip>       |
|                     |                           |                       |
|  Worker CI Tokio    |   push .gitrust-ci.yml    |  /opt/gitrust-ci/     |
|  (dans gitrust.bin) |   + code source checkout  |   ├── ci-engine/      |
|                     |                           |   └── workspaces/     |
|                     |   dagger call ...         |                       |
|                     |                           |  Docker + Dagger CLI  |
+---------------------+                           +-----------------------+

Le worker CI de gitrust (tâche Tokio dans le binaire principal) ne fait pas tourner Docker localement — il délègue au runner via SSH. Cela permet d’isoler les workloads CPU/RAM gourmands (builds Rust, Node, Docker) du process web de gitrust.


4. Pré-requis

Sur la machine d’exécution (là où on lance le script)

OutilRôle
bash ≥ 4Interpréteur
ssh, rsyncTransport et synchro
ssh-agent chargé ou CI_REMOTE_SSH_KEY définiAuth SSH sans mot de passe interactif (BatchMode=yes)
Fichier .env avec les variables CI_REMOTE_*Config (voir section 5)

Sur le runner distant

Pré-requisPourquoi
OS Linux récent (Debian 12+, Ubuntu 22.04+)Docker install script compatible
User avec sudo passwordless ou droits dockerget.docker.com fait sudo en interne
Clé SSH publique du user local dans ~/.ssh/authorized_keysAuth SSH non-interactive
Réseau sortant autorisé vers get.docker.com et dl.dagger.ioInstallation des binaires
Au minimum ~5 Go libres dans CI_REMOTE_PATHImages Docker + workspaces

5. Variables d’environnement attendues (le .env)

Le script source un fichier .env dont le chemin est passé en argument, ou par défaut ../.env (relatif au script, donc <racine_projet>/.env).

Tableau des variables

VariableObligatoireDéfautDescription
CI_REMOTE_HOSTOUIHostname ou IP du runner (ex: ci-runner.internal)
CI_REMOTE_USERnon$(whoami) (user courant)Compte SSH sur le runner
CI_REMOTE_SSH_PORTnon22Port SSH du runner
CI_REMOTE_PATHnon/opt/gitrust-ciRépertoire de travail distant (créé par le script)
CI_REMOTE_SSH_KEYnon— (ssh-agent)Chemin d’une clé privée SSH spécifique

Ces variables sont les mêmes que celles lues par gitrust au runtime. Centraliser dans .env évite les divergences entre le provisionnement et le runtime.

Exemple minimal de .env (runner dédié)

# --- CI runner distant ---
CI_REMOTE_HOST=<ci-runner-ip>
CI_REMOTE_USER=ci-runner
CI_REMOTE_SSH_PORT=22
CI_REMOTE_PATH=/opt/gitrust-ci
CI_REMOTE_SSH_KEY=/home/gitrust/.ssh/ci_runner_ed25519

Exemple .env complet coexistant avec config gitrust

DATABASE_URL=postgres://...
JWT_SECRET=...
# ... (voir .env.example pour le reste)

# --- CI/CD (lu par gitrust ET par setup-remote-ci.sh) ---
CI_ENABLED=true
CI_MAX_CONCURRENT=4
CI_REMOTE_HOST=ci-runner.internal
CI_REMOTE_USER=ci-runner
CI_REMOTE_PATH=/opt/gitrust-ci
CI_REMOTE_SSH_KEY=/opt/gitrust/data/ci_runner_key

Cas « runner local » (pas besoin du script)

CI_REMOTE_HOST=localhost
# Les autres CI_REMOTE_* sont ignorés

Dans ce cas, installer Docker et Dagger directement sur la machine gitrust, copier deployment/ci-engine/ vers CI_ENGINE_PATH (défaut /opt/gitrust/ci-engine) et passer à la suite. Ne pas exécuter setup-remote-ci.sh avec CI_REMOTE_HOST=localhost.


6. Usage

Depuis la racine du projet (par défaut)

cd /chemin/vers/gitrust

# .env a la racine (défaut) :
./deployment/setup-remote-ci.sh

Avec un .env explicite

./deployment/setup-remote-ci.sh /chemin/vers/mon.env
# Utile si vous avez .env.production, .env.staging, etc.
./deployment/setup-remote-ci.sh .env.production

Sortie attendue

==> Configuration :
    Serveur  : ci-runner@<ci-runner-ip>
    Port SSH : 22
    Chemin   : /opt/gitrust-ci

==> [1/6] Vérification de la connectivité SSH...
SSH OK
    OK
==> [2/6] Vérification de Docker...
    Docker déjà installé
==> [3/6] Vérification de Dagger CLI...
    Installation de Dagger CLI...
    Dagger installé
==> [4/6] Création du répertoire distant...
    /opt/gitrust-ci créé
==> [5/6] Synchronisation du ci-engine...
sending incremental file list
ci-engine/
ci-engine/profiles/
...
    ci-engine synchronisé vers /opt/gitrust-ci/ci-engine/
==> [6/6] Smoke test...
    Versions distantes :
Docker version 28.0.1, build abcd123
dagger v0.19.2 (linux/amd64)

==> Setup terminé. Le serveur ci-runner@<ci-runner-ip> est prêt pour l'exécution CI.
    Pensez à configurer CI_EXECUTION_MODE=remote dans votre .env

7. Ce qui se retrouve sur le runner

Après exécution réussie :

/opt/gitrust-ci/                    <- CI_REMOTE_PATH
└── ci-engine/                      <- synchronisé depuis deployment/ci-engine/
    ├── README.md
    └── profiles/                   <- templates par stack (Python, Node, Rust, ...)

Plus, installés globalement :

  • /usr/bin/docker (ou équivalent) + socket /var/run/docker.sock
  • /usr/local/bin/dagger

Les workspaces de pipelines (checkouts temporaires) sont créés par le worker CI de gitrust au moment de l’exécution sous CI_WORKSPACE_PATH (défaut /tmp/gitrust-ci) — pas par ce script.


8. Vérifications post-setup

Depuis la machine gitrust

# Source le .env
set -a; source .env.production; set +a

# 1. SSH direct (doit passer sans prompt)
ssh -p ${CI_REMOTE_SSH_PORT:-22} \
    ${CI_REMOTE_SSH_KEY:+-i $CI_REMOTE_SSH_KEY} \
    $CI_REMOTE_USER@$CI_REMOTE_HOST 'docker info && dagger version'

# 2. Rsync round-trip (lecture/écriture sur CI_REMOTE_PATH)
echo "test" | ssh $CI_REMOTE_USER@$CI_REMOTE_HOST \
    "cat > $CI_REMOTE_PATH/.gitrust-test && cat $CI_REMOTE_PATH/.gitrust-test && rm $CI_REMOTE_PATH/.gitrust-test"
# Attendu : "test"

Pipeline de test via gitrust

Pousser un dépôt avec un .gitrust-ci.yml minimal :

# .gitrust-ci.yml
version: 1
pipeline:
  - name: smoke
    image: alpine:3
    run: echo "CI runner OK"

Puis dans l’UI gitrust : onglet Pipelines → la pipeline doit passer au statut success.


9. Mise à jour après modification du ci-engine/

Le ci-engine/ synchronisé n’est pas « live-linké » : rejouer le script après modification côté source.

# Modifier deployment/ci-engine/profiles/*.py ou similaire
./deployment/setup-remote-ci.sh
# Les étapes 2-3 sont skip (déjà installés), seule l'étape 5 refait le rsync

rsync -az --delete supprime les fichiers côté runner qui n’existent plus localement — garantit la cohérence.


10. Dépannage

SymptômeCause probableFix
ERREUR: fichier .env introuvable.env absent ou chemin incorrectcp .env.example .env && $EDITOR .env ou passer le chemin : ./setup-remote-ci.sh /path/to/.env
ERREUR: CI_REMOTE_HOST non définiVariable commentée ou absenteDécommenter CI_REMOTE_HOST=... dans le .env
ERREUR: impossible de se connecterSSH bloqué, mauvais user, clé non autoriséeTester manuellement : ssh -v -p X user@host ; vérifier ~/.ssh/authorized_keys sur le runner
Permission denied (publickey)BatchMode=yes interdit les prompts, clé non chargéessh-add ~/.ssh/ci_runner_ed25519 ou définir CI_REMOTE_SSH_KEY=/path/to/key
sudo: a password is required pendant l’install DockerUser sans NOPASSWD sudoAjouter le user au sudoers : ci-runner ALL=(ALL) NOPASSWD:ALL (runner uniquement)
curl: (7) Failed to connect to get.docker.comRéseau sortant du runner bloquéWhitelist get.docker.com et dl.dagger.io, ou pré-installer Docker + Dagger manuellement
ATTENTION: ... ci-engine introuvableLancé hors du repo gitrustcd dans la racine du projet avant de lancer
Pipeline reste queued indéfinimentWorker CI ne trouve pas le runnerVérifier CI_EXECUTION_MODE=remote dans .env gitrust + logs : journalctl -u gitrust | grep -i 'ci|dagger'
dagger: command not found au smoke test$PATH du user SSH ne contient pas /usr/local/binecho 'export PATH=$PATH:/usr/local/bin' >> ~/.bashrc sur le runner, ou CI_DAGGER_BIN=/usr/local/bin/dagger côté gitrust

11. Sécurité

  • Le runner CI exécute du code arbitraire venant des dépôts hébergés. Ne jamais le colocaliser avec des secrets sensibles (PG prod, clés de prod).
  • Isoler réseau : bloquer l’accès sortant du runner vers le LAN privé (seul l’accès Internet pour docker pull est nécessaire).
  • Limiter sudo NOPASSWD au strict minimum sur le runner (idéalement : juste pour les commandes docker et apt).
  • Rotation régulière de la clé SSH CI_REMOTE_SSH_KEY. La révoquer dans ~/.ssh/authorized_keys côté runner en cas de suspicion.
  • Le runner ne doit pas pouvoir se connecter en SSH à la machine gitrust (unidirectionnel).

Intégrer la CI et Dependency-Track

Ce document explique comment activer et configurer les deux fonctionnalités de qualité/sécurité de gitrust :

  1. La CI intégrée (Dagger) qui exécute les builds/tests/lints à chaque push.
  2. Le dependency tracker (Syft + Dependency-Track) qui scanne les composants du code à chaque push et détecte les vulnérabilités connues.

Ces deux systèmes sont indépendants : on peut activer l’un sans l’autre.


1. Architecture générale

flowchart TB
  subgraph Client
    Dev[Développeur]
  end
  subgraph Gitrust[Instance Gitrust]
    HTTP[:4000 HTTP/Git]
    SSH[:2222 SSH/Git]
    Worker[CiWorker<br/>tokio task]
    Sbom[SbomService<br/>tokio spawn]
    DB[(PostgreSQL)]
  end
  subgraph Builder[Serveur de build]
    Docker[Docker/Podman]
    Dagger[Dagger CLI]
    CiEngine[/opt/gitrust-ci/ci-engine/]
  end
  subgraph Security[Stack sécurité optionnelle]
    Syft[syft]
    Dtrack[Dependency-Track]
  end

  Dev -->|git push| HTTP
  Dev -->|git push| SSH
  HTTP --> Worker
  SSH --> Worker
  HTTP --> Sbom
  SSH --> Sbom
  Worker -->|SSH + rsync| Builder
  Builder -->|logs streamés| Worker
  Worker --> DB
  Sbom -->|scan workspace| Syft
  Sbom -->|PUT BOM| Dtrack
  Dtrack -->|findings| Sbom
  Sbom --> DB

Trois composants fonctionnent en parallèle :

  • CiWorker : tâche Tokio qui consomme des CiTask depuis un channel mpsc, limitée par un Semaphore à CI_MAX_CONCURRENT pipelines simultanés.
  • SbomService : exécuté dans un tokio::spawn après chaque push, totalement découplé du CI.
  • Serveur de build : machine distante (ou localhost) qui exécute Docker + Dagger. Gitrust y envoie le workspace par rsync et lance Dagger par SSH.

2. Mettre en place la CI

2.1 Pré-requis serveur de build

Le serveur de build peut être la même machine que Gitrust (CI_REMOTE_HOST=localhost) ou une machine dédiée. Il doit disposer de :

OutilRôle
Docker ou PodmanContainers Dagger
Dagger CLIMoteur d’exécution
SSH (accès sortant depuis Gitrust)Transport
rsyncCopie du workspace
git, tarExtraction du tree du commit

Installation automatique recommandée :

# Depuis la machine Gitrust (l'utilisateur doit pouvoir SSH vers le builder)
./deployment/setup-remote-ci.sh .env

Le script exécute :

  1. Vérification de la connectivité SSH
  2. Installation de Docker si absent
  3. Installation de Dagger CLI si absent
  4. Création du répertoire de travail distant
  5. Synchronisation du ci-engine
  6. Smoke test (versions)

2.2 Configuration Gitrust (fichier .env)

Ajouter au .env de Gitrust :

# Activer globalement le CI
CI_ENABLED=true

# Chemin du ci-engine sur le serveur de build
CI_ENGINE_PATH=/opt/gitrust-ci/ci-engine

# Limite de parallélisme
CI_MAX_CONCURRENT=4
CI_DEFAULT_TIMEOUT=3600
CI_WORKSPACE_PATH=/tmp/gitrust-ci

# Serveur de build (mettre localhost pour "même machine")
CI_REMOTE_HOST=builder.example.com
CI_REMOTE_USER=ci-runner
CI_REMOTE_PATH=/opt/gitrust-ci
CI_REMOTE_SSH_PORT=22
# CI_REMOTE_SSH_KEY=/home/gitrust/.ssh/id_ed25519   # si pas ssh-agent

Redémarrer Gitrust : systemctl restart gitrust (ou cargo run --release).

2.3 Activer la CI sur un dépôt

Dans l’interface web : aller sur le dépôt → SettingsCI.

  • Cocher CI enabled (requis — par défaut false)
  • Cocher Trigger on push (active l’exécution à chaque push)
  • Optionnel : Auto-cancel (annule les pipelines en cours quand un nouveau démarre)
  • Optionnel : Allowed branches (ex. main,develop — vide = toutes)

2.4 Choisir le mode : Easy ou Power

flowchart LR
  Push[Commit<br/>poussé] --> Tree{Arbre du<br/>commit contient ?}
  Tree -->|.dagger/| Power[Mode Power<br/>dagger call -m .dagger/ ci]
  Tree -->|.gitrust-ci.yml| Easy[Mode Easy<br/>dagger call -m ci-engine test-pr]
  Tree -->|rien| None[Pas de pipeline]
  Power --> Run[Exécution<br/>distante SSH]
  Easy --> Run

Mode Easy (recommandé pour commencer)

Créer .gitrust-ci.yml à la racine du dépôt :

# Raccourci : charge un profil pré-configuré (rust | python | node)
language: rust

build:
  command: "cargo build --release"

checks:
  lint: "cargo clippy -- -D warnings"
  format: "cargo fmt -- --check"

tests:
  command: "cargo test --release"

Les profils disponibles se trouvent dans deployment/ci-engine/profiles/ (rust.yaml, python.yaml, node.yaml) dans le dépôt source.

Mode Power

Pour les utilisateurs avancés, créer un module Dagger complet dans .dagger/ :

.dagger/
├── dagger.json
├── src/
│   └── main.py         # ou Go/TypeScript

La fonction ci du module est appelée directement :

dagger call -m .dagger/ ci

Avantage : accès au Daggerverse, composition, tests de pipeline. Voir la documentation Dagger.

2.5 Suivi d’un pipeline

Après un git push, une entrée apparaît dans l’onglet CI du dépôt :

stateDiagram-v2
  [*] --> Pending: create_pipeline
  Pending --> Running: worker picks CiTask
  Running --> Success: exit 0
  Running --> Failure: exit != 0
  Running --> Cancelled: timeout / auto_cancel / manuel
  Success --> [*]
  Failure --> [*]
  Cancelled --> [*]

Les logs stdout/stderr sont streamés ligne par ligne dans la table ci_logs et visibles en direct dans l’UI. En cas d’échec, une notification est envoyée au propriétaire du dépôt.


3. Mettre en place le Dependency Tracker

Cette partie est totalement indépendante de la CI. Elle scanne le code poussé et produit un SBOM CycloneDX, puis (optionnellement) l’envoie à Dependency-Track pour une analyse de vulnérabilités.

3.1 Installer Syft

Sur la machine Gitrust (le scan se fait localement, pas sur le builder) :

# Installation officielle
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
  | sh -s -- -b /usr/local/bin

# Vérification
syft --version

Dans .env :

CI_SBOM_ENABLED=true
CI_SYFT_BIN=/usr/local/bin/syft   # optionnel, défaut: syft dans PATH

À ce stade : à chaque push, un SBOM CycloneDX est généré et stocké (sans upload externe). Visible dans l’onglet Security du dépôt : nombre de composants, sha256 du BOM.

3.2 Déployer Dependency-Track

Dependency-Track est une application Java qui stocke les SBOM et corrèle avec les bases CVE/OSV/NVD. Déploiement Docker recommandé :

mkdir -p /opt/dtrack && cd /opt/dtrack
curl -L -o docker-compose.yml \
  https://dependencytrack.org/docker-compose.yml
docker compose up -d

L’API est disponible sur http://localhost:8081 et l’UI sur http://localhost:8080. Login initial : admin / admin (à changer immédiatement).

3.3 Créer une clé API

Dans Dependency-Track → AdministrationAccess ManagementTeams :

  1. Créer (ou réutiliser) une équipe gitrust.
  2. Lui attribuer les permissions :
    • BOM_UPLOAD
    • PROJECT_CREATION_UPLOAD
    • VIEW_PORTFOLIO
    • VIEW_VULNERABILITY
  3. Générer une API key et la copier.

3.4 Configurer Gitrust

Ajouter au .env :

CI_DTRACK_ENABLED=true
CI_DTRACK_URL=http://localhost:8081
CI_DTRACK_API_KEY=odt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Redémarrer Gitrust. À chaque push :

sequenceDiagram
  participant Push as post-receive
  participant Sbom as SbomService
  participant Git as git archive
  participant Syft
  participant Dtrack as Dependency-Track
  participant DB as sbom_reports

  Push->>Sbom: process_push(sha, ref)
  Sbom->>DB: upsert status=pending
  Sbom->>Git: archive sha | tar -x tmpdir
  Sbom->>Syft: scan dir:tmpdir -o cyclonedx-json
  Syft-->>Sbom: BOM bytes
  Sbom->>DB: update sha256
  Sbom->>Dtrack: PUT /api/v1/bom (base64)
  Dtrack-->>Sbom: token
  loop max 30s
    Sbom->>Dtrack: GET /api/v1/bom/token/{token}
    Dtrack-->>Sbom: processing ?
  end
  Sbom->>Dtrack: GET /api/v1/finding/project/{uuid}
  Dtrack-->>Sbom: findings[]
  Sbom->>DB: update status=success<br/>+ critical/high/medium/low

3.5 Lire les résultats

Onglet Security du dépôt → encart SBOM :

  • Nombre de composants détectés
  • Compteurs par sévérité : Critical / High / Medium / Low
  • Lien direct vers le projet dans Dependency-Track (si l’UUID est résolu)
  • Hash sha256 du BOM (traçabilité)

Si Dependency-Track met plus de 30s à analyser, le status reste processing — un sweeper ultérieur ira rechercher les findings.


4. Débogage

Problèmes CI courants

SymptômeCause probableVérifier
Pipeline reste pendingWorker non démarré, ou channel saturéLogs CI worker started, CI_MAX_CONCURRENT
Échec rsync vers le serveur de buildSSH bloqué, clé absentessh -p ${CI_REMOTE_SSH_PORT} ${CI_REMOTE_USER}@${CI_REMOTE_HOST} manuel
dagger: command not foundDagger non installé sur le builderRelancer setup-remote-ci.sh
Status Cancelled inattenduTimeout (CI_DEFAULT_TIMEOUT) ou auto-cancelAugmenter le timeout, vérifier pipelines concurrents

Problèmes SBOM courants

SymptômeCause probableVérifier
SBOM generation disabledCI_SBOM_ENABLED=false.env
syft spawn failedBinaire introuvablewhich syft, CI_SYFT_BIN
invalid commit shaSHA non hex 40 charsRare, signale un bug
Dtrack upload 401API key invalide ou permissions manquantesRégénérer, vérifier les 4 permissions
Status processing indéfinimentDtrack surchargé, analyse lenteAttendre, ou relancer un push

Logs utiles

# Logs Gitrust (systemd)
journalctl -u gitrust -f | grep -E "CI|SBOM|sbom|pipeline"

# Vérifier un pipeline en DB
psql $DATABASE_URL -c "SELECT id, status, commit_sha, created_at FROM ci_pipelines ORDER BY created_at DESC LIMIT 10;"

# Vérifier un SBOM
psql $DATABASE_URL -c "SELECT commit_sha, status, components_count, critical_count, high_count FROM sbom_reports ORDER BY created_at DESC LIMIT 10;"

5. Checklist récapitulative

CI :

  • Docker + Dagger installés sur le serveur de build (setup-remote-ci.sh)
  • CI_ENABLED=true et CI_REMOTE_* dans .env
  • Gitrust redémarré
  • CI activée dans Settings → CI pour chaque dépôt
  • Fichier .gitrust-ci.yml (Easy) ou .dagger/ (Power) commité
  • Push → pipeline visible dans l’onglet CI

Dependency Tracker :

  • Syft installé sur la machine Gitrust
  • CI_SBOM_ENABLED=true dans .env
  • (Optionnel) Dependency-Track déployé + API key créée avec les 4 permissions
  • (Optionnel) CI_DTRACK_ENABLED=true, CI_DTRACK_URL, CI_DTRACK_API_KEY dans .env
  • Gitrust redémarré
  • Push → SBOM visible dans l’onglet Security

Configurer ssh-guard (durcissement SSH)

À qui s’adresse cette page

Administrateurs qui déploient ou ajustent la couche ssh-guard de gitrust selon leur topologie réseau (Internet direct, derrière nginx stream, derrière HAProxy, ou réseau privé).

Avant de commencer : si vous voulez d’abord comprendre ce que fait ssh-guard et ses compromis, lisez ssh-guard : détection d’attaques SSH. La référence des variables est dans Variables d’environnement — SSH_GUARD_*.


1. Choisir le profil de déploiement

Le profil pré-configure les défauts cohérents avec votre topologie. Une variable unique pilote tout le reste : SSH_GUARD_PROFILE.

ProfilTopologieQuand l’utiliser
directgitrust écoute en :22 ou :2222 exposé Internet, sans proxy devantVPS minimal, démo publique
nginxnginx stream sur :22 avec proxy_protocol on; → gitrust sur 127.0.0.1:2222Production avec reverse-proxy nginx (cas le plus courant)
haproxyHAProxy en frontal SSH avec send-proxy ou send-proxy-v2Topologies multi-services
privateInstance interne (VPN, réseau d’entreprise), pas d’attaque externe attendueIntranet, lab
customAucun preset, vous fixez chaque variable individuellementCas exotiques

Le défaut au démarrage si SSH_GUARD_PROFILE n’est pas défini est custom, qui équivaut au profil direct côté détecteurs mais avec PROXY protocol désactivé.


2. Recette : profil direct (Internet direct)

Cas typique : VPS, pas de reverse-proxy SSH devant gitrust. Le port :2222 est ouvert sur Internet et peer_addr() est l’IP réelle du client.

2.1 .env minimal

Copiez template/env/ssh-guard-direct.env dans votre /opt/gitrust/.env ou ajoutez :

SSH_GUARD_ENABLED=true
SSH_GUARD_DRY_RUN=false
SSH_GUARD_PROFILE=direct

# Stockage hybride (RAM chaude + write-through PostgreSQL)
SSH_GUARD_STORE_BACKEND=hybrid

# Logs JSON dans journald + fichier dédié pour fail2ban
SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Les seuils par défaut s’appliquent : brute-force 5/5min, énumération 10/5min, scan de clés 10/5min, flood 10/s burst 20, ban 1 h.

2.2 Redémarrage et vérification

sudo systemctl restart gitrust

# Le module annonce sa configuration au démarrage
sudo journalctl -u gitrust --since "1 min ago" | grep "SSH guard runtime assembly"
# Attendu : profile=direct enabled=true dry_run=false store=Hybrid

# Logrotate sur le fichier dédié
sudo tee /etc/logrotate.d/gitrust-ssh-guard <<'EOF'
/var/log/gitrust-ssh-guard.json {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    copytruncate
}
EOF

3. Recette : profil nginx (derrière nginx stream)

Cas typique : nginx termine TLS sur :443, fait du SSH stream sur :22127.0.0.1:2222 avec PROXY protocol v2.

3.1 nginx (rappel — voir aussi Durcir avec Fail2ban)

# /etc/nginx/nginx.conf (bloc stream)
stream {
    upstream gitrust_ssh {
        server 127.0.0.1:2222;
    }

    server {
        listen 22;
        proxy_pass         gitrust_ssh;
        proxy_protocol     on;       # IMPORTANT — sinon ssh-guard rejettera
        proxy_timeout      30m;      # git push de gros pack peut être long
    }
}

3.2 .env côté gitrust

Copiez template/env/ssh-guard-nginx.env ou :

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=nginx

# Le profil nginx pose déjà :
#   SSH_GUARD_PROXY_PROTOCOL=v2
#   SSH_GUARD_PROXY_PROTOCOL_STRICT=true
#   SSH_GUARD_TRUSTED_PROXIES=127.0.0.1/32,::1/128
# (override individuel possible)

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Côté binding gitrust :

SSH_LISTEN_ADDR=127.0.0.1
SSH_PORT=2222

3.3 Vérification que la vraie IP est bien extraite

Faites un git ls-remote ssh://git@VOTRE_FQDN/owner/repo.git depuis une IP externe connue, puis :

sudo journalctl -u gitrust --since "1 min ago" | grep '"event":"connection_accepted"' | tail -1
# Attendu : "ip":"<VOTRE IP EXTERNE>"   et NON "ip":"127.0.0.1"

Si vous voyez 127.0.0.1, c’est que le PROXY protocol n’est pas correctement transmis : vérifiez proxy_protocol on; dans nginx.


4. Recette : profil haproxy

Le profil pose SSH_GUARD_PROXY_PROTOCOL=any (auto-détection v1 ou v2) avec strict=true, mais n’a pas de trusted_proxies par défaut (HAProxy est presque toujours sur une IP distincte). À fournir explicitement :

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=haproxy
SSH_GUARD_TRUSTED_PROXIES=10.0.0.5/32   # IP de l'haproxy

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=both
SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json

Si SSH_GUARD_TRUSTED_PROXIES est vide, gitrust refuse de démarrer avec l’erreur :

SSH_GUARD_TRUSTED_PROXIES required when SSH_GUARD_PROXY_PROTOCOL is enabled

C’est volontaire : sans cette protection, n’importe qui pourrait forger un en-tête PROXY pour usurper une IP cliente.


5. Recette : profil private (réseau interne)

Détecteurs désactivés (seuils u32::MAX), événements toujours émis pour audit, store en mémoire (rapide, perdu au restart).

SSH_GUARD_ENABLED=true
SSH_GUARD_PROFILE=private

# Tous les détecteurs sont désactivés par le preset.
# Les événements connection_accepted / auth_failed / auth_succeeded
# restent émis pour audit.

SSH_GUARD_LOG_FORMAT=json
SSH_GUARD_LOG_TARGET=stderr   # journald uniquement, pas de fichier

6. Mode dry-run : observer avant de bloquer

Idéal pour valider un nouveau seuil ou laisser fail2ban faire le ban réel :

SSH_GUARD_DRY_RUN=true

Effets :

  • Les détecteurs continuent à corréler.
  • Un événement ip_banned est émis pour chaque déclenchement (signal pour fail2ban / Loki).
  • Aucun ban n’est persisté côté ssh-guard. Le BanManager reste no-op.

C’est le mode recommandé pendant les premiers jours après un changement de seuil agressif. Surveillez :

sudo journalctl -u gitrust --since "24 hours ago" --no-pager \
  | grep '"event":"ip_banned"' \
  | jq -r '.ip + " " + .reason' \
  | sort | uniq -c | sort -rn

7. Ajuster les seuils

7.1 Variables disponibles

# Brute-force : nombre d'échecs auth depuis une IP dans la fenêtre
SSH_GUARD_BRUTE_FORCE_THRESHOLD=5         # défaut 5
SSH_GUARD_BRUTE_FORCE_WINDOW_SECS=300     # défaut 300 (5 min)

# Énumération : nombre d'usernames distincts essayés depuis une IP
SSH_GUARD_USER_ENUM_THRESHOLD=10
SSH_GUARD_USER_ENUM_WINDOW_SECS=300

# Scan de clés : nombre de fingerprints distincts essayés depuis une IP
SSH_GUARD_KEY_SCAN_THRESHOLD=10
SSH_GUARD_KEY_SCAN_WINDOW_SECS=300

# Flood TCP : cap dur de connexions par seconde par IP
SSH_GUARD_CONN_FLOOD_PER_SEC=10
SSH_GUARD_CONN_FLOOD_BURST=20

# Durée d'un ban auto. 0 = permanent.
SSH_GUARD_AUTO_BAN_DURATION_SECS=3600     # défaut 3600 (1 h)

7.2 Profils suggérés

PostureBrute-forceConn floodTTL ban
Stricte (instance publique très exposée)3 / 5 min5/s burst 106 h
Standard (défaut)5 / 5 min10/s burst 201 h
Tolérante (équipe interne, partenaires CI)10 / 5 min20/s burst 5030 min

Désactiver complètement un détecteur : mettre son seuil à 4294967295 (u32::MAX). Le préfixe private le fait déjà pour les quatre.


8. Allowlist d’un partenaire CI

Une IP allowlistée bypasse les détecteurs et le flood-limit, mais reste auditée. Les ACL se gèrent via la table ssh_guard_acl (admin UI ou SQL direct).

-- Allow le runner CI de l'équipe build (CIDR /32)
INSERT INTO ssh_guard_acl (id, kind, ip_cidr, reason, created_by, created_at, updated_at)
VALUES (
  gen_random_uuid(),
  'allow',
  '198.51.100.42/32',
  'Runner CI équipe build (ticket OPS-1234)',
  '<UUID admin>',
  NOW(), NOW()
);

Vérifier : depuis cette IP, déclencher 50 tentatives ratées en 1 minute, puis vérifier qu’aucun ip_banned n’a été émis :

sudo journalctl -u gitrust --since "5 min ago" --no-pager \
  | grep '"ip":"198.51.100.42"' \
  | jq -r '.event' | sort | uniq -c
# Attendu : auth_failed: 50, brute_force_detected: 0, ip_banned: 0

9. Bannir manuellement un CIDR abusif

Pour un AS qui scanne en masse, posez un ban permanent :

-- Denylist permanente d'un CIDR
INSERT INTO ssh_guard_bans (id, ip_cidr, reason, banned_at, expires_at, auto_banned)
VALUES (
  gen_random_uuid(),
  '198.51.100.0/24',
  'admin_deny_list',
  NOW(),
  NULL,         -- NULL = permanent
  false
);

expires_at = NULL rend le ban permanent. Pour un TTL fini : NOW() + INTERVAL '7 days'.


10. Troubleshooting

10.1 Tous les ip valent 127.0.0.1

Vous êtes en profil direct mais nginx fait du stream devant gitrust. Passez en nginx et activez proxy_protocol on; dans nginx.

10.2 « SSH_GUARD_TRUSTED_PROXIES required »

Vous avez activé le profil haproxy (ou SSH_GUARD_PROXY_PROTOCOL=v1|v2|any) sans déclarer de proxies de confiance. Ajoutez SSH_GUARD_TRUSTED_PROXIES=<CIDR> et redémarrez.

10.3 Faux positifs sur un développeur avec 8 clés SSH

L’agent SSH du dev présente toutes ses clés successivement. Si la 9e est la bonne, le détecteur de scan de clés pourrait s’activer (seuil 10 par défaut). Solutions, par ordre de préférence :

  1. Côté dev : forcer une clé spécifique avec IdentitiesOnly yes dans ~/.ssh/config.
  2. Côté admin : monter le seuil à 15 (SSH_GUARD_KEY_SCAN_THRESHOLD=15).

10.4 Lever un ban auto sans attendre l’expiration

UPDATE ssh_guard_bans
SET unbanned_at = NOW(), unbanned_by = '<UUID admin>'
WHERE id = '<UUID du ban>';

ssh-guard émet un ip_unbanned au prochain cycle d’effective_status.

10.5 Désactiver temporairement ssh-guard

# .env
SSH_GUARD_ENABLED=false
sudo systemctl restart gitrust

ssh-guard devient un pass-through complet : aucune détection, aucun ban, le listener délègue directement à russh. À éviter sauf urgence.


11. Pour aller plus loin

Durcir l’instance avec Fail2ban

Évolution importante : depuis l’introduction de la crate gitrust-ssh-guard, le jail [gitrust-ssh] ne lit plus les logs russh via journalctl mais consomme directement le flux JSON stable émis par ssh-guard dans /var/log/gitrust-ssh-guard.json. Cela rend le jail bien plus fiable (format garanti stable, pas de regex fragile sur les messages de russh). Activez SSH_GUARD_LOG_TARGET=both dans .env — voir Configurer ssh-guard.

Configuration complète pour protéger un déploiement gitrust exposant :

  • sshd système sur :2022 (admin)
  • Nginx en reverse proxy HTTPS :443 + redirect :80 + stream :22 → :2222
  • Gitrust (web 127.0.0.1:4000, SSH russh 127.0.0.1:2222)
  • Dagger CI runner (local, pas d’exposition réseau directe)
  • Dependency-Track API :8081 + UI :8080 (si exposés)
  • PostgreSQL 127.0.0.1:5432 (défensif au cas où le bind fuit)

1. Installation

sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban
fail2ban-client --version

Vérifier que ufw est actif (fournit banaction = ufw dans la config) :

sudo ufw status

2. Fichier principal /etc/fail2ban/jail.local

À placer via :

sudo tee /etc/fail2ban/jail.local <<'EOF'
[Le contenu ci-dessous]
EOF
# =============================================================================
# Défauts globaux
# =============================================================================
[DEFAULT]
backend  = systemd
bantime  = 1h
findtime = 10m
maxretry = 5

# LAN privé et loopback jamais bannis
ignoreip = 127.0.0.1/8 ::1 192.168.1.0/24

# UFW
banaction          = ufw
banaction_allports = ufw

# Notifications (optionnel — nécessite un relay SMTP local)
# destemail = contact@gitrust.eu
# sender    = fail2ban@votre-serveur.example.com
# action    = %(action_mwl)s

# =============================================================================
# 1) sshd admin système — port 2022
# =============================================================================
[sshd]
enabled  = true
port     = 2022
filter   = sshd
backend  = %(sshd_backend)s
logpath  = %(sshd_log)s
maxretry = 3
findtime = 10m
bantime  = 1h

# =============================================================================
# 2) Nginx — 401/403 auth basic (si un jour activé sur /admin, /metrics, etc.)
# =============================================================================
[nginx-http-auth]
enabled  = true
port     = http,https
filter   = nginx-http-auth
logpath  = /var/log/nginx/gitrust.error.log
maxretry = 5

# =============================================================================
# 3) Nginx — scanners d'URL (wp-admin, .env, phpmyadmin, config.php, etc.)
# =============================================================================
[nginx-botsearch]
enabled  = true
port     = http,https
filter   = nginx-botsearch
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 2
findtime = 10m
bantime  = 24h

# =============================================================================
# 4) Nginx — bad user-agents (scanners agressifs, masscan, nikto, etc.)
# =============================================================================
[nginx-badbots]
enabled  = true
port     = http,https
filter   = nginx-badbots
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 2
bantime  = 24h

# =============================================================================
# 5) Nginx — dépassement limit_req (voir section 4 — zones HTTPS)
# =============================================================================
[nginx-limit-req]
enabled  = true
port     = http,https
filter   = nginx-limit-req
logpath  = /var/log/nginx/gitrust.error.log
maxretry = 10
findtime = 5m
bantime  = 1h

# =============================================================================
# 6) Gitrust — brute force login (formulaire + API JWT)
# =============================================================================
[gitrust-login]
enabled  = true
port     = http,https
filter   = gitrust-login
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 5
findtime = 10m
bantime  = 1h

# =============================================================================
# 7) Gitrust — abus API (tokens personnels leaked, scrapers)
# =============================================================================
[gitrust-api-abuse]
enabled  = true
port     = http,https
filter   = gitrust-api-abuse
logpath  = /var/log/nginx/gitrust.access.log
maxretry = 30
findtime = 1m
bantime  = 2h

# =============================================================================
# 8) Gitrust SSH — consomme les événements JSON stables de ssh-guard
# =============================================================================
# IMPORTANT : ce jail consomme le flux JSON émis par la crate gitrust-ssh-guard
# (champs stables documentés dans ../reference/ssh-guard-evenements.md).
# Le ban est déclenché soit sur le "signal fort" ip_banned (ssh-guard a déjà
# détecté la brute-force, fail2ban relaye au firewall) — un seul ip_banned
# suffit (maxretry=1) — soit sur le brut auth_failed avec le seuil habituel.
#
# Prérequis dans /opt/gitrust/.env :
#   SSH_GUARD_LOG_TARGET=both
#   SSH_GUARD_LOG_FILE=/var/log/gitrust-ssh-guard.json
[gitrust-ssh]
enabled  = true
port     = 22,2222
filter   = gitrust-ssh
logpath  = /var/log/gitrust-ssh-guard.json
maxretry = 1            # ssh-guard a déjà corrélé : 1 ip_banned = 1 ban firewall
findtime = 10m
bantime  = 1h

# =============================================================================
# 9) Gitrust import worker — tokens invalides sur clone de dépôts externes
# =============================================================================
[gitrust-import]
enabled      = true
port         = http,https
filter       = gitrust-import
backend      = systemd
journalmatch = _SYSTEMD_UNIT=gitrust.service
maxretry     = 3
findtime     = 5m
bantime      = 30m

# =============================================================================
# 10) Dependency-Track UI — brute force login (port 8080)
# =============================================================================
# Activer UNIQUEMENT si DTrack est exposé publiquement.
# Si DTrack est sur réseau privé/loopback, laisser enabled=false.
[dtrack-login]
enabled  = false
port     = 8080,http,https
filter   = dtrack-login
logpath  = /var/log/nginx/dtrack.access.log
maxretry = 5
findtime = 10m
bantime  = 2h

# =============================================================================
# 11) Dependency-Track API — abus clé API (port 8081)
# =============================================================================
[dtrack-api]
enabled  = false
port     = 8081,http,https
filter   = dtrack-api
logpath  = /var/log/nginx/dtrack.access.log
maxretry = 10
findtime = 5m
bantime  = 1h

# =============================================================================
# 12) PostgreSQL — tentatives de connexion invalides (défensif)
# =============================================================================
# PG doit binder 127.0.0.1 uniquement. Ce jail protège si la config fuit.
[postgresql]
enabled      = true
port         = 5432
filter       = postgresql
backend      = systemd
journalmatch = _SYSTEMD_UNIT=postgresql.service + _SYSTEMD_UNIT=docker.service
maxretry     = 5
findtime     = 10m
bantime      = 1h

# =============================================================================
# 13) Récidive — ban long pour IPs bannies 3x en 24h, tous jails confondus
# =============================================================================
[recidive]
enabled   = true
logpath   = /var/log/fail2ban.log
banaction = %(banaction_allports)s
bantime   = 1w
findtime  = 1d
maxretry  = 3

3. Filtres personnalisés

À placer dans /etc/fail2ban/filter.d/ (un fichier par jail custom).

3.1 gitrust-login.conf

sudo tee /etc/fail2ban/filter.d/gitrust-login.conf <<'EOF'
[Definition]
# POST sur /login ou endpoints JWT avec code 4xx (401/403/422/429)
failregex = ^<HOST> .* "POST /(login|api/v1/auth/login|api/v1/auth/refresh|api/v1/auth/2fa)[^"]*" (401|403|422|429) .*$
ignoreregex =
EOF

3.2 gitrust-api-abuse.conf

sudo tee /etc/fail2ban/filter.d/gitrust-api-abuse.conf <<'EOF'
[Definition]
# Abus API : 401/403 répétés sur /api/v1/* (hors auth déjà couvert par gitrust-login)
failregex = ^<HOST> .* "(GET|POST|PUT|DELETE|PATCH) /api/v1/(?!auth/)[^"]*" (401|403) .*$
ignoreregex =
EOF

3.3 gitrust-ssh.conf (consomme le JSON ssh-guard)

Le filtre matche les événements JSON stables produits par gitrust-ssh-guard. Voir Événements ssh-guard (JSON) pour le schéma complet.

Le « signal fort » ip_banned est privilégié : ssh-guard a déjà corrélé brute-force / énumération / scan de clés, et fail2ban n’a plus qu’à appliquer le ban au niveau firewall (UFW/iptables) pour les autres ports si désiré.

sudo tee /etc/fail2ban/filter.d/gitrust-ssh.conf <<'EOF'
[Definition]
# Filtre les événements stables émis par gitrust-ssh-guard dans
# /var/log/gitrust-ssh-guard.json. Format : une ligne JSON par événement.
#
# Capture <HOST> depuis le champ "ip" du JSON pour les variants pertinents.
#
# Tester :
#   sudo fail2ban-regex /var/log/gitrust-ssh-guard.json \
#                       /etc/fail2ban/filter.d/gitrust-ssh.conf

failregex = ^.*"event":"ip_banned".*"ip":"<HOST>".*$
            ^.*"event":"brute_force_detected".*"ip":"<HOST>".*$
            ^.*"event":"user_enumeration_detected".*"ip":"<HOST>".*$
            ^.*"event":"key_scanning_detected".*"ip":"<HOST>".*$
            ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"untrusted_proxy".*$
            ^.*"event":"connection_dropped".*"ip":"<HOST>".*"reason":"proxy_header_invalid".*$

ignoreregex =

# Date au format ISO 8601 UTC produit par ssh-guard
datepattern = "ts":"%%Y-%%m-%%dT%%H:%%M:%%S
EOF

Variante « brute uniquement » — si vous préférez que fail2ban corrèle lui-même à partir des auth_failed sans dépendre du verdict ssh-guard, remplacez le bloc ci-dessus par failregex = ^.*"event":"auth_failed".*"ip":"<HOST>".*$ et passez maxretry = 5 dans le jail. Les deux approches sont valides ; la première est plus rapide à réagir, la seconde est plus indépendante.

3.4 gitrust-import.conf

sudo tee /etc/fail2ban/filter.d/gitrust-import.conf <<'EOF'
[Definition]
# Import worker : tokens OAuth/GitHub invalides sur clone de dépôts externes
failregex = ^.*import.*authentication failed.*from <HOST>.*$
            ^.*import.*invalid (token|credentials).*from <HOST>.*$
            ^.*import.*clone failed.*401.*from <HOST>.*$
ignoreregex =
EOF

3.5 dtrack-login.conf (si DTrack exposé)

sudo tee /etc/fail2ban/filter.d/dtrack-login.conf <<'EOF'
[Definition]
# Dependency-Track : brute force du POST /api/v1/user/login
failregex = ^<HOST> .* "POST /api/v1/user/login[^"]*" (401|403) .*$
            ^<HOST> .* "POST /api/v1/user/forceChangePassword[^"]*" (401|403) .*$
ignoreregex =
EOF

3.6 dtrack-api.conf (si DTrack exposé)

sudo tee /etc/fail2ban/filter.d/dtrack-api.conf <<'EOF'
[Definition]
# Dependency-Track API : abus de clé API ou requêtes non authentifiées
failregex = ^<HOST> .* "[^"]+ /api/v1/[^"]*" 401 .*$
            ^<HOST> .* "[^"]+ /api/v1/[^"]*" 403 .*$
ignoreregex =
EOF

3.7 postgresql.conf (créer si absent)

if [ ! -f /etc/fail2ban/filter.d/postgresql.conf ]; then
sudo tee /etc/fail2ban/filter.d/postgresql.conf <<'EOF'
[Definition]
failregex = ^.*authentication failed for user.*host=<HOST>.*$
            ^.*FATAL:.*password authentication failed.*<HOST>.*$
            ^.*no pg_hba\.conf entry for host "<HOST>".*$
ignoreregex =
EOF
fi

3.8 nginx-limit-req.conf (créer si absent sur votre distro)

if [ ! -f /etc/fail2ban/filter.d/nginx-limit-req.conf ]; then
sudo tee /etc/fail2ban/filter.d/nginx-limit-req.conf <<'EOF'
[Definition]
failregex = ^.*limiting requests, excess: .* by zone .*, client: <HOST>.*$
ignoreregex =
EOF
fi

3.9 nginx-badbots.conf — OBLIGATOIRE (pas fourni par Debian/Ubuntu)

Le paquet fail2ban Debian/Ubuntu fournit apache-badbots.conf mais PAS nginx-badbots.conf. Sans ce filter, fail2ban-client reload affiche :

Found no accessible config files for 'filter.d/nginx-badbots' under /etc/fail2ban

Le filter ci-dessous réutilise la liste de bad user-agents de apache-badbots avec un failregex adapté au format combined de nginx :

sudo tee /etc/fail2ban/filter.d/nginx-badbots.conf <<'EOF'
[Definition]
badbotscustom = EmailCollector|WebEMailExtrac|TrackBack/1\.02|sogou music spider
badbots = Atomic_Email_Hunter/4\.0|atSpider/1\.0|autoemailspider|bwh3_user_agent|China Local Browse 2\.6|ContactBot/0\.2|ContentSmartz|DataCha0s/2\.0|DBrowse 1\.4b|DBrowse 1\.4d|Demo Bot DOT 16b|Demo Bot Z 16b|DSurf15a 01|DSurf15a 71|DSurf15a 81|DSurf15a VA|EBrowse 1\.4b|Educate Search VxB|EmailSiphon|EmailSpider|EmailWolf 1\.00|ExtractorPro|Franklin Locator 1\.8|Full Web Bot 0416B|Guestbook Auto Submitter|ISC Systems iRc Search 2\.1|LMQueueBot/0\.2|LWP\:\:Simple/5\.803|Microsoft URL Control - 6\.00\.8xxx|Missigua Locator 1\.9|Mozilla/4\.0 efp@gmx\.net|Nsauditor/1\.x|PBrowse 1\.4b|PEval 1\.4b|Poirot|psycheclone|sogou spider|sohu agent|VadixBot|WebVulnCrawl\.unknown/1\.0 libwww-perl/5\.803|Wells Search II|WEP Search 00

failregex = ^<HOST> -.*"(GET|POST|HEAD).*HTTP.*"(?:%(badbots)s|%(badbotscustom)s)"$
ignoreregex =

datepattern = ^[^\[]*\[({DATE})
              {^LN-BEG}
EOF

Alternative : si vous ne voulez pas maintenir la liste, simplement désactiver le jail (enabled = false dans le [nginx-badbots]) — la combinaison nginx-botsearch + nginx-limit-req + gitrust-api-abuse couvre déjà l’essentiel.


4. Rate limiting côté Nginx (requis par nginx-limit-req)

4.1 Déclarer les zones globales

sudo tee /etc/nginx/conf.d/gitrust-limit.conf <<'EOF'
# Zones de rate limiting gitrust
limit_req_zone $binary_remote_addr zone=gitrust_login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=gitrust_api:10m   rate=10r/s;
limit_req_zone $binary_remote_addr zone=gitrust_git:10m   rate=30r/s;
EOF

4.2 Ajouter les location dans /etc/nginx/sites-available/gitrust

Dans le bloc server { listen 443 ssl; ... }, avant location / finale :

# Protection brute force login (1 req/s par IP, burst 5)
location ~ ^/(login|api/v1/auth/) {
    limit_req zone=gitrust_login burst=5 nodelay;
    proxy_pass http://gitrust_backend;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# Protection API (10 req/s par IP, burst 20)
location ^~ /api/v1/ {
    limit_req zone=gitrust_api burst=20 nodelay;
    proxy_pass http://gitrust_backend;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

# Protection Git smart HTTP (30 req/s — clone/push légitime peut être verbeux)
location ~ ^/[^/]+/[^/]+\.git/ {
    limit_req zone=gitrust_git burst=50 nodelay;
    proxy_pass http://gitrust_backend;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
    proxy_request_buffering off;
    client_max_body_size 2G;
}

4.3 Reload

sudo nginx -t && sudo systemctl reload nginx

5. Démarrage et validation

sudo systemctl restart fail2ban

# Liste des jails actifs
sudo fail2ban-client status
# Attendu :
#   sshd, nginx-http-auth, nginx-botsearch, nginx-badbots, nginx-limit-req,
#   gitrust-login, gitrust-api-abuse, gitrust-ssh, gitrust-import,
#   postgresql, recidive
#   (+ dtrack-login, dtrack-api si activées)

# Stats par jail
sudo fail2ban-client status gitrust-login
sudo fail2ban-client status sshd

Valider chaque filtre custom

# 1. gitrust-login contre l'access log nginx
sudo fail2ban-regex /var/log/nginx/gitrust.access.log \
                    /etc/fail2ban/filter.d/gitrust-login.conf

# 2. gitrust-api-abuse
sudo fail2ban-regex /var/log/nginx/gitrust.access.log \
                    /etc/fail2ban/filter.d/gitrust-api-abuse.conf

# 3. gitrust-ssh contre le flux JSON ssh-guard
sudo fail2ban-regex /var/log/gitrust-ssh-guard.json \
                    /etc/fail2ban/filter.d/gitrust-ssh.conf
# Si /var/log/gitrust-ssh-guard.json n'existe pas, vérifiez SSH_GUARD_LOG_TARGET
# et SSH_GUARD_LOG_FILE dans /opt/gitrust/.env (voir how-to/configurer-ssh-guard.md).

# 4. postgresql
journalctl -u docker --no-pager -n 5000 | grep -i postgres > /tmp/pg.log
sudo fail2ban-regex /tmp/pg.log \
                    /etc/fail2ban/filter.d/postgresql.conf

0 failregex found = regex à ajuster après observation des logs réels de chaque service.

Test de bannissement

# Depuis un host HORS LAN (sinon ignoreip s'applique)
for i in $(seq 1 10); do
  curl -sk -o /dev/null -w "%{http_code}\n" \
    -X POST https://<votre-domaine>/login \
    -d 'username=admin&password=wrong'
done

# Vérifier le ban
ssh gitrust-host 'sudo fail2ban-client status gitrust-login'
# Currently banned : 1 — Banned IP list : <IP test>

# Débannir manuellement
ssh gitrust-host 'sudo fail2ban-client unban <IP>'

6. Monitoring

# Logs fail2ban temps réel
sudo tail -f /var/log/fail2ban.log

# Toutes les IPs bannies, tous jails confondus
sudo fail2ban-client banned

# Stats détaillées d'un jail
sudo fail2ban-client status gitrust-ssh
#    Currently failed : 2
#    Total failed     : 47
#    Currently banned : 1
#    Total banned     : 12
#    Banned IP list   : 203.0.113.42

sudo grep "Ban " /var/log/fail2ban.log | tail -20

7. Matrice récapitulative

#JailPort(s)Source logsMax retryBan timeSurface protégée
1sshd2022journald sshd31hSSH admin
2nginx-http-auth80,443nginx error51hBasic auth (admin futur)
3nginx-botsearch80,443nginx access224hScanners WP/PHP
4nginx-badbots80,443nginx access224hUser-agents malveillants
5nginx-limit-req80,443nginx error101hFlood global
6gitrust-login80,443nginx access51hBrute force login UI + API
7gitrust-api-abuse80,443nginx access302hScrapers API (tokens fuités)
8gitrust-ssh22,2222/var/log/gitrust-ssh-guard.json (JSON stable ssh-guard)11hBrute force / scan clés / énumération SSH Git
9gitrust-import80,443journald gitrust330mBrute force PAT/OAuth import
10dtrack-login8080nginx access52hBrute force UI Dep-Track
11dtrack-api8081nginx access101hAbus clé API Dep-Track
12postgresql5432journald51hDéfense en profondeur PG
13recidiveallfail2ban.log31wMéta-ban 3×/24h

Ordre de déclenchement typique : brute force → jail spécifique (ban 1h) → si récidive 3x → recidive (ban 1 semaine, tous ports).


8. Notes spécifiques à la stack gitrust

8.1 Dagger (CI)

Dagger s’exécute localement sur la machine gitrust (ou un runner distant — voir administration_manual/how-to/configurer-ci-runner-remote.md). Aucun port réseau ouvert → pas de jail dédié.

8.2 Dependency-Track

Si DTrack est déployé sur le même serveur et exposé, activer les jails 10 et 11 en mettant enabled = true et en pointant logpath vers les logs nginx du vhost DTrack.

Si DTrack est interne (VPN, réseau privé), laisser enabled = false — le firewall suffit.

Config DTrack recommandée :

  • Bind sur 127.0.0.1 via dtrack.url.base=http://127.0.0.1:8080

8.3 PostgreSQL

Le jail postgresql est défensif. PG doit rester bindé sur 127.0.0.1:5432 via Docker. Vérifier :

sudo ss -tlnp | grep 5432
# Attendu : 127.0.0.1:5432 — PAS 0.0.0.0:5432

8.4 Notifications

Pour recevoir un mail à chaque ban :

  1. Installer un relay SMTP local : sudo apt install msmtp-mta

  2. Configurer /etc/msmtprc avec un compte SMTP

  3. Décommenter dans [DEFAULT] :

    destemail = contact@gitrust.eu
    sender    = fail2ban@votre-serveur.example.com
    action    = %(action_mwl)s
    
  4. sudo systemctl restart fail2ban


9. Limites et évolutions

  • IPv6 : toutes les regex utilisent <HOST> qui matche IPv4 ET IPv6. Vérifier que UFW est configuré pour v6 également.
  • CDN/Cloudflare devant : si un CDN est ajouté, $remote_addr côté Nginx sera l’IP du CDN — il faut récupérer la vraie IP via X-Forwarded-For et propager au logging Nginx. Changer failregex en conséquence. Sinon les jails banniront le CDN.
  • Docker/Podman : si gitrust passe en conteneur, les logs de gitrust.service deviennent docker.service ou podman.service → mettre à jour journalmatch dans le jail 9 (gitrust-import). Le jail 8 (gitrust-ssh) n’est pas affecté car il consomme le fichier JSON ssh-guard, à condition que ce fichier soit monté côté hôte.
  • GeoIP : pour bloquer des pays entiers en amont, ajouter geo $blocked_country dans Nginx (module ngx_http_geoip2_module) — complémentaire à fail2ban.

Régler le rate limiting

À qui s’adresse cette page

Administrateurs qui souhaitent ajuster les limites de débit pour protéger l’instance contre les attaques par force brute et les abus d’API, tout en évitant de bloquer les utilisateurs légitimes.


Variables disponibles

Ces variables se définissent dans /opt/gitrust/.env. Un redémarrage est nécessaire après modification. 0 désactive la limite (déconseillé en production).

VariableDéfautDescription
RATE_LIMIT_LOGIN_PER_MINUTE5Tentatives de connexion par IP par minute
RATE_LIMIT_REFRESH_PER_MINUTE10Rafraîchissements de token par IP par minute
RATE_LIMIT_GENERAL_PER_MINUTE100Requêtes générales par IP par minute (tous les autres endpoints)

Les limites s’appliquent par adresse IP. Si gitrust est derrière un reverse-proxy, assurez-vous que le vrai IP du client est transmis (en-tête X-Forwarded-For ou X-Real-IP).


Profils de configuration recommandés

Petite équipe (≤ 5 personnes, réseau de confiance)

Toutes les personnes travaillent depuis un réseau connu. On peut se permettre des limites légèrement plus souples pour éviter les faux positifs lors des scripts d’intégration.

RATE_LIMIT_LOGIN_PER_MINUTE=10
RATE_LIMIT_REFRESH_PER_MINUTE=20
RATE_LIMIT_GENERAL_PER_MINUTE=200

Équipe moyenne (6-20 personnes, instance interne)

Configuration par défaut, adaptée à la cible principale de gitrust.

RATE_LIMIT_LOGIN_PER_MINUTE=5
RATE_LIMIT_REFRESH_PER_MINUTE=10
RATE_LIMIT_GENERAL_PER_MINUTE=100

Instance publique ou exposée sur Internet (50+ utilisateurs)

Limites plus strictes sur le login pour contrer les attaques par dictionnaire. La limite générale peut être augmentée si vos utilisateurs utilisent intensivement l’API.

RATE_LIMIT_LOGIN_PER_MINUTE=3
RATE_LIMIT_REFRESH_PER_MINUTE=10
RATE_LIMIT_GENERAL_PER_MINUTE=150

CI/CD avec beaucoup d’appels API

Si vos pipelines CI font de nombreuses requêtes API (création de statuts, commentaires, webhooks), augmentez uniquement RATE_LIMIT_GENERAL_PER_MINUTE depuis les IPs des runners :

RATE_LIMIT_GENERAL_PER_MINUTE=300

Ou configurez Nginx pour whitelister les IPs des runners CI avant le rate limiting de gitrust.


Surveiller les réponses 429

Quand une limite est dépassée, gitrust retourne HTTP 429 Too Many Requests. Ces réponses apparaissent dans les logs avec le niveau WARN.

# Surveiller les 429 en temps réel
sudo journalctl -u gitrust -f | grep "429\|rate_limit\|too many"

# Compter les 429 sur les 24 dernières heures
sudo journalctl -u gitrust --since "24 hours ago" --no-pager \
  | grep -c "429\|rate_limit"

Si vous utilisez Nginx comme reverse-proxy, vous pouvez aussi consulter les logs Nginx :

grep " 429 " /var/log/nginx/gitrust-access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

Cela vous donne les IPs les plus souvent bloquées, utile pour distinguer une vraie attaque d’un utilisateur légitime avec un script trop agressif.


Gitrust derrière un reverse-proxy

Si gitrust écoute sur 127.0.0.1:4000 derrière Nginx, le rate limiting de gitrust voit l’IP 127.0.0.1 pour toutes les requêtes, ce qui rend la protection inefficace.

Configurez Nginx pour transmettre le vrai IP :

# Dans le bloc location de gitrust
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

Et dans /opt/gitrust/.env, indiquez que gitrust est derrière un proxy de confiance :

# Ajouter si le reverse-proxy est sur 127.0.0.1
SERVER_HOST=127.0.0.1

Gitrust utilise alors X-Real-IP pour identifier l’IP cliente réelle dans ses mécanismes de rate limiting.


Rate-limit TCP par IP (ssh-guard)

Les variables RATE_LIMIT_* ci-dessus s’appliquent à la couche HTTP (login, API, refresh JWT). Pour le port SSH (:2222 par défaut), un mécanisme distinct est fourni par la crate gitrust-ssh-guard : un cap dur de connexions TCP/seconde par IP, indépendant de tout traitement applicatif.

VariableDéfautDescription
SSH_GUARD_CONN_FLOOD_PER_SEC10Cap soutenu de connexions/sec par IP. 0 ou u32::MAX = désactivé.
SSH_GUARD_CONN_FLOOD_BURST20Burst momentané autorisé.

Différences avec le rate-limit HTTP :

  • Pas de réponse 429 : la connexion TCP est droppée immédiatement au listener (avant tout handshake SSH).
  • Pas de ban persistant en DB : un scan de port n’engendre aucune écriture.
  • Budgets indépendants par IP via token bucket GCRA (algorithme leaky bucket sans biais).
  • Une IP allowlistée dans ssh_guard_acl bypasse complètement ce cap.

Pour la recette complète (profils par topologie, dry-run, allowlist CI), voir Configurer ssh-guard. Pour le détail des événements émis (connection_flood_detected, connection_dropped avec reason=flood_limit), voir Événements ssh-guard (JSON).


Fail2ban comme couche complémentaire

Le rate limiting de gitrust agit requête par requête. Fail2ban analyse les logs et peut bannir les IPs pour une durée configurable après plusieurs 429.

Voir Durcir avec Fail2ban pour la configuration complète.


Pour aller plus loin

Forcer le 2FA sur toute l’instance

À qui s’adresse cette page

Administrateurs qui souhaitent obliger tous les utilisateurs à configurer l’authentification à deux facteurs (TOTP) avant d’accéder à l’instance.


Activer le 2FA obligatoire

Le paramètre totp_force_enabled est un paramètre dynamique stocké en base de données. Il prend effet immédiatement, sans redémarrage du service.

  1. Connectez-vous à l’interface d’administration : /admin
  2. Accédez à /admin/settings
  3. Trouvez le paramètre totp_force_enabled
  4. Activez le toggle et cliquez Enregistrer

Activation du 2FA obligatoire dans /admin/settings


Comportement après activation

Dès que totp_force_enabled=true est enregistré :

  • Tout utilisateur sans 2FA configuré est redirigé vers la page de configuration TOTP à sa prochaine connexion. Il ne peut pas accéder à l’application tant que la configuration n’est pas complète.
  • Les utilisateurs qui ont déjà configuré le 2FA ne sont pas affectés.
  • Les administrateurs sont soumis à la même règle — vérifiez que votre propre compte a le 2FA actif avant d’activer ce paramètre.

Conseil : prévenez vos utilisateurs avant d’activer ce paramètre. Un e-mail ou une annonce interne évite les appels au support pour des connexions bloquées.


Vérifier que le 2FA est actif sur votre compte

Avant d’activer totp_force_enabled, confirmez que votre compte admin a bien le 2FA configuré :

  1. Cliquez sur votre avatar → Paramètres du profil
  2. Section Sécurité → vérifiez que Authentification à deux facteurs affiche « Actif »

Si ce n’est pas le cas, configurez le 2FA sur votre compte avant d’activer le paramètre global.


Révoquer le 2FA d’un utilisateur spécifique

Si un utilisateur a perdu accès à son application TOTP (téléphone perdu, application réinstallée) :

  1. Accédez à /admin/users
  2. Ouvrez la fiche de l’utilisateur concerné
  3. Cliquez Révoquer le 2FA
  4. L’utilisateur sera invité à reconfigurer le TOTP à sa prochaine connexion

Désactiver le 2FA obligatoire

  1. Accédez à /admin/settings
  2. Désactivez le toggle totp_force_enabled
  3. Cliquez Enregistrer

Les utilisateurs peuvent à nouveau se connecter sans 2FA. Les configurations TOTP existantes sont conservées.


Pour aller plus loin

Dépanner les problèmes SSH (clés, ports, fingerprints)

À qui s’adresse cette page

Administrateurs confrontés à des erreurs lors de l’authentification SSH ou des opérations git clone/git push via SSH sur gitrust.


Architecture SSH de gitrust

Gitrust intègre son propre serveur SSH via la bibliothèque Russh — ce n’est pas le démon sshd du système. Il écoute par défaut sur le port 2222. Depuis l’introduction de la crate gitrust-ssh-guard, toute connexion TCP passe d’abord par un sas (SecureListener) qui peut la dropper avant le handshake SSH (ban, flood, en-tête PROXY invalide). Voir Configurer ssh-guard et ssh-guard : détection d’attaques SSH.

sequenceDiagram
    participant U as Client git (utilisateur)
    participant G as ssh-guard (SecureListener)
    participant R as Serveur gitrust (Russh :2222)
    participant DB as PostgreSQL

    U->>G: Connexion TCP (port 2222)
    G->>G: ACL / ban / flood
    alt Drop
        G-->>U: Connexion fermée
    else Accept
        G->>R: TcpStream + ClientIdentity
        R->>U: Présente clé hôte Ed25519
        U->>U: Vérifie fingerprint (known_hosts)
        U->>R: Envoie clé publique SSH
        R->>DB: Cherche fingerprint dans ssh_keys
        DB-->>R: Trouvé (user_id)
        R->>G: AuthTracker.record_auth_attempt
        R-->>U: Authentifié
        U->>R: git-upload-pack / git-receive-pack
    end

Erreur 1 : Connection refused sur le port 2222

ssh: connect to host SERVEUR port 2222: Connection refused

Diagnostic :

# Depuis le serveur : vérifier que gitrust écoute
ss -tlnp | grep 2222
# Attendu : LISTEN 0 ... *:2222 ... gitrust

# Depuis l'extérieur
nc -zv SERVEUR 2222

Causes et corrections :

CauseCorrection
gitrust n’est pas démarrésudo systemctl start gitrust
SSH_PORT mal configuré dans .envVérifier SSH_PORT=2222 et redémarrer
Firewall bloque le portsudo ufw allow 2222/tcp ou règle iptables équivalente
SSH_LISTEN_ADDR=127.0.0.1Le serveur SSH n’est accessible que localement — changer en 0.0.0.0 si accès externe souhaité

Erreur 2 : WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING A MITM ATTACK!

Cause : la clé SSH hôte du serveur a changé (migration vers un nouveau serveur, régénération de la clé, restauration depuis une sauvegarde différente).

Vérifier le nouveau fingerprint :

# Sur le serveur
sudo -u gitrust ssh-keygen -l -f /opt/gitrust/data/ssh_host_ed25519_key.pub
# Exemple : SHA256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX gitrust-host-key (ED25519)

Si ce fingerprint correspond à un changement légitime (migration, etc.), demandez aux utilisateurs de supprimer l’ancienne entrée de leur known_hosts :

# Sur la machine du client git
ssh-keygen -R "[SERVEUR]:2222"
# Puis re-cloner ou accepter le nouveau fingerprint

Si vous ne reconnaissez pas ce changement, il peut indiquer une compromission. Investiguez avant de continuer.


Erreur 3 : Permission denied (publickey)

git@SERVEUR: Permission denied (publickey).
fatal: Could not read from remote repository.

Diagnostic pas à pas :

# 1. Tester la connexion avec verbosité maximale
ssh -vvv -p 2222 git@SERVEUR

# 2. Vérifier quelle clé est proposée par le client
# Dans la sortie -vvv, chercher :
# debug1: Offering public key: /home/user/.ssh/id_ed25519 ED25519 SHA256:...
# debug1: Authentications that can continue: publickey

# 3. Obtenir le fingerprint de votre clé locale
ssh-keygen -l -f ~/.ssh/id_ed25519.pub
# SHA256:YYYYYYYYYYYYYYYYYYYYYYYYYYYYYY (ED25519)

Comparez ce fingerprint avec celui enregistré dans gitrust pour votre compte :

  1. Connectez-vous à l’interface web gitrust
  2. Accédez à ParamètresClés SSH
  3. Vérifiez que le fingerprint correspond

Causes courantes :

CauseCorrection
Aucune clé SSH enregistrée dans gitrustAjouter la clé publique dans les paramètres du compte
La clé proposée par le client n’est pas celle enregistréeVérifier ~/.ssh/config et l’agent SSH
L’utilisateur est désactivé dans gitrustL’administrateur doit réactiver le compte via /admin/users
La clé enregistrée a été révoquéeEnregistrer une nouvelle clé publique

Vérifier les permissions des fichiers de clés côté client :

chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

Erreur 4 : Could not resolve hostname

ssh: Could not resolve hostname SERVEUR: Name or service not known

Cause : le nom de domaine dans l’URL de clone ne correspond pas à SSH_PUBLIC_HOST ou n’est pas résolvable.

Vérifier la configuration :

# Dans .env
grep SSH_PUBLIC_HOST /opt/gitrust/.env
# Doit correspondre au FQDN ou à l'IP utilisé dans les URLs de clone

# Tester la résolution DNS
nslookup VOTRE_SSH_PUBLIC_HOST

Erreur 5 : Timeout ou connexion très lente

ssh: connect to host SERVEUR port 2222: Operation timed out

ou connexion qui s’établit mais met 30+ secondes.

Causes et corrections :

CauseCorrection
Résolution DNS inverse lenteAjouter UseDNS no dans la config SSH hôte (non applicable à Russh — configurable via SSH_LISTEN_ADDR côté gitrust)
Firewall qui filtre silencieusement (DROP vs REJECT)nc -zv SERVEUR 2222 pour distinguer refused (REJECT) de timeout (DROP)
Trop de connexions simultanéesVérifier les logs gitrust pour des erreurs de pool de threads

Erreur 6 : la connexion est droppée par ssh-guard

Symptôme côté client : la connexion TCP est acceptée puis fermée immédiatement, sans aucun message d’authentification SSH. Ce comportement est typique d’un drop ssh-guard (ban actif, flood, en-tête PROXY refusé).

Diagnostic :

# Si SSH_GUARD_LOG_TARGET=both ou file
sudo tail -100 /var/log/gitrust-ssh-guard.json \
  | jq 'select(.event=="connection_dropped")'

# Sinon (target=stderr) — passer par journald
sudo journalctl -u gitrust --since "5 min ago" --no-pager \
  | grep '"event":"connection_dropped"' | tail -10
reason retournéCauseCorrection
bannedIP couverte par un ban actif (auto ou denylist admin)Lever le ban via la table ssh_guard_bans ou attendre l’expiration. Voir Configurer ssh-guard §10.4.
flood_limitTrop de connexions/sec depuis cette IPAllowlister l’IP si c’est un partenaire CI, ou monter SSH_GUARD_CONN_FLOOD_PER_SEC.
untrusted_proxyEn-tête PROXY reçu d’une IP non listéeVérifier SSH_GUARD_TRUSTED_PROXIES ; si nginx tourne sur le même hôte, le défaut 127.0.0.1/32,::1/128 du profil nginx doit suffire.
proxy_header_invalid ou proxy_header_missingnginx/HAProxy n’envoie pas le header attenduVérifier proxy_protocol on; dans nginx (ou send-proxy-v2 dans HAProxy). En transition, basculer temporairement SSH_GUARD_PROXY_PROTOCOL_STRICT=false.

Diagnostic ssh-guard rapide

Lire le flux d’événements en direct

# Dernier événement par IP (ssh-guard log target = stderr/journald)
sudo journalctl -u gitrust -f | grep '"event":"' | jq -c '.'

# Idem si SSH_GUARD_LOG_TARGET=file ou both
sudo tail -f /var/log/gitrust-ssh-guard.json | jq -c '.'

Mode dry-run pour reproduire un blocage sans persister

Si vous suspectez qu’un seuil est trop agressif, passez SSH_GUARD_DRY_RUN=true dans .env puis redémarrez. Les détecteurs continuent à émettre les ip_banned (signal pour fail2ban / observabilité) sans que ssh-guard pose réellement le ban. Vous pouvez observer le motif sur 24-48 h avant de décider.

Vérifier que la vraie IP est bien extraite (profils nginx/haproxy)

# Faire un git ls-remote depuis une IP externe connue, puis :
sudo journalctl -u gitrust --since "1 min ago" --no-pager \
  | grep '"event":"connection_accepted"' | tail -1 | jq .ip
# Doit retourner l'IP externe, PAS 127.0.0.1

Désactiver temporairement ssh-guard

# .env
SSH_GUARD_ENABLED=false
sudo systemctl restart gitrust

ssh-guard devient un pass-through complet (aucune détection, aucun ban). À utiliser seulement pour confirmer qu’un problème vient bien de la couche guard ; remettre true immédiatement après.

Pour le détail des événements, voir Événements ssh-guard (JSON).


Consulter les logs SSH

# Logs du service gitrust (contient les événements SSH)
sudo journalctl -u gitrust -f | grep -i "ssh\|auth\|key\|fingerprint"

# Filtrer les erreurs d'authentification
sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
  | grep -i "auth.*failed\|permission denied\|invalid key"

Pour aller plus loin

Dépanner la CI intégrée (Dagger)

À qui s’adresse cette page

Administrateurs confrontés à des pipelines bloqués, des workers CI qui ne répondent plus, ou des erreurs SBOM/Dependency-Track.


Architecture CI de gitrust

flowchart LR
    A[git push] --> B[gitrust-hooks<br/>on_push]
    B --> C[CiWorker async<br/>gitrust-core]
    C --> D{CI_REMOTE_HOST}
    D -->|localhost| E[Dagger Engine<br/>local]
    D -->|runner distant| F[Runner SSH<br/>CI_REMOTE_HOST]
    E --> G[Pipeline exécuté]
    F --> G
    G --> H[Logs stockés<br/>CI_WORKSPACE_PATH]
    G --> I[Status → DB]
    G -->|CI_SBOM_ENABLED=true| J[Syft → SBOM]
    J -->|CI_DTRACK_ENABLED=true| K[Dependency-Track]

Vérifier l’état de la CI

Pipeline bloqué à l’état pending

Un pipeline reste en pending si le worker ne le prend pas en charge.

# Vérifier les logs du worker CI
sudo journalctl -u gitrust -n 100 --no-pager | grep -i "ci\|pipeline\|worker\|dagger"

# Vérifier que CI_ENABLED est bien à true
grep CI_ENABLED /opt/gitrust/.env

Si CI_ENABLED=false, aucun pipeline ne s’exécute. Pour désactiver temporairement la CI sans arrêter gitrust :

# Désactiver la CI (les pipelines s'accumulent en pending mais ne s'exécutent pas)
sed -i 's/^CI_ENABLED=.*/CI_ENABLED=false/' /opt/gitrust/.env
sudo systemctl restart gitrust

Nombre maximum de pipelines concurrentes atteint

grep CI_MAX_CONCURRENT /opt/gitrust/.env
# Défaut : 4 — augmenter si la machine a les ressources

Les pipelines supplémentaires attendent en file. Vérifiez qu’aucun pipeline n’est bloqué indéfiniment (timeout CI_DEFAULT_TIMEOUT, défaut : 3600 s).


Erreur : dagger: command not found

Le binaire dagger n’est pas dans le PATH de l’utilisateur gitrust.

# Vérifier depuis l'utilisateur gitrust
sudo -u gitrust which dagger
sudo -u gitrust dagger version

Installation de Dagger :

curl -fsSL https://dl.dagger.io/dagger/install.sh | sudo sh
# Le binaire est installé dans /usr/local/bin/dagger

Si Dagger est installé dans un chemin non standard, précisez-le dans .env :

CI_DAGGER_BIN=/usr/local/bin/dagger

Erreur : connexion au runner distant échoue

Si CI_REMOTE_HOST pointe vers une machine distante :

# Tester la connexion SSH depuis l'utilisateur gitrust
sudo -u gitrust ssh -i /opt/gitrust/data/ci_runner_key \
  -p ${CI_REMOTE_SSH_PORT:-22} \
  ${CI_REMOTE_USER:-$USER}@${CI_REMOTE_HOST} \
  "dagger version"

Causes courantes :

SymptômeCauseCorrection
Permission denied (publickey)Clé CI runner non autorisée sur le runnerAjouter la clé publique dans ~/.ssh/authorized_keys du runner
Connection refusedLe runner est éteint ou le port SSH est ferméVérifier l’état du runner et le firewall
dagger: command not found sur le runnerDagger non installé sur la machine distanteInstaller Dagger sur le runner
Timeout rsyncCI_REMOTE_PATH inexistant sur le runnerssh runner "mkdir -p /opt/gitrust-ci"

Erreur SBOM : syft: command not found

# Vérifier depuis l'utilisateur gitrust
sudo -u gitrust which syft

# Installer Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sudo sh -s -- -b /usr/local/bin

# Ou préciser le chemin dans .env
CI_SYFT_BIN=/usr/local/bin/syft

Si vous ne souhaitez pas utiliser SBOM :

CI_SBOM_ENABLED=false
CI_DTRACK_ENABLED=false

Erreur Dependency-Track : push SBOM échoue

# Vérifier la connectivité vers Dependency-Track
curl -s -H "X-Api-Key: ${CI_DTRACK_API_KEY}" \
  "${CI_DTRACK_URL}/v1/project" | head -100

# Attendu : liste JSON des projets
# Erreur 401 : clé API invalide ou expirée
# Connection refused : CI_DTRACK_URL incorrect ou Dtrack non démarré

Vérifiez dans les logs gitrust :

sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
  | grep -i "dtrack\|sbom\|dependency"

Timeout de pipeline

Si un pipeline dépasse CI_DEFAULT_TIMEOUT (défaut : 3600 s = 1 h), il est annulé avec le statut timeout.

Pour les projets avec des builds très longs (compilation Rust complète, etc.) :

# Augmenter le timeout global
CI_DEFAULT_TIMEOUT=7200   # 2 heures

# Ou surcharger par pipeline dans .gitrust-ci.yml :
# pipeline:
#   timeout: 7200

Nettoyer l’espace disque CI

Les artefacts et workspaces CI s’accumulent dans CI_WORKSPACE_PATH (défaut : /tmp/gitrust-ci) et les logs dans la base de données.

# Espace occupé par les workspaces CI
du -sh /tmp/gitrust-ci/

# Nettoyer manuellement (uniquement si aucun pipeline en cours)
sudo systemctl stop gitrust
sudo rm -rf /tmp/gitrust-ci/*
sudo systemctl start gitrust

Les logs en base sont purgés automatiquement selon CI_LOG_RETENTION_DAYS (défaut : 30 jours).


Désactiver la CI temporairement

Pour désactiver toute exécution CI sans arrêter gitrust (utile lors d’une maintenance) :

# Dans .env
CI_ENABLED=false
sudo systemctl restart gitrust

Les pipelines déclenchés pendant cette période restent en pending et peuvent être relancés une fois la CI réactivée.


Pour aller plus loin

Gérer les utilisateurs via le panel d’administration

À qui s’adresse cette page

Administrateurs qui gèrent les comptes utilisateurs de leur instance gitrust depuis l’interface /admin/users.


Accéder au panel utilisateurs

Connectez-vous avec un compte administrateur et accédez à :

https://VOTRE_INSTANCE/admin/users

Liste des utilisateurs dans /admin/users

La liste affiche pour chaque utilisateur : nom d’utilisateur, adresse e-mail, date de création, statut du compte (actif/désactivé), rôle (admin ou utilisateur standard), et si la vérification e-mail a été effectuée.


Créer un utilisateur

Si l’inscription publique est désactivée (allow_registration=false dans /admin/settings), seuls les administrateurs peuvent créer des comptes.

  1. Cliquez Nouvel utilisateur
  2. Renseignez : nom d’utilisateur, adresse e-mail, mot de passe temporaire
  3. Cochez Forcer le changement de mot de passe à la prochaine connexion si souhaité
  4. Cliquez Créer

L’utilisateur reçoit un e-mail de validation si EMAIL_VALIDATION_REQUIRED est activé.

Conseil : pour les instances privées (équipe interne), désactivez l’inscription publique dans /admin/settings et créez les comptes manuellement pour garder le contrôle.


Désactiver un compte utilisateur

La désactivation empêche la connexion sans supprimer les données (dépôts, issues, commentaires restent intacts).

  1. Dans /admin/users, cliquez sur l’utilisateur concerné
  2. Cliquez Désactiver le compte
  3. Confirmez

Après désactivation :

  • L’utilisateur ne peut plus se connecter (HTTP ou SSH)
  • Ses dépôts restent accessibles aux autres membres des équipes
  • La désactivation est réversible : cliquez Réactiver pour rétablir l’accès

Supprimer un compte utilisateur

La suppression est irréversible. Les dépôts dont l’utilisateur est propriétaire doivent être transférés ou supprimés au préalable.

  1. Désactivez d’abord le compte (étape ci-dessus)
  2. Cliquez Supprimer le compte
  3. Confirmez en saisissant le nom d’utilisateur

Les données supprimées : compte utilisateur, clés SSH, personal access tokens, sessions actives.
Les données conservées : dépôts transférés, issues/commentaires (auteur anonymisé).


Forcer la réinitialisation du mot de passe

Utile si un compte est suspecté d’être compromis ou si l’utilisateur a oublié son mot de passe.

  1. Ouvrez la fiche de l’utilisateur dans /admin/users
  2. Cliquez Forcer la réinitialisation du mot de passe
  3. L’utilisateur reçoit un e-mail avec un lien de reset (valable PASSWORD_RESET_EXPIRATION_HOURS heures, défaut : 1 h)

Si le compte n’a pas d’adresse e-mail valide ou si le SMTP n’est pas configuré, définissez un nouveau mot de passe directement :

  1. Ouvrez la fiche utilisateur
  2. Cliquez Définir un nouveau mot de passe
  3. Saisissez le mot de passe temporaire et cochez Forcer le changement à la prochaine connexion

Forcer l’activation du 2FA

Pour exiger que tous les utilisateurs activent l’authentification à deux facteurs (TOTP), configurez le paramètre global dans /admin/settings :

  1. Accédez à /admin/settings
  2. Activez Forcer le 2FA pour tous les utilisateurs (totp_force_enabled=true)
  3. Enregistrez

Après activation, tout utilisateur sans 2FA configuré est redirigé vers la page de configuration TOTP à sa prochaine connexion. L’accès à l’application est bloqué jusqu’à la configuration.

Pour forcer le 2FA uniquement pour un utilisateur spécifique :

  1. Ouvrez la fiche de l’utilisateur dans /admin/users
  2. Cliquez Révoquer le 2FA (si actif mais compromis) pour forcer une reconfiguration

Gérer les permissions administrateur

Accorder les droits administrateur

  1. Ouvrez la fiche de l’utilisateur
  2. Cliquez Accorder le rôle administrateur
  3. Confirmez

Un administrateur a accès à /admin complet : gestion des utilisateurs, paramètres, équipes, logs d’audit.

Révoquer les droits administrateur

  1. Ouvrez la fiche de l’utilisateur
  2. Cliquez Révoquer le rôle administrateur

Vous ne pouvez pas révoquer vos propres droits administrateur. Un autre administrateur doit effectuer cette opération.

Droits sur les dépôts (niveau ressource)

Les droits sur les dépôts spécifiques (Reader, Developer, Maintainer) sont gérés au niveau de chaque dépôt ou via les équipes — pas depuis /admin/users. Voir le modèle de permissions pour les détails.


Pour aller plus loin

Auditer une instance gitrust

À qui s’adresse cette page

Administrateurs qui souhaitent consulter le journal d’audit, filtrer les événements suspects, exporter les logs et les corréler avec les journaux système.


Le journal d’audit dans /admin/audit

Gitrust enregistre toutes les actions significatives dans la table audit_log. Chaque entrée contient : l’acteur (utilisateur), l’action effectuée, la ressource concernée, l’adresse IP source et l’horodatage.

Les actions auditées par défaut (audit_log_actions dans app_settings) :

  • create — création d’un dépôt, utilisateur, équipe, etc.
  • update — modification de paramètres, de profil
  • delete — suppression d’une ressource
  • reset_password — réinitialisation de mot de passe

Accédez au journal : https://VOTRE_INSTANCE/admin/audit

Journal d’audit dans /admin/audit


Filtrer les événements

L’interface /admin/audit propose des filtres combinables :

FiltreDescription
UtilisateurFiltrer par nom d’utilisateur de l’acteur
ActionFiltrer par type d’action (create, update, delete, reset_password)
PériodeSélectionner une plage de dates
RessourceFiltrer par type de ressource (repository, user, team…)

Exemple de requête typique : « toutes les suppressions de dépôts depuis 30 jours »

  1. Action : delete
  2. Période : 30 derniers jours
  3. Cliquez Filtrer

Exporter les logs en CSV

  1. Appliquez les filtres souhaités
  2. Cliquez Exporter CSV
  3. Le fichier téléchargé contient les colonnes : timestamp, actor_username, action, resource_type, resource_id, ip_address, details

Ce fichier peut être importé dans un tableur ou un SIEM pour des analyses avancées.


Interroger la base de données directement

Pour des requêtes avancées non disponibles dans l’UI :

-- Connexions de l'administrateur sur les 7 derniers jours
SELECT
    al.created_at,
    u.username,
    al.action,
    al.resource_type,
    al.ip_address
FROM audit_log al
JOIN users u ON al.actor_id = u.id
WHERE u.username = 'admin'
  AND al.created_at > NOW() - INTERVAL '7 days'
ORDER BY al.created_at DESC;
-- Suppressions massives (plus de 5 suppressions par utilisateur sur 1 heure)
SELECT
    u.username,
    COUNT(*) AS nb_suppressions,
    MIN(al.created_at) AS premiere,
    MAX(al.created_at) AS derniere
FROM audit_log al
JOIN users u ON al.actor_id = u.id
WHERE al.action = 'delete'
  AND al.created_at > NOW() - INTERVAL '1 hour'
GROUP BY u.username
HAVING COUNT(*) > 5
ORDER BY nb_suppressions DESC;

Configurer le niveau d’audit

Le niveau et les actions auditées sont des paramètres dynamiques modifiables via /admin/settings :

ParamètreValeurDescription
audit_log_levelINFONiveau de détail (INFO, WARN, ERROR)
audit_log_actionsJSON arrayActions enregistrées

Pour ajouter l’action login aux événements audités :

  1. Accédez à /admin/settings
  2. Trouvez audit_log_actions
  3. Ajoutez "login" au tableau JSON : ["create","update","delete","reset_password","login"]
  4. Enregistrez (effet immédiat, pas de redémarrage nécessaire)

Corréler avec journalctl

Les logs systemd de gitrust complètent le journal d’audit en capturant les erreurs techniques et les événements bas niveau (connexions SSH refusées, erreurs de migration, etc.).

# Logs gitrust des 24 dernières heures
sudo journalctl -u gitrust --since "24 hours ago" --no-pager

# Filtrer les erreurs et avertissements uniquement
sudo journalctl -u gitrust -p err..warning --since "24 hours ago" --no-pager

# Corréler avec une adresse IP spécifique
sudo journalctl -u gitrust --since "24 hours ago" --no-pager \
  | grep "192.168.1.100"

# Suivre les logs en temps réel
sudo journalctl -u gitrust -f

Signaux d’alerte à surveiller

# Tentatives d'authentification SSH échouées répétées
sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
  | grep -i "auth.*fail\|invalid.*key\|permission denied" \
  | wc -l

# Réponses 429 (rate limiting déclenché)
sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
  | grep "429" | awk '{print $NF}' | sort | uniq -c | sort -rn | head

Conservation des logs

La rétention des logs d’audit en base de données n’a pas de limite automatique dans la version actuelle — les entrées s’accumulent indéfiniment. Pour une instance à long terme, planifiez une purge périodique :

-- Supprimer les entrées d'audit de plus de 1 an (hors suppressions et reset mdp)
DELETE FROM audit_log
WHERE created_at < NOW() - INTERVAL '1 year'
  AND action NOT IN ('delete', 'reset_password');

Vérifiez les exigences de conservation de votre organisation avant de purger (RGPD, ANSSI PA-074 recommande 12 mois minimum pour les événements de sécurité).


Pour aller plus loin

Référence — Manuel d’administration

Les pages de référence sont des tableaux factuels exhaustifs. Consultez-les quand vous cherchez une valeur précise, une définition, ou une liste complète. Elles ne contiennent pas d’explications pédagogiques.


Pages de référence

Variables d’environnement

Tableau exhaustif de toutes les variables du fichier .env, groupées par thème : base de données, serveur HTTP, SSH, JWT, sessions, rate limiting, SMTP, dépôts Git, CI/CD, SBOM, import, bootstrap admin, OAuth. Pour chaque variable : nom, valeur par défaut, caractère obligatoire, description et impact sécurité.

Utilisez cette page quand : vous ne connaissez pas le nom exact d’une variable, vous voulez vérifier la valeur par défaut, ou vous provisionnez une nouvelle instance.

Paramètres dynamiques (/admin/settings)

Référence des paramètres modifiables à chaud depuis l’interface /admin/settings (table app_settings). Contrairement aux variables .env, ces paramètres sont effectifs immédiatement sans redémarrage. Inclut les règles de précédence entre .env et base de données.

Utilisez cette page quand : vous cherchez à modifier un comportement fonctionnel (inscription ouverte, 2FA obligatoire, politique de mots de passe) sans redémarrer le service.

Schéma de la base de données

Diagramme ERD Mermaid + description de toutes les tables principales : users, repositories, ssh_keys, teams, personal_access_tokens, issues, pull_requests, labels, app_settings, audit_log. Colonnes, types, contraintes, index, clés étrangères, modèle de permissions.

Utilisez cette page quand : vous construisez une requête SQL directe sur la base, vous voulez comprendre les relations entre entités, ou vous évaluez l’impact d’une suppression de données.

Ports et services

Tableau de tous les ports réseau : 4000 (HTTP), 2222 (SSH Git), 5432 (PostgreSQL), 6379 (Redis), 25/465/587 (SMTP sortant). Binding recommandé par topologie, règles firewall, configuration nginx stream proxy pour SSH sur port 22.

Utilisez cette page quand : vous configurez un firewall, vous vérifiez qu’un port n’est pas exposé à tort, ou vous planifiez un déploiement derrière un reverse-proxy.

Conformité ANSSI PA-074

Checklist exhaustive des critères ANSSI PA-074 appliqués à gitrust : authentification, chiffrement, protection contre les injections, gestion des secrets, journalisation, gestion des accès, durcissement système. Pour chaque point : statut dans gitrust et action requise de l’administrateur.

Utilisez cette page quand : vous préparez un audit de sécurité, vous durcissez une instance pour une utilisation en milieu sensible, ou vous documentez votre conformité.


Variables d’environnement

Référence exhaustive de toutes les variables d’environnement de gitrust. Ces variables se définissent dans le fichier .env (ou .env.production) et sont chargées une seule fois au démarrage. Modifier le fichier sans redémarrer le service n’a aucun effet.

Principe d’autonomie : ce tableau contient intégralement le contenu documenté de .env.example. La documentation ne référence pas le fichier source par lien — tout est ici.

Les variables marquées SEC ont des contraintes de sécurité vérifiées au démarrage : gitrust refuse de démarrer si elles ne sont pas conformes.


1. Base de données

VariableDéfautObligatoireDescription
DATABASE_URLOuiURL de connexion PostgreSQL. Format : postgres://USER:PASSWORD@HOST:PORT/DB. Le mot de passe doit être fort en production. PostgreSQL ne doit jamais binder sur 0.0.0.0 en production.

Exemples :

# Développement local (Docker Compose)
DATABASE_URL=postgres://gitrust:gitrust@localhost:5432/gitrust

# Production (réseau privé)
DATABASE_URL=postgres://gitrust:MOT_DE_PASSE_FORT@db.internal:5432/gitrust

2. Serveur HTTP

VariableDéfautDescription
SERVER_HOST0.0.0.0Adresse d’écoute HTTP. En production derrière un reverse-proxy : 127.0.0.1. En développement : 0.0.0.0.
SERVER_PORT3000Port HTTP. Convention gitrust : 4000 en production.

3. Logging

VariableDéfautDescription
RUST_LOGdebugNiveau de log (syntaxe tracing_subscriber::EnvFilter). Valeurs : error, warn, info, debug, trace. Syntaxe avancée : "info,gitrust_core=debug,sea_orm=warn".

4. Serveur SSH Git

VariableDéfautDescription
SSH_HOST_KEY_PATH./data/ssh_host_ed25519_keyChemin de la clé hôte SSH Ed25519. Générée automatiquement au premier démarrage si absente. SEC : permissions 600, propriétaire = utilisateur systemd. En production : chemin absolu.
SSH_PORT2222Port d’écoute du serveur SSH Russh (intégré, distinct de sshd).
SSH_LISTEN_ADDR0.0.0.0Adresse d’écoute SSH. 127.0.0.1 si derrière un proxy nginx stream.
SSH_PUBLIC_HOSTlocalhostNom d’hôte affiché dans les URLs de clone SSH dans l’UI. En production : FQDN public.
MAX_SSH_KEY_SIZE16384Taille maximale d’une clé SSH publique acceptée (octets).

5. JWT et authentification

VariableDéfautDescription
JWT_SECRETSEC OBLIGATOIRE — Secret de signature JWT. Minimum 32 bytes. Générer avec openssl rand -hex 64. Les valeurs exemples (change-me-in-production, etc.) sont explicitement rejetées au démarrage. Changer cette valeur invalide toutes les sessions actives.
JWT_EXPIRATION_MINUTES15Durée de vie d’un JWT d’accès (minutes).
JWT_ISSUERmon-appValeur du claim iss dans le JWT. En production : FQDN public.
REFRESH_TOKEN_EXPIRATION_DAYS7Durée du refresh token (jours). Rotation à chaque usage.
REMEMBER_ME_EXPIRATION_DAYS30Durée étendue quand « Se souvenir de moi » est coché. SEC : doit être ≥ REFRESH_TOKEN_EXPIRATION_DAYS et ≤ 90 jours. Au-delà de 60 jours : warning dans les logs.

6. Sessions

VariableDéfautDescription
SESSION_TIMEOUT_MINUTES30Timeout d’inactivité d’une session (minutes).
SESSION_BACKENDseaormBackend de stockage : memory (volatil, dev uniquement), seaorm (PostgreSQL, recommandé), redis (multi-instances, nécessite REDIS_URL).
REDIS_URLURL Redis (obligatoire si SESSION_BACKEND=redis). Exemple : redis://localhost:6379.

7. Rate limiting

VariableDéfautDescription
RATE_LIMIT_LOGIN_PER_MINUTE5Tentatives de connexion par IP par minute. Protection contre le brute force.
RATE_LIMIT_REFRESH_PER_MINUTE10Rafraîchissements de token par IP par minute.
RATE_LIMIT_GENERAL_PER_MINUTE100Limite globale sur tous les autres endpoints. 0 = désactivé (déconseillé).

8. Cookies et debug

VariableDéfautDescription
APP_DEBUGfalseSEC : true = logs verbeux, cookies non sécurisés (HTTP local). false = production, enforce COOKIE_SECURE=true même si défini à false. Ne jamais mettre true en production.
COOKIE_SECUREfalseCookies envoyés uniquement via HTTPS. SEC : forcé à true si APP_DEBUG=false.
COOKIE_SAME_SITELaxPolitique SameSite. Strict (le plus sûr, casse les redirections OAuth cross-site), Lax (compatible OAuth), None (nécessite Secure=true).

9. Bootstrap admin initial

VariableDéfautDescription
ADMIN_USERNAMEadminNom d’utilisateur du compte admin créé au premier démarrage (si la table users est vide). 3-64 caractères alphanumériques + _-.
ADMIN_EMAILadmin@example.comAdresse e-mail du compte admin initial. Doit contenir @ + domaine avec ..
ADMIN_PASSWORDSEC OBLIGATOIRE — Mot de passe du compte admin initial. Minimum 8 caractères. À remplacer immédiatement après le premier démarrage.
CREATE_DEFAULT_ADMINtrueMettre à false une fois d’autres admins créés pour désactiver la création automatique.

10. Email SMTP

VariableDéfautDescription
SMTP_HOSTServeur SMTP. Obligatoire pour activer les e-mails. Sans cette variable, gitrust démarre mais les fonctionnalités e-mail sont désactivées.
SMTP_PORT587Port SMTP : 25 (non chiffré, interdit en prod), 465 (TLS natif), 587 (STARTTLS, recommandé), 1025 (dev/Mailpit).
SMTP_USERIdentifiant SMTP. Souvent = adresse e-mail expéditrice.
SMTP_PASSWORDMot de passe applicatif SMTP (pas le mot de passe du compte e-mail).
SMTP_FROMAdresse expéditeur. Doit être valide (validée par is_valid_email). En prod : valider DKIM/SPF sur le domaine.
SMTP_FROM_NAMENom affiché dans le champ « De : » des e-mails.
SMTP_USE_TLStrueActiver TLS natif (port 465).
SMTP_USE_STARTTLStrueActiver STARTTLS (port 587). SEC : ne jamais désactiver les deux en production.
SMTP_ACCEPT_INVALID_CERTSfalseSEC : accepter les certificats SMTP invalides. Warning au démarrage si true. Uniquement pour les serveurs de mail internes en développement.
SMTP_CA_CERT_PATHChemin vers un CA personnalisé (PEM) pour valider le certificat SMTP.
EMAIL_BASE_URLhttp://localhost:3000URL de base pour les liens dans les e-mails. Obligatoire en prod sinon les liens pointent vers localhost. Doit commencer par http:// ou https://.
EMAIL_QUEUE_BATCH_SIZE10Nombre d’e-mails traités par tick du worker.
EMAIL_QUEUE_RETRY_ATTEMPTS5Tentatives d’envoi avant abandon.
EMAIL_QUEUE_RETRY_DELAY_SECONDS300Délai entre deux tentatives (secondes).
EMAIL_QUEUE_INTERVAL_SECS30Intervalle du tick du worker e-mail (secondes).
EMAIL_VALIDATION_REQUIREDtrueExiger la validation e-mail à l’inscription. Note : cette variable est documentée dans .env.example mais la valeur effective est contrôlée par le paramètre dynamique validation_email_required en base de données.

11. Inscription

VariableDéfautDescription
ALLOW_REGISTRATIONfalseAutoriser l’inscription publique. Note : valeur effective contrôlée par le paramètre dynamique allow_registration en base de données — voir Paramètres dynamiques.

12. Application (branding)

VariableDéfautDescription
APP_NAMEMon ApplicationNom affiché dans l’UI, les e-mails et la balise <title>.
APP_THEMElightThème visuel. Valeurs : light, dark.
DEFAULT_LOCALEfrLangue par défaut. Valeurs : fr, en, de, es, pt, it.

13. IMAP — traitement des bounces (optionnel)

Si IMAP_HOST est vide, la fonctionnalité est désactivée sans erreur.

VariableDéfautDescription
IMAP_HOSTServeur IMAP. Obligatoire pour activer le traitement des bounces.
IMAP_PORT993Port IMAP : 993 (TLS direct, recommandé), 143 (STARTTLS).
IMAP_USER= SMTP_USERIdentifiant IMAP.
IMAP_PASSWORD= SMTP_PASSWORDMot de passe IMAP.
IMAP_USE_TLStrueTLS direct (port 993).
IMAP_USE_STARTTLSfalseSTARTTLS après connexion (port 143).
IMAP_ACCEPT_INVALID_CERTSfalseAccepter les certificats invalides (warning si true).
IMAP_MAILBOXINBOXBoîte à scanner pour les bounces.
IMAP_POLL_INTERVAL_SECONDS300Intervalle de poll (secondes).
IMAP_SOCKET_TIMEOUT_SECS30Timeout socket read/write.

14. Tâches de fond — intervalles et expirations

Ces variables ont des valeurs par défaut raisonnables. Ne les ajustez que si vous avez un besoin spécifique.

VariableDéfautDescription
PASSWORD_CHANGE_RETRY_INTERVAL_SECS600Retry e-mail changement de mot de passe (10 min).
PASSWORD_CHANGE_RETRY_GAP_MINUTES20Espacement minimum entre deux retries.
PASSWORD_CHANGE_MAX_RETRIES3Abandon après N échecs.
PASSWORD_CHANGE_CLEANUP_INTERVAL_SECS86400Nettoyage quotidien des demandes expirées.
PASSWORD_CHANGE_EXPIRATION_HOURS24Validité d’une demande de changement de mot de passe.
PASSWORD_CHANGE_RATE_LIMIT_MAX3Demandes maximum par fenêtre.
PASSWORD_CHANGE_RATE_LIMIT_WINDOW_SECS3600Fenêtre de rate limit (1 heure).
PASSWORD_RESET_EXPIRATION_HOURS1Validité du lien de réinitialisation de mot de passe.
EMAIL_VALIDATION_EXPIRATION_HOURS24Validité du lien de validation e-mail.
JWT_CLEANUP_INTERVAL_SECS3600Purge de la blacklist JWT expirés.
COOKIE_PREFERENCE_MAX_AGE_SECS31536000Durée de vie du cookie de préférences (locale/thème) — 1 an.
PAGINATION_DEFAULT_PER_PAGE50Taille par défaut des pages de liste.
PAGINATION_MAX_PER_PAGE100Taille maximale des pages de liste.

15. Dépôts Git

VariableDéfautDescription
GIT_REPOS_BASE_PATH./data/reposChemin des dépôts bare. Structure : {base}/{owner}/{repo}.git. En production : chemin absolu (ex. /opt/gitrust/data/repos). SEC : path traversal bloqué au niveau service.

16. Fichiers statiques

VariableDéfautDescription
STATIC_FILES_PATH./staticChemin du dossier des assets statiques (CSS, JS, images). En production avec systemd : chemin absolu (ex. /opt/gitrust/static).

17. CI/CD Dagger

VariableDéfautDescription
CI_ENABLEDtrueActivation globale de la CI. false = plus aucune exécution de pipeline, mais l’UI reste accessible.
CI_DAGGER_BINdaggerChemin du binaire Dagger. En production : chemin absolu recommandé.
CI_ENGINE_PATH./deployment/ci-engineChemin du module CI (Easy Mode — interprète .gitrust-ci.yml).
CI_MAX_CONCURRENT4Pipelines exécutés simultanément. Au-delà : file d’attente.
CI_DEFAULT_TIMEOUT3600Timeout par défaut d’un pipeline (secondes). Surchargeable par pipeline.
CI_WORKSPACE_PATH/tmp/gitrust-ciDossier de travail temporaire CI (checkouts, artefacts). En production : /var/lib/gitrust-ci.
CI_LOG_RETENTION_DAYS30Rétention des logs de pipeline (jours).

18. Runner CI distant (SSH + rsync)

VariableDéfautDescription
CI_REMOTE_HOSTlocalhostHost du runner CI. localhost = CI sur la même machine.
CI_REMOTE_USER$USERUtilisateur SSH sur le runner.
CI_REMOTE_PATH/opt/gitrust-ciChemin de travail sur le runner.
CI_REMOTE_SSH_KEYChemin de la clé privée SSH pour le runner. Optionnel si ssh-agent est chargé.
CI_REMOTE_SSH_PORT22Port SSH du runner.

19. Import de dépôts externes

VariableDéfautDescription
IMPORT_MAX_CONCURRENT2Nombre maximum de clones concurrents.
IMPORT_TIMEOUT_SECS1800Timeout par import (secondes). Augmenter pour les gros monorepos.
DB_WORKER_POOL_SIZE4Pool de connexions DB dédié au worker d’import. Évite la saturation du pool HTTP principal.

20. SBOM / Dependency-Track

VariableDéfautDescription
CI_SBOM_ENABLEDfalseGénérer un SBOM (Software Bill of Materials) à chaque pipeline via Syft. Nécessite syft installé.
CI_SYFT_BINsyftChemin du binaire Syft.
CI_DTRACK_ENABLEDfalsePousser le SBOM vers Dependency-Track (nécessite CI_SBOM_ENABLED=true).
CI_DTRACK_URLURL API de Dependency-Track (ex. https://dtrack.internal/api).
CI_DTRACK_API_KEYClé API Dependency-Track (générer dans l’UI Dtrack : Administration > API keys).
CI_DTRACK_FRONTEND_URLURL UI Dependency-Track. Si absent : déduite de CI_DTRACK_URL.

21. OAuth / SSO

VariableDéfautDescription
OAUTH_ENCRYPTION_KEYClé de chiffrement AES-256-GCM des secrets OAuth en base. Générer avec openssl rand -hex 32.
OAUTH_ENABLEDfalseActivation globale OAuth. Fallback si pas de valeur en base.
OAUTH_REDIRECT_BASE_URLhttp://localhost:3000URL de base pour les callbacks OAuth : {URL}/api/v1/auth/oauth/{provider}/callback.
OAUTH_GOOGLE_ENABLEDfalseActiver Google OAuth.
OAUTH_GOOGLE_CLIENT_IDClient ID Google.
OAUTH_GOOGLE_CLIENT_SECRETClient Secret Google.
OAUTH_GITHUB_ENABLEDfalseActiver GitHub OAuth.
OAUTH_GITHUB_CLIENT_IDClient ID GitHub.
OAUTH_GITHUB_CLIENT_SECRETClient Secret GitHub.
OAUTH_DISCORD_ENABLEDfalseActiver Discord OAuth.
OAUTH_DISCORD_CLIENT_IDClient ID Discord.
OAUTH_DISCORD_CLIENT_SECRETClient Secret Discord.
OAUTH_MICROSOFT_ENABLEDfalseActiver Microsoft / Azure AD OAuth.
OAUTH_MICROSOFT_CLIENT_IDClient ID Azure (Application ID).
OAUTH_MICROSOFT_CLIENT_SECRETClient Secret Azure.
OAUTH_MICROSOFT_TENANTcommonTenant Azure AD : "common" (comptes perso + pro), "organizations" (pro uniquement), ou GUID du tenant.

22. Durcissement SSH (SSH_GUARD_*)

Variables consommées par la crate gitrust-ssh-guard. Toutes sont préfixées SSH_GUARD_. Pour le détail des comportements, voir Configurer ssh-guard et ssh-guard : détection d’attaques SSH.

Modèle de configuration : SSH_GUARD_PROFILE sélectionne un preset cohérent (direct / nginx / haproxy / private / custom). Chaque autre variable override individuellement le défaut du preset.

22.1 Kill switch et profil

VariableDéfautDescription
SSH_GUARD_ENABLEDtruefalse = pass-through complet, ssh-guard ne fait rien. À éviter sauf urgence.
SSH_GUARD_DRY_RUNfalsetrue = les détecteurs tournent et émettent les événements ip_banned, mais aucun ban n’est persisté. Idéal pour valider un nouveau seuil.
SSH_GUARD_PROFILEcustomPreset : direct (Internet direct), nginx (derrière nginx stream + PROXY v2), haproxy (derrière HAProxy), private (réseau interne, détecteurs OFF), custom (aucun preset).

22.2 PROXY protocol

VariableDéfautDescription
SSH_GUARD_PROXY_PROTOCOLdisabledMode parsing PROXY : disabled, v1 (texte HAProxy legacy), v2 (binaire nginx stream / HAProxy moderne), any (auto-détection v1/v2).
SSH_GUARD_PROXY_PROTOCOL_STRICTtruetrue = drop si en-tête PROXY absent ou invalide quand le mode est activé. false = fallback sur peer_addr avec warn (utile en transition).
SSH_GUARD_PROXY_PROTOCOL_TIMEOUT_MS3000Timeout (ms) pour lire l’en-tête PROXY après accept TCP.
SSH_GUARD_TRUSTED_PROXIESObligatoire si PROXY activé. Liste CIDR (séparée par virgules) des proxies autorisés à émettre un en-tête PROXY. Sans cette protection, n’importe qui pourrait forger une IP cliente. Exemple : 127.0.0.1/32,::1/128.

22.3 Détecteurs (seuils + fenêtres)

VariableDéfautDescription
SSH_GUARD_BRUTE_FORCE_THRESHOLD5Nombre d’échecs d’auth depuis une IP avant ban auto. 4294967295 (u32::MAX) = détecteur désactivé.
SSH_GUARD_BRUTE_FORCE_WINDOW_SECS300Fenêtre glissante (secondes).
SSH_GUARD_USER_ENUM_THRESHOLD10Nombre d’usernames distincts essayés depuis une IP.
SSH_GUARD_USER_ENUM_WINDOW_SECS300
SSH_GUARD_KEY_SCAN_THRESHOLD10Nombre de fingerprints de clé distincts essayés depuis une IP.
SSH_GUARD_KEY_SCAN_WINDOW_SECS300
SSH_GUARD_CONN_FLOOD_PER_SEC10Cap dur de nouvelles connexions TCP par IP par seconde. 0 ou u32::MAX = désactivé.
SSH_GUARD_CONN_FLOOD_BURST20Burst autorisé au-dessus du cap soutenu.
SSH_GUARD_MAX_CONCURRENT_PER_IP10Limite de sessions concurrentes par IP (placeholder, non encore appliqué).

22.4 Ban

VariableDéfautDescription
SSH_GUARD_AUTO_BAN_DURATION_SECS3600TTL des bans posés par les détecteurs. 0 = ban permanent.

22.5 Stockage

VariableDéfautDescription
SSH_GUARD_STORE_BACKENDhybridmemory (DashMap, perdu au restart), postgres (chaque write en DB), hybrid (RAM chaude + write-through DB + rehydrate au boot — défaut recommandé).
SSH_GUARD_STORE_FLUSH_INTERVAL_MS1000Intervalle entre deux flushs RAM → Postgres pour le mode hybrid.
SSH_GUARD_EVENTS_RETENTION_DAYS90Rétention de la table ssh_guard_events (utilisée par les détecteurs). Au-delà, les lignes sont purgées.

22.6 Observabilité

VariableDéfautDescription
SSH_GUARD_LOG_FORMATjsonjson (stable, fail2ban-friendly) ou text (debug uniquement).
SSH_GUARD_LOG_TARGETstderrstderr (via tracing → journald), file (fichier dédié uniquement), both (les deux).
SSH_GUARD_LOG_FILE/var/log/gitrust-ssh-guard.jsonChemin du fichier dédié si LOG_TARGET=file ou both. Logrotate quasi obligatoire (sinon le fichier grossit sans limite).
SSH_GUARD_METRICS_ENABLEDtrueExpose les métriques Prometheus (placeholder, exposition future).

22.7 Validations bloquantes au démarrage

gitrust refuse de démarrer si :

  • SSH_GUARD_PROXY_PROTOCOL est activé sans SSH_GUARD_TRUSTED_PROXIES (forge d’IP triviale sinon).
  • SSH_GUARD_LOG_TARGET=file ou both sans SSH_GUARD_LOG_FILE valide.

Le message d’erreur identifie la variable fautive.


Récapitulatif local vs production

DimensionLocal (dev)Production
DATABASE_URL hostlocalhostRéseau privé
SERVER_HOST0.0.0.0127.0.0.1 (derrière nginx)
SERVER_PORT3000 ou 40004000 (nginx devant)
SSH_LISTEN_ADDR0.0.0.0127.0.0.1 (nginx stream)
SSH_PUBLIC_HOSTlocalhostFQDN public
COOKIE_SECUREfalse (HTTP)true (HTTPS, forcé)
APP_DEBUGtruefalse (force sécurité)
EMAIL_BASE_URLhttp://localhost:...https://FQDN
JWT_SECRETValeur courte OKopenssl rand -hex 64
ADMIN_PASSWORDFaible acceptéFort (≥ 16 caractères)
ALLOW_REGISTRATIONtruefalse (instance fermée)
RUST_LOGdebuginfo ou warn
GIT_REPOS_BASE_PATHRelatif (./data)Absolu (/opt/gitrust)

Pour aller plus loin

Événements ssh-guard (JSON)

Référence stable du flux d’événements émis par la couche ssh-guard du serveur SSH gitrust. Ce format est garanti stable pour les consommateurs externes (fail2ban, Loki, Vector, SIEM). Tout changement incompatible passe par un nouveau nom d’événement.

Où trouver ces événements : selon SSH_GUARD_LOG_TARGET, dans le journald du service (stderr), dans /var/log/gitrust-ssh-guard.json (file), ou les deux (both). Voir Variables d’environnement — SSH_GUARD_*.


1. Forme générale

Tous les événements partagent trois champs :

ChampTypeDescription
eventstringNom snake_case du type d’événement (clé de filtrage).
tsstring ISO 8601 UTCHorodatage UTC de l’événement (ex. 2026-04-19T14:32:11.482Z).
ipstringAdresse IPv4 ou IPv6 réelle du client (après extraction PROXY si applicable).

Champs additionnels selon le type d’événement (voir tables ci-dessous).


2. Catalogue des événements

eventCatégorieÉmis parCas d’usage admin
connection_acceptedTraficSecureListenerAudit volume
connection_droppedDécisionSecureListener ou ConnectionFloodDetectorVolumétrie des refus
auth_failedAuthentificationAuthTrackerSource principale fail2ban
auth_succeededAuthentificationAuthTrackerAudit accès légitimes
brute_force_detectedDétectionBruteForceDetectorSignal fort fail2ban
user_enumeration_detectedDétectionUserEnumerationDetectorSignal fort fail2ban
key_scanning_detectedDétectionKeyScanningDetectorSignal fort fail2ban
connection_flood_detectedDétectionConnectionFloodDetectorSignal fort fail2ban
ip_bannedActionBanManagerBan à appliquer côté firewall (fail2ban)
ip_unbannedActionBanManagerLevée de ban (manuelle ou TTL)

3. Référence par événement

3.1 connection_accepted

Une connexion TCP a passé tous les contrôles ssh-guard. Le russh handshake va démarrer.

ChampTypeDescription
event"connection_accepted"
tsstringHorodatage UTC
session_idstring UUID v4Identifiant unique de session, présent ensuite dans tous les événements liés (auth_failed, auth_succeeded)
ipstringIP cliente réelle
{"event":"connection_accepted","ts":"2026-04-19T14:32:11.482Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42"}

3.2 connection_dropped

ssh-guard a refusé la connexion avant le handshake SSH.

ChampTypeDescription
event"connection_dropped"
tsstringHorodatage UTC
ipstringIP source (réelle si PROXY parsé, sinon peer_addr)
reasonenum stringVoir tableau des raisons ci-dessous

Valeurs de reason :

reasonSignification
bannedIP couverte par un ban actif (auto ou denylist admin)
flood_limitCap de connexions/seconde par IP atteint
proxy_header_missingPROXY protocol obligatoire mais en-tête absent (timeout)
untrusted_proxyEn-tête PROXY reçu d’un socket pas dans trusted_proxies
proxy_header_invalidEn-tête PROXY malformé ou version non autorisée
concurrent_limitLimite de sessions concurrentes par IP atteinte (placeholder, non actif)
{"event":"connection_dropped","ts":"2026-04-19T14:33:02.117Z","ip":"203.0.113.42","reason":"banned"}

3.3 auth_failed

Tentative d’authentification SSH refusée. Source principale pour fail2ban.

ChampTypeDescription
event"auth_failed"
tsstringHorodatage UTC
session_idstring UUIDLien avec le connection_accepted
ipstringIP cliente réelle
userstring ou nullNom d’utilisateur tenté (si fourni par le client)
methodenum stringMéthode SSH : none, password, public_key, keyboard_interactive, host_based
fingerprintstring ou nullFingerprint SHA256 de la clé publique tentée (forme SHA256:...) ou null si non public_key
{"event":"auth_failed","ts":"2026-04-19T14:32:13.221Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42","user":"root","method":"public_key","fingerprint":"SHA256:k1Qp9xJ8r6Z3HfV2Bn7tT5Cw"}

3.4 auth_succeeded

Tentative d’authentification SSH validée. Audit des accès légitimes.

ChampTypeDescription
event"auth_succeeded"
tsstringHorodatage UTC
session_idstring UUID
ipstring
userstringNom d’utilisateur authentifié
fingerprintstring ou nullFingerprint de la clé utilisée (peut être null si méthode sans clé)
{"event":"auth_succeeded","ts":"2026-04-19T14:32:14.005Z","session_id":"a4c2b8e1-9f3d-4d7e-8c11-0a5d9b6f4e22","ip":"203.0.113.42","user":"alice","fingerprint":"SHA256:p3Lm7nB6xQz9fK1WyT8c"}

3.5 brute_force_detected

Le seuil SSH_GUARD_BRUTE_FORCE_THRESHOLD a été atteint. Un événement ip_banned suit immédiatement.

ChampTypeDescription
event"brute_force_detected"
tsstringHorodatage UTC
ipstringIP fautive
countnumberNombre d’auth_failed comptés dans la fenêtre
window_secsnumberLargeur de la fenêtre (en secondes)
{"event":"brute_force_detected","ts":"2026-04-19T14:36:42.998Z","ip":"203.0.113.42","count":5,"window_secs":300}

3.6 user_enumeration_detected

Le seuil SSH_GUARD_USER_ENUM_THRESHOLD (nombre d’usernames distincts essayés depuis la même IP) a été atteint.

ChampTypeDescription
event"user_enumeration_detected"
tsstringHorodatage UTC
ipstring
distinct_usersnumberNombre d’usernames distincts dans la fenêtre
window_secsnumber
{"event":"user_enumeration_detected","ts":"2026-04-19T14:38:15.402Z","ip":"203.0.113.42","distinct_users":10,"window_secs":300}

3.7 key_scanning_detected

Le seuil SSH_GUARD_KEY_SCAN_THRESHOLD (nombre de fingerprints distincts essayés depuis la même IP) a été atteint.

ChampTypeDescription
event"key_scanning_detected"
tsstringHorodatage UTC
ipstring
distinct_keysnumberNombre de fingerprints distincts dans la fenêtre
window_secsnumber
{"event":"key_scanning_detected","ts":"2026-04-19T14:39:08.117Z","ip":"203.0.113.42","distinct_keys":10,"window_secs":300}

3.8 connection_flood_detected

Le cap SSH_GUARD_CONN_FLOOD_PER_SEC a été dépassé pour cette IP. Un connection_dropped avec reason="flood_limit" suit dans le même millième de seconde.

ChampTypeDescription
event"connection_flood_detected"
tsstringHorodatage UTC
ipstring
rate_per_secnumberCap nominal (valeur de SSH_GUARD_CONN_FLOOD_PER_SEC)
{"event":"connection_flood_detected","ts":"2026-04-19T14:40:01.555Z","ip":"203.0.113.42","rate_per_sec":10}

3.9 ip_banned

Un ban a été appliqué (auto par un détecteur, manuel par un admin, ou simulé en dry_run).

ChampTypeDescription
event"ip_banned"
tsstringHorodatage UTC
ipstringIP bannie (pour un CIDR > /32, l’adresse réseau)
reasonenum stringVoir tableau ci-dessous
expires_atstring ou nullHorodatage UTC d’expiration. null = ban permanent

Valeurs de reason :

reasonOrigine
brute_forceBruteForceDetector
user_enumerationUserEnumerationDetector
key_scanningKeyScanningDetector
connection_floodConnectionFloodDetector (rare : le flood drop ne pose pas de ban persistant par défaut)
admin_deny_listAjout admin dans l’ACL deny
manualBan manuel via UI/API admin
{"event":"ip_banned","ts":"2026-04-19T14:36:42.999Z","ip":"203.0.113.42","reason":"brute_force","expires_at":"2026-04-19T15:36:42.999Z"}

Mode dry_run : un ip_banned est émis pour signal externe (fail2ban) sans que ssh-guard inscrive le ban dans son store. Utile pour observer un nouveau seuil sans risque.

3.10 ip_unbanned

Un ban a été levé (TTL expiré ou action admin).

ChampTypeDescription
event"ip_unbanned"
tsstringHorodatage UTC
ipstring
manualbooleantrue = action admin, false = expiration auto
{"event":"ip_unbanned","ts":"2026-04-19T15:36:43.001Z","ip":"203.0.113.42","manual":false}

4. Filtrage côté outils externes

4.1 Fail2ban (filtre par regex sur le fichier JSON)

[Definition]
# Brute-force détecté → ban dur côté firewall
failregex = ^.*"event":"auth_failed".*"ip":"<HOST>".*$
ignoreregex =

Voir le template clé en main : template/security/fail2ban-gitrust-ssh-guard.conf et le how-to Durcir avec Fail2ban.

4.2 Loki (LogQL)

# Toutes les détections fortes (signal pour alerting Grafana)
{job="gitrust-ssh-guard"}
  | json
  | event=~"brute_force_detected|user_enumeration_detected|key_scanning_detected|connection_flood_detected"

4.3 jq (extraction shell)

# 10 IP les plus actives en échec d'auth sur la dernière heure
sudo journalctl -u gitrust --since "1 hour ago" --no-pager \
  | jq -r 'select(.event=="auth_failed") | .ip' \
  | sort | uniq -c | sort -rn | head -10

5. Pour aller plus loin

Paramètres dynamiques

Ce document explique comment fonctionne la configuration de la plateforme, les deux mécanismes en jeu, leur précédence, et les pièges à connaître.


Vue d’ensemble : deux systèmes distincts

Gitrust utilise deux systèmes de configuration indépendants :

SystèmeSourceModifiable à chaudVisible dans l’UI admin
Configuration statiqueFichier .env + variables d’environnementNon (restart requis)Non
Configuration dynamiqueTable app_settings (PostgreSQL)Oui (effet immédiat)Oui (/admin/settings)

Il existe un cas hybride pour OAuth (DB prioritaire, .env en fallback).


1. Configuration statique (.env)

Principe

Les variables d’environnement sont chargées une seule fois au démarrage par dotenvy::dotenv() et stockées dans des structs Rust immuables. Elles ne sont jamais relues après le boot.

Fichiers de référence

FichierRôle
.env.exampleTemplate documenté avec toutes les variables
.envConfiguration locale (gitignore)
.env.productionConfiguration de déploiement
.env.testConfiguration de tests

Structs de chargement

StructVariables concernées
GitrustConfigGIT_REPOS_BASE_PATH, SSH_PORT, SSH_LISTEN_ADDR, SSH_HOST_KEY_PATH, CI_*, IMPORT_*
AppConfigAPP_NAME, APP_THEME, APP_DEBUG
AuthConfigJWT_SECRET, JWT_EXPIRATION_MINUTES, SESSION_*, RATE_LIMIT_*, COOKIE_*
EmailConfigSMTP_*, EMAIL_*, IMAP_*

Comportement

  • Chargées dans RustwardenApp::build() au démarrage
  • Stockées dans des Arc<Config> partagés via l’état axum
  • Modifier le .env sans redémarrer n’a aucun effet
  • Ces variables ne sont PAS affichées dans l’interface admin

Liste des variables statiques (non exhaustive)

DATABASE_URL, SERVER_HOST, SERVER_PORT, RUST_LOG,
SSH_HOST_KEY_PATH, SSH_PORT, SSH_LISTEN_ADDR, SSH_PUBLIC_HOST,
JWT_SECRET, JWT_EXPIRATION_MINUTES, JWT_ISSUER,
REFRESH_TOKEN_EXPIRATION_DAYS, REMEMBER_ME_EXPIRATION_DAYS,
SESSION_TIMEOUT_MINUTES, SESSION_BACKEND,
RATE_LIMIT_LOGIN_PER_MINUTE, RATE_LIMIT_REFRESH_PER_MINUTE,
RATE_LIMIT_GENERAL_PER_MINUTE,
APP_DEBUG, COOKIE_SECURE, COOKIE_SAME_SITE,
ADMIN_USERNAME, ADMIN_EMAIL, ADMIN_PASSWORD,
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM,
EMAIL_BASE_URL, EMAIL_QUEUE_*,
APP_NAME, APP_THEME, DEFAULT_LOCALE,
GIT_REPOS_BASE_PATH,
CI_ENABLED, CI_MAX_CONCURRENT, CI_REMOTE_HOST, CI_WORKSPACE_PATH

2. Configuration dynamique (table app_settings)

Principe

Les réglages dynamiques sont stockés en base PostgreSQL dans la table app_settings et lus à chaque requête via AppSettingsService. Ils sont modifiables à chaud par un administrateur depuis l’interface /admin/settings.

Schéma de la table

CREATE TABLE app_settings (
    id          UUID PRIMARY KEY,
    key         VARCHAR(100) UNIQUE NOT NULL,
    value       TEXT NOT NULL,
    description TEXT,
    updated_by  UUID REFERENCES users(id) ON DELETE SET NULL,
    updated_at  TIMESTAMPTZ NOT NULL
);

Initialisation au démarrage

Au boot, AppSettingsService::initialize_default_settings() crée les réglages par défaut uniquement s’ils n’existent pas encore en base :

#![allow(unused)]
fn main() {
let existing = Self::get_setting(db, key).await?;
if existing.is_none() {
    // ... insert valeur par défaut
}
}

Conséquence critique : une fois qu’un réglage existe en base (créé au premier démarrage ou modifié via l’UI), il n’est jamais écrasé par un redémarrage ultérieur.

Liste des réglages dynamiques et leurs valeurs par défaut

CléDéfautDescription
app_domainenv::var("APP_DOMAIN") ou "localhost"Domaine de l’application
allow_registrationfalseAutoriser l’inscription publique
validation_email_requiredtrueExiger la validation email
audit_log_levelINFONiveau de log d’audit
audit_log_actions["create","update","delete","reset_password"]Actions auditées
password_min_length8Longueur minimum mot de passe
password_require_uppercasefalseExiger des majuscules
password_require_lowercasefalseExiger des minuscules
password_require_digitsfalseExiger des chiffres
password_require_specialfalseExiger des caractères spéciaux
password_change_require_emailtrueConfirmation email pour changement mdp
password_expiration_enabledfalseActiver l’expiration des mots de passe
password_expiration_days0Durée d’expiration (jours)
password_expiration_alert_enabledfalseAlerte email avant expiration
password_expiration_alert_days_before7Jours avant expiration pour alerter
oauth_enabledfalseActiver OAuth/SSO
oauth_google_enabledfalseActiver Google OAuth
oauth_github_enabledfalseActiver GitHub OAuth
oauth_discord_enabledfalseActiver Discord OAuth
oauth_microsoft_enabledfalseActiver Microsoft OAuth
oauth_redirect_base_url""URL de base pour les callbacks OAuth
oauth_auto_registertrueCréer un compte auto au premier login OAuth
oauth_link_existing_accounttrueLier un compte OAuth à un utilisateur existant
oauth_{provider}_client_id""Client ID du provider OAuth
oauth_{provider}_client_secret""Client secret (chiffré AES-256-GCM en base)
oauth_microsoft_tenant""Tenant Azure AD

3. Cas hybride : OAuth

OAuth est le seul sous-système qui combine les deux sources. La logique dans OAuthConfig::load() :

Pour chaque réglage OAuth :
  1. Chercher dans app_settings (DB)
     → si trouvé et non vide : utiliser cette valeur
  2. Sinon : fallback sur la variable d'environnement
     → si absente : utiliser la valeur par défaut codée en dur

Important : OAuthConfig::load() est appelé au démarrage. Le résultat est stocké dans un Arc et n’est pas relu dynamiquement. Donc :

  • Modifier un réglage OAuth via l’UI admin nécessite un redémarrage pour que OAuthConfig soit rechargé
  • Le .env n’est qu’un fallback si la DB n’a pas de valeur

4. Précédence et comportement au redémarrage

Réglages statiques (.env uniquement)

ActionEffet immédiatAprès restart
Modifier .envNonOui

Réglages dynamiques (DB uniquement)

ActionEffet immédiatAprès restart
Modifier via UI adminOuiOui (valeur en DB persiste)
Modifier le .envAucun effetAucun effet

Réglages hybrides (OAuth)

ActionEffet immédiatAprès restart
Modifier via UI adminNon (OAuthConfig est en Arc)Oui (DB prime sur .env)
Modifier .envNonSeulement si aucune valeur en DB

5. Piège principal : les variables .env fantômes

Certaines variables présentes dans .env.example donnent l’illusion de configurer des réglages qui sont en réalité gérés par la base de données :

Variable .envRéglage DB correspondantLa variable .env est-elle lue ?
ALLOW_REGISTRATION=trueallow_registrationNon — valeur par défaut codée en dur ("false")
EMAIL_VALIDATION_REQUIRED=truevalidation_email_requiredNon — valeur par défaut codée en dur ("true")
OAUTH_ENABLED=trueoauth_enabledOui, mais seulement en fallback si la DB n’a pas de valeur

Pour ALLOW_REGISTRATION et EMAIL_VALIDATION_REQUIRED :

  • La variable .env est documentée dans .env.example mais n’est jamais consultée par initialize_default_settings()
  • La valeur initiale est codée en dur dans le service
  • Seule la valeur en base de données (modifiable via /admin/settings) fait foi

Pour app_domain, c’est le seul réglage dynamique qui lit le .env comme seed initial.


6. Diagramme de décision

                    ┌─────────────────────────────┐
                    │  Quel type de réglage ?      │
                    └──────────┬──────────────────-┘
                               │
              ┌────────────────┼────────────────┐
              ▼                ▼                ▼
     Infrastructure       Fonctionnel        OAuth
     (port, JWT, SMTP)    (inscription,      (providers,
                          mots de passe)      secrets)
              │                │                │
              ▼                ▼                ▼
         .env seul        DB seule         DB + fallback .env
              │                │                │
              ▼                ▼                ▼
      Restart requis    Effet immédiat    Restart requis
                        via /admin/       (OAuthConfig en Arc)
                        settings

7. Guide pour les administrateurs

Modifier un réglage d’infrastructure

Éditer .env (ou .env.production) puis redémarrer le service :

# Éditer
vim .env

# Redémarrer
systemctl restart gitrust    # production
# ou Ctrl+C + cargo run      # dev

Modifier un réglage fonctionnel

Se connecter en tant qu’administrateur, aller dans /admin/settings, modifier la valeur, et cliquer “Save”. L’effet est immédiat, aucun redémarrage nécessaire.

Modifier un réglage OAuth

Via /admin/settings, modifier les valeurs OAuth puis redémarrer le service (la config OAuth est chargée en mémoire au boot et n’est pas relue dynamiquement).

Vérifier la valeur effective d’un réglage

Pour les réglages dynamiques, interroger la base directement :

SELECT key, value, updated_at, updated_by
FROM app_settings
WHERE key = 'allow_registration';

Pour les réglages statiques, vérifier les logs au démarrage (RUST_LOG=debug).


8. Guide pour les développeurs

Ajouter un nouveau réglage dynamique

  1. Ajouter le tuple (clé, valeur_défaut, description) dans AppSettingsService::initialize_default_settings() :

    #![allow(unused)]
    fn main() {
    ("ma_nouvelle_cle", "valeur_par_defaut", Some("Description pour l'UI admin")),
    }
  2. Lire la valeur dans le code via le service :

    #![allow(unused)]
    fn main() {
    // Boolean avec défaut
    let enabled = AppSettingsService::get_bool(&db, "ma_nouvelle_cle", false).await?;
    
    // String
    let val = AppSettingsService::get_setting(&db, "ma_nouvelle_cle").await?;
    }
  3. La valeur apparaîtra automatiquement dans /admin/settings.

Ajouter un nouveau réglage statique

  1. Ajouter la variable dans .env.example avec documentation complète
  2. Lire la variable dans le struct Config correspondant via env::var()
  3. La valeur ne sera pas visible dans l’interface admin

Ajouter un réglage hybride (DB + fallback .env)

Suivre le pattern OAuth dans crates/rustwarden-core/src/config/oauth.rs :

#![allow(unused)]
fn main() {
let ma_valeur = match db_val("ma_cle").await {
    Some(v) => v,
    None => env::var("MA_CLE").unwrap_or_else(|_| "defaut".to_string()),
};
}

Schéma de la base de données

Référence des tables PostgreSQL de gitrust, avec colonnes, index, contraintes et relations. Cette page concerne les tables visibles par un administrateur — pour les détails internes du code (migrations SeaORM, patterns de contribution), voir le manuel développeur.


Diagramme entité-relation

erDiagram
    users {
        uuid id PK
        string username UK
        string email UK
        string password_hash
        boolean must_change_password
        timestamptz last_login_at
        boolean email_verified
        timestamptz created_at
        timestamptz updated_at
    }

    resources {
        uuid id PK
        string resource_type
        uuid resource_id
        uuid owner_id FK
        boolean is_public
        timestamptz created_at
    }

    resource_shares {
        uuid id PK
        uuid resource_id FK
        uuid shared_with_user_id FK
        string permission_level
        uuid shared_by_user_id FK
        timestamptz created_at
    }

    repositories {
        uuid id PK
        uuid owner_id FK
        string slug
        string description
        string disk_path
        string default_branch
        boolean is_empty
        timestamptz created_at
        timestamptz updated_at
    }

    ssh_keys {
        uuid id PK
        uuid user_id FK
        string title
        string fingerprint UK
        string key_type
        text key_data
        timestamptz last_used_at
        timestamptz created_at
    }

    teams {
        uuid id PK
        uuid owner_id FK
        string slug
        string description
        timestamptz created_at
        timestamptz updated_at
    }

    team_members {
        uuid id PK
        uuid team_id FK
        uuid user_id FK
        string role
        timestamptz created_at
    }

    team_repository_access {
        uuid id PK
        uuid team_id FK
        uuid repository_id FK
        string permission
        timestamptz created_at
    }

    personal_access_tokens {
        uuid id PK
        uuid user_id FK
        string name
        string token_hash UK
        string scopes
        timestamptz expires_at
        timestamptz last_used_at
        timestamptz created_at
    }

    issues {
        uuid id PK
        uuid repository_id FK
        integer number
        uuid author_id FK
        string title
        text body
        string state
        uuid closed_by FK
        timestamptz created_at
        timestamptz updated_at
    }

    pull_requests {
        uuid id PK
        uuid repository_id FK
        integer number
        uuid author_id FK
        string title
        text body
        string source_branch
        string target_branch
        string state
        timestamptz merged_at
        uuid merged_by FK
        string merge_commit_sha
        timestamptz created_at
        timestamptz updated_at
    }

    labels {
        uuid id PK
        uuid owner_id FK
        uuid repository_id FK
        string name
        string color
        string description
        string label_type
        timestamptz created_at
    }

    app_settings {
        uuid id PK
        string key UK
        text value
        text description
        uuid updated_by FK
        timestamptz updated_at
    }

    audit_log {
        uuid id PK
        uuid actor_id FK
        string action
        string resource_type
        uuid resource_id
        string ip_address
        text details
        timestamptz created_at
    }

    users ||--o{ repositories : "possède"
    users ||--o{ ssh_keys : "enregistre"
    users ||--o{ teams : "crée"
    users ||--o{ resources : "possède"
    users ||--o{ resource_shares : "partage avec"
    users ||--o{ personal_access_tokens : "crée"
    users ||--o{ audit_log : "acteur"
    repositories ||--o| resources : "enregistré dans"
    resources ||--o{ resource_shares : "partagé"
    teams ||--o{ team_members : "contient"
    users ||--o{ team_members : "membre de"
    teams ||--o{ team_repository_access : "accède à"
    repositories ||--o{ team_repository_access : "accessible par"
    repositories ||--o{ issues : "contient"
    repositories ||--o{ pull_requests : "contient"

Tables rustwarden-core (framework)

Ces tables sont gérées par le framework. Ne les modifiez pas directement.

TableRôle
usersComptes utilisateurs (authentification, profil)
roles / permissions / role_permissions / user_rolesRBAC global (admin, user)
resourcesRegistre générique de ressources (ownership, visibilité publique/privée)
resource_sharesPartage de ressources avec niveaux (read/write/admin)
refresh_tokensRefresh tokens JWT
jwt_blacklistTokens JWT révoqués (invalidation après déconnexion)
app_settingsConfiguration dynamique de l’application
audit_logJournal d’audit de toutes les actions significatives
oauth_accountsComptes OAuth liés

Tables gitrust

repositories

Dépôts Git hébergés sur la plateforme.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
owner_idUUIDFK → users.id CASCADE, NOT NULLPropriétaire du dépôt
slugVARCHAR(64)NOT NULLNom URL du dépôt (ex. mon-projet)
descriptionVARCHAR(500)NULLDescription affichée dans l’UI
disk_pathVARCHAR(512)NOT NULLChemin absolu du bare repo sur disque
default_branchVARCHAR(255)NOT NULL, DEFAULT 'main'Branche par défaut
is_emptyBOOLEANNOT NULL, DEFAULT trueVrai si aucun commit poussé
created_atTIMESTAMPTZNOT NULLDate de création
updated_atTIMESTAMPTZNOT NULLDate de dernière modification

Index : UNIQUE(owner_id, slug), INDEX(owner_id)

Chaque dépôt est aussi enregistré dans resources avec resource_type = "repository" pour gérer la visibilité (public/privé) et le partage via resource_shares.

ssh_keys

Clés publiques SSH des utilisateurs pour l’authentification Git.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
user_idUUIDFK → users.id CASCADE, NOT NULLPropriétaire de la clé
titleVARCHAR(255)NOT NULLLibellé (ex. MacBook Pro)
fingerprintVARCHAR(128)UNIQUE, NOT NULLFingerprint SHA256 calculé à l’insertion
key_typeVARCHAR(32)NOT NULLType : ssh-ed25519, ssh-rsa, ecdsa-*
key_dataTEXTNOT NULLClé au format authorized_keys
last_used_atTIMESTAMPTZNULLDernière utilisation SSH
created_atTIMESTAMPTZNOT NULLDate d’ajout

Validation : types autorisés — Ed25519, RSA ≥ 4096 bits, ECDSA P-256/P-384.

teams

Groupes d’utilisateurs pour le partage d’accès aux dépôts.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
owner_idUUIDFK → users.id CASCADE, NOT NULLCréateur / administrateur de l’équipe
slugVARCHAR(64)NOT NULLNom URL de l’équipe
descriptionVARCHAR(500)NULLDescription
created_atTIMESTAMPTZNOT NULLDate de création
updated_atTIMESTAMPTZNOT NULLDate de dernière modification

Index : UNIQUE(owner_id, slug)

team_members

Appartenance d’un utilisateur à une équipe avec un rôle.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
team_idUUIDFK → teams.id CASCADE, NOT NULLÉquipe
user_idUUIDFK → users.id CASCADE, NOT NULLMembre
roleVARCHAR(20)NOT NULL, DEFAULT 'read'Rôle dans l’équipe
created_atTIMESTAMPTZNOT NULLDate d’ajout

Index : UNIQUE(team_id, user_id), INDEX(user_id)

team_repository_access

Permissions d’accès d’une équipe sur un dépôt.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
team_idUUIDFK → teams.id CASCADE, NOT NULLÉquipe
repository_idUUIDFK → repositories.id CASCADE, NOT NULLDépôt
permissionVARCHAR(20)NOT NULL, DEFAULT 'read'Niveau : read, write, admin
created_atTIMESTAMPTZNOT NULLDate d’attribution

Index : UNIQUE(team_id, repository_id), INDEX(repository_id)

personal_access_tokens

Jetons d’accès personnels pour l’authentification API et Git HTTPS.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
user_idUUIDFK → users.id CASCADE, NOT NULLPropriétaire du jeton
nameVARCHAR(255)NOT NULLNom descriptif (ex. CI pipeline)
token_hashVARCHAR(128)UNIQUE, NOT NULLHash SHA-256 du jeton (le jeton brut n’est jamais stocké)
scopesVARCHAR(255)NOT NULLPortées autorisées (séparées par espace)
expires_atTIMESTAMPTZNULLExpiration (NULL = sans expiration)
last_used_atTIMESTAMPTZNULLDernière utilisation
created_atTIMESTAMPTZNOT NULLDate de création

issues

Tickets de suivi (bugs, tâches, demandes de fonctionnalité).

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
repository_idUUIDFK → repositories.id CASCADEDépôt propriétaire
numberINTEGERNOT NULL (auto-incrémenté par dépôt)Numéro affiché (#1, #2…)
author_idUUIDFK → users.id CASCADEAuteur
titleVARCHAR(255)NOT NULLTitre
bodyTEXTNOT NULL DEFAULT ''Corps (Markdown)
stateVARCHAR(20)NOT NULL DEFAULT 'open'État : open, closed
closed_byUUIDFK → users.id NULLUtilisateur qui a fermé l’issue
created_atTIMESTAMPTZNOT NULLDate de création
updated_atTIMESTAMPTZNOT NULLDate de dernière modification

Index : UNIQUE(repository_id, number), INDEX(repository_id, state)

pull_requests

Propositions de merge d’une branche source vers une branche cible.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
repository_idUUIDFK → repositories.id CASCADEDépôt
numberINTEGERNOT NULL (auto-incrémenté par dépôt)Numéro affiché
author_idUUIDFK → users.id CASCADEAuteur
titleVARCHAR(255)NOT NULLTitre
bodyTEXTNOT NULL DEFAULT ''Description
source_branchVARCHAR(255)NOT NULLBranche source
target_branchVARCHAR(255)NOT NULLBranche cible (ex. main)
stateVARCHAR(20)NOT NULL DEFAULT 'open'État : open, merged, closed
merged_atTIMESTAMPTZNULLDate de merge
merged_byUUIDFK → users.id NULLUtilisateur qui a mergé
merge_commit_shaVARCHAR(40)NULLSHA du commit de merge
created_atTIMESTAMPTZNOT NULLDate de création
updated_atTIMESTAMPTZNOT NULLDate de dernière modification

Index : UNIQUE(repository_id, number), INDEX(repository_id, state)

labels

Étiquettes à deux niveaux : classification (scope owner) et subject (scope dépôt).

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
owner_idUUIDFK → users.id NULLPropriétaire (labels de classification)
repository_idUUIDFK → repositories.id NULLDépôt (labels de sujet)
nameVARCHAR(50)NOT NULLNom du label
colorVARCHAR(7)NOT NULLCouleur hexadécimale (ex. #e11d48)
descriptionVARCHAR(255)NULLDescription
label_typeVARCHAR(20)NOT NULL'classification' ou 'subject'
created_atTIMESTAMPTZNOT NULLDate de création

Index : UNIQUE(owner_id, repository_id, name, label_type)

app_settings

Configuration dynamique de l’application (modifiable à chaud via /admin/settings).

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
keyVARCHAR(100)UNIQUE, NOT NULLClé de configuration (ex. allow_registration)
valueTEXTNOT NULLValeur sous forme de texte
descriptionTEXTNULLDescription affichée dans l’UI admin
updated_byUUIDFK → users.id ON DELETE SET NULLDernier administrateur ayant modifié
updated_atTIMESTAMPTZNOT NULLDate de dernière modification

audit_log

Journal d’audit de toutes les actions significatives.

ColonneTypeContraintesDescription
idUUIDPKIdentifiant unique
actor_idUUIDFK → users.id NULLUtilisateur auteur de l’action
actionVARCHAR(50)NOT NULLType d’action (create, update, delete, reset_password…)
resource_typeVARCHAR(50)NULLType de ressource concernée
resource_idUUIDNULLIdentifiant de la ressource
ip_addressVARCHAR(45)NULLAdresse IP source
detailsTEXTNULLDétails supplémentaires (JSON)
created_atTIMESTAMPTZNOT NULLHorodatage

Modèle de permissions

graph TB
    subgraph "Accès individuel"
        Owner["Owner (resources.owner_id)"]
        Share["resource_shares (read/write/admin)"]
        Public["Accès public (resources.is_public)"]
    end

    subgraph "Accès équipe"
        TM["team_members (rôle dans l'équipe)"]
        TRA["team_repository_access (permission sur le dépôt)"]
    end

    subgraph "Rôle effectif"
        Eff["max(individuel, équipe)"]
    end

    Owner -->|"Owner (full)"| Eff
    Share -->|"read/write/admin"| Eff
    Public -->|"read only"| Eff
    TM --> TRA
    TRA -->|"read/write/admin"| Eff
RôlePermissions
ReaderClone, navigation (lecture seule)
DeveloperReader + push
MaintainerDeveloper + paramètres, collaborateurs, protections de branche
OwnerMaintainer + suppression, transfert

Ordre des migrations

#MigrationTables créées
1m20260305_000001_initial_schemausers, refresh_tokens, jwt_blacklist… (core)
2m20260306_000002_create_app_settings_tableapp_settings (core)
3m20260309_000003_create_permissions_tablesroles, permissions, role_permissions, user_roles (core)
4m20260309_000004_create_resources_tablesresources, resource_shares (core)
5m20260310_000005_create_oauth_accounts_tableoauth_accounts (core)
6m20260325_000001_create_repositoriesrepositories
7m20260325_000002_create_ssh_keysssh_keys
8m20260325_000003_create_teamsteams
9m20260325_000004_create_team_membersteam_members
10m20260325_000005_create_team_repository_accessteam_repository_access
11m20260327_000006_create_personal_access_tokenspersonal_access_tokens
12m20260327_000007_create_issuesissues
13m20260327_000008_create_issue_commentsissue_comments
14m20260327_000009_create_labelslabels, issue_labels
15m20260327_000010_add_label_typealter labels
16m20260327_000011_labels_owner_scopealter labels
17m20260327_000012_create_pull_requestspull_requests
18m20260327_000013_create_pr_commentspr_comments

Toutes les migrations sont appliquées automatiquement au démarrage par AppMigrator. Elles sont idempotentes.


Pour aller plus loin

Ports et services

Référence des ports réseau utilisés par gitrust et ses dépendances.


Tableau des ports

PortProtocoleServiceProcessusBinding par défautConfigurable
4000TCP/HTTPInterface web + API RESTgitrust (axum)SERVER_HOST:SERVER_PORTOui
2222TCP/SSHServeur Git SSHgitrust (Russh)SSH_LISTEN_ADDR:SSH_PORTOui
5432TCPPostgreSQLpostgres127.0.0.1:5432Via PG config
6379TCPRedis (sessions)redis-server127.0.0.1:6379Via Redis config
25TCP/SMTPEnvoi e-mail sortant (non chiffré)Client SMTP gitrustOutbound seulementVia SMTP_PORT
465TCP/SMTPSEnvoi e-mail sortant (TLS natif)Client SMTP gitrustOutbound seulementVia SMTP_PORT
587TCP/SMTP+STARTTLSEnvoi e-mail sortant (recommandé)Client SMTP gitrustOutbound seulementVia SMTP_PORT

Détail par service

Port 4000 — Interface web et API REST (HTTP)

gitrust expose son interface web HTMX et son API REST v1 sur ce port via le framework axum.

Configuration :

SERVER_HOST=127.0.0.1   # derrière un reverse-proxy (recommandé en prod)
SERVER_PORT=4000

En production : gitrust écoute sur 127.0.0.1:4000 et un reverse-proxy (Nginx, Caddy, Traefik) termine TLS et proxifie vers ce port. Les clients n’accèdent jamais directement au port 4000.

Règle firewall (si reverse-proxy sur la même machine) :

# Aucune ouverture nécessaire depuis l'extérieur — le proxy écoute sur 443
# Bloquer le port 4000 depuis l'extérieur :
sudo ufw deny 4000/tcp

En développement : gitrust peut écouter sur 0.0.0.0:4000 pour un accès depuis le LAN.


Port 2222 — Serveur Git SSH (Russh)

gitrust intègre son propre serveur SSH via la bibliothèque Russh — ce n’est pas le démon sshd du système d’exploitation. Les deux peuvent coexister sans conflit.

Configuration :

SSH_PORT=2222
SSH_LISTEN_ADDR=0.0.0.0    # accès direct
# ou
SSH_LISTEN_ADDR=127.0.0.1  # derrière nginx stream proxy

Accès direct (recommandé pour la simplicité) : ouvrir le port 2222 dans le firewall.

sudo ufw allow 2222/tcp

URL de clone : ssh://git@VOTRE_DOMAINE:2222/owner/repo.git

Via nginx stream proxy (port 22) : si vous souhaitez que les utilisateurs clonent sur le port standard 22, configurez nginx comme proxy de flux TCP :

# Dans nginx.conf, section stream (hors section http)
stream {
    upstream gitrust_ssh {
        server 127.0.0.1:2222;
    }
    server {
        listen 22;
        proxy_pass gitrust_ssh;
    }
}

Dans ce cas, SSH_LISTEN_ADDR=127.0.0.1 et SSH_PUBLIC_HOST=VOTRE_DOMAINE (sans port dans les URLs de clone).

Attention : si le port 22 est déjà utilisé par sshd pour l’administration du serveur, déplacez sshd sur un autre port avant de configurer le proxy nginx.

Règle firewall :

sudo ufw allow 2222/tcp   # accès direct
# ou
sudo ufw allow 22/tcp     # si proxy nginx stream

Port 5432 — PostgreSQL

PostgreSQL doit toujours écouter sur 127.0.0.1 (ou un réseau privé), jamais sur 0.0.0.0 en production.

Vérification :

sudo -u postgres psql -c "SHOW listen_addresses;"
# Attendu : localhost ou 127.0.0.1

Règle firewall : aucune ouverture vers l’extérieur.

# Bloquer explicitement depuis l'extérieur (si ufw)
sudo ufw deny 5432/tcp

Pour une architecture multi-serveurs (gitrust et PostgreSQL sur des machines distinctes), utilisez un réseau privé ou un tunnel SSH — jamais une exposition directe sur Internet.


Port 6379 — Redis (sessions multi-instances)

Redis n’est nécessaire que si SESSION_BACKEND=redis dans .env. Par défaut, gitrust utilise PostgreSQL pour les sessions.

Configuration Redis (dans /etc/redis/redis.conf) :

bind 127.0.0.1
requirepass VOTRE_MOT_DE_PASSE_REDIS

Règle firewall : aucune ouverture vers l’extérieur.


Ports SMTP sortants (25, 465, 587)

Ces ports sont utilisés en sortant uniquement : gitrust se connecte au serveur SMTP configuré dans SMTP_HOST. Gitrust n’héberge pas de serveur SMTP.

PortUsageRecommandation
25SMTP non chiffréInterdit en production, souvent bloqué par les FAI et hébergeurs
465SMTPS (TLS natif)Recommandé avec SMTP_USE_TLS=true
587SMTP + STARTTLSRecommandé avec SMTP_USE_STARTTLS=true

Aucune règle firewall entrante nécessaire pour SMTP.


Architecture réseau par topologie

Développement local

Navigateur → http://localhost:4000  (direct, pas de proxy)
Git client  → ssh://localhost:2222

Tous les ports en 0.0.0.0, COOKIE_SECURE=false, APP_DEBUG=true.

Production mono-machine (recommandé)

Internet → :443 (HTTPS) → nginx → 127.0.0.1:4000 (gitrust HTTP)
Internet → :2222 (SSH)  → gitrust directement
Internet ← SMTP sortant → smtp.prestataire.com:587
SERVER_HOST=127.0.0.1
SERVER_PORT=4000
SSH_LISTEN_ADDR=0.0.0.0
SSH_PORT=2222

Règles firewall :

sudo ufw allow 443/tcp   # HTTPS via nginx
sudo ufw allow 2222/tcp  # Git SSH
sudo ufw deny 4000/tcp   # Masqué derrière nginx
sudo ufw deny 5432/tcp   # PostgreSQL interne uniquement

Production avec proxy SSH sur port 22

Internet → :22 (SSH) → nginx stream → 127.0.0.1:2222 (gitrust SSH)
SSH_LISTEN_ADDR=127.0.0.1
SSH_PORT=2222
SSH_PUBLIC_HOST=gitrust.votre-domaine.com

URL de clone : git@gitrust.votre-domaine.com:owner/repo.git


Pour aller plus loin

Conformité ANSSI PA-074

Checklist de conformité au guide PA-074 de l’ANSSI (Recommandations de sécurité pour les applications web) appliquée à une instance gitrust. Pour chaque point : statut dans gitrust et action requise de l’administrateur.

Base : docs/QA_RULES.md du dépôt gitrust source, enrichi des contrôles applicables à l’administration d’une instance.


1. Authentification et gestion des sessions

Critère ANSSIStatut gitrustAction administrateur requise
Mot de passe haché avec algorithme fort (bcrypt/Argon2)Implémenté (bcrypt)Aucune — automatique
Longueur minimale de mot de passe configurableImplémenté (password_min_length dans app_settings)Configurer password_min_length ≥ 12 via /admin/settings
Complexité du mot de passe configurableImplémenté (majuscules, minuscules, chiffres, spéciaux)Activer les règles souhaitées via /admin/settings
Expiration des mots de passeImplémenté (password_expiration_enabled)Activer si requis par votre politique SSI
Tokens comparés en temps constantImplémenté (subtle::ConstantTimeEq)Aucune
Durée de vie des sessions limitéeImplémenté (SESSION_TIMEOUT_MINUTES, défaut 30 min)Ajuster selon votre politique (recommandé : ≤ 30 min)
Invalidation de session à la déconnexionImplémenté (JWT blacklist + refresh token révoqué)Aucune
Refresh token rotation à chaque usageImplémentéAucune
Durée « Se souvenir de moi » bornéeImplémenté (≤ 90 jours, warning > 60 jours)Vérifier REMEMBER_ME_EXPIRATION_DAYS ≤ 30
Authentification multi-facteurs (2FA TOTP)ImplémentéActiver totp_force_enabled=true via /admin/settings pour forcer le 2FA sur tous les comptes
Protection contre le brute force (rate limiting)Implémenté (RATE_LIMIT_LOGIN_PER_MINUTE)Vérifier RATE_LIMIT_LOGIN_PER_MINUTE ≤ 5 ; ajouter Fail2ban

2. Chiffrement des communications

Critère ANSSIStatut gitrustAction administrateur requise
HTTPS obligatoire en productionNon géré par gitrust (délégué au reverse-proxy)Configurer TLS sur Nginx/Caddy avec certificat valide (Let’s Encrypt ou CA interne)
HSTS activéNon géré par gitrustAjouter Strict-Transport-Security: max-age=63072000; includeSubDomains dans Nginx
TLS ≥ 1.2 uniquementNon géré par gitrustConfigurer ssl_protocols TLSv1.2 TLSv1.3 dans Nginx
Cookies Secure sur HTTPSImplémenté (COOKIE_SECURE=true si APP_DEBUG=false)Vérifier APP_DEBUG=false en production
Cookies HttpOnlyImplémenté (défaut framework)Aucune
Cookies SameSite=Lax minimumImplémenté (COOKIE_SAME_SITE=Lax par défaut)Aucune
SSH via Ed25519 (algorithme moderne)Implémenté (Russh, clé hôte Ed25519)Aucune
TLS pour SMTP sortantImplémenté (SMTP_USE_TLS / SMTP_USE_STARTTLS)Vérifier SMTP_USE_STARTTLS=true ou SMTP_USE_TLS=true ; jamais les deux à false

3. Protection contre les injections et les failles web

Critère ANSSIStatut gitrustAction administrateur requise
Validation des entrées utilisateur (formulaires, params)Implémenté (newtypes RepoSlug, Fingerprint, etc.)Aucune
Protection CSRF sur toutes les actions mutantesImplémenté (token CSRF sur POST/PUT/DELETE)Aucune
Pas d’injection SQL (ORM paramétré)Implémenté (SeaORM, requêtes préparées)Aucune
Pas d’accès inter-utilisateurs (anti-IDOR)Implémenté (vérification user_id côté serveur)Aucune
Path traversal bloqué (dépôts Git)Implémenté (RepoSlug validé, pas de .. ou /)Aucune
Content Security PolicyPartiellement implémenté (CSP bloque les CDN externes)Vérifier les en-têtes CSP dans Nginx si souhaité
Pas de ressources CDN externesImplémenté (zéro CDN, tout servi depuis static/)Aucune
unsafe_code interdit (Rust)Implémenté (#![forbid(unsafe_code)] dans core/web/hooks)Aucune

4. Gestion des secrets et des clés

Critère ANSSIStatut gitrustAction administrateur requise
JWT_SECRET minimum 32 bytesImplémenté (refus au démarrage si trop court)Générer avec openssl rand -hex 64
Secrets OAuth chiffrés en base (AES-256-GCM)ImplémentéDéfinir OAUTH_ENCRYPTION_KEY avec openssl rand -hex 32
Personal access tokens hachés (SHA-256)ImplémentéAucune
Secrets jamais logguésImplémenté (pas de Display/Debug sur les secrets)Vérifier RUST_LOG=info ou warn en production
Clé SSH hôte avec permissions restrictivesImplémenté (600, owner = user systemd)Vérifier après restauration depuis sauvegarde
Rotation de la clé JWT possiblePossible (modifier JWT_SECRET + redémarrage)Planifier une rotation annuelle ou en cas de compromission
Zeroize des types sensiblesImplémenté (Zeroize/ZeroizeOnDrop)Aucune

5. Journalisation et audit

Critère ANSSIStatut gitrustAction administrateur requise
Journal d’audit des actions significativesImplémenté (table audit_log)Vérifier que audit_log_actions couvre vos besoins via /admin/settings
Horodatage des événementsImplémenté (TIMESTAMPTZ avec fuseau)Vérifier le fuseau horaire du serveur (timedatectl)
IP source enregistréeImplémenté (ip_address dans audit_log)Si derrière proxy : vérifier que X-Real-IP est transmis par Nginx
Non-répudiation (acteur identifié)Implémenté (actor_idusers.id)Aucune
Conservation des logs ≥ 12 moisNon automatique (pas de purge auto actuellement)Planifier une purge manuelle annuelle en conservant les événements de sécurité
Logs système via journaldImplémenté (tous les logs gitrust dans journald)Configurer journald.conf avec SystemMaxUse et MaxRetentionSec appropriés
Export des logs d’auditImplémenté (CSV depuis /admin/audit)Archiver régulièrement les exports CSV

6. Gestion des accès et des droits

Critère ANSSIStatut gitrustAction administrateur requise
Principe du moindre privilègeImplémenté (4 rôles : Reader/Developer/Maintainer/Owner)Vérifier que les utilisateurs ont le niveau minimal nécessaire
Séparation des rôles (utilisateur / admin)Implémenté (rôle admin global distinct des rôles dépôt)Limiter le nombre de comptes admin
Révocation immédiate des accèsImplémenté (désactivation compte = effet immédiat)Désactiver les comptes des personnes ayant quitté l’équipe
Inscription publique désactivée par défautImplémenté (allow_registration=false par défaut)Garder false pour les instances privées
Validation e-mail obligatoireImplémenté (validation_email_required=true par défaut)Maintenir activé en production
Audit des créations/suppressions de comptesImplémenté (enregistré dans audit_log)Consulter régulièrement /admin/audit

7. Durcissement système (responsabilité de l’administrateur)

Ces points sont hors du périmètre de gitrust mais nécessaires pour la conformité globale.

Critère ANSSIAction requise
Service lancé sous utilisateur non-rootCréer l’utilisateur gitrust (voir tutoriel 02)
Durcissement systemdUtiliser les directives NoNewPrivileges, ProtectSystem=strict, PrivateTmp (incluses dans gitrust.service)
Mises à jour de sécurité OSsudo apt-get update && apt-get upgrade planifié (cron/unattended-upgrades)
Fail2ban contre le brute force SSH/HTTPVoir Durcir avec Fail2ban
Sauvegarde chiffrée hors siteChiffrer les archives de sauvegarde avec GPG ou age ; stocker sur un système distinct
Inventaire des dépendances (SBOM)Activer CI_SBOM_ENABLED=true et CI_DTRACK_ENABLED=true pour le suivi CVE

Résumé des actions prioritaires

Pour une instance conforme PA-074, effectuez ces actions dans l’ordre :

  1. TLS obligatoire — configurer Nginx avec certificat valide, HSTS, TLS 1.2+ uniquement
  2. JWT_SECRET fortopenssl rand -hex 64 dans .env
  3. APP_DEBUG=false — force COOKIE_SECURE=true automatiquement
  4. Forcer le 2FAtotp_force_enabled=true dans /admin/settings
  5. Politique de mots de passepassword_min_length=12 et complexité dans /admin/settings
  6. Inscription ferméeallow_registration=false dans /admin/settings
  7. Rate limitingRATE_LIMIT_LOGIN_PER_MINUTE=3 + Fail2ban
  8. Sauvegarde chiffrée — script backup.sh + chiffrement GPG + stockage hors site
  9. Conservation des logs — configurer journald et archivage CSV trimestriel
  10. SBOMCI_SBOM_ENABLED=true + Dependency-Track pour le suivi CVE

Pour aller plus loin

Explanations — Manuel d’administration

Les explanations construisent votre modèle mental de gitrust. Elles répondent à la question « Pourquoi ça fonctionne ainsi ? » et vous donnent le recul nécessaire pour prendre de bonnes décisions opérationnelles — bien au-delà de ce que les guides pratiques peuvent vous offrir.

Ces pages ne contiennent pas de commandes à exécuter. Pour les recettes, allez dans Guides pratiques (how-to). Pour les tableaux de valeurs, allez dans Référence.


Carte des explanations

graph TB
    A[Architecture globale<br/>Composants + flux] --> B[Modèle de déploiement<br/>Topologies + limites]
    A --> C[Stratégie de sauvegarde<br/>3-2-1 + RPO/RTO]
    A --> D[CI Dagger<br/>Easy Mode vs Power Mode]

    style A fill:#e3f2fd
    style B fill:#f3e5f5
    style C fill:#e8f5e9
    style D fill:#fff3e0

Pages d’explanation

Architecture globale

Composants au runtime (processus axum, serveur SSH Russh, workers CI/Import, PostgreSQL, système de fichiers), flux d’une requête HTTP et d’un push SSH. Implications opérationnelles : pourquoi la clé SSH hôte ne doit jamais être régénérée, pourquoi la base de données et les dépôts bare doivent être sauvegardés ensemble.

Lisez cette page quand : vous débutez en administration gitrust, vous cherchez à comprendre quel composant est responsable d’un comportement, ou vous voulez anticiper l’impact d’un redémarrage.

Modèle de déploiement

Les trois topologies (dev local, mono-machine production, haute disponibilité), leurs compromis coût/complexité/disponibilité, les limites actuelles de la mise à l’échelle horizontale (workers stateful, volume partagé pour les dépôts bare).

Lisez cette page quand : vous évaluez si votre équipe a besoin d’une architecture HA, vous planifiez une migration vers un nouveau serveur, ou vous expliquez gitrust à un décideur technique.

CI Dagger : Easy Mode vs Power Mode

Pourquoi gitrust utilise Dagger plutôt qu’un système CI YAML classique. Différence entre le profil .gitrust-ci.yml (Easy Mode, interprété par le moteur CI de la plateforme) et le module .dagger/ (Power Mode, code natif Dagger). Quand conseiller le passage de l’un à l’autre.

Lisez cette page quand : un utilisateur vous demande pourquoi sa CI ne fait pas ce qu’il veut, vous évaluez les capacités CI de gitrust, ou vous voulez comprendre les implications opérationnelles des deux modes.

Stratégie de sauvegarde

La règle 3-2-1 appliquée aux trois sources de vérité de gitrust. RPO et RTO réalistes pour une instance mono-machine. Comparaison pg_dump vs réplication WAL. Pourquoi tester la restauration (drill) est non-négociable.

Lisez cette page quand : vous concevez votre politique de sauvegarde, vous devez justifier un choix technique devant votre direction, ou vous voulez comprendre ce que vous perdriez dans différents scénarios d’incident.


Comprendre l’architecture globale de gitrust

Ce que vous allez comprendre

  • Identifier les processus qui s’exécutent au sein d’une instance gitrust et leur rôle respectif
  • Analyser le chemin d’une requête HTTP et d’un push SSH du client jusqu’à la réponse
  • Évaluer les implications opérationnelles de cette architecture pour les décisions d’administration (sauvegarde, mise à l’échelle, durcissement)

Le problème concret

Vous administrez gitrust et vous devez décider : « Sur quel processus dois-je redémarrer quand je modifie la configuration SMTP ? Pourquoi la clé SSH hôte ne doit-elle jamais être régénérée ? Qu’est-ce qui se passe si PostgreSQL est indisponible pendant 2 minutes ? »

Ces questions n’ont de réponse claire que si vous comprenez l’architecture interne.


L’analogie

Pensez à gitrust comme à un immeuble de bureaux avec un seul accueil (le binaire gitrust) qui abrite plusieurs services distincts :

  • Le guichet HTTP reçoit les navigateurs et les appels API
  • Le guichet SSH reçoit les clients git
  • La salle des archives (PostgreSQL) conserve toutes les données
  • Les ateliers (workers CI, import) font du travail en arrière-plan
  • Le vestiaire (système de fichiers) garde les manteaux (les dépôts bare)

Un seul processus, mais plusieurs « couloirs » internes. Si l’immeuble ferme (redémarrage), tous les guichets ferment simultanément.


Le modèle

Composants au runtime

graph TB
    subgraph "Processus gitrust (binaire unique)"
        AX[Serveur HTTP axum<br/>:4000]
        GUARD[ssh-guard<br/>SecureListener + détecteurs]
        SSH[Serveur SSH Russh<br/>:2222]
        CIW[Worker CI async<br/>Dagger]
        IMW[Worker Import async<br/>git clone]
        EMW[Worker Email async<br/>SMTP queue]
    end

    subgraph "Dépendances externes"
        PG[(PostgreSQL<br/>:5432)]
        FS[Système de fichiers<br/>GIT_REPOS_BASE_PATH]
        SMTP[Serveur SMTP<br/>sortant]
        DAGGER[Dagger Engine<br/>Docker]
    end

    subgraph "Clients"
        BR[Navigateur / API]
        GIT[Client git SSH]
        CI[Push git → CI trigger]
    end

    BR -->|HTTP/HTTPS via nginx| AX
    GIT -->|SSH :2222| GUARD
    GUARD -->|"AcceptOutcome::Accepted"| SSH
    CI -->|git push → hook| CIW

    AX -->|SeaORM| PG
    AX -.->|"admin ACL/ban"| GUARD
    GUARD -->|Bans, ACL, events| PG
    SSH -->|Lit authorized_keys| PG
    SSH -->|git-receive-pack| FS
    AX -->|Lit/écrit dépôts| FS
    CIW -->|dagger call| DAGGER
    IMW -->|git clone| FS
    EMW -->|SMTP| SMTP

Flux d’une requête HTTP (ex. : afficher un dépôt)

sequenceDiagram
    participant B as Navigateur
    participant N as Nginx (TLS)
    participant AX as axum handler
    participant DB as PostgreSQL
    participant FS as Système de fichiers

    B->>N: GET https://gitrust.domain/alice/mon-depot
    N->>AX: proxy_pass http://127.0.0.1:4000/alice/mon-depot
    AX->>DB: SELECT * FROM repositories WHERE owner=alice AND slug=mon-depot
    DB-->>AX: Repository { id, disk_path, ... }
    AX->>FS: git log --oneline HEAD (libgit2)
    FS-->>AX: Liste des commits
    AX-->>N: HTML (template Askama rendu)
    N-->>B: HTTP 200 + HTML

Flux d’un push SSH (ex. : git push origin main)

sequenceDiagram
    participant G as git client
    participant SSH as Russh :2222
    participant DB as PostgreSQL
    participant FS as Dépôt bare (.git)
    participant H as gitrust-hooks

    G->>SSH: Connexion SSH, propose clé publique
    SSH->>DB: SELECT id FROM ssh_keys WHERE fingerprint=SHA256:...
    DB-->>SSH: user_id = alice
    SSH-->>G: Authentifié
    G->>SSH: git-receive-pack /alice/mon-depot.git
    SSH->>DB: Vérifie permission write sur le dépôt
    DB-->>SSH: OK (Developer ou Owner)
    SSH->>FS: Reçoit les objets Git, met à jour les refs
    FS-->>SSH: Succès
    SSH->>H: Déclenche post-receive hook
    H->>DB: Enregistre l'événement push
    H-->>SSH: Fin
    SSH-->>G: remote: OK

Implications opérationnelles

Un seul processus, une seule unité d’arrêt

gitrust est un binaire unique. Redémarrer le service (systemctl restart gitrust) interrompt simultanément :

  • Le serveur HTTP (quelques secondes d’indisponibilité web)
  • Le serveur SSH (les push en cours sont coupés)
  • Les workers CI/Import (les tâches en cours peuvent être interrompues et reprises au prochain démarrage)

Conséquence : planifiez les redémarrages (mise à jour, changement de .env) en dehors des heures de pointe.

PostgreSQL est le point central

Toutes les décisions d’authentification, d’autorisation et de persistance passent par PostgreSQL. Si PostgreSQL est indisponible :

  • Les connexions HTTP retournent 503
  • Les connexions SSH sont refusées (impossible de valider la clé publique)
  • Les workers mettent leurs tâches en file d’attente locale

Conséquence : la sauvegarde et la haute disponibilité de PostgreSQL sont prioritaires sur tout le reste.

Le système de fichiers et la base de données doivent être cohérents

Un dépôt existe à deux endroits : en base (repositories table) et sur disque (disk_path). Une restauration partielle (base seule, ou disque seul) laisse l’instance dans un état incohérent.

Conséquence : toujours sauvegarder et restaurer la base ET les dépôts ensembles (voir Sauvegarder et restaurer).

ssh-guard intercale un sas devant le serveur SSH

Toutes les connexions sur :2222 passent d’abord par SecureListener (ssh-guard). Cette couche extrait l’IP réelle (PROXY protocol si nginx stream est devant), consulte ACL et bans actifs, applique un cap TCP par IP, puis ne remet le TcpStream à russh que si tout est en règle. Un événement JSON stable est émis pour chaque décision (connection_accepted, connection_dropped, auth_failed, ip_banned, …) — pratique pour fail2ban et SIEM.

Conséquence : modifier SSH_GUARD_* dans .env change immédiatement le comportement défensif après redémarrage. Les ACL admin (allow/deny par CIDR) sont en revanche modifiables à chaud via l’UI : la table ssh_guard_acl est partagée par référence entre le routeur HTTP et le listener SSH. Voir Configurer ssh-guard et ssh-guard : détection d’attaques SSH.

La clé SSH hôte identifie l’instance

La clé Ed25519 dans SSH_HOST_KEY_PATH est l’identité SSH de votre serveur. Tous vos utilisateurs ont ce fingerprint dans leur ~/.ssh/known_hosts. La régénérer provoque une alerte REMOTE HOST IDENTIFICATION HAS CHANGED sur toutes les machines de vos utilisateurs.

Conséquence : sauvegardez la clé SSH hôte et ne la régénérez jamais sauf en cas de compromission avérée.


Alternatives et compromis

Pourquoi un seul binaire plutôt que des microservices ?

gitrust a choisi l’architecture monolithe modulaire (crates Rust séparées, un binaire) pour :

  • Simplicité opérationnelle : un seul processus à surveiller, une seule unité de déploiement
  • Performances : pas de sérialisation/désérialisation inter-services sur le chemin critique
  • Cohérence transactionnelle : les transactions PostgreSQL couvrent plusieurs domaines (auth + repository + audit) sans coordinateur distribué

Le compromis : mise à l’échelle horizontale plus contrainte (voir Modèle de déploiement).

Pourquoi Russh plutôt que sshd ?

L’intégration du serveur SSH dans le processus gitrust permet :

  • Authentification via la base de données (pas de authorized_keys fichier)
  • Autorisation au niveau dépôt (pas juste au niveau système)
  • Logs d’audit unifiés

Le compromis : gitrust gère sa propre implémentation SSH — les mises à jour de sécurité SSH dépendent des releases gitrust.


Vérifier votre compréhension

  1. Un utilisateur ne peut plus faire git push mais peut se connecter à l’interface web. Quel composant est en cause ? Quelles informations cherchez-vous en premier dans les logs ?

  2. Vous devez augmenter la mémoire allouée aux workers CI sans affecter le serveur HTTP. Est-ce possible avec l’architecture actuelle ? Que faudrait-il changer ?


Pour aller plus loin

Comprendre les modèles de déploiement de gitrust

Ce que vous allez comprendre

  • Comparer les trois topologies de déploiement (dev local, mono-machine prod, haute disponibilité) et leurs compromis
  • Identifier les limites de la mise à l’échelle horizontale de gitrust dans sa version actuelle
  • Évaluer quelle topologie correspond à votre contexte (taille d’équipe, contraintes de disponibilité, budget)

Le problème concret

Vous hébergez gitrust pour une équipe de 8 développeurs. Tout fonctionne bien, mais vous vous posez la question : faut-il un deuxième serveur pour la résilience ? Peut-on mettre gitrust derrière un load balancer ? Que se passe-t-il si le disque dur tombe ?


L’analogie

Les trois topologies ressemblent aux trois tailles de cuisine dans un restaurant :

  • Cuisine de maison (dev local) : tout sur le même plan de travail, pratique, pas question d’accueillir 50 couverts
  • Cuisine de restaurant (mono-machine prod) : séparation des postes, robuste, suffisant pour 20-50 couverts
  • Cuisine industrielle (HA) : plusieurs cuisines qui travaillent en parallèle, mais la coordination est un métier à part entière

La plupart des équipes de 3-20 personnes n’ont besoin que de la cuisine de restaurant.


Le modèle

Topologie 1 — Développement local

graph LR
    subgraph "Machine du développeur"
        G[gitrust :4000 + :2222]
        PG[(PostgreSQL :5432)]
        FS[./data/repos]
    end
    DEV[Navigateur / git] --> G
    G --> PG
    G --> FS

Caractéristiques :

  • Tout sur la même machine, tout en localhost
  • APP_DEBUG=true, COOKIE_SECURE=false, RUST_LOG=debug
  • PostgreSQL lancé via docker compose up -d dans database/
  • Pas de reverse-proxy, pas de TLS
  • SESSION_BACKEND=memory acceptable (données perdues au redémarrage)

Usage : test de fonctionnalités, développement de plugins, démonstrations offline.


Topologie 2 — Mono-machine production (recommandée)

graph LR
    subgraph "Internet"
        CLI[Navigateur / git client]
    end

    subgraph "Serveur unique"
        NGX[Nginx :443 TLS]
        G[gitrust 127.0.0.1:4000<br/>+ 0.0.0.0:2222]
        PG[(PostgreSQL 127.0.0.1:5432)]
        FS[/opt/gitrust/data/repos]
    end

    CLI -->|HTTPS :443| NGX
    CLI -->|SSH :2222| G
    NGX -->|proxy_pass| G
    G --> PG
    G --> FS

Caractéristiques :

  • Nginx termine TLS et proxifie vers gitrust sur 127.0.0.1:4000
  • PostgreSQL accessible uniquement en local
  • Dépôts bare sur le disque local (ou volume attaché)
  • Un seul processus gitrust, un seul PostgreSQL
  • Sauvegarde avec pg_dump + rsync nocturne vers stockage externe

Dimensionnement indicatif :

ÉquipevCPURAMDisque
1-5 personnes1-21 Go20 Go SSD
6-20 personnes2-42-4 Go50-100 Go SSD
20-50 personnes4-88 Go200+ Go SSD

Limites : point de défaillance unique — si le serveur tombe, tout tombe. Acceptable si RTO < 1 h (restauration depuis sauvegarde sur nouvelle VM).


Topologie 3 — Haute disponibilité (HA)

graph LR
    subgraph "Internet"
        CLI[Navigateurs / git clients]
    end

    subgraph "Load balancer"
        LB[HAProxy / Nginx LB<br/>:443 + :2222]
    end

    subgraph "Backends gitrust"
        G1[gitrust-1 :4000 + :2222]
        G2[gitrust-2 :4000 + :2222]
    end

    subgraph "Données partagées"
        PG[(PostgreSQL primaire<br/>+ réplica lecture)]
        NFS[Volume partagé NFS<br/>ou S3-compatible]
    end

    CLI --> LB
    LB --> G1
    LB --> G2
    G1 --> PG
    G2 --> PG
    G1 --> NFS
    G2 --> NFS

Caractéristiques :

  • Plusieurs instances gitrust derrière un load balancer
  • PostgreSQL en mode primary/replica (streaming replication)
  • SESSION_BACKEND=redis obligatoire (sessions partagées entre instances)
  • Dépôts bare sur volume partagé (NFS, CEPH, ou objet S3 via FUSE)

Limites de la mise à l’échelle horizontale

Workers stateful

Les workers CI et Import sont des goroutines async dans le processus gitrust. Leur état (pipelines en cours, file d’attente) n’est pas partagé entre instances. En HA, il faut s’assurer qu’un seul nœud exécute les workers CI, ou accepter que des pipelines soient redémarrés si le nœud tombe.

Contournement actuel : désigner un nœud « primary » pour les workers (CI_ENABLED=true sur un seul nœud, CI_ENABLED=false sur les autres).

Dépôts bare sur volume partagé

Les dépôts Git bare doivent être accessibles depuis tous les nœuds gitrust. Options :

  • NFS : simple, suffisant pour la plupart des cas, mais latence réseau visible sur les gros push
  • CEPH / GlusterFS : haute performance distribuée, complexité opérationnelle élevée
  • S3-compatible (s3fs/mountpoint) : économique pour l’archivage, trop lent pour les accès Git fréquents
  • Réplication rsync en quasi-temps-réel : simple mais non-atomique (risque de dépôt corrompu pendant une réplication)

Recommandation : pour la grande majorité des équipes (< 50 personnes), la topologie mono-machine avec une bonne stratégie de sauvegarde offre un rapport complexité/disponibilité bien meilleur que la HA.

PostgreSQL en réplication

La réplication streaming PostgreSQL (primary/replica) est bien documentée et fiable. Le failover automatique nécessite un outil supplémentaire (Patroni, repmgr). Sans failover automatique, la HA PostgreSQL n’apporte qu’un RPO bas (perte de données minimale), pas un RTO bas (indisponibilité réduite).


Alternatives et compromis

CritèreMono-machineHA (2 nœuds)
Complexité opérationnelleFaibleÉlevée
Coût infraFaible2-3x
RTO (temps de restauration)15-60 min (depuis sauvegarde)< 5 min (failover)
RPO (perte de données max)Depuis la dernière sauvegardeQuelques secondes (WAL)
Maintenance sans coupureNon (redémarrage = coupure)Oui (rolling update)
Adapté pourÉquipes 1-50 pers.Équipes > 50 pers. ou SLA strict

Pour la cible principale de gitrust (équipes de 3-20 personnes) : la topologie mono-machine avec sauvegarde quotidienne offre un RTO acceptable. Investissez plutôt dans une bonne stratégie de sauvegarde que dans une architecture HA complexe.


Vérifier votre compréhension

  1. Votre équipe passe de 10 à 40 personnes. Quels indicateurs surveilleriez-vous sur votre serveur mono-machine pour décider si une migration vers la HA est nécessaire ?

  2. En topologie HA avec deux nœuds gitrust et un volume NFS partagé, quel est le scénario de défaillance qui ne peut pas être résolu par la HA seule ? Que faut-il prévoir en plus ?


Pour aller plus loin

Comprendre la CI Dagger : Easy Mode et Power Mode

Ce que vous allez comprendre

  • Expliquer pourquoi gitrust utilise Dagger plutôt qu’un système CI YAML classique
  • Distinguer le « Easy Mode » (.gitrust-ci.yml) du « Power Mode » (module .dagger/)
  • Évaluer quand conseiller à vos utilisateurs de passer de l’un à l’autre

Le problème concret

Un utilisateur vous signale que sa CI « ne fait pas ce qu’il veut » avec .gitrust-ci.yml. Il a besoin de logique conditionnelle, de dépendances entre jobs, et de construire plusieurs images Docker en parallèle. Devez-vous modifier le moteur CI ou lui dire qu’il doit changer d’approche ?


L’analogie

Easy Mode, c’est la recette de cuisine imprimée sur la boîte de gâteau : elle marche pour 95 % des cas, est accessible aux débutants, mais on ne peut pas y ajouter de la fleur d’oranger sans sortir du cadre.

Power Mode, c’est le livre de pâtisserie professionnel : vous écrivez vos propres recettes en code (Go, Python, TypeScript), vous pouvez tout faire, mais il faut savoir pâtisser.

Dagger est l’atelier qui garantit que les deux approches s’exécutent dans exactement le même environnement, reproductible sur n’importe quelle machine.


Le modèle

Pourquoi Dagger ?

Les systèmes CI YAML classiques (GitHub Actions, GitLab CI) ont un problème fondamental : la logique de pipeline est encodée dans du YAML qui devient vite illisible, non-testable, et non-composable. Dagger a été créé par l’équipe fondatrice de Docker pour résoudre ce problème.

graph LR
    subgraph "CI YAML classique"
        Y[.github/workflows/ci.yml<br/>GitLab CI / Jenkins]
        Y --> E[Exécuté sur runner<br/>CI propriétaire]
        E --> R1[Résultat dépend<br/>du runner]
    end

    subgraph "CI Dagger"
        D[Pipeline en code<br/>Go / Python / TS]
        D --> DE[Dagger Engine<br/>Docker containers]
        DE --> R2[Reproductible<br/>local = CI = prod]
    end

Avantages de Dagger pour gitrust :

  • Reproductibilité : le même pipeline s’exécute identiquement en local et sur le serveur
  • Isolation : chaque job dans son propre conteneur Docker
  • Cache automatique : les layers Docker non modifiés ne sont pas reconstruits
  • Pas de dépendance à un service CI externe (GitHub Actions, etc.)

Easy Mode : .gitrust-ci.yml

Le fichier .gitrust-ci.yml est un profil YAML simplifié interprété par le moteur CI générique de gitrust (un module Dagger Python interne à la plateforme).

flowchart LR
    A[.gitrust-ci.yml<br/>dans le dépôt] -->|lecture| B[Moteur CI gitrust<br/>module Dagger Python]
    B -->|dagger call| C[Dagger Engine]
    C --> D[Container isolé<br/>build + test]
    D --> E[Résultat → DB + UI]

Structure type :

# .gitrust-ci.yml
pipeline:
  name: CI principale
  image: rust:1.77-slim
  steps:
    - name: Vérifier le formatage
      run: cargo fmt --check
    - name: Linting
      run: cargo clippy -- -D warnings
    - name: Tests
      run: cargo test --workspace
  cache:
    - path: ~/.cargo/registry
    - path: target/

Ce que Easy Mode fait bien :

  • Build + test + lint pour des projets simples à intermédiaires
  • Configuration en quelques lignes
  • Accessible aux utilisateurs sans expertise CI/CD

Limites du Easy Mode :

  • Pas de logique conditionnelle complexe (si branche = main alors déployer)
  • Pas de fan-out/fan-in (jobs parallèles avec synchronisation)
  • Pas d’accès au Daggerverse (bibliothèque de modules Dagger communautaires)
  • La logique reste dans le YAML de la plateforme, pas dans le code du projet

Power Mode : module .dagger/

Quand un dépôt contient un dossier .dagger/, gitrust détecte sa présence et exécute directement le module Dagger natif. La plateforme ne l’interprète pas : elle lance dagger call sur le module du projet.

flowchart LR
    A[.dagger/<br/>main.go ou main.py] -->|détecté| B[gitrust CI Worker]
    B -->|dagger call -m .dagger/ ci| C[Dagger Engine]
    C --> D[Pipeline défini<br/>par l'utilisateur]
    D --> E[Résultat → DB + UI]

Ce que Power Mode apporte :

  • La logique pipeline est du code versionné avec le projet
  • Tests unitaires des pipelines possibles
  • Composition via le Daggerverse
  • Accès à toutes les fonctionnalités Dagger (secrets, services, multi-platform builds)
  • Aucune dépendance au moteur CI de gitrust pour la logique

Exemple minimal en Go :

// .dagger/main.go
package main

import (
    "context"
    "dagger.io/dagger"
)

func main() {
    ctx := context.Background()
    client, _ := dagger.Connect(ctx)
    defer client.Close()

    rust := client.Container().
        From("rust:1.77-slim").
        WithMountedCache("/usr/local/cargo/registry", client.CacheVolume("cargo-registry")).
        WithDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src")

    rust.WithExec([]string{"cargo", "fmt", "--check"}).
        WithExec([]string{"cargo", "clippy", "--", "-D", "warnings"}).
        WithExec([]string{"cargo", "test", "--workspace"}).
        Sync(ctx)
}

Alternatives et compromis

CritèreEasy Mode (.gitrust-ci.yml)Power Mode (.dagger/)
AccessibilitéExcellent (quelques lignes YAML)Courbe d’apprentissage
PuissanceLimitée aux cas simplesIllimitée
Logique conditionnelleNonOui
Parallélisme avancéNonOui
Testabilité du pipelineNulleTotale
Composition (Daggerverse)NonOui
MaintenanceMoteur géré par la plateformeChaque dépôt est autonome
Alignement philosophie DaggerPartiel (YAML encode de la logique)Total

Recommandation pour vos utilisateurs :

  • Commencer en Easy Mode pour les projets simples
  • Migrer en Power Mode quand : besoin de logique conditionnelle, de fan-out/fan-in, d’intégration avec des services externes, ou quand le .gitrust-ci.yml dépasse 50 lignes

Implications pour l’administration

En tant qu’administrateur :

  • CI_ENABLED=false désactive toute exécution CI (Easy et Power Mode), utile lors d’une maintenance
  • CI_MAX_CONCURRENT s’applique aux deux modes
  • Le moteur Easy Mode (CI_ENGINE_PATH) doit être synchronisé lors des mises à jour de gitrust
  • Les modules Power Mode sont autonomes — leur mise à jour est la responsabilité de l’utilisateur

Vérifier votre compréhension

  1. Un utilisateur vous demande d’ajouter une étape « déploiement vers staging si la branche est main » dans son .gitrust-ci.yml. Quelle est la réponse correcte ? Quelle alternative proposez-vous ?

  2. En quoi le fait que Dagger garantisse la reproductibilité facilite-t-il le débogage d’un pipeline qui échoue uniquement sur le serveur et pas en local ?


Pour aller plus loin

Comprendre la stratégie de sauvegarde adaptée à gitrust

Ce que vous allez comprendre

  • Appliquer la règle 3-2-1 aux trois sources de vérité de gitrust (base de données, dépôts bare, clé SSH hôte)
  • Analyser les RPO et RTO réalistes pour une instance gitrust mono-machine
  • Évaluer les compromis entre pg_dump classique et réplication WAL en continu

Le problème concret

Votre serveur gitrust est hors service suite à une défaillance disque. Vous avez une sauvegarde de la base de données, mais pas des dépôts bare. Ou l’inverse. Combien de temps pour remettre l’instance en service ? Combien de données perdez-vous ?


L’analogie

Imaginez gitrust comme un cabinet d’architecte :

  • La base de données est le registre des contrats et des clients (qui possède quoi, les métadonnées)
  • Les dépôts bare sont les tiroirs de plans (les fichiers réels)
  • La clé SSH hôte est la plaque officielle à l’entrée (l’identité du cabinet)

Si vous avez le registre mais pas les tiroirs, vous savez que le projet « Maison Martin » existe mais vous n’avez plus les plans. Si vous avez les tiroirs mais pas le registre, vous avez des dossiers anonymes sans savoir à qui ils appartiennent. Les deux doivent être sauvegardés ensemble.


Le modèle

La règle 3-2-1 appliquée à gitrust

graph TB
    subgraph "3 copies"
        C1[Copie 1 : données en production<br/>PostgreSQL + dépôts bare]
        C2[Copie 2 : sauvegarde locale<br/>/var/backups/gitrust]
        C3[Copie 3 : stockage distant<br/>S3 / NAS / autre serveur]
    end

    subgraph "2 supports différents"
        S1[Disque SSD serveur principal]
        S2[Disque NAS ou volume objet]
    end

    subgraph "1 copie hors site"
        O1[Datacenter différent<br/>ou cloud object storage]
    end

    C1 --> S1
    C2 --> S1
    C3 --> S2
    S2 --> O1

Les 3 copies :

  1. Production (données en vie sur le serveur)
  2. Sauvegarde locale (/var/backups/gitrust sur le même serveur ou volume attaché)
  3. Sauvegarde distante (rsync vers NAS, rclone vers S3, etc.)

Les 2 supports : le disque du serveur principal + un support physiquement distinct.

1 hors site : en cas d’incendie, d’inondation ou de compromission du datacenter, une copie doit être inaccessible depuis le réseau principal.


Les trois sources de vérité de gitrust

SourceTaille typiqueConsistanceFréquence recommandée
PostgreSQL (pg_dump)Quelques Mo à plusieurs GoCohérente à un instant T (--format=custom)Quotidien minimum, idéalement 2x/jour
Dépôts bare (rsync)Dizaines de Mo à plusieurs ToCohérente par dépôt (Git garantit l’atomicité des packs)Quotidien minimum
Clé SSH hôte (ssh_host_ed25519_key)< 1 KoImmuable après le premier démarrageUne seule fois, puis conserver en lieu sûr

RPO et RTO réalistes

RPO (Recovery Point Objective) : quantité maximale de données perdues.

StratégieRPO
pg_dump quotidien à 2h≤ 24 h de données perdues
pg_dump toutes les 6 h≤ 6 h de données perdues
WAL streaming (wal-g)≤ quelques secondes

Pour la cible principale de gitrust (équipes 3-20 personnes), un RPO de 24 h est généralement acceptable. Si votre équipe code intensément la nuit, envisagez 2 sauvegardes quotidiennes.

RTO (Recovery Time Objective) : temps pour remettre l’instance en service après incident.

ScénarioRTO estimé
Restauration sur la même machine (disque remplacé)30-60 min
Restauration sur une nouvelle VM (même datacenter)15-30 min
Restauration sur une nouvelle VM (nouveau datacenter)60-90 min (transfert des sauvegardes)

pg_dump vs réplication WAL

graph LR
    subgraph "pg_dump (snapshot)"
        A1[pg_dump à 2h00] --> B1[Fichier .dump]
        B1 --> C1[Transfert vers NAS]
        C1 --> D1[RPO ≤ 24h]
    end

    subgraph "WAL streaming (continu)"
        A2[PostgreSQL primary] -->|WAL segments| B2[wal-g / pgbackrest]
        B2 -->|continu| C2[Stockage objet S3]
        C2 --> D2[RPO ≤ quelques secondes]
    end

pg_dump — recommandé pour commencer :

  • Simple à mettre en place et à tester
  • Fichier standalone restaurable avec pg_restore
  • RPO = interval entre deux dumps
  • Fonctionne même si PostgreSQL est sous charge (cohérence transactionnelle garantie par PostgreSQL)

WAL streaming (wal-g, pgbackrest) — pour les cas avancés :

  • RPO quasi nul (Point-in-Time Recovery)
  • Nécessite un stockage objet (S3, MinIO)
  • Complexité opérationnelle significativement plus élevée
  • Indispensable si votre activité impose un RPO < 1 h

Recommandation : commencez par pg_dump quotidien + rsync. Migrez vers WAL streaming uniquement si votre SLA l’exige.


Cohérence entre base de données et dépôts bare

La sauvegarde la plus dangereuse est celle qui est partiellement réussie : base sauvegardée, dépôts non sauvegardés (ou l’inverse).

Un dépôt existe à deux endroits :

  • En base : repositories.disk_path enregistre le chemin
  • Sur disque : le dossier .git bare contient les objets Git

Pour éviter l’incohérence, sauvegardez toujours les deux dans la même fenêtre temporelle. Le script backup.sh recommandé effectue les deux en séquence :

# Ordre correct : d'abord la DB, puis les dépôts
pg_dump → fichier .dump
rsync repos → destination

En pratique, le décalage entre les deux (quelques secondes/minutes) est acceptable : un dépôt créé entre les deux sauvegardes sera absent du rsync mais présent en base — une re-synchronisation après restauration suffit.


Tester la restauration (le drill)

Une sauvegarde non testée n’est pas une sauvegarde — c’est une espérance.

Planifiez un drill trimestriel sur une VM éphémère :

  1. Copier les fichiers de sauvegarde sur la VM
  2. Installer gitrust et PostgreSQL
  3. Restaurer avec les procédures documentées dans Sauvegarder et restaurer
  4. Vérifier : connexion admin, liste des dépôts, git clone d’un dépôt connu
  5. Mesurer le temps effectif → comparer avec votre RTO cible
  6. Documenter les étapes imprévues → améliorer le script

Le drill révèle les problèmes invisibles : fichier de sauvegarde corrompu, script qui suppose un chemin qui n’existe pas, mot de passe PostgreSQL non documenté.


Alternatives et compromis

ApprocheRPOComplexitéCoût
pg_dump quotidien + rsync≤ 24 hFaibleFaible
pg_dump 2x/jour + rsync≤ 12 hFaibleFaible
WAL streaming + rsync≤ 1 minÉlevéeMoyen (stockage objet)
Réplication PostgreSQL (HA) + rsync≤ quelques sTrès élevéeÉlevé

Pour les équipes de 3-20 personnes avec gitrust, la première ligne est le bon point de départ.


Vérifier votre compréhension

  1. Vous utilisez pg_dump quotidien à 2h00. Un développeur pousse 3 jours de travail le mercredi soir à 23h45. Le serveur tombe à minuit. Que perdez-vous, et pourquoi ?

  2. Quelle est la différence entre RPO et RTO ? Lequel est le plus facile à améliorer avec une architecture mono-machine ? Lequel nécessite une refonte architecturale ?


Pour aller plus loin

ssh-guard : détection d’attaques SSH

Ce que vous allez comprendre

  • Identifier les quatre motifs d’attaque détectés automatiquement et les seuils par défaut.
  • Analyser comment ssh-guard décide de bannir une IP, dans quel ordre les règles s’appliquent et pourquoi.
  • Évaluer les compromis (fail-open, dry-run, allowlist bypass) pour ajuster la posture de sécurité de votre instance.

Public : administrateurs gitrust qui veulent comprendre avant de toucher aux variables SSH_GUARD_*. Pour la recette pas à pas, voir Configurer ssh-guard.


1. Le problème concret

Vous administrez une instance gitrust exposée sur Internet. Les logs russh remontent des dizaines de tentatives d’authentification ratées chaque jour, sans corrélation. Vous savez intuitivement qu’il y a une attaque par dictionnaire en cours, mais :

  • vous ne savez pas combien d’IP distinctes la mènent ;
  • vous ne pouvez pas distinguer un développeur qui a 3 clés mal configurées d’un scanner qui essaie 50 clés en série ;
  • votre fail2ban actuel se base sur des regex sur les messages d’erreur de russh, qui changent à chaque mise à jour ;
  • vous craignez de bannir un partenaire CI qui ouvre 200 connexions SSH par minute pour ses pipelines.

ssh-guard a été conçu exactement pour ce périmètre : observer, classer, corréler, bannir — sans casser les utilisations légitimes intensives.


2. L’analogie

Imaginez le hall d’entrée d’un immeuble. Quatre comportements doivent déclencher un signal pour le gardien :

  1. Quelqu’un sonne 5 fois en 5 minutes au même appartement → c’est une tentative de force brute. (brute-force)
  2. Quelqu’un sonne aux 10 appartements différents en 5 minutes → il cherche qui est chez lui pour cibler ensuite. (énumération d’utilisateurs)
  3. Quelqu’un présente 10 cartes magnétiques différentes en 5 minutes → il essaie un trousseau volé. (scanning de clés)
  4. Quelqu’un ouvre la porte 30 fois en 1 seconde → il sature le système, peu importe ses intentions. (flood TCP)

ssh-guard distingue les quatre, parce qu’ils appellent des réponses différentes. Le flood est un drop instantané (sinon le système se noie). Les trois autres déclenchent un ban temporaire après un nombre d’essais avéré.


3. Les quatre détecteurs

3.1 Vue synthétique

DétecteurMesureSeuil défautFenêtreAction
Brute-forceÉchecs d’auth depuis une IP55 minBan 1 h
Énumération d’utilisateursUsernames distincts essayés depuis une IP105 minBan 1 h
Scan de clésFingerprints distincts essayés depuis une IP105 minBan 1 h
Flood TCPNouvelles connexions TCP par IP par seconde10 (burst 20)1 s glissanteDrop immédiat (pas de ban persistant)

Tous les seuils sont ajustables via SSH_GUARD_* — voir Variables d’environnement.

3.2 Brute-force

Compte les événements auth_failed par IP dans la fenêtre glissante. Au seuil, émet brute_force_detected puis pose un ban auto avec TTL SSH_GUARD_AUTO_BAN_DURATION_SECS. Ne compte pas les auth_succeeded (c’est l’objectif : ignorer le bruit légitime).

3.3 Énumération d’utilisateurs

Compte les usernames distincts essayés par IP. Un attaquant qui sonde root, admin, git, deploy, ci, bot, … pour trouver un compte existant déclenche ce détecteur même si chacune de ses tentatives n’est faite qu’une fois.

Cas typiques de faux positif : un script CI qui clone plusieurs dépôts d’orgs différentes avec différents git@host:org/... — mais le user SSH reste git, donc le détecteur ne s’active pas. Un développeur qui se trompe trois fois de username en jouant avec son ~/.ssh/config reste largement sous le seuil.

3.4 Scan de clés

Compte les fingerprints SHA256 distincts présentés par IP. Aligné sur les recommandations CrowdSec : un humain n’a jamais 10 clés SSH distinctes en 5 minutes. Un trousseau volé qui défile en automatique, oui.

Cas typique d’observation : ssh-add -L côté client liste 8 clés, l’agent SSH les présente toutes successivement. Le seuil par défaut (10) absorbe ce cas.

3.5 Flood TCP

Implémenté avec un token bucket GCRA (algorithme de Generic Cell Rate, équivalent leaky bucket sans biais), keyé par IP. Différent des trois autres :

  • Pas de ban persistant : sinon un scan de port créerait des milliers de lignes inutiles dans la DB.
  • Drop au niveau du listener TCP : la connexion ne consomme aucune ressource au-delà du accept().
  • Budgets indépendants par IP : une IP rate-limitée n’affecte pas les autres.

C’est la première ligne de défense, peu coûteuse, qui absorbe les scans avant que les détecteurs comportementaux ne soient sollicités.


4. Le modèle de décision

4.1 Priorité des règles d’ACL

Quand une connexion arrive, ssh-guard consulte dans cet ordre :

flowchart LR
    A[Connexion] --> B{Denylist admin ?}
    B -- Oui --> X1[Drop banned]
    B -- Non --> C{Ban auto actif ?}
    C -- Oui --> X1
    C -- Non --> D{Allowlist admin ?}
    D -- Oui --> Y[Accept,<br/>bypass détecteurs]
    D -- Non --> E{Flood OK ?}
    E -- Non --> X2[Drop flood_limit]
    E -- Oui --> Z[Accept normal]

Conséquences pratiques :

  • deny admin > tout : un opérateur peut bannir une IP même si elle a été allowlistée par erreur ailleurs.
  • auto_ban > allow : un ban posé par un détecteur ne saute pas en allowlistant l’IP après coup. Pour libérer l’IP, il faut explicitement la débannir. C’est volontaire — un allow ne doit pas effacer la trace d’un comportement passé.
  • allow > default : une IP allowlistée bypasse les détecteurs et le flood-limit, mais ses événements auth_succeeded/auth_failed restent loggés pour audit.

4.2 Cycle de vie d’un ban brute-force

sequenceDiagram
    participant C as Client SSH (203.0.113.42)
    participant L as SecureListener
    participant T as AuthTracker
    participant D as BruteForceDetector
    participant B as BanManager
    participant S as Sink JSON
    participant F as Fail2ban

    Note over C,F: Tentatives 1 à 4 (sous le seuil)
    C->>L: TCP accept
    L->>S: connection_accepted
    C->>T: auth fail (russh)
    T->>S: auth_failed
    T->>D: on_event(auth_failed)
    D->>D: count = 4 < seuil 5
    Note over D: Aucun ban

    Note over C,F: Tentative 5 (atteint le seuil)
    C->>T: auth fail (russh)
    T->>S: auth_failed
    T->>D: on_event(auth_failed)
    D->>D: count = 5 >= seuil
    D->>S: brute_force_detected
    D->>B: auto_ban(BruteForce)
    B->>S: ip_banned (expires_at = now + 1h)
    F->>F: Lit ip_banned, applique iptables/UFW

    Note over C,F: Tentative 6 (bannie)
    C->>L: TCP accept
    L->>B: effective_status(203.0.113.42)
    B-->>L: BannedAuto
    L->>S: connection_dropped (banned)
    L-->>C: connexion fermée

4.3 Idempotence

Un détecteur ne rebannit jamais une IP déjà bannie. Le BanManager.auto_ban retourne Ok(None) dans trois cas :

  • dry_run actif (un événement ip_banned est tout de même émis pour fail2ban) ;
  • l’IP est allowlistée ;
  • un ban auto actif couvre déjà l’IP.

Sans cette idempotence, chaque nouvelle tentative ratée après le ban produirait un nouvel ip_banned et noierait le sink.


5. Les leviers admin

5.1 Modes opérationnels

VariableEffet
SSH_GUARD_ENABLED=falseCoupe totale. ssh-guard devient un pass-through. À éviter sauf urgence.
SSH_GUARD_DRY_RUN=trueLes détecteurs tournent et émettent les événements ip_banned, mais aucun ban n’est persisté. Mode parfait pour valider un nouveau seuil ou laisser fail2ban faire le ban réel.
SSH_GUARD_PROFILE=privateProfil réseau interne : tous les détecteurs désactivés (seuil u32::MAX), les événements continuent d’être émis pour audit.

5.2 Allowlist d’un partenaire CI ou d’un VPN

Une IP allowlistée bypasse les quatre détecteurs et le flood-limit. C’est le levier propre pour autoriser :

  • un runner CI qui ouvre des dizaines de connexions SSH par minute ;
  • un bastion VPN qui multiplexe plusieurs développeurs vers une seule IP source ;
  • un job de monitoring qui teste la disponibilité SSH.

Configuré via l’API admin (table ssh_guard_acl, kind = Allow, CIDR du runner). Voir Configurer ssh-guard.

5.3 Denylist d’un AS abusif

Un opérateur peut bannir un CIDR entier (ex. 198.51.100.0/24) en mode permanent. C’est le pendant inverse de l’allowlist, prioritaire sur tout (y compris les bans auto qui n’auraient pas encore été posés).


6. Pourquoi ces compromis

6.1 Fail-open quand PostgreSQL est indisponible

Si la lecture du store échoue (DB down, latence anormale), ssh-guard laisse passer la connexion et log un warn. Raison : éviter de transformer une panne DB en panne SSH généralisée. Conséquence acceptée : pendant la fenêtre de panne, un attaquant peut atteindre le handshake russh (mais doit toujours présenter une clé valide pour s’authentifier).

Pour basculer en fail-closed, il faut modifier le code (voir page développeur). C’est rare dans la vraie vie ; la plupart des admins préfèrent la disponibilité.

6.2 Bans temporaires plutôt que permanents

Le défaut SSH_GUARD_AUTO_BAN_DURATION_SECS=3600 (1 heure) est un compromis :

  • assez long pour ralentir significativement un attaquant patient ;
  • assez court pour qu’un faux positif (un script mal écrit chez un utilisateur légitime) se résolve sans intervention admin.

Mettre 0 rend les bans permanents — à n’utiliser qu’avec un workflow admin pour purger régulièrement la table.

6.3 Fenêtre glissante de 5 minutes

Suffisamment large pour qu’un attaquant ne puisse pas « réinitialiser » le compteur en attendant 30 secondes entre tentatives. Suffisamment courte pour qu’un développeur qui se trompe 4 fois de mot de passe à 8h, puis revient à 14h, ne déclenche pas de ban.

6.4 Allowlist qui bypasse les détecteurs

Une IP allowlistée n’est plus surveillée par les détecteurs. Donc une compromission depuis une IP « de confiance » ne sera pas détectée par ssh-guard. Le compromis est explicite : l’allowlist est un signal de confiance opérationnelle, pas une garantie de sécurité. Si un partenaire CI est compromis, l’attaque sera vue par les logs auth_succeeded (qui sont toujours émis), pas par les détecteurs.


7. Quand ssh-guard ne suffit pas

ssh-guard est une couche de défense. Elle ne dispense pas de :

  • un firewall en amont (UFW, iptables) qui ferme les ports inutiles ;
  • des clés SSH fortes (ed25519, ou RSA 4096+) côté utilisateur ;
  • une politique de rotation des PAT pour l’API HTTP (gitrust ne sert pas que SSH) ;
  • un fail2ban pour l’HTTP (login web, API), couvert par d’autres jails — voir Durcir avec Fail2ban.

Les quatre motifs d’attaque détectés sont les plus fréquents au niveau SSH, mais ils ne couvrent pas : attaques par chronométrie, exfiltration via tunnel, abus de privilèges après auth réussie. Ces vecteurs nécessitent d’autres outils (audit syscalls, EDR, revue de RBAC).


8. Vérifier votre compréhension

  1. Une IP allowlistée présente 50 fingerprints différents en 30 secondes. Combien d’événements key_scanning_detected sont émis ? L’IP est-elle bannie ?
  2. Vous activez SSH_GUARD_DRY_RUN=true puis vous voyez 12 événements ip_banned dans la dernière heure. Combien d’IP sont effectivement bloquées par ssh-guard ? Que faut-il pour qu’elles soient bloquées ?
  3. PostgreSQL est inaccessible pendant 10 minutes. Une attaque brute-force depuis une IP nouvelle démarre à la 5e minute. (a) Le détecteur va-t-il déclencher un ban ? (b) Les événements auth_failed apparaissent-ils dans les logs ?

9. Pour aller plus loin

Manuel développeur

Ce manuel s’adresse à deux publics : les contributeurs au code gitrust (core ou via fork rustwarden) et les développeurs externes qui construisent des intégrations sur l’API publique. Il fournit tout le nécessaire pour comprendre l’architecture des 6 crates, contribuer du code conforme aux standards du projet, ou bâtir un client qui parle à l’API gitrust. Il ne couvre pas l’installation d’une instance de production ni l’usage quotidien de la forge.

Parcours d’apprentissage — contributeur core

Le parcours contributeur représente environ 4 heures au total, avec un étayage dégressif : le premier tutoriel est entièrement guidé, le troisième (capstone) demande de résoudre sans guide pas-à-pas.

graph LR
    A[Utilisateur autonome] --> B[01-getting-started<br/>45 min]
    B --> C[02-premiere-contribution<br/>90 min]
    C --> D[03-creer-un-worker-async<br/>capstone 120 min]
    D --> E[Core contributor]

Checkpoint de parcours : avant de passer au tutoriel 02, vous devez avoir compilé gitrust localement, lancé les tests unitaires et obtenu une instance de développement fonctionnelle. Si ce n’est pas le cas, relisez le tutoriel 01.

Checkpoint de parcours : avant le tutoriel 03 (capstone), vous devez avoir soumis une PR complète avec tests, passé la gate QA et reçu une review. Si ce n’est pas le cas, relisez le tutoriel 02.


Tutoriels — apprendre en faisant

Les tutoriels sont guidés pas-à-pas avec des sorties verbatim attendues. Suivez-les dans l’ordre — chacun suppose le précédent complété.


How-to — recettes pour les tâches courantes

Les how-to répondent à « comment faire X » sans explication de fond. Consultez-les dans n’importe quel ordre selon votre besoin.


Référence — informations techniques exactes

La référence documente de façon exhaustive l’architecture, les API internes, le schéma de données et les règles QA. Destinée à la consultation ponctuelle.


Explication — comprendre le pourquoi

Les explanations construisent les modèles mentaux nécessaires pour contribuer de façon éclairée. Lisez-les quand vous voulez comprendre les décisions d’architecture, pas seulement les appliquer.

Tutoriels

Les tutoriels sont des parcours d’apprentissage guidés. Vous suivez une série d’étapes précises pour construire quelque chose de concret. L’objectif n’est pas de comprendre en profondeur chaque décision — c’est d’acquérir la confiance de faire fonctionner gitrust entre vos mains.

Diátaxis — mode apprentissage : chaque tutoriel est orienté vers l’expérience pratique. Vous apprendrez en faisant, pas en lisant.

Parcours recommandé

flowchart LR
    T01["01 — Installer\nl'environnement\n~45 min"]
    T02["02 — Première\ncontribution\n~90 min"]
    T03["03 — Worker\nasync\n~120 min"]

    T01 --> T02
    T02 --> T03

    style T01 fill:#4ade80,color:#000
    style T02 fill:#60a5fa,color:#000
    style T03 fill:#f472b6,color:#000

Chaque tutoriel indique ses prérequis au début. Ne sautez pas un niveau sans avoir vérifié les prérequis — les étapes suivantes supposent que vous avez les outils installés et un dépôt fonctionnel en local.

Les tutoriels

01 — Installer l’environnement de développement

Durée estimée : 45 minutes | Niveau : débutant

Vous installez Rust, PostgreSQL, et les outils gitrust. À la fin, vous lancez l’application en local et vous voyez la page d’accueil.

Prérequis : Linux ou macOS, accès sudo.


02 — Première contribution : corriger un bug

Durée estimée : 90 minutes | Niveau : intermédiaire

Vous identifiez un bug réel, créez une branche, écrivez un test qui échoue (TDD), corrigez le code, passez la QA, et ouvrez une Pull Request.

Prérequis : tutoriel 01 terminé, fork du dépôt gitrust.


03 — Créer un worker async de bout en bout

Durée estimée : 120 minutes | Niveau : avancé

Tutoriel capstone. Vous construisez un worker complet : migration DB, struct d’état, canal mpsc, Semaphore, handler SSE, et tests d’intégration.

Prérequis : tutoriels 01 et 02 terminés, connaissance de base de Rust async.


Ce que les tutoriels ne font pas

Les tutoriels ne vous expliquent pas pourquoi les choses fonctionnent ainsi. Pour comprendre les décisions d’architecture, consultez la section Explication. Pour accomplir une tâche spécifique sans apprentissage guidé, consultez les Guides pratiques.

Premiers pas avec gitrust : compilez, configurez et poussez votre premier commit

Source : ce tutoriel intègre le contenu de gitrust/docs/GETTING_STARTED.md avec la structure pédagogique complète (objectifs Bloom, modèle mental, checkpoints observables, section « Et si ça ne marche pas »).

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Compiler gitrust depuis les sources et lancer l’instance de développement localement
  • O2. Configurer l’environnement de développement (base de données, variables d’environnement, assets CSS)
  • O3. Exécuter un premier push SSH vers votre instance locale et vérifier le résultat dans l’interface web

Pré-requis

  • Technique : Rust stable ≥ 1.80 (rustc --version), PostgreSQL ≥ 15 (psql --version), Node.js ≥ 18 (node --version), Git 2.x (git --version), Docker (optionnel, recommandé pour la base de données)
  • Pédagogique : être un utilisateur autonome de gitrust (parcours user_manual complété ou équivalent) ; familiarité avec Rust (au moins 3 mois de pratique) et avec HTTP/PostgreSQL
  • Temps estimé : ~45 minutes

Vue d’ensemble

Avant d’exécuter la première commande, prenons 3 minutes pour comprendre ce que vous allez monter et comment les pièces s’articulent.

gitrust est un monorepo Rust à 6 crates qui compile en un seul binaire. Quand vous lancez cargo run, ce binaire démarre deux serveurs simultanément : un serveur HTTP (:4000) pour l’interface web et l’API REST, et un serveur SSH (:2222) pour les opérations git push/pull. Ils partagent la même base de données PostgreSQL et le même stockage de dépôts bare sur disque.

graph TB
    subgraph sources ["Sources gitrust (monorepo Rust)"]
        C1[rustwarden-core<br/>auth, sessions, middleware]
        C2[gitrust-core<br/>modèles, services métier]
        C3[gitrust-git<br/>opérations Git bare]
        C4[gitrust-web<br/>handlers HTTP + templates]
        C5[gitrust-ssh<br/>serveur SSH Git]
        C6[gitrust-hooks<br/>post-receive, pre-receive]
    end

    subgraph runtime ["Processus unique (cargo run)"]
        HTTP["HTTP :4000<br/>UI + API REST"]
        SSH["SSH :2222<br/>git push/pull"]
    end

    subgraph storage ["Stockage"]
        PG[(PostgreSQL<br/>comptes, dépôts,<br/>issues, PRs)]
        REPOS[Dépôts bare<br/>data/repos/]
    end

    C1 & C2 & C3 & C4 & C5 & C6 -->|cargo build| HTTP
    C1 & C2 & C3 & C4 & C5 & C6 -->|cargo build| SSH
    HTTP & SSH --> PG
    HTTP & SSH --> REPOS

Ce que vous allez faire : cloner les sources → préparer PostgreSQL → copier et remplir .env → compiler les assets CSS → lancer cargo run → vérifier l’instance → pousser un premier commit via SSH.


Étape 1 : Vérifiez les pré-requis système

Avant de cloner quoi que ce soit, vérifiez que tous les outils nécessaires sont installés :

rustc --version && cargo --version && psql --version && node --version && git --version

Sortie attendue (versions exactes peuvent varier) :

rustc 1.82.0 (f6e511eec 2024-10-15)
cargo 1.82.0 (8f40fc59f 2024-10-15)
psql (PostgreSQL) 16.3
v20.11.0
git version 2.43.0

Si des dépendances système manquent, installez-les :

# Debian / Ubuntu
sudo apt install build-essential pkg-config libssl-dev libpq-dev cmake git

# macOS
brew install postgresql openssl cmake

Checkpoint : toutes les commandes de vérification doivent retourner un numéro de version sans erreur. Si rustc n’est pas trouvé, installez Rust via curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh puis rechargez votre shell.


Étape 2 : Clonez le projet

git clone https://demo.gitrust.eu/gitrust/gitrust.git
cd gitrust

Sortie attendue :

Cloning into 'gitrust'...
remote: Enumerating objects: 12847, done.
remote: Counting objects: 100% (12847/12847), done.
Receiving objects: 100% (12847/12847), 18.42 MiB | 4.20 MiB/s, done.

Checkpoint : vérifiez que la structure de crates est présente :

ls crates/

Sortie attendue :

gitrust-core  gitrust-git  gitrust-hooks  gitrust-ssh  gitrust-web  rustwarden-core

Étape 3 : Configurez l’environnement (.env)

Copiez le fichier d’exemple et éditez les 5 valeurs essentielles :

cp .env.example .env

Ouvrez .env dans votre éditeur et renseignez ces valeurs :

# Connexion à la base de données
DATABASE_URL=postgres://gitrust:motdepasse@localhost:5432/gitrust

# Clé secrète JWT (générez-la avec : openssl rand -hex 32)
JWT_SECRET=votre_cle_secrete_64_caracteres

# Compte administrateur créé au premier lancement
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=MotDePasseFort123!

Les autres variables ont des valeurs par défaut raisonnables pour le développement. Consultez .env.example pour la liste complète.

Générez une vraie valeur pour JWT_SECRET :

openssl rand -hex 32

Sortie attendue (valeur différente à chaque exécution) :

4a7f2c9e1b8d3f6a0e5c2b9d7f4a1e8c3b6d9f2a5e8c1b4d7f0a3e6c9b2d5f8a

Copiez cette valeur dans .env pour JWT_SECRET.

Checkpoint : vérifiez qu’aucune valeur d’exemple reste dans .env :

grep -E "votre_cle|example\.com|MotDePasse" .env

La commande doit retourner vide (aucune ligne). Si elle retourne des lignes, ces valeurs placeholder sont encore présentes — l’application démarrera mais avec une configuration non sécurisée.


Étape 4 : Préparez la base de données PostgreSQL

Vous avez deux options :

Option A — Docker (recommandé pour débuter) :

cd database && docker compose up -d && cd ..

Sortie attendue :

[+] Running 2/2
 ✔ Container gitrust_postgres_dev  Started    0.8s
 ✔ Container gitrust_pgadmin       Started    1.1s

Option B — PostgreSQL existant sur la machine :

psql -U postgres << 'SQL'
CREATE USER gitrust WITH PASSWORD 'motdepasse';
CREATE DATABASE gitrust OWNER gitrust;
SQL

Sortie attendue :

CREATE ROLE
CREATE DATABASE

Adaptez le mot de passe dans DATABASE_URL dans .env si vous utilisez l’option B.

Checkpoint : vérifiez que la base est accessible depuis l’application :

psql "$DATABASE_URL" -c "SELECT version();"

Sortie attendue :

                                                version
-------------------------------------------------------------------------------------------------------
 PostgreSQL 16.3 on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0, 64-bit
(1 row)

Si vous obtenez psql: error: connection to server failed, vérifiez que PostgreSQL tourne et que DATABASE_URL correspond.


Étape 5 : Compilez les assets CSS

gitrust utilise Tailwind CSS + DaisyUI. Les assets doivent être compilés localement — aucun CDN, tous les fichiers sont servis depuis static/ :

npm install
npx tailwindcss -i static/css/input.css -o static/css/style.css --minify

Sortie attendue :

added 98 packages in 3s

Rebuilding...
Done in 1842ms.

Checkpoint : vérifiez que le fichier CSS est généré :

ls -lh static/css/style.css

Sortie attendue :

-rw-r--r-- 1 vous vous 45K avr 17 10:15 static/css/style.css

Si le fichier fait 0 octet ou n’existe pas, la compilation a échoué — relancez npm install puis la commande npx tailwindcss.


Étape 6 : Lancez l’application

cargo run

La première compilation prend 2 à 5 minutes (toutes les dépendances sont compilées). Les compilations suivantes sont incrémentales et beaucoup plus rapides.

Au premier lancement, gitrust exécute automatiquement :

  1. Les migrations de base de données (création de toutes les tables)
  2. La création du compte administrateur (depuis .env)
  3. La génération de la clé SSH host (data/ssh_host_ed25519_key)
  4. La création du répertoire de stockage des dépôts (data/repos/)

Sortie attendue (après compilation) :

   Compiling gitrust-web v0.9.0 (/home/vous/gitrust/crates/gitrust-web)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 187.42s
     Running `target/debug/gitrust`
2026-04-17T10:20:00Z INFO  gitrust > Starting gitrust v0.9.0
2026-04-17T10:20:00Z INFO  gitrust > Running database migrations... applied 42
2026-04-17T10:20:01Z INFO  gitrust > Admin account created: admin
2026-04-17T10:20:01Z INFO  gitrust > SSH host key: SHA256:xxxxxxxxxxxxxxxxxxxx
Server listening on http://0.0.0.0:4000

Checkpoint : ouvrez http://localhost:4000 dans votre navigateur. Vous devez voir la page d’accueil gitrust. Connectez-vous avec les identifiants ADMIN_USERNAME / ADMIN_PASSWORD définis dans .env. Si la page ne charge pas, vérifiez que le message Server listening on http://0.0.0.0:4000 est bien apparu dans les logs.


Étape 7 : Créez un dépôt et poussez votre premier commit

Dans l’interface web (http://localhost:4000), cliquez sur « New repository », donnez un nom (ex. mon-projet) et cliquez Créer.

Ajoutez votre clé SSH publique dans Settings > SSH Keys en collant le contenu de ~/.ssh/id_ed25519.pub.

Puis dans votre terminal :

git clone ssh://git@localhost:2222/admin/mon-projet.git
cd mon-projet

echo "# Mon projet" > README.md
git add README.md
git commit -m "Initial commit"
git push origin main

Sortie attendue :

Cloning into 'mon-projet'...
warning: You appear to have cloned an empty repository.
[main (root-commit) a1b2c3d] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 226 bytes | 226.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To ssh://localhost:2222/admin/mon-projet.git
 * [new branch]      main -> main

Checkpoint : retournez dans le navigateur sur http://localhost:4000/admin/mon-projet. La page doit afficher le fichier README.md avec le contenu # Mon projet et indiquer 1 commit sur la branche main.


Fonctionnalités supplémentaires disponibles

CI intégrée (optionnelle)

La CI s’exécute sur un serveur de build via SSH + rsync. Configurez dans .env :

# Même machine (défaut pour le développement)
CI_REMOTE_HOST=localhost

# Ou serveur dédié
CI_REMOTE_HOST=192.168.13.110
CI_REMOTE_USER=ci-runner

Easy Mode — ajoutez .gitrust-ci.yml à la racine de votre dépôt :

language: rust

build:
  command: "cargo build --release"

tests:
  command: "cargo test"

Power Mode — placez un module Dagger dans .dagger/ à la racine. gitrust exécutera dagger call -m .dagger/ ci.

Notifications

Configurez SMTP dans .env pour les notifications e-mail :

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user
SMTP_PASSWORD=password
SMTP_FROM=noreply@gitrust.eu

Langue de l’interface

gitrust est disponible en 6 langues (fr, en, de, es, pt, it). Définissez la langue par défaut de l’instance :

DEFAULT_LOCALE=fr

Récapitulatif

  • O1 accompli : gitrust est compilé depuis les sources et tourne localement — cargo run affiche « Server listening on http://0.0.0.0:4000 »
  • O2 accompli : l’environnement est configuré — .env rempli, PostgreSQL connecté, assets CSS générés, migrations appliquées
  • O3 accompli : un premier push SSH a réussi — le commit est visible dans l’interface web à http://localhost:4000/admin/mon-projet

Et si ça ne marche pas

SymptômeCause probableCorrection
error[E0463]: can't find crate for 'std' à la compilationToolchain Rust manquante ou mauvaise versionExécutez rustup update stable && rustup default stable. Vérifiez avec rustc --version ≥ 1.80
connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failedPostgreSQL non démarré ou DATABASE_URL incorrectDémarrez PostgreSQL (sudo systemctl start postgresql ou docker compose up -d dans database/). Vérifiez que DATABASE_URL dans .env correspond
Permission denied (publickey) lors du git pushClé SSH non enregistrée dans gitrust ou mauvais portVérifiez dans Settings > SSH Keys que la clé est présente. Testez avec ssh -T git@localhost -p 2222 — vous devez voir « Vous êtes authentifié »

Prochaine étape

02 — Première contribution : clone, build, test, PR : apprenez à créer une feature branch, écrire des tests, et soumettre votre première pull request en suivant le workflow gitrust (~90 min)

Votre première contribution à gitrust

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Créer une branche de fonctionnalité depuis main et identifier un good first issue dans le backlog.
  • O2. Écrire un test unitaire qui échoue avant d’écrire le code de production (cycle TDD rouge-vert).
  • O3. Appliquer la chaîne QA complète (cargo fmt, cargo clippy, cargo test) et ouvrir une pull request sur la plateforme gitrust.

Pré-requis

  • Technique : Rust ≥ 1.77 installé, git ≥ 2.40, accès en lecture/écriture à une instance gitrust (locale ou demo.gitrust.eu), tutoriel 01-getting-started complété (environnement local qui compile).
  • Pédagogique : tutoriel 01-getting-started complété.
  • Temps estimé : ~90 minutes.

Vue d’ensemble

Contribuer à gitrust suit un cycle court et répétable : fork ou branche → test rouge → code minimal → tests verts → QA → PR.

sequenceDiagram
    participant Vous
    participant Git
    participant Cargo
    participant Plateforme

    Vous->>Git: git switch -c feat/mon-fix
    Vous->>Cargo: cargo test → ROUGE (test échoue)
    Vous->>Cargo: écrire le code minimal
    Vous->>Cargo: cargo test → VERT
    Vous->>Cargo: cargo fmt + cargo clippy
    Vous->>Git: git push origin feat/mon-fix
    Vous->>Plateforme: ouvrir Pull Request

Le cycle TDD (Test-Driven Development) est essentiel ici : en écrivant d’abord le test, vous définissez précisément ce que votre code doit faire avant de l’écrire. C’est ce que gitrust appelle rouge → vert → refactor.

Étape 1 : Identifier un good first issue

Rendez-vous sur la page issues du dépôt gitrust (ou sur votre instance locale). Filtrez par le label de classification good-first-issue.

Lisez l’issue attentivement. Pour ce tutoriel, nous allons simuler le cas d’un issue fictif :

Issue #42RepoSlug devrait rejeter les slugs commençant par un tiret.

Assignez-vous l’issue (bouton « Assignee » dans la sidebar) pour signaler que vous travaillez dessus.

Checkpoint : vous devriez voir votre nom dans la liste des assignés de l’issue.

Étape 2 : Créer une branche de travail

Depuis la racine du dépôt gitrust, créez une branche nommée selon la convention fix/<numéro>-description-courte ou feat/<numéro>-description-courte :

git switch main
git pull --rebase origin main
git switch -c fix/42-repo-slug-rejects-leading-dash

Sortie attendue :

Switched to a new branch 'fix/42-repo-slug-rejects-leading-dash'

Checkpoint : git branch doit afficher votre branche avec un * devant.

Étape 3 : Écrire un test qui échoue (rouge)

Avant d’écrire le moindre code de production, écrivez le test qui décrit le comportement attendu. Ouvrez le fichier contenant la validation des slugs :

crates/gitrust-core/src/models/repository.rs

Repérez le bloc #[cfg(test)] existant en bas du fichier (ou créez-le s’il est absent). Ajoutez ce test dans la section tests :

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Tests existants...

    #[test]
    fn repo_slug_rejects_leading_dash() {
        // Un slug qui commence par '-' doit être invalide.
        // Ce test DOIT échouer avant notre correctif.
        let result = RepoSlug::new("-invalid-slug");
        assert!(
            result.is_err(),
            "Un slug commençant par '-' devrait être rejeté"
        );
    }
}
}

Exécutez maintenant les tests pour confirmer que ce test échoue :

cargo test --package gitrust-core repo_slug_rejects_leading_dash

Sortie attendue (rouge) :

test models::repository::tests::repo_slug_rejects_leading_dash ... FAILED

failures:
    models::repository::tests::repo_slug_rejects_leading_dash

test result: FAILED. 0 passed; 1 failed

Checkpoint : le test échoue. C’est normal — c’est exactement l’état « rouge ».

Jargon : RepoSlug est un newtype — un type Rust qui enveloppe String et applique une validation à la construction via RepoSlug::new(). Cette technique garantit qu’un RepoSlug valide ne peut jamais exister sans passer par la validation.

Étape 4 : Écrire le code minimal (vert)

Localisez la fonction RepoSlug::new dans crates/gitrust-core/src/models/repository.rs. Elle ressemble à ceci (simplifié) :

#![allow(unused)]
fn main() {
impl RepoSlug {
    pub fn new(s: &str) -> Result<Self, ValidationError> {
        if s.is_empty() || s.len() > 64 {
            return Err(ValidationError::InvalidSlug("longueur invalide".into()));
        }
        if s.contains("..") || s.contains('/') {
            return Err(ValidationError::InvalidSlug("caractères interdits".into()));
        }
        // ... autres validations
        Ok(Self(s.to_lowercase()))
    }
}
}

Ajoutez uniquement la vérification manquante — ne modifiez rien d’autre :

#![allow(unused)]
fn main() {
impl RepoSlug {
    pub fn new(s: &str) -> Result<Self, ValidationError> {
        if s.is_empty() || s.len() > 64 {
            return Err(ValidationError::InvalidSlug("longueur invalide".into()));
        }
        // Nouveau : rejeter les slugs commençant par un tiret
        if s.starts_with('-') {
            return Err(ValidationError::InvalidSlug(
                "un slug ne peut pas commencer par un tiret".into(),
            ));
        }
        if s.contains("..") || s.contains('/') {
            return Err(ValidationError::InvalidSlug("caractères interdits".into()));
        }
        Ok(Self(s.to_lowercase()))
    }
}
}

Relancez le test :

cargo test --package gitrust-core repo_slug_rejects_leading_dash

Sortie attendue (vert) :

test models::repository::tests::repo_slug_rejects_leading_dash ... ok

test result: ok. 1 passed; 0 failed

Checkpoint : le test passe. Vous êtes en état « vert ».

Étape 5 : Vérifier que rien d’autre ne régresse

Un seul test qui passe ne suffit pas. Vérifiez l’ensemble de la suite du crate :

cargo test --package gitrust-core

Sortie attendue :

running XX tests
...
test result: ok. XX passed; 0 failed; 0 ignored

Checkpoint : aucun test ne régresse.

Étape 6 : Passer la chaîne QA

Gitrust impose trois gates obligatoires avant toute PR. Exécutez-les dans cet ordre :

Gate 1 — Formatage :

cargo fmt --all -- --check

Si des différences apparaissent, appliquez le format automatiquement :

cargo fmt --all

Gate 2 — Linting (zéro warning) :

cargo clippy --workspace -- -D warnings

Sortie attendue :

    Checking gitrust-core v0.1.0
    ...
    Finished `dev` profile

Gate 3 — Tests complets :

cargo test --workspace

Sortie attendue :

test result: ok. XXX passed; 0 failed; 0 ignored

Checkpoint : les trois commandes se terminent sans erreur ni warning.

Étape 7 : Committer selon la convention

Gitrust utilise des messages de commit conventionnels (feat:, fix:, test:, refactor:, docs:). Commitez votre travail :

git add crates/gitrust-core/src/models/repository.rs
git commit -m "fix: rejeter les slugs commençant par un tiret (issue #42)"

Ne committez jamais avec git add -A sans avoir inspecté git diff --staged au préalable. Vérifiez qu’aucun fichier .env, target/, ou fichier généré n’est inclus.

Checkpoint : git log --oneline -3 montre votre commit en tête.

Étape 8 : Pousser et ouvrir une PR

Poussez votre branche vers l’origine :

git push origin fix/42-repo-slug-rejects-leading-dash

Rendez-vous sur la plateforme gitrust. Un bandeau « Vous venez de pousser une branche — ouvrir une PR ? » devrait apparaître. Sinon, naviguez vers /{votre-username}/gitrust/pulls/new.

Remplissez le formulaire :

  • Titre : fix: rejeter les slugs commençant par un tiret
  • Corps : Renseignez le contexte, la cause du bug, la solution choisie, et ajoutez Closes #42 pour lier automatiquement l’issue.
  • Branche source : fix/42-repo-slug-rejects-leading-dash
  • Branche cible : main

Cliquez sur « Ouvrir la pull request ».

Checkpoint : la PR apparaît dans la liste /{owner}/gitrust/pulls avec le statut open.

Récapitulatif

  • O1 accompli en identifiant l’issue #42 et en créant la branche fix/42-repo-slug-rejects-leading-dash.
  • O2 accompli en écrivant repo_slug_rejects_leading_dash qui échouait avant l’ajout de s.starts_with('-').
  • O3 accompli en passant cargo fmt, cargo clippy -- -D warnings et cargo test --workspace sans erreur, puis en ouvrant la PR liée à l’issue.

Et si ça ne marche pas

SymptômeCause probableCorrection
cargo clippy signale needless_pass_by_valueVotre nouvelle fonction prend une String alors qu’un &str suffitChangez le paramètre en &str
cargo test échoue sur un test non liéVotre modification a cassé un invariant adjacentRelisez les tests voisins ; revenez à un diff minimal
git push refusé avec remote: pre-receive hook declinedLa branche est protégée ou votre token SSH n’est pas enregistréVérifiez /settings/keys sur la plateforme

Prochaine étape

Tutoriel 03 — Créer un worker async (capstone)

Créer un worker asynchrone de bout en bout (capstone)

Objectifs

À la fin de ce tutoriel, vous saurez :

  • O1. Concevoir le modèle de données SeaORM pour une table de jobs avec une state machine à 5 états.
  • O2. Implémenter la boucle d’un worker Tokio avec sémaphore de concurrence et timeout.
  • O3. Tester l’ensemble du cycle (enqueue → exécution → SSE) avec des tests d’intégration hermétiques.

Pré-requis

  • Technique : tutoriels 01 et 02 complétés ; notions de Tokio (async/await, mpsc, spawn) ; PostgreSQL local ou testcontainer disponible.
  • Pédagogique : tutoriel 02-premiere-contribution complété.
  • Temps estimé : ~120 minutes.

Vue d’ensemble

Ce tutoriel est un capstone : vous combinez des recettes connues (migration, service, handler) sans guide pas-à-pas détaillé. L’objectif est de reproduire le patron du worker d’import (import_service + import_worker) pour un cas générique appelé notify_worker — un worker qui envoie des notifications externes de manière asynchrone.

flowchart LR
    UI[Handler POST\n/notify] -->|create_job| DB[(notify_jobs)]
    UI -->|try_send| Chan[mpsc NotifyTask]
    UI -->|302| Page[GET /notify/:id]
    Chan --> Worker[NotifyWorker\nSémaphore N]
    Worker -->|update status| DB
    Worker -->|HTTP POST externe| External[Endpoint distant]
    Page -. SSE .-> DB

Le patron est identique à celui utilisé pour l’import de dépôts externe (voir crates/gitrust-core/src/services/import_service.rs) et pour le worker CI. Vous n’inventez rien : vous combinez des pièces existantes.

Modèle mental : pensez à une file de caissiers de supermarché. La file d’attente (mpsc channel) accumule les clients (tâches). Le nombre de caisses ouvertes est le sémaphore. Chaque caissier (worker goroutine) traite un client et met à jour le tableau d’affichage (DB + SSE).

Étape 1 : Concevoir la state machine

Avant d’écrire une ligne de code, formalisez la state machine de votre job.

stateDiagram-v2
    [*] --> pending : create_job()
    pending --> running : worker démarre
    pending --> cancelled : POST /cancel
    running --> success : HTTP 200 reçu
    running --> failed : erreur ou timeout
    running --> cancelled : flag DB checked

Les 5 états sont : pending, running, success, failed, cancelled.

Règles de transition :

  • Seul pending peut être annulé avant démarrage.
  • running peut être annulé via un flag en DB vérifié par le worker.
  • success et failed sont terminaux.

Checkpoint : dessinez la state machine sur papier avant de continuer. Répondez à : « que se passe-t-il si le serveur redémarre pendant un job running ? » (réponse : au redémarrage, les jobs running doivent être marqués failed avec le message « server restarted »).

Étape 2 : Créer la migration SeaORM

Créez le fichier de migration :

crates/gitrust-core/src/migrations/m20260501_000023_create_notify_jobs.rs
#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(NotifyJobs::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(NotifyJobs::Id).uuid().not_null().primary_key())
                    .col(ColumnDef::new(NotifyJobs::OwnerId).uuid().not_null())
                    .col(ColumnDef::new(NotifyJobs::TargetUrl).text().not_null())
                    .col(ColumnDef::new(NotifyJobs::Payload).text().not_null())
                    .col(
                        ColumnDef::new(NotifyJobs::Status)
                            .string()
                            .not_null()
                            .default("pending"),
                    )
                    .col(ColumnDef::new(NotifyJobs::ErrorMessage).text().null())
                    .col(ColumnDef::new(NotifyJobs::StartedAt).timestamp_with_time_zone().null())
                    .col(ColumnDef::new(NotifyJobs::FinishedAt).timestamp_with_time_zone().null())
                    .col(
                        ColumnDef::new(NotifyJobs::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(NotifyJobs::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await?;

        manager
            .create_index(
                Index::create()
                    .table(NotifyJobs::Table)
                    .name("notify_jobs_owner_status_idx")
                    .col(NotifyJobs::OwnerId)
                    .col(NotifyJobs::Status)
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(NotifyJobs::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum NotifyJobs {
    Table,
    Id,
    OwnerId,
    TargetUrl,
    Payload,
    Status,
    ErrorMessage,
    StartedAt,
    FinishedAt,
    CreatedAt,
    UpdatedAt,
}
}

Enregistrez la migration dans crates/gitrust-core/src/migrations/mod.rs en ajoutant m20260501_000023_create_notify_jobs::Migration à la liste du Migrator.

Test rouge à faire passer :

#![allow(unused)]
fn main() {
#[tokio::test]
async fn migration_creates_notify_jobs_table() {
    let db = setup_test_db().await;
    // Si la migration s'est appliquée, la requête ne renvoie pas d'erreur.
    let count: i64 = db
        .query_one(Statement::from_string(
            DatabaseBackend::Postgres,
            "SELECT COUNT(*) FROM notify_jobs".to_owned(),
        ))
        .await
        .expect("La table notify_jobs doit exister")
        .unwrap()
        .try_get("", "count")
        .unwrap();
    assert_eq!(count, 0);
}
}

Checkpoint : cargo test --package gitrust-core migration_creates_notify_jobs_table passe au vert.

Étape 3 : Définir les DTOs et l’enum de statut

Créez crates/gitrust-core/src/dto/notify_dto.rs :

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

/// Statut d'un job de notification — state machine à 5 états.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotifyStatus {
    Pending,
    Running,
    Success,
    Failed,
    Cancelled,
}

impl NotifyStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Running => "running",
            Self::Success => "success",
            Self::Failed => "failed",
            Self::Cancelled => "cancelled",
        }
    }

    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "pending" => Some(Self::Pending),
            "running" => Some(Self::Running),
            "success" => Some(Self::Success),
            "failed" => Some(Self::Failed),
            "cancelled" => Some(Self::Cancelled),
            _ => None,
        }
    }
}

/// Payload envoyé via SSE au client.
#[derive(Debug, Serialize)]
pub struct NotifyJobSse {
    pub status: String,
    pub error_message: Option<String>,
}

/// Input pour créer un job.
#[derive(Debug, Deserialize)]
pub struct CreateNotifyJobInput {
    pub target_url: String,
    pub payload: String,
}
}

Tests round-trip obligatoires :

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn status_round_trip() {
        for (s, expected) in [
            ("pending", NotifyStatus::Pending),
            ("running", NotifyStatus::Running),
            ("success", NotifyStatus::Success),
            ("failed", NotifyStatus::Failed),
            ("cancelled", NotifyStatus::Cancelled),
        ] {
            let status = NotifyStatus::from_str(s).expect("doit parser");
            assert_eq!(status, expected);
            assert_eq!(status.as_str(), s);
        }
    }

    #[test]
    fn unknown_status_returns_none() {
        assert!(NotifyStatus::from_str("unknown").is_none());
    }
}
}

Checkpoint : cargo test --package gitrust-core status_round_trip passe.

Étape 4 : Implémenter le service

Créez crates/gitrust-core/src/services/notify_service.rs. Le service ne fait que orchestrer la DB — aucune logique métier IO ici :

#![allow(unused)]
fn main() {
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait};
use uuid::Uuid;

use crate::{
    dto::notify_dto::{CreateNotifyJobInput, NotifyStatus},
    error::GitrustError,
    models::notify_job,
};

pub struct NotifyService;

impl NotifyService {
    /// Crée un job en statut `pending` et retourne son ID.
    pub async fn create_job(
        db: &DatabaseConnection,
        owner_id: Uuid,
        input: CreateNotifyJobInput,
    ) -> Result<Uuid, GitrustError> {
        let id = Uuid::new_v4();
        let now = Utc::now();
        let model = notify_job::ActiveModel {
            id: Set(id),
            owner_id: Set(owner_id),
            target_url: Set(input.target_url),
            payload: Set(input.payload),
            status: Set(NotifyStatus::Pending.as_str().to_owned()),
            error_message: Set(None),
            started_at: Set(None),
            finished_at: Set(None),
            created_at: Set(now),
            updated_at: Set(now),
        };
        model.insert(db).await.map_err(GitrustError::Database)?;
        Ok(id)
    }

    /// Passe le job en `running`.
    pub async fn mark_running(db: &DatabaseConnection, id: Uuid) -> Result<(), GitrustError> {
        Self::update_status(db, id, NotifyStatus::Running, None).await
    }

    /// Passe le job en `success`.
    pub async fn mark_success(db: &DatabaseConnection, id: Uuid) -> Result<(), GitrustError> {
        Self::update_status(db, id, NotifyStatus::Success, None).await
    }

    /// Passe le job en `failed` avec un message d'erreur.
    pub async fn mark_failed(
        db: &DatabaseConnection,
        id: Uuid,
        error: &str,
    ) -> Result<(), GitrustError> {
        Self::update_status(db, id, NotifyStatus::Failed, Some(error)).await
    }

    /// Passe le job en `cancelled`.
    pub async fn mark_cancelled(db: &DatabaseConnection, id: Uuid) -> Result<(), GitrustError> {
        Self::update_status(db, id, NotifyStatus::Cancelled, None).await
    }

    /// Vérifie si le job a été annulé (pour le poll du worker).
    pub async fn is_cancelled(
        db: &DatabaseConnection,
        id: Uuid,
    ) -> Result<bool, GitrustError> {
        let job = notify_job::Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(GitrustError::Database)?
            .ok_or_else(|| GitrustError::NotFound("job introuvable".into()))?;
        Ok(job.status == NotifyStatus::Cancelled.as_str())
    }

    async fn update_status(
        db: &DatabaseConnection,
        id: Uuid,
        status: NotifyStatus,
        error: Option<&str>,
    ) -> Result<(), GitrustError> {
        use sea_orm::ActiveModelTrait;
        let now = Utc::now();
        let mut model: notify_job::ActiveModel = notify_job::Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(GitrustError::Database)?
            .ok_or_else(|| GitrustError::NotFound("job introuvable".into()))?
            .into();

        model.status = Set(status.as_str().to_owned());
        model.updated_at = Set(now);
        model.error_message = Set(error.map(str::to_owned));

        if status == NotifyStatus::Running {
            model.started_at = Set(Some(now));
        }
        if matches!(status, NotifyStatus::Success | NotifyStatus::Failed | NotifyStatus::Cancelled) {
            model.finished_at = Set(Some(now));
        }

        model.update(db).await.map_err(GitrustError::Database)?;
        Ok(())
    }
}
}

Étape 5 : Implémenter le worker

Le worker tourne dans un tokio::spawn séparé. Il lit les tâches depuis un canal mpsc et les exécute avec un sémaphore de concurrence :

#![allow(unused)]
fn main() {
// crates/gitrust-core/src/services/notify_worker.rs

use std::sync::Arc;

use sea_orm::DatabaseConnection;
use tokio::sync::{mpsc, Semaphore};
use uuid::Uuid;

use super::notify_service::NotifyService;

/// Tâche envoyée via le canal mpsc au worker.
pub struct NotifyTask {
    pub job_id: Uuid,
    pub target_url: String,
    pub payload: String,
}

pub struct NotifyWorkerConfig {
    /// Nombre maximal de jobs concurrents.
    pub max_concurrent: usize,
    /// Timeout en secondes par job.
    pub timeout_secs: u64,
}

/// Boucle principale du worker. À appeler avec `tokio::spawn`.
pub async fn run(
    db: DatabaseConnection,
    mut rx: mpsc::Receiver<NotifyTask>,
    config: NotifyWorkerConfig,
) {
    let semaphore = Arc::new(Semaphore::new(config.max_concurrent));

    while let Some(task) = rx.recv().await {
        let permit = semaphore.clone().acquire_owned().await.expect("semaphore fermé");
        let db = db.clone();
        let timeout = config.timeout_secs;

        tokio::spawn(async move {
            let _permit = permit; // libéré à la fin du bloc
            execute_task(&db, task, timeout).await;
        });
    }
}

async fn execute_task(db: &DatabaseConnection, task: NotifyTask, timeout_secs: u64) {
    // Vérifier si annulé avant de démarrer
    match NotifyService::is_cancelled(db, task.job_id).await {
        Ok(true) => return,
        Err(e) => {
            tracing::error!(job_id = %task.job_id, error = %e, "Erreur lecture job");
            return;
        }
        Ok(false) => {}
    }

    if let Err(e) = NotifyService::mark_running(db, task.job_id).await {
        tracing::error!(job_id = %task.job_id, error = %e, "Impossible de marquer running");
        return;
    }

    let result = tokio::time::timeout(
        std::time::Duration::from_secs(timeout_secs),
        send_notification(&task.target_url, &task.payload),
    )
    .await;

    match result {
        Ok(Ok(())) => {
            if let Err(e) = NotifyService::mark_success(db, task.job_id).await {
                tracing::error!(job_id = %task.job_id, error = %e, "Impossible de marquer success");
            }
        }
        Ok(Err(e)) => {
            let msg = format!("Erreur HTTP: {e}");
            if let Err(db_err) = NotifyService::mark_failed(db, task.job_id, &msg).await {
                tracing::error!(job_id = %task.job_id, error = %db_err, "Impossible de marquer failed");
            }
        }
        Err(_elapsed) => {
            let msg = format!("Timeout après {timeout_secs}s");
            if let Err(e) = NotifyService::mark_failed(db, task.job_id, &msg).await {
                tracing::error!(job_id = %task.job_id, error = %e, "Impossible de marquer failed (timeout)");
            }
        }
    }
}

async fn send_notification(url: &str, payload: &str) -> Result<(), reqwest::Error> {
    let client = reqwest::Client::new();
    client
        .post(url)
        .header("Content-Type", "application/json")
        .body(payload.to_owned())
        .send()
        .await?
        .error_for_status()?;
    Ok(())
}
}

Checkpoint : cargo build --package gitrust-core compile sans erreur.

Étape 6 : Ajouter le handler et la route SSE

Dans crates/gitrust-web/src/handlers/notify.rs, créez le handler POST qui enqueues le job et redirige, ainsi que le handler SSE :

#![allow(unused)]
fn main() {
use axum::{
    extract::{Path, State},
    response::{sse::{Event, KeepAlive, Sse}, Redirect},
    Extension, Form,
};
use sea_orm::DatabaseConnection;
use tokio::time::{interval, Duration};
use tokio_stream::wrappers::IntervalStream;
use tokio_stream::StreamExt;
use uuid::Uuid;

use gitrust_core::{
    dto::notify_dto::CreateNotifyJobInput,
    services::{notify_service::NotifyService, notify_worker::NotifyTask},
};

use crate::auth::AuthUser;

pub type NotifySender = tokio::sync::mpsc::Sender<NotifyTask>;

/// POST /notify — crée un job et redirige vers la page de suivi.
pub async fn create_notify_job(
    State(db): State<DatabaseConnection>,
    Extension(tx): Extension<NotifySender>,
    user: AuthUser,
    Form(input): Form<CreateNotifyJobInput>,
) -> Result<Redirect, crate::error::AppError> {
    let job_id = NotifyService::create_job(&db, user.user_id, input.clone()).await?;

    // Essai d'envoi dans le canal — si plein, le job reste `pending` jusqu'au
    // prochain redémarrage (les jobs `pending` peuvent être re-envoyés au démarrage).
    let _ = tx.try_send(NotifyTask {
        job_id,
        target_url: input.target_url,
        payload: input.payload,
    });

    Ok(Redirect::to(&format!("/notify/{job_id}")))
}

/// GET /notify/:id/stream — SSE : pousse les mises à jour de statut au client.
pub async fn notify_job_stream(
    State(db): State<DatabaseConnection>,
    Path(id): Path<Uuid>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, std::convert::Infallible>>> {
    let stream = IntervalStream::new(interval(Duration::from_secs(1)))
        .map(move |_| {
            let db = db.clone();
            async move {
                // Poll la DB pour l'état courant
                use gitrust_core::models::notify_job;
                use sea_orm::EntityTrait;
                let job = notify_job::Entity::find_by_id(id)
                    .one(&db)
                    .await
                    .ok()
                    .flatten();

                let data = match job {
                    Some(j) => format!(
                        r#"{{"status":"{}","error":{}}}"#,
                        j.status,
                        j.error_message
                            .as_deref()
                            .map(|e| format!(r#""{}""#, e.replace('"', "\\\"")))
                            .unwrap_or("null".to_owned())
                    ),
                    None => r#"{"status":"not_found"}"#.to_owned(),
                };

                Ok(Event::default().data(data))
            }
        })
        .then(|fut| fut);

    Sse::new(stream).keep_alive(KeepAlive::default())
}
}

Enregistrez les routes dans routes.rs :

#![allow(unused)]
fn main() {
.route("/notify", post(handlers::notify::create_notify_job))
.route("/notify/:id", get(handlers::notify::notify_job_page))
.route("/notify/:id/stream", get(handlers::notify::notify_job_stream))
.route("/notify/:id/cancel", post(handlers::notify::cancel_notify_job))
}

Étape 7 : Intégrer le worker dans main.rs

Dans crates/gitrust-web/src/main.rs, initialisez le canal et démarrez le worker :

#![allow(unused)]
fn main() {
// Dans la fonction main(), après la construction de l'app :
let (notify_tx, notify_rx) = tokio::sync::mpsc::channel::<NotifyTask>(100);

let notify_db = db.clone();
tokio::spawn(gitrust_core::services::notify_worker::run(
    notify_db,
    notify_rx,
    NotifyWorkerConfig {
        max_concurrent: std::env::var("NOTIFY_MAX_CONCURRENT")
            .unwrap_or("4".into())
            .parse()
            .unwrap_or(4),
        timeout_secs: std::env::var("NOTIFY_TIMEOUT_SECS")
            .unwrap_or("30".into())
            .parse()
            .unwrap_or(30),
    },
));

// Injecter l'émetteur dans le router via Extension
let app = routes::routes(&static_path)
    .layer(Extension(notify_tx))
    .with_state(db);
}

Étape 8 : Tests d’intégration

Écrivez au minimum ces trois tests dans crates/gitrust-core/src/services/notify_service_test.rs :

#![allow(unused)]
fn main() {
#[tokio::test]
async fn create_job_inserts_with_pending_status() {
    let db = setup_test_db().await;
    let id = NotifyService::create_job(
        &db,
        Uuid::new_v4(),
        CreateNotifyJobInput {
            target_url: "https://example.com/hook".into(),
            payload: r#"{"event":"test"}"#.into(),
        },
    )
    .await
    .expect("create_job doit réussir");

    let job = notify_job::Entity::find_by_id(id)
        .one(&db)
        .await
        .unwrap()
        .unwrap();
    assert_eq!(job.status, "pending");
    assert!(job.started_at.is_none());
}

#[tokio::test]
async fn mark_running_sets_started_at() {
    let db = setup_test_db().await;
    let id = create_test_job(&db).await;

    NotifyService::mark_running(&db, id).await.unwrap();

    let job = notify_job::Entity::find_by_id(id).one(&db).await.unwrap().unwrap();
    assert_eq!(job.status, "running");
    assert!(job.started_at.is_some());
}

#[tokio::test]
async fn cancel_before_running_is_terminal() {
    let db = setup_test_db().await;
    let id = create_test_job(&db).await;

    NotifyService::mark_cancelled(&db, id).await.unwrap();

    let is_cancelled = NotifyService::is_cancelled(&db, id).await.unwrap();
    assert!(is_cancelled);
}
}

Checkpoint : cargo test --package gitrust-core notify — tous les tests passent.

Récapitulatif

  • O1 accompli en concevant la table notify_jobs avec 5 états (pending/running/success/failed/cancelled) et la migration SeaORM correspondante.
  • O2 accompli en implémentant notify_worker::run avec Semaphore, tokio::time::timeout, et lecture du flag d’annulation via la DB.
  • O3 accompli en écrivant les tests create_job_inserts_with_pending_status, mark_running_sets_started_at et cancel_before_running_is_terminal, tous hermétiques (pas de réseau réel).

Et si ça ne marche pas

SymptômeCause probableCorrection
channel closed au démarrageLe Receiver est droppé avant le spawn du workerVérifiez que tokio::spawn(run(..., notify_rx, ...)) est appelé avant que notify_rx ne soit droppé
Le job reste pending indéfinimentLe canal est plein ou le worker n’est pas démarréAjoutez un log dans execute_task ; vérifiez que tokio::spawn est bien appelé dans main
SSE ne reçoit aucun événementLe Content-Type: text/event-stream n’est pas reconnuVérifiez que le handler retourne bien Sse<...> et non String

Prochaine étape

Vous êtes désormais core contributor. Consultez les how-to pour les recettes quotidiennes :

Guides pratiques

Les guides pratiques répondent à une question concrète : « comment faire X ? ». Ils supposent que vous savez déjà ce que vous voulez accomplir. Pas d’introduction, pas de contexte superflu — directement à l’action.

Diátaxis — mode tâche : vous avez un objectif précis. Le guide vous amène au résultat le plus directement possible.

Contribuer au projet

GuideQuand l’utiliser
Workflow contribution et PROuvrir une Pull Request, conventions de commit, rebase
Passer la QA avant mergeChecklist des 7 gates QA, sécurité, clippy, tests

Ajouter des fonctionnalités

GuideQuand l’utiliser
Ajouter une route webNouvelle page SSR avec template Askama
Ajouter un service métierNouveau service avec CRUD, anti-IDOR, tests
Ajouter une migration DBNouvelle table, colonne, index, contrainte
Implémenter des webhooksÉvénements sortants avec signature HMAC, retry
Implémenter un endpoint API RESTEndpoint JSON v1, DTO, pagination, rate limiting
  • Vous ne savez pas comment la base de données est structurée ? → Schéma DB
  • Vous voulez comprendre le modèle de permissions avant d’écrire un service ? → RBAC
  • Vous démarrez de zéro ? → Tutoriels

Contribuer via le workflow Git et pull request

Ce guide décrit le workflow Git utilisé par le projet gitrust pour toute contribution : nouvelle fonctionnalité, correctif, ou documentation.

Pré-requis

Créer une branche depuis main

Toujours partir d’un main à jour :

git switch main
git pull --rebase origin main
git switch -c <type>/<numéro>-<description-courte>

Convention de nommage des branches :

PréfixeUsage
feat/Nouvelle fonctionnalité
fix/Correctif de bug
refactor/Refactorisation sans changement de comportement
docs/Documentation uniquement
test/Tests uniquement

Exemples : feat/42-webhook-dispatch, fix/88-slug-leading-dash, docs/15-api-rest.

Conventions de commit

Gitrust suit les Conventional Commits. Le format est :

<type>(<scope optionnel>): <description en impératif>

[corps optionnel]

[footer : Closes #N, BREAKING CHANGE, etc.]

Types acceptés : feat, fix, refactor, test, docs, chore, perf.

Exemples valides :

feat(webhooks): ajouter la signature HMAC-SHA256 sur les livraisons
fix(slug): rejeter les slugs commençant par un tiret (Closes #42)
test(import): ajouter test hermétique pour le timeout worker

Règle des petits commits atomiques : un commit = un changement logique cohérent. Évitez les commits « WIP » ou « fix fix fix » — utilisez git commit --amend ou git rebase -i pour réécrire l’historique avant de pousser.

Rebase avant push

Avant de pousser votre branche, rebasez-la sur main pour obtenir un historique linéaire :

git fetch origin
git rebase origin/main

En cas de conflit, résolvez-les fichier par fichier, puis :

git add <fichier-résolu>
git rebase --continue

Ne fusionnez jamais main dans votre branche de feature (git merge main) — cela crée des commits de merge parasites.

Checklist pre-merge (QA obligatoire)

Avant d’ouvrir la PR, vérifiez chaque point :

  • cargo fmt --all -- --check — zéro diff de formatage
  • cargo clippy --workspace -- -D warnings — zéro warning
  • cargo test --workspace — 100 % de tests passants
  • npm run test:e2e — tests Playwright passants (si templates ou routes modifiés)
  • CSS rebuild : npx tailwindcss -i static/css/input.css -o static/css/style.css --minify (si templates modifiés)
  • Aucun secret, token ou clé en clair dans le code ou les logs
  • Aucune modification dans crates/rustwarden-core/ (framework read-only)

Voir la checklist complète dans passer-la-qa-avant-merge.md.

Ouvrir la pull request

Poussez votre branche :

git push origin <votre-branche>

Sur la plateforme gitrust, naviguez vers /{owner}/gitrust/pulls/new et remplissez :

  • Titre : message de commit principal (convention Conventional Commits).
  • Corps : contexte du problème, solution choisie, alternatives considérées, lien vers l’issue (Closes #N).
  • Reviewers : assignez au moins un reviewer.
  • Labels : appliquez les labels de classification appropriés.

Critères d’approbation

Un reviewer approuve quand :

  1. Tous les gates QA passent (CI verte).
  2. La logique métier est correcte et les cas limites sont couverts par des tests.
  3. Aucun .unwrap(), .expect(), ou panic!() non justifié.
  4. Le diff ne contient pas de changements de périmètre non demandés.

Stratégie de merge par défaut

Gitrust utilise le merge commit pour conserver la traçabilité des branches dans l’historique. Le squash est réservé aux PRs de correction mineure (1-2 commits). Le rebase-merge est proscrit car il réécrit les SHA des commits existants.

Après le merge

Supprimez votre branche locale et distante :

git switch main
git pull --rebase origin main
git branch -d <votre-branche>
git push origin --delete <votre-branche>

Vérifiez que l’issue liée est bien fermée automatiquement par le Closes #N dans le message de commit ou le corps de la PR.

Lancer les tests unitaires

Référence des commandes et de la stratégie de tests pour gitrust.

Infrastructure existante

Tests unitaires Rust

cargo test --workspace          # Tous les tests
cargo test -p gitrust-core      # Un crate spécifique
cargo test -p gitrust-git       # Tests Git (tempdir)

Les tests unitaires sont intégrés dans chaque crate via #[cfg(test)].

Tests e2e Playwright

17 specs couvrant : auth, admin, repos, issues, labels, teams, tokens, settings, navigation.

# Pré-requis (première fois)
npm install
./scripts/e2e-setup-db.sh       # Crée la DB gitrust_test

# Lancer les tests
npm run test:e2e                 # Mode headless
npm run test:e2e:ui              # Mode interactif (navigateur visible)
npm run test:e2e:debug           # Mode debug
npm run test:e2e:report          # Voir le rapport HTML

Configuration : playwright.config.ts (port 4001, DB gitrust_test, locale fr-FR).

Structure des répertoires

tests/
  e2e/                    # Specs Playwright (TypeScript)
    global-setup.ts       # Setup : lancer l'app, créer admin
    global-teardown.ts    # Teardown : arrêter l'app
    fixtures.ts           # Fixtures Playwright partagées
    auth.spec.ts          # Tests authentification
    admin.spec.ts         # Tests administration
    issues.spec.ts        # Tests issues
    ...
  integration/            # Tests d'intégration Rust (DB réelle) [à créer]
  fixtures/               # Fichiers de test (repos, configs) [à créer]
  seeds/                  # Seeds Rust pour initialiser la DB de test
    mod.rs
    seed.rs

Tests à ajouter

1. Tests fonctionnels API REST (intégration Rust)

Tests avec une DB PostgreSQL réelle pour vérifier les endpoints API.

Emplacement : tests/integration/ ou crates/gitrust-web/tests/

Approche recommandée : utiliser reqwest + un serveur de test :

#![allow(unused)]
fn main() {
// tests/integration/api_test.rs
use reqwest::Client;

#[tokio::test]
#[ignore] // Nécessite une DB + serveur running
async fn test_api_login() {
    let client = Client::new();
    let res = client.post("http://localhost:4001/api/v1/auth/login")
        .json(&serde_json::json!({
            "login": "admin",
            "password": "test_password"
        }))
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 200);
}
}

Alternative : axum::test pour tester les handlers sans serveur HTTP :

#![allow(unused)]
fn main() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;

#[tokio::test]
async fn test_health_check() {
    let app = create_test_app().await; // Build router with test DB
    let response = app
        .oneshot(Request::builder().uri("/api/hello").body(Body::empty()).unwrap())
        .await
        .unwrap();
    assert_eq!(response.status(), 200);
}
}

2. Tests fonctionnels CI

TestDescriptionPriorité
CI detectionRepo avec .gitrust-ci.yml -> Easy, .dagger/ -> Power, rien -> NoneHaute
Pipeline CRUDCréer, lister, mettre à jour, annuler un pipelineHaute
Config CIActiver/désactiver CI, modifier triggers, vérifier effetHaute
Variables héritageTeam var + repo var -> merge correctMoyenne
Auto-cancelNouveau push annule les pipelines en coursMoyenne
Logs streamingAppend logs + lecture paginéeMoyenne
Notifications CIPipeline échoue -> notification crééeMoyenne

Pour les tests CI : mocker dagger avec un script shell qui simule success/failure :

#!/bin/bash
# tests/fixtures/mock-dagger.sh
echo "Step 1: Building..."
echo "Step 2: Testing..."
if [ "$MOCK_FAIL" = "true" ]; then
    echo "FAILED" >&2
    exit 1
fi
echo "All checks passed"
exit 0

Configurer CI_DAGGER_BIN=tests/fixtures/mock-dagger.sh dans les tests.

3. Tests e2e Playwright à ajouter

Nouvelles specs pour les fonctionnalités CI et notifications :

SpecCouverture
ci-pipelines.spec.tsListe pipelines, détail, status badges
ci-config.spec.tsConfig CI (enable/disable, triggers, timeout)
ci-variables.spec.tsCRUD variables CI, masquage secrets
notifications.spec.tsListe notifications, marquer lu, préférences
api-docs.spec.tsSwagger UI accessible, spec chargée
i18n.spec.tsChangement de langue, textes traduits
docker-smoke.spec.tsdocker compose up + smoke test

Exemple de spec CI :

// tests/e2e/ci-pipelines.spec.ts
import { test, expect } from './fixtures';

test.describe('CI Pipelines', () => {
  test('affiche la page CI vide', async ({ authenticatedPage }) => {
    await authenticatedPage.goto('/admin/mon-repo/ci');
    await expect(authenticatedPage.locator('text=Aucun pipeline')).toBeVisible();
  });

  test('bouton trigger CI visible pour le maintainer', async ({ authenticatedPage }) => {
    await authenticatedPage.goto('/admin/mon-repo/ci');
    await expect(authenticatedPage.locator('text=Lancer CI')).toBeVisible();
  });
});

4. Tests settings ↔ comportement

Les tests les plus critiques vérifient que changer un setting modifie le comportement observable :

SettingActionVérification
PAT révoquéAppel API401 immédiatement
ci_enabled = falsePushPas de pipeline créé
trigger_on_push = falsePushPas de pipeline
auto_cancel = true2 pushes rapides1er pipeline annulé
email_on_pipeline_failure = falsePipeline échouePas d’email, notif in-app ok
DEFAULT_LOCALE = enCharger pageTexte en anglais

Commandes de test

# Tests unitaires Rust
cargo test --workspace

# Tests avec filtrage
cargo test -p gitrust-core -- ci_service
cargo test -p gitrust-core -- notification

# Tests e2e Playwright
npm run test:e2e

# Tests e2e spécifiques
npx playwright test tests/e2e/ci-pipelines.spec.ts
npx playwright test --grep "notifications"

# Lint
cargo clippy --workspace -- -D warnings

# Format check
cargo fmt --all -- --check

CI locale (exécuter avant un push)

# Script rapide de vérification pré-push
cargo fmt --all -- --check && \
cargo clippy --workspace -- -D warnings && \
cargo test --workspace && \
echo "All checks passed"

Lancer les tests end-to-end (E2E)

Les tests E2E de gitrust utilisent Playwright pour piloter un navigateur Chromium contre une instance de l’application configurée avec une base de données et un répertoire de dépôts dédiés.

Pré-requis

  • Node.js (déjà présent pour Tailwind/DaisyUI)
  • PostgreSQL accessible en local (Docker via database/docker-compose.yml)
  • psql, createdb, dropdb disponibles dans le PATH
  • Chromium installé via Playwright (npx playwright install chromium)

Commandes

# Créer (ou remettre à zéro) la base de données de test
npm run test:e2e:setup

# Lancer tous les tests E2E
npm run test:e2e

# Mode debug interactif (inspecteur Playwright, pas-à-pas)
npm run test:e2e:debug

# Mode UI (interface graphique Playwright)
npm run test:e2e:ui

# Consulter le rapport HTML après exécution
npm run test:e2e:report

Architecture

.env.test                         # Variables d'environnement de test
playwright.config.ts              # Configuration Playwright

scripts/
  e2e-setup-db.sh                 # Crée la DB gitrust_test + nettoie depot_test
  e2e-server.sh                   # Démarre l'app avec .env.test

tests/e2e/
  fixtures.ts                     # Fixtures partagées (loginAs, credentials)
  global-setup.ts                 # Seed : crée les utilisateurs via /register
  global-teardown.ts              # Nettoyage post-tests
  auth.spec.ts                    # Tests d'authentification
  repository.spec.ts              # Tests de gestion des dépôts
  admin.spec.ts                   # Tests du panneau d'administration
  navigation.spec.ts              # Tests de navigation et UI

Isolation de l’environnement de test

RessourceDéveloppementTests E2E
Base de donnéesgitrustgitrust_test
Port HTTP40004001
Port SSH22222223
Dépôts Git./data/repos/./depot_test/
Rate limitingStandardRelâché (×20)
Validation emailActivéeDésactivée

Fonctionnement

  1. npm run test:e2e:setup supprime puis recrée la base gitrust_test et vide depot_test/.
  2. Playwright démarre automatiquement l’application via scripts/e2e-server.sh, qui charge les variables de .env.test avant d’exécuter cargo run.
  3. Le global-setup crée les utilisateurs de test (alice, bob, charlie) en passant par le formulaire /register — approche 100% E2E, aucune insertion directe en base.
  4. Les tests s’exécutent séquentiellement (un seul worker) pour éviter les conflits sur la DB partagée.
  5. Le global-teardown est exécuté à la fin de la session.

Fixtures

Le fichier fixtures.ts fournit une fixture loginAs réutilisable dans tous les tests :

import { test, expect } from './fixtures';

test('mon test', async ({ page, loginAs }) => {
  await loginAs('alice');
  // alice est connectée, on peut naviguer
});

Utilisateurs disponibles : admin, alice, bob, charlie.

Liste des tests

auth.spec.ts — Authentification

TestDescription
login avec des identifiants validesVérifie le login standard et la redirection vers /dashboard
login avec un mauvais mot de passeVérifie l’affichage d’un message d’erreur
login avec un utilisateur inexistantVérifie le rejet d’un username inconnu
logout déconnecte l’utilisateurVérifie la déconnexion et l’impossibilité d’accéder au dashboard
accès au dashboard sans login redirige vers loginVérifie la protection des routes authentifiées
option remember me est présenteVérifie la présence de la checkbox remember_me
formulaire d’inscription est accessibleVérifie la présence de tous les champs du formulaire
inscription avec username déjà pris échoueVérifie l’unicité des usernames
inscription avec mots de passe différents échoueVérifie la validation de confirmation du mot de passe

repository.spec.ts — Gestion des dépôts

TestDescription
créer un dépôt avec nom et descriptionCrée un dépôt complet et vérifie la redirection
créer un dépôt sans descriptionVérifie que la description est optionnelle
créer un dépôt avec un nom déjà pris échoueVérifie l’unicité des slugs par utilisateur
le formulaire de création nécessite un loginVérifie la protection de la route /new
le dashboard liste les dépôts de l’utilisateurVérifie l’affichage des dépôts sur le dashboard
un dépôt public est accessible sans loginVérifie l’accès anonyme aux dépôts publics

admin.spec.ts — Administration

TestDescription
l’admin peut accéder au panneau d’administrationVérifie l’accès admin à /admin
un utilisateur normal ne peut pas accéder à l’adminVérifie le contrôle d’accès
lister les utilisateursVérifie l’affichage de la liste des utilisateurs
créer un nouvel utilisateur via l’adminTeste le formulaire de création d’utilisateur admin
créer un utilisateur avec un username déjà pris échoueVérifie la gestion des doublons côté admin
les liens du menu admin sont présentsVérifie la navigation du panneau admin
TestDescription
la page de login est accessibleVérifie le rendu de la page de login
la navbar est présente après loginVérifie la présence de la barre de navigation
le sidebar est présent après loginVérifie la présence du menu latéral
le lien vers le dashboard fonctionneVérifie la navigation vers le dashboard
le lien vers la création de dépôt fonctionneVérifie le lien /new
la page utilise le thème DaisyUIVérifie la présence de l’attribut data-theme

Ajouter un nouveau test

  1. Créer un fichier tests/e2e/mon-module.spec.ts
  2. Importer les fixtures : import { test, expect } from './fixtures';
  3. Utiliser loginAs pour les tests nécessitant une authentification
  4. Lancer avec npm run test:e2e ou cibler le fichier : npx playwright test mon-module

Ajouter une route web (handler axum + template Askama)

Ce guide explique comment ajouter un endpoint HTTP SSR complet dans gitrust : route, handler, template Askama, et test E2E Playwright.

Pré-requis

  • Familiarité avec le tutoriel 01-getting-started.md.
  • Notions de base d’Axum (extracteurs, State, IntoResponse).

Vue d’ensemble du flux

routes.rs → handler (handlers/*.rs) → service (gitrust-core) → template Askama

Gitrust suit le patron vertical slice : chaque route traverse toutes les couches. Ne créez jamais un handler sans template, ni un service sans route.

Étape 1 : Déclarer la route dans routes.rs

Ouvrez crates/gitrust-web/src/routes.rs. Ajoutez votre route dans la section qui correspond à son contexte (routes authentifiées, routes repo, routes admin…) :

#![allow(unused)]
fn main() {
// Exemple : GET /settings/notifications
.route(
    "/settings/notifications",
    get(handlers::notifications::preferences_form)
        .post(handlers::notifications::preferences_submit),
)
}

La signature de routes.rs utilise Router<DatabaseConnection> — l’état partagé est toujours la connexion DB injectée via State.

Étape 2 : Créer le handler

Créez ou complétez crates/gitrust-web/src/handlers/mon_handler.rs :

#![allow(unused)]
fn main() {
use axum::{extract::State, response::IntoResponse};
use sea_orm::DatabaseConnection;

use crate::{
    auth::AuthUser,
    error::AppError,
    templates::MonPageTemplate,
};
use gitrust_core::services::mon_service::MonService;

/// GET /settings/mon-endpoint
pub async fn mon_form(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
    let data = MonService::get_data(&db, user.user_id).await?;

    Ok(MonPageTemplate {
        username: user.username.clone(),
        is_admin: user.has_role("admin"),
        current_path: "/settings/mon-endpoint".to_owned(),
        data,
    })
}

/// POST /settings/mon-endpoint
pub async fn mon_submit(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
    axum::Form(input): axum::Form<MonInput>,
) -> Result<impl IntoResponse, AppError> {
    MonService::update_data(&db, user.user_id, input).await?;
    Ok(axum::response::Redirect::to("/settings/mon-endpoint"))
}
}

Règles obligatoires :

  • Pas de .unwrap() ni .expect() — toutes les erreurs remontent via ? vers AppError.
  • AppError implémente IntoResponse et mappe les variantes vers les codes HTTP appropriés.
  • AuthUser (extracteur rustwarden-core) rejette automatiquement les requêtes non authentifiées avec 401.

Étape 3 : Déclarer le module handler

Dans crates/gitrust-web/src/handlers/mod.rs, ajoutez :

#![allow(unused)]
fn main() {
pub mod mon_handler;
}

Étape 4 : Créer la struct de template

Dans crates/gitrust-web/src/templates.rs, ajoutez la struct Askama :

#![allow(unused)]
fn main() {
#[derive(Template)]
#[template(path = "settings/mon_endpoint.html")]
pub struct MonPageTemplate {
    pub username: String,
    pub is_admin: bool,
    pub current_path: String,
    pub data: MonData, // votre DTO métier
}
}

Les champs username, is_admin, et current_path sont présents sur toutes les structs de template — ils alimentent la sidebar contextuelle.

Étape 5 : Créer le template Askama

Créez crates/gitrust-web/templates/settings/mon_endpoint.html :

{% extends "base.html" %}

{% block title %}Mon endpoint — gitrust{% endblock %}

{% block content %}
<div class="container mx-auto px-4 py-8">
  <h1 class="text-2xl font-bold mb-6">Mon endpoint</h1>

  <form method="POST" action="/settings/mon-endpoint">
    <input type="hidden" name="_csrf" value="{{ csrf_token }}">

    <div class="form-control mb-4">
      <label class="label">
        <span class="label-text">Valeur</span>
      </label>
      <input
        type="text"
        name="valeur"
        value="{{ data.valeur }}"
        class="input input-bordered"
        required
      >
    </div>

    <button type="submit" class="btn btn-primary">Enregistrer</button>
  </form>
</div>
{% endblock %}

Règle CSRF : tout formulaire POST doit inclure <input type="hidden" name="_csrf" value="{{ csrf_token }}">. Le middleware rustwarden-core rejette les requêtes sans token CSRF valide avec 403.

Étape 6 : Gestion des erreurs dans les templates

Pour afficher les erreurs de validation à l’utilisateur, utilisez le pattern flash :

#![allow(unused)]
fn main() {
// Dans le handler POST, en cas d'erreur de validation :
return Ok(axum::response::Redirect::to(
    "/settings/mon-endpoint?error=valeur+invalide"
));
}

Dans le template :

{% if let Some(err) = query_params.error %}
  <div class="alert alert-error">{{ err }}</div>
{% endif %}

Étape 7 : Écrire les tests E2E Playwright

Créez tests/e2e/mon_endpoint.spec.ts :

import { test, expect } from "@playwright/test";

test("la page mon-endpoint est accessible après login", async ({ page }) => {
  // Authentification (helper existant)
  await page.goto("/login");
  await page.fill("[name=username]", "alice");
  await page.fill("[name=password]", "SecurePass123!");
  await page.click("[type=submit]");

  await page.goto("/settings/mon-endpoint");
  await expect(page).toHaveTitle(/Mon endpoint/);
});

test("soumettre le formulaire redirige vers la même page", async ({ page }) => {
  // ... login ...
  await page.goto("/settings/mon-endpoint");
  await page.fill("[name=valeur]", "nouvelle-valeur");
  await page.click("[type=submit]");
  await expect(page).toHaveURL("/settings/mon-endpoint");
});

Lancez les tests E2E :

npm run test:e2e

Exemple complet : la page de gestion des labels

Pour un exemple réel de ce patron appliqué à une feature complète, consultez :

  • Route : crates/gitrust-web/src/routes.rs — section Issues / Labels
  • Handler : crates/gitrust-web/src/handlers/labels.rs
  • Template : crates/gitrust-web/templates/repository/labels.html
  • Service : crates/gitrust-core/src/services/label_service.rs
  • Tests E2E : tests/e2e/labels.spec.ts

Voir aussi

Ajouter un service métier dans gitrust-core

Ce guide couvre la création d’un nouveau service dans crates/gitrust-core/src/services/, en respectant les conventions du projet.

Pré-requis

Convention générale

Tous les services gitrust-core suivent ce patron uniforme :

Handler (axum) → Service (logique métier) → SeaORM (base de données)

Règles structurelles :

  • Les services sont des structs sans état (pas de champs, uniquement des méthodes async fn associées).
  • Le premier paramètre de chaque méthode est toujours db: &DatabaseConnection.
  • Le type de retour est toujours Result<T, GitrustError>.
  • Aucun unwrap() / expect() / panic!() en production.
  • Aucun SQL brut — tout passe par SeaORM.

Créer le fichier du service

Créez crates/gitrust-core/src/services/mon_service.rs :

#![allow(unused)]
fn main() {
use sea_orm::{
    ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait,
};
use uuid::Uuid;

use crate::{
    dto::mon_dto::{CreateMonInput, MonOutput},
    error::GitrustError,
    models::mon_model,
};

pub struct MonService;

impl MonService {
    /// Crée un nouvel élément.
    ///
    /// # Erreurs
    /// - `GitrustError::Validation` si le nom est vide ou trop long.
    /// - `GitrustError::Conflict` si un élément avec le même nom existe déjà.
    /// - `GitrustError::Database` pour toute erreur SeaORM.
    pub async fn create(
        db: &DatabaseConnection,
        owner_id: Uuid,
        input: CreateMonInput,
    ) -> Result<mon_model::Model, GitrustError> {
        // 1. Validation aux frontières
        if input.name.is_empty() || input.name.len() > 64 {
            return Err(GitrustError::Validation(
                "Le nom doit comporter entre 1 et 64 caractères".into(),
            ));
        }

        // 2. Vérification unicité
        let existing = mon_model::Entity::find()
            .filter(mon_model::Column::OwnerId.eq(owner_id))
            .filter(mon_model::Column::Name.eq(&input.name))
            .one(db)
            .await
            .map_err(GitrustError::Database)?;

        if existing.is_some() {
            return Err(GitrustError::Conflict(format!(
                "Un élément nommé '{}' existe déjà",
                input.name
            )));
        }

        // 3. Insertion
        let now = chrono::Utc::now();
        let model = mon_model::ActiveModel {
            id: Set(Uuid::new_v4()),
            owner_id: Set(owner_id),
            name: Set(input.name),
            description: Set(input.description),
            created_at: Set(now),
            updated_at: Set(now),
        };

        model.insert(db).await.map_err(GitrustError::Database)
    }

    /// Liste les éléments d'un propriétaire.
    pub async fn list_by_owner(
        db: &DatabaseConnection,
        owner_id: Uuid,
    ) -> Result<Vec<mon_model::Model>, GitrustError> {
        mon_model::Entity::find()
            .filter(mon_model::Column::OwnerId.eq(owner_id))
            .order_by_asc(mon_model::Column::Name)
            .all(db)
            .await
            .map_err(GitrustError::Database)
    }

    /// Trouve un élément par ID et vérifie l'appartenance.
    ///
    /// # Erreurs
    /// - `GitrustError::NotFound` si l'ID est inconnu.
    /// - `GitrustError::Forbidden` si `owner_id` ne correspond pas (anti-IDOR).
    pub async fn find_by_id(
        db: &DatabaseConnection,
        id: Uuid,
        owner_id: Uuid,
    ) -> Result<mon_model::Model, GitrustError> {
        let model = mon_model::Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(GitrustError::Database)?
            .ok_or_else(|| GitrustError::NotFound("Élément introuvable".into()))?;

        // Anti-IDOR : vérification d'ownership systématique
        if model.owner_id != owner_id {
            return Err(GitrustError::Forbidden);
        }

        Ok(model)
    }

    /// Supprime un élément (vérifie l'ownership).
    pub async fn delete(
        db: &DatabaseConnection,
        id: Uuid,
        owner_id: Uuid,
    ) -> Result<(), GitrustError> {
        let model = Self::find_by_id(db, id, owner_id).await?;
        mon_model::Entity::delete_by_id(model.id)
            .exec(db)
            .await
            .map_err(GitrustError::Database)?;
        Ok(())
    }
}
}

Déclarer le module dans services/mod.rs

Ouvrez crates/gitrust-core/src/services/mod.rs et ajoutez :

#![allow(unused)]
fn main() {
pub mod mon_service;
}

Injecter le service dans un handler via State

Les services gitrust ne sont pas des structs instanciées — leurs méthodes sont associées (pas de self). Vous n’avez pas besoin de les injecter dans le State d’Axum. Il suffit d’appeler la méthode statique depuis le handler :

#![allow(unused)]
fn main() {
use gitrust_core::services::mon_service::MonService;

pub async fn list_handler(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
    let items = MonService::list_by_owner(&db, user.user_id).await?;
    Ok(MonListTemplate { items, username: user.username, is_admin: user.has_role("admin"), current_path: "/mon-chemin".into() })
}
}

Écrire les tests unitaires

Gitrust impose des tests sur vraie DB (pas de mocks) pour la couche persistance. Utilisez une base SQLite in-memory pour les tests unitaires rapides, ou testcontainers PostgreSQL pour les tests qui dépendent de comportements PG spécifiques (contraintes, index partiels, etc.).

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use sea_orm::{Database, DatabaseConnection};

    async fn setup_db() -> DatabaseConnection {
        let db = Database::connect("sqlite::memory:").await.unwrap();
        // Appliquer les migrations nécessaires
        crate::migrations::run_migrations(&db).await.unwrap();
        db
    }

    #[tokio::test]
    async fn create_inserts_model() {
        let db = setup_db().await;
        let owner = Uuid::new_v4();

        let result = MonService::create(
            &db,
            owner,
            CreateMonInput {
                name: "mon-element".into(),
                description: None,
            },
        )
        .await;

        assert!(result.is_ok());
        let model = result.unwrap();
        assert_eq!(model.name, "mon-element");
        assert_eq!(model.owner_id, owner);
    }

    #[tokio::test]
    async fn create_rejects_empty_name() {
        let db = setup_db().await;
        let err = MonService::create(
            &db,
            Uuid::new_v4(),
            CreateMonInput { name: "".into(), description: None },
        )
        .await
        .unwrap_err();

        assert!(matches!(err, GitrustError::Validation(_)));
    }

    #[tokio::test]
    async fn find_by_id_rejects_wrong_owner() {
        let db = setup_db().await;
        let owner = Uuid::new_v4();
        let other = Uuid::new_v4();

        let model = MonService::create(
            &db,
            owner,
            CreateMonInput { name: "priv".into(), description: None },
        )
        .await
        .unwrap();

        // Un autre utilisateur ne doit pas accéder à cet élément
        let err = MonService::find_by_id(&db, model.id, other).await.unwrap_err();
        assert!(matches!(err, GitrustError::Forbidden));
    }
}
}

Gestion des erreurs — variantes GitrustError

VarianteCode HTTPQuand l’utiliser
GitrustError::NotFound(msg)404Ressource introuvable par ID
GitrustError::Validation(msg)400Input utilisateur invalide
GitrustError::Forbidden403Ownership non vérifiée (anti-IDOR)
GitrustError::Conflict(msg)409Contrainte d’unicité violée
GitrustError::Database(err)500Erreur SeaORM remontée telle quelle

N’utilisez jamais GitrustError::Database pour une erreur de validation — la distinction est importante pour les messages d’erreur utilisateur.

Voir aussi

Ajouter une migration de base de données (SeaORM)

Ce guide couvre la création, l’application et le rollback d’une migration SeaORM dans gitrust.

Pré-requis

Convention de nommage

Chaque fichier de migration suit le format :

m{AAAAMMJJ}_{NNNNNN}_{description_snake_case}.rs
  • AAAAMMJJ : date du jour en UTC.
  • NNNNNN : numéro séquentiel sur 6 chiffres, incrémenté par rapport au dernier fichier existant.
  • description_snake_case : description courte en minuscules.

Exemple : m20260501_000023_create_notify_jobs.rs

Consultez le tableau des migrations existantes dans reference/schema-base-donnees.md avant d’attribuer un numéro pour éviter les collisions.

Structure d’un fichier de migration

Créez crates/gitrust-core/src/migrations/m20260501_000023_create_notify_jobs.rs :

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

/// Nom de migration dérivé automatiquement du nom du module.
#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(NotifyJobs::Table)
                    .if_not_exists()
                    .col(ColumnDef::new(NotifyJobs::Id).uuid().not_null().primary_key())
                    .col(ColumnDef::new(NotifyJobs::OwnerId).uuid().not_null())
                    .col(
                        ColumnDef::new(NotifyJobs::Status)
                            .string()
                            .not_null()
                            .default("pending"),
                    )
                    .col(ColumnDef::new(NotifyJobs::ErrorMessage).text().null())
                    .col(
                        ColumnDef::new(NotifyJobs::CreatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .col(
                        ColumnDef::new(NotifyJobs::UpdatedAt)
                            .timestamp_with_time_zone()
                            .not_null(),
                    )
                    .to_owned(),
            )
            .await?;

        // Index composite pour les requêtes fréquentes
        manager
            .create_index(
                Index::create()
                    .table(NotifyJobs::Table)
                    .name("notify_jobs_owner_status_idx")
                    .col(NotifyJobs::OwnerId)
                    .col(NotifyJobs::Status)
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(NotifyJobs::Table).to_owned())
            .await
    }
}

/// Identifiant Iden pour chaque colonne et la table.
#[derive(DeriveIden)]
enum NotifyJobs {
    Table,
    Id,
    OwnerId,
    Status,
    ErrorMessage,
    CreatedAt,
    UpdatedAt,
}
}

Règles obligatoires :

  • up() doit toujours utiliser .if_not_exists() pour être idempotent.
  • down() doit annuler exactement ce que up() a créé. Toute migration sans down() fonctionnel sera refusée en review.
  • Les colonnes created_at et updated_at sont TIMESTAMPTZ NOT NULL (pas TIMESTAMP — gitrust est UTC strict).
  • Les clés primaires sont des UUID (jamais des entiers auto-incrémentés).

Enregistrer la migration dans le Migrator

Ouvrez crates/gitrust-core/src/migrations/mod.rs et ajoutez la migration dans la liste du Migrator en respectant l’ordre chronologique :

#![allow(unused)]
fn main() {
pub mod m20260501_000023_create_notify_jobs;

pub struct Migrator;

#[async_trait::async_trait]
impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            // ... migrations existantes dans l'ordre ...
            Box::new(m20260501_000023_create_notify_jobs::Migration),
        ]
    }
}
}

L’ordre est strict : SeaORM exécute les migrations dans l’ordre de la liste. Ne réorganisez jamais les migrations existantes.

Appliquer la migration en local

cargo run --bin gitrust -- migrate

Sortie attendue :

Applying migration 'm20260501_000023_create_notify_jobs'
Migration applied successfully

Vérifiez la table dans PostgreSQL :

psql $DATABASE_URL -c "\d notify_jobs"

Tester le rollback

cargo run --bin gitrust -- migrate down

Sortie attendue :

Rolling back migration 'm20260501_000023_create_notify_jobs'
Rollback applied successfully

Vérifiez que la table a bien disparu :

psql $DATABASE_URL -c "\dt notify_jobs"
# → doit retourner "no relations found"

Cas d’usage courants

Ajouter une colonne à une table existante

#![allow(unused)]
fn main() {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Repositories::Table)
                .add_column(
                    ColumnDef::new(Repositories::IsArchived)
                        .boolean()
                        .not_null()
                        .default(false),
                )
                .to_owned(),
        )
        .await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
    manager
        .alter_table(
            Table::alter()
                .table(Repositories::Table)
                .drop_column(Repositories::IsArchived)
                .to_owned(),
        )
        .await
}
}

Créer un index unique composite

#![allow(unused)]
fn main() {
manager
    .create_index(
        Index::create()
            .table(Labels::Table)
            .name("labels_owner_repo_name_type_idx")
            .col(Labels::OwnerId)
            .col(Labels::RepositoryId)
            .col(Labels::Name)
            .col(Labels::LabelType)
            .unique()
            .to_owned(),
    )
    .await
}

Gotchas PostgreSQL

SituationComportementSolution
Ajouter une colonne NOT NULL sans DEFAULT sur une table peupléeErreur PG : column cannot be added without a default valueToujours fournir un DEFAULT lors de l’ajout d’une colonne NOT NULL
ALTER TABLE ... DROP COLUMN avec des FK dépendantesErreur PG : contrainte FK bloquanteSupprimer d’abord les FK avec ForeignKey::drop() dans down()
TIMESTAMPTZ vs TIMESTAMPTIMESTAMP ignore le fuseau horaire, crée des bugs sur les serveurs non-UTCUtiliser toujours timestamp_with_time_zone()
Migration dans une transactionToutes les migrations gitrust s’exécutent dans une transaction impliciteNe pas appeler BEGIN/COMMIT manuellement dans up()

Voir aussi

Implémenter des webhooks dans gitrust

Ce guide couvre l’ajout d’un nouveau déclencheur webhook : modèle de données, service de dispatch, signature HMAC, configuration UI par dépôt, et rotation de secret.

Pré-requis

Vue d’ensemble

sequenceDiagram
    participant Push as git push
    participant Hook as receive-pack handler
    participant Svc as WebhookService
    participant Disp as WebhookDispatcher
    participant Ext as Endpoint HTTP distant

    Push->>Hook: réception des objets
    Hook->>Svc: list_by_repo_and_event(repo_id, "push")
    Svc-->>Hook: Vec<Webhook>
    Hook->>Disp: deliver(webhook, "push", payload)
    Disp->>Ext: POST url (payload signé HMAC-SHA256)
    Ext-->>Disp: 200 OK
    Disp->>Svc: record_delivery(webhook_id, status, duration)

Modèle de données

La table webhooks stocke la configuration par dépôt :

CREATE TABLE webhooks (
    id           UUID PRIMARY KEY,
    repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
    url          TEXT NOT NULL,
    secret_hash  TEXT NOT NULL,        -- HMAC secret haché SHA-256 (jamais en clair)
    events       TEXT NOT NULL,        -- JSON array : ["push","pull_request","issues"]
    is_active    BOOLEAN NOT NULL DEFAULT true,
    created_at   TIMESTAMPTZ NOT NULL,
    updated_at   TIMESTAMPTZ NOT NULL
);

CREATE INDEX webhooks_repo_active_idx ON webhooks(repository_id, is_active);

La table webhook_deliveries trace chaque envoi :

CREATE TABLE webhook_deliveries (
    id           UUID PRIMARY KEY,
    webhook_id   UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
    event        TEXT NOT NULL,
    payload      TEXT NOT NULL,
    response_status INTEGER NULL,
    response_body   TEXT NULL,
    duration_ms  INTEGER NULL,
    success      BOOLEAN NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL
);

Créez les deux migrations correspondantes (voir ajouter-migration-db.md).

WebhookService — méthodes publiques

#![allow(unused)]
fn main() {
// crates/gitrust-core/src/services/webhook_service.rs
pub struct WebhookService;

impl WebhookService {
    /// Crée un webhook pour un dépôt.
    pub async fn create(
        db: &DatabaseConnection,
        repo_id: Uuid,
        url: String,
        secret: &str,          // secret en clair — haché avant stockage
        events: Vec<String>,
    ) -> Result<webhook::Model, GitrustError>;

    /// Liste les webhooks actifs pour un dépôt et un événement donné.
    pub async fn list_by_repo_and_event(
        db: &DatabaseConnection,
        repo_id: Uuid,
        event: &str,
    ) -> Result<Vec<webhook::Model>, GitrustError>;

    /// Met à jour l'URL, le secret ou les événements.
    pub async fn update(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,           // anti-IDOR : vérifie l'appartenance
        url: Option<String>,
        new_secret: Option<&str>,
        events: Option<Vec<String>>,
    ) -> Result<webhook::Model, GitrustError>;

    /// Désactive / active un webhook.
    pub async fn set_active(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
        active: bool,
    ) -> Result<(), GitrustError>;

    /// Supprime un webhook et ses deliveries en cascade.
    pub async fn delete(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
    ) -> Result<(), GitrustError>;

    /// Enregistre le résultat d'une livraison.
    pub async fn record_delivery(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        event: &str,
        payload: &str,
        response_status: Option<i32>,
        response_body: Option<&str>,
        duration_ms: i32,
        success: bool,
    ) -> Result<(), GitrustError>;

    /// Liste les dernières deliveries d'un webhook (pagination).
    pub async fn list_deliveries(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
        page: u64,
        per_page: u64,
    ) -> Result<Vec<webhook_delivery::Model>, GitrustError>;
}
}

Sécurité — stockage du secret : le secret ne doit jamais être stocké en clair. Lors de la création ou mise à jour, hachez-le avec SHA-256 avant insertion. La signature HMAC utilise le secret en clair fourni par l’utilisateur au moment de la création (retourné une seule fois) ou récupéré depuis un secret manager.

#![allow(unused)]
fn main() {
fn hash_secret(secret: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(secret.as_bytes());
    format!("{:x}", hasher.finalize())
}
}

WebhookDispatcher — envoi avec retry et signature HMAC

#![allow(unused)]
fn main() {
// crates/gitrust-core/src/services/webhook_dispatcher.rs
pub struct WebhookDispatcher;

impl WebhookDispatcher {
    /// Envoie le payload à l'URL du webhook avec signature HMAC-SHA256.
    /// Retry exponentiel : 3 tentatives (0s, 5s, 25s).
    pub async fn deliver(
        webhook: &webhook::Model,
        event: &str,
        payload: serde_json::Value,
        secret_plain: &str,     // secret en clair pour la signature
    ) -> DeliveryResult;
}

pub struct DeliveryResult {
    pub success: bool,
    pub response_status: Option<i32>,
    pub response_body: Option<String>,
    pub duration_ms: i32,
    pub attempts: u32,
}
}

Calcul de la signature HMAC

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha2::Sha256;

fn compute_hmac_signature(secret: &str, body: &str) -> String {
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
        .expect("HMAC accepte toutes les longueurs de clé");
    mac.update(body.as_bytes());
    let result = mac.finalize();
    format!("sha256={}", hex::encode(result.into_bytes()))
}
}

Le header HTTP envoyé avec chaque livraison :

X-Gitrust-Signature-256: sha256=<hmac_hex>
X-Gitrust-Event: push
X-Gitrust-Delivery: <uuid de la delivery>
Content-Type: application/json

Retry exponentiel

#![allow(unused)]
fn main() {
const RETRY_DELAYS: [u64; 3] = [0, 5, 25]; // secondes

for (attempt, delay) in RETRY_DELAYS.iter().enumerate() {
    if *delay > 0 {
        tokio::time::sleep(Duration::from_secs(*delay)).await;
    }
    match client.post(&webhook.url).send().await {
        Ok(resp) if resp.status().is_success() => return Ok(DeliveryResult { success: true, attempts: attempt + 1, .. }),
        Ok(resp) => last_error = format!("HTTP {}", resp.status()),
        Err(e) => last_error = e.to_string(),
    }
}
}

Règle SSRF : l’URL du webhook est validée à la création. Les adresses RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) et localhost sont interdites en production.

Injecter le dispatch dans le hook post-receive

Dans le handler receive_pack (crates/gitrust-web/src/handlers/git_http.rs), après la réception des objets Git :

#![allow(unused)]
fn main() {
// Déclencher les webhooks "push"
let payload = serde_json::json!({
    "repository": { "id": repo.id, "slug": repo.slug, "owner": owner.username },
    "pusher": { "username": user.username },
    "ref": pushed_ref,
    "commits": commit_summaries,
});

let webhooks = WebhookService::list_by_repo_and_event(&db, repo.id, "push").await?;
for webhook in webhooks {
    // Le dispatch est non-bloquant : on ne veut pas bloquer le push si le
    // serveur distant est lent.
    let db_clone = db.clone();
    let payload_clone = payload.clone();
    tokio::spawn(async move {
        let result = WebhookDispatcher::deliver(&webhook, "push", payload_clone, &secret_plain).await;
        let _ = WebhookService::record_delivery(
            &db_clone,
            webhook.id,
            "push",
            &payload_clone.to_string(),
            result.response_status,
            result.response_body.as_deref(),
            result.duration_ms,
            result.success,
        ).await;
    });
}
}

UI de configuration par dépôt

Ajoutez les routes dans routes.rs :

#![allow(unused)]
fn main() {
.route(
    "/{owner}/{repo}/settings/webhooks",
    get(handlers::webhooks::list_webhooks).post(handlers::webhooks::create_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}",
    get(handlers::webhooks::webhook_detail),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/edit",
    post(handlers::webhooks::edit_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/delete",
    post(handlers::webhooks::delete_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/test",
    post(handlers::webhooks::test_webhook),
)
}

Rotation du secret

Quand un utilisateur régénère le secret via l’UI, le nouveau secret est :

  1. Affiché une seule fois à l’utilisateur (jamais re-lisible depuis l’UI).
  2. Haché SHA-256 et stocké dans webhooks.secret_hash.
  3. Utilisé immédiatement pour les livraisons suivantes.

Les livraisons en cours avec l’ancien secret ne sont pas réessayées — informez l’utilisateur dans l’UI.

Checklist de test

  • Créer un webhook avec URL + secret + événements → visible dans la liste.
  • Push sur le dépôt → delivery créée avec statut success ou failed.
  • Désactiver le webhook → push ne déclenche aucune livraison.
  • URL RFC1918 → rejet à la création avec message d’erreur.
  • Bouton « Test » → delivery avec événement ping créée.
  • Rotation du secret → livraisons suivantes signées avec le nouveau secret.

Voir aussi

Implémenter un endpoint REST /api/v1/...

Ce guide couvre l’ajout d’un nouvel endpoint JSON à l’API REST v1 de gitrust : modèle de requête/réponse, authentification PAT/JWT, codes d’erreur standardisés, et tests.

Pré-requis

Vue d’ensemble

Les handlers API et les handlers SSR partagent les mêmes services. La seule différence est le format de réponse :

Handler SSR → service → réponse HTML (template Askama)
Handler API → service → réponse JSON (serde_json)

Ne dupliquez jamais la logique métier entre un handler SSR et un handler API.

Structure des handlers API

Les handlers API se trouvent dans crates/gitrust-web/src/handlers/api/. Chaque domaine a son fichier :

handlers/api/
├── repos.rs      — dépôts, branches, commits
├── issues.rs     — issues et commentaires
├── pulls.rs      — pull requests
├── ci.rs         — pipelines CI
├── user.rs       — profil utilisateur courant
└── docs.rs       — Swagger UI

Étape 1 : Définir les types de requête et réponse

Créez ou complétez les DTOs dans crates/gitrust-core/src/dto/ :

#![allow(unused)]
fn main() {
// crates/gitrust-core/src/dto/mon_api_dto.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Corps de la requête POST /api/v1/repos/{owner}/{repo}/mon-resource
#[derive(Debug, Deserialize)]
pub struct CreateMonResourceRequest {
    pub name: String,
    pub description: Option<String>,
}

/// Réponse JSON pour un élément unique
#[derive(Debug, Serialize)]
pub struct MonResourceResponse {
    pub id: Uuid,
    pub name: String,
    pub description: Option<String>,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

/// Réponse JSON pour une liste paginée
#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T> {
    pub items: Vec<T>,
    pub total: u64,
    pub page: u64,
    pub per_page: u64,
}
}

Étape 2 : Déclarer la route dans routes.rs

Dans la fonction api_v1_routes() de crates/gitrust-web/src/routes.rs :

#![allow(unused)]
fn main() {
.route(
    "/api/v1/repos/{owner}/{repo}/mon-resource",
    get(handlers::api::mon_resource::list).post(handlers::api::mon_resource::create),
)
.route(
    "/api/v1/repos/{owner}/{repo}/mon-resource/{id}",
    get(handlers::api::mon_resource::detail)
        .patch(handlers::api::mon_resource::update)
        .delete(handlers::api::mon_resource::delete_resource),
)
}

Étape 3 : Implémenter le handler

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/handlers/api/mon_resource.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;

use crate::auth::AuthUser;
use gitrust_core::{
    dto::mon_api_dto::{CreateMonResourceRequest, MonResourceResponse, PaginatedResponse},
    services::mon_service::MonService,
};

#[derive(Deserialize)]
pub struct PaginationParams {
    #[serde(default = "default_page")]
    pub page: u64,
    #[serde(default = "default_per_page")]
    pub per_page: u64,
}

fn default_page() -> u64 { 1 }
fn default_per_page() -> u64 { 30 }

/// GET /api/v1/repos/{owner}/{repo}/mon-resource
pub async fn list(
    State(db): State<DatabaseConnection>,
    Path((owner, repo)): Path<(String, String)>,
    Query(pagination): Query<PaginationParams>,
    user: AuthUser,
) -> impl IntoResponse {
    let per_page = pagination.per_page.min(100); // cap à 100

    match MonService::list_paginated(
        &db, &owner, &repo, user.user_id, pagination.page, per_page,
    )
    .await
    {
        Ok((items, total)) => {
            let response = PaginatedResponse {
                items: items.into_iter().map(MonResourceResponse::from).collect(),
                total,
                page: pagination.page,
                per_page,
            };
            let mut headers = axum::http::HeaderMap::new();
            headers.insert("X-Total-Count", total.to_string().parse().unwrap());
            headers.insert("X-Page", pagination.page.to_string().parse().unwrap());
            headers.insert("X-Per-Page", per_page.to_string().parse().unwrap());
            (StatusCode::OK, headers, Json(response)).into_response()
        }
        Err(e) => api_error(e).into_response(),
    }
}

/// POST /api/v1/repos/{owner}/{repo}/mon-resource
pub async fn create(
    State(db): State<DatabaseConnection>,
    Path((owner, repo)): Path<(String, String)>,
    user: AuthUser,
    Json(body): Json<CreateMonResourceRequest>,
) -> impl IntoResponse {
    match MonService::create_for_repo(&db, &owner, &repo, user.user_id, body.into()).await {
        Ok(model) => (StatusCode::CREATED, Json(MonResourceResponse::from(model))).into_response(),
        Err(e) => api_error(e).into_response(),
    }
}
}

Authentification : PAT et JWT Bearer

L’extracteur AuthUser supporte deux modes d’authentification :

  1. Cookie JWT (jwt_token) — utilisé par le navigateur web.
  2. Header Authorization: Bearer <token> — utilisé par les clients API avec un PAT ou un JWT.

Les Personal Access Tokens sont vérifiés via PatService::validate_token() dans le middleware. Aucune modification n’est nécessaire côté handler — AuthUser gère les deux méthodes transparentement.

Codes de retour en cas d’échec d’auth :

  • 401 Unauthorized — token absent, expiré, ou invalide. Réponse JSON, jamais de redirect HTML.
  • 403 Forbidden — token valide mais permissions insuffisantes.

Format des erreurs JSON standardisé

Toutes les erreurs API retournent le même format :

{
    "error": "not_found",
    "message": "Le dépôt 'alice/myrepo' est introuvable",
    "status": 404
}

La fonction utilitaire api_error à placer dans handlers/api/mod.rs :

#![allow(unused)]
fn main() {
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde_json::json;
use gitrust_core::error::GitrustError;

pub fn api_error(err: GitrustError) -> impl IntoResponse {
    let (status, code) = match &err {
        GitrustError::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
        GitrustError::Validation(_) => (StatusCode::BAD_REQUEST, "validation_error"),
        GitrustError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
        GitrustError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
        _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
    };
    (
        status,
        Json(json!({
            "error": code,
            "message": err.to_string(),
            "status": status.as_u16()
        })),
    )
}
}

Pagination

Tous les endpoints de liste supportent ?page=N&per_page=N (défaut : page=1, per_page=30, max per_page=100).

Headers de réponse :

X-Total-Count: 142
X-Page: 2
X-Per-Page: 30
Link: <https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues?page=1&per_page=30>; rel="prev",
      <https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues?page=3&per_page=30>; rel="next"

Rate limiting

Les endpoints API sont couverts par le middleware de rate limiting de rustwarden-core :

CatégorieLimiteFenêtre
Endpoints lecture1000 req1 heure
Endpoints écriture200 req1 heure
Trigger CI manuel10 req1 heure

En cas de dépassement : 429 Too Many Requests avec header Retry-After: <secondes>.

Documenter l’endpoint (OpenAPI)

Ajoutez les annotations utoipa sur le handler :

#![allow(unused)]
fn main() {
#[utoipa::path(
    get,
    path = "/api/v1/repos/{owner}/{repo}/mon-resource",
    params(
        ("owner" = String, Path, description = "Propriétaire du dépôt"),
        ("repo" = String, Path, description = "Slug du dépôt"),
        ("page" = Option<u64>, Query, description = "Numéro de page (défaut: 1)"),
        ("per_page" = Option<u64>, Query, description = "Éléments par page (défaut: 30, max: 100)"),
    ),
    responses(
        (status = 200, description = "Liste paginée", body = PaginatedResponse<MonResourceResponse>),
        (status = 401, description = "Non authentifié"),
        (status = 403, description = "Accès refusé"),
        (status = 404, description = "Dépôt introuvable"),
    ),
    security(("bearer_token" = []), ("cookie_auth" = []))
)]
pub async fn list(...) { ... }
}

Tester l’endpoint

Test curl rapide :

# Avec un PAT
curl -H "Authorization: Bearer <votre-pat>" \
     https://demo.gitrust.eu/api/v1/repos/alice/myrepo/mon-resource

# Vérifier le format d'erreur sur auth invalide
curl -H "Authorization: Bearer token-invalide" \
     https://demo.gitrust.eu/api/v1/repos/alice/myrepo/mon-resource
# → {"error":"unauthorized","message":"Token invalide","status":401}

Test d’intégration Rust :

#![allow(unused)]
fn main() {
#[tokio::test]
async fn list_returns_401_without_auth() {
    let app = setup_test_app().await;
    let response = app
        .oneshot(
            Request::builder()
                .uri("/api/v1/repos/alice/myrepo/mon-resource")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
    let body: serde_json::Value = parse_json_body(response).await;
    assert_eq!(body["error"], "unauthorized");
}

#[tokio::test]
async fn create_returns_201_with_valid_pat() {
    let app = setup_test_app().await;
    let pat = create_test_pat(&app).await;

    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/repos/alice/myrepo/mon-resource")
                .header("Authorization", format!("Bearer {pat}"))
                .header("Content-Type", "application/json")
                .body(Body::from(r#"{"name":"test"}"#))
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::CREATED);
}
}

Voir aussi

Importer un dépôt externe

Référence du flux d’import de dépôts externes dans gitrust : rôle de la base de données, schéma de séquence complet et pistes d’optimisation.

1. Rôle de la base de données

Pendant un import, la DB sert quatre fonctions distinctes :

RôleTableFréquence
File d’attente persistanteimport_jobs1 INSERT à la création
Suivi d’état pour l’UIimport_jobs1 UPDATE toutes les ~1.5 s
Enregistrement final du dépôtrepositories + resources2 INSERT à la fin
Journal d’auditaudit_log1 INSERT à la fin

Le clone lui-même ne passe jamais par la DB : seuls les métadonnées et compteurs de progression y transitent. Les objets Git sont écrits directement sur disque dans {GIT_REPOS_BASE_PATH}/{owner}/{slug}.git/.

Pourquoi persister l’état ?

  • Reprise après redémarrage : le serveur peut crasher pendant un clone de plusieurs minutes. La table permet de marquer ces jobs comme failed au redémarrage.
  • SSE : l’UI lit la progression via un endpoint SSE qui interroge la DB toutes les 2 s. Sans DB, il faudrait un canal in-memory plus un mécanisme de routage vers le bon client.
  • Multi-navigateurs : si l’utilisateur ferme l’onglet puis le rouvre, il retrouve l’état exact du job.
  • Audit / historique : conservation des imports passés avec leurs statistiques.

2. Schéma du flux actuel

sequenceDiagram
  autonumber
  participant UI as Navigateur
  participant H as Handler HTTP
  participant Svc as ImportService
  participant Chan as mpsc Channel
  participant W as ImportWorker
  participant G as git2 (libgit2)
  participant DB as PostgreSQL
  participant FS as Disque

  UI->>H: POST /import<br/>(url, slug, pat)
  H->>Svc: create_job
  Svc->>DB: INSERT import_jobs (pending)
  H->>Chan: try_send(ImportTask+PAT)
  H-->>UI: 302 /imports/{id}

  UI->>H: GET /imports/{id}/stream (SSE)
  Note over UI,H: EventSource ouvert

  Chan->>W: ImportTask
  W->>Svc: mark_running
  Svc->>DB: SELECT import_jobs<br/>UPDATE status=running

  W->>G: RepoBuilder.bare(true).clone()
  G->>FS: init_bare + fetch
  loop callbacks transfer_progress (flood)
    G-->>W: stats (objets/bytes)
    alt throttle 1500 ms écoulé
      W->>Svc: update_progress (tokio::spawn)
      Svc->>DB: SELECT + UPDATE import_jobs
    else dans le throttle
      W--xW: ignore
    end
  end

  loop toutes les 2 s
    H->>DB: SELECT import_jobs WHERE id=?
    DB-->>H: état courant
    H-->>UI: SSE event (JSON)
    UI->>UI: bar.value = percent
  end

  G-->>FS: objets écrits
  G-->>W: Ok

  W->>Svc: check cancel (SELECT import_jobs)
  W->>Svc: update_progress (Finalizing)
  Svc->>DB: SELECT + UPDATE

  W->>RepositoryService: create
  RepositoryService->>DB: INSERT resources<br/>INSERT repositories
  W->>DB: UPDATE repositories<br/>(import_source_url, is_empty=false)
  W->>Svc: mark_success
  Svc->>DB: SELECT + UPDATE import_jobs<br/>(status=success, duration)
  W->>AuditService: log REPO_IMPORTED
  AuditService->>DB: INSERT audit_log

  H->>DB: SELECT (prochain tick SSE)
  H-->>UI: terminal=true
  UI->>UI: window.location.reload()

3. Coût DB par import

Pour un clone de 90 secondes avec throttle 1500 ms :

ÉtapeOpérations DBCumul
create_job1 INSERT1
mark_running1 SELECT + 1 UPDATE3
update_progress pendant clone~60 x (1 SELECT + 1 UPDATE)123
check cancel1 SELECT124
update_progress finalizing1 SELECT + 1 UPDATE126
RepositoryService::create2 INSERT (repo + resource)128
update repositories1 UPDATE129
mark_success1 SELECT + 1 UPDATE131
audit log1 INSERT132
SSE stream (45 ticks à 2 s)45 SELECT177

~177 requêtes pour un import, dont 120+ sont des progress updates.

4. Pistes d’optimisation (sans changer libgit2)

4.1 Éliminer le SELECT avant UPDATE dans update_progress

update_progress fait actuellement load_model (SELECT) puis active.update(db) (UPDATE + RETURNING). Le SELECT est redondant : on connaît l’ID, on veut juste patcher 4 colonnes.

Gain : divise par 2 les requêtes de progression (~60 requêtes en moins pour un clone de 90 s).

Refonte suggérée avec UpdateMany (pattern déjà utilisé dans ci_service.rs::cancel_running_pipelines) :

#![allow(unused)]
fn main() {
import_job::Entity::update_many()
    .col_expr(Column::ReceivedObjects, Expr::value(received as i32))
    .col_expr(Column::TotalObjects, Expr::value(total as i32))
    .col_expr(Column::ReceivedBytes, Expr::value(bytes as i64))
    .col_expr(Column::Phase, Expr::value(phase.as_str()))
    .col_expr(Column::UpdatedAt, Expr::value(Utc::now()))
    .filter(Column::Id.eq(job_id))
    .exec(db)
    .await?;
}

Même principe pour mark_running, mark_success, mark_failed, mark_cancelled → toutes divisent leurs aller-retours DB par 2.

4.2 Downsampler la progression dans le worker (dédié)

Plan alternatif : le callback écrit simplement dans un tokio::sync::watch<TransferStats> (en mémoire, zéro DB). Une tâche dédiée consomme ce channel et UPDATE la DB à 1 Hz seulement.

flowchart LR
  G[git2 callback] -->|watch::send<br/>très haute fréquence| W[watch channel]
  W --> T[Tâche update<br/>ticker 1 Hz]
  T -->|1 UPDATE / s| DB

Gains :

  • 1 seul émetteur DB au lieu de N tokio::spawn concurrents → zéro contention sur le pool
  • Fréquence déterministe (1 Hz) indépendante de la vitesse réseau
  • Code plus clair (la tâche est async, on peut await sans détour)

4.3 Supprimer le RETURNING implicite de SeaORM

ActiveModel::update(db) renvoie le modèle complet via ... RETURNING *. Pour les progress updates, on n’utilise pas le retour. UpdateMany::exec n’émet pas de RETURNING → moins de bytes sur le wire, moins de désérialisation.

4.4 Pool DB dédié pour le worker

Aujourd’hui le worker partage le pool principal avec les handlers HTTP. Un pool de 4-8 connexions dédié au worker éviterait qu’un import sature le pool et fasse timeouter les requêtes utilisateur.

4.5 Debouncer le SSE côté serveur

Le SSE SELECT toutes les 2 s même quand rien ne change. Alternative : PostgreSQL LISTEN/NOTIFY sur une chaîne import_job_{uuid}. Chaque UPDATE émet un NOTIFY, la connexion SSE fait LISTEN → push direct, zéro polling.

Coût : nécessite une connexion DB dédiée par SSE (LISTEN est stateful). Compromis à évaluer.

5. Optimisation majeure : shell-out vers git clone --bare

Indépendant des optimisations DB mais de loin le plus gros gain. libgit2 est structurellement 2-5x plus lent que git CLI sur gros clones HTTPS (pas de multiplexage, résolution de deltas mono-threadée, etc.).

flowchart TB
  subgraph Actuel[Flux actuel — libgit2]
    A1[RepoBuilder::clone] --> A2[libgit2: fetch + resolve]
    A2 -->|lent 2-5x| A3[bare repo]
  end
  subgraph Cible[Flux optimisé — git CLI]
    B1[Command::new git] --> B2[git clone --bare --progress]
    B2 -->|stderr| B3[Parser regex<br/>Receiving objects: X%]
    B3 --> B4[update watch channel]
    B2 -->|vitesse native| B5[bare repo]
  end

Avantages :

  • Vitesse native (parité avec git CLI)
  • Progression native via stderr Receiving objects: 42% (368/876), 1.10 MiB | 2.05 MiB/s
  • HEAD correctement positionné (plus besoin du workaround RepoBuilder)
  • Moins de dépendance sur libgit2 pour le cas du clone initial

Contrepartie : binaire git requis sur le serveur (déjà le cas — utilisé par SbomService::git_archive).

6. Priorités recommandées

Si on voulait optimiser maintenant, ordre proposé :

  1. Shell-out git clone --bare — gain réel pour l’utilisateur (clone 3-5x plus rapide).
  2. update_many pour update_progress — divise la pression DB par 2, changement mécanique de ~20 lignes.
  3. watch channel + tâche 1 Hz — architecture plus propre, élimine les problèmes de pool.
  4. Pool DB dédié worker — filet de sécurité opérationnel.
  5. LISTEN/NOTIFY pour SSE — seulement si beaucoup de clients simultanés.

Les gains 2-4 sont utiles même avec libgit2 ; le gain 1 est le plus visible pour l’utilisateur final.

Contribuer à rustwarden

Ce document décrit les interactions entre gitrust et le framework rustwarden-core, et explique comment étendre le framework côté gitrust sans modifier ses sources.

Services framework utilisés

ServiceUsage dans gitrust
UserServiceCRUD users, roles, delete_user, admin_reset_password, change_password_direct, unassign_role_from_user
RoleServiceListe/création/suppression roles, get_role_by_name
PasswordServicehash_password (async), verify_password (async), generate_secure_password
ResourceServiceregister/unregister repos, set_public, effective_permission, find_by_type_and_id
AuthServiceauthenticate_user (login)
JwtServicegenerate_token (login)
EmailValidationServiceVérification email
PasswordResetServiceFlux “mot de passe oublié”
AppSettingsServiceget_bool pour registration_enabled

Hook on_user_deleted implémenté

Le hook on_user_deleted dans gitrust-hooks supprime le répertoire utilisateur sur le filesystem (repos_base_path/username/) avant la cascade DB. Appelé automatiquement par UserService::delete_user.

Règle fondamentale

Ne jamais modifier crates/rustwarden-core/. Ce crate est le framework partagé. Toute extension passe par :

  • Des wrappers côté gitrust-core (services qui délèguent au framework)
  • Des trait impls (RustwardenHooks dans gitrust-hooks)
  • Des extensions via les mécanismes d’injection Axum (Extension, State)

Aucun contournement en cours

Tous les anciens contournements (entités directes, hash manuels) ont été remplacés par les services du framework suite à la mise à jour du 2026-03-26.

Ajouter une fonctionnalité nécessitant un service framework absent

Si un service nécessaire n’existe pas dans rustwarden-core :

  1. Implémenter le service côté gitrust-core en réutilisant les primitives du framework (ex: PasswordService::hash_password pour le hashage)
  2. Si la fonctionnalité est générique et bénéficierait au framework lui-même, ouvrir une issue ou une PR upstream sur le dépôt rustwarden-core
  3. Ne pas dupliquer la logique d’auth, de session, ou de JWT — ces domaines appartiennent exclusivement au framework

Pattern d’extension via hooks

Les hooks (gitrust-hooks/src/lib.rs) implémentent RustwardenHooks et sont injectés dans le routeur Axum au démarrage :

#![allow(unused)]
fn main() {
// Dans main.rs
let hooks = Arc::new(GitrustHooks::new(config.clone()));
// Passé via .merge_routes(app_routes(hooks))
// Puis disponible via axum::Extension dans les handlers
}

Pour réagir à un nouvel événement du framework, ajouter la méthode correspondante dans l’impl RustwardenHooks de gitrust-hooks, sans toucher au code du framework qui émet l’événement.

Passer la QA avant un merge

Ce guide liste dans l’ordre exact toutes les vérifications à effectuer avant d’ouvrir ou de merger une pull request dans gitrust.

Pourquoi cette checklist existe

Gitrust vise la conformité ANSSI PA-074. Chaque gate protège contre une catégorie spécifique de régressions : qualité de code, sécurité mémoire, vulnérabilités de dépendances, ou comportement utilisateur. Aucun gate ne peut être ignoré sauf exception documentée dans la PR.

Ordre d’exécution local

Exécutez les gates dans cet ordre — un gate qui échoue bloque les suivants.

Gate 1 — Formatage

cargo fmt --all -- --check

En cas de diff : appliquez le format, puis relancez --check.

cargo fmt --all
cargo fmt --all -- --check   # doit maintenant retourner 0

Sortie attendue (succès) :

(aucune sortie — code de retour 0)

Gate 2 — Linting (zéro warning)

cargo clippy --workspace -- -D warnings

Sortie attendue :

    Finished `dev` profile [unoptimized + debuginfo] target(s) in Xs

Aucun warning: ne doit apparaître. Les warnings sont traités comme des erreurs (-D warnings).

Lints Rust obligatoires (déjà configurés dans lib.rs de chaque crate) :

#![allow(unused)]
#![forbid(unsafe_code)]          // crates core, web, hooks
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![deny(clippy::indexing_slicing)]
#![deny(clippy::mem_forget)]
fn main() {
}

Gate 3 — Tests unitaires et d’intégration

cargo test --workspace

Sortie attendue :

test result: ok. XXX passed; 0 failed; 0 ignored; 0 measured

Règles de test :

  • Tests sur vraie DB (pas de mocks pour la couche persistance).
  • Tests négatifs obligatoires : pour chaque chemin heureux, un test couvre l’accès non authentifié, l’input invalide, et la ressource inexistante.
  • Pas de sleep() dans les tests — utilisez retry/poll avec timeout.
  • Chaque test crée ses propres données (pas de dépendance sur l’ordre d’exécution).

Gate 4 — Tests E2E Playwright (si UI modifiée)

À exécuter si vous avez modifié des templates, routes SSR, ou le comportement des pages :

npm run test:e2e

Les tests E2E se trouvent dans tests/e2e/. Sortie attendue :

  XX passed (Xs)

En cas d’échec : consultez tests/e2e/reports/ pour les captures d’écran et traces.

Gate 5 — Rebuild CSS (si templates modifiés)

À exécuter si vous avez modifié un fichier dans templates/ ou ajouté des classes Tailwind :

npx tailwindcss -i static/css/input.css -o static/css/style.css --minify

Commitez le fichier static/css/style.css généré dans votre PR.

Gate 6 — Audit des dépendances

cargo audit

Sortie attendue :

    Fetching advisory database from `https://github.com/RustSec/advisory-db.git`
    Scanning Cargo.lock for vulnerabilities (X crates)
0 vulnerabilities found

En cas de vulnérabilité : mettez à jour la dépendance concernée ou ajoutez une exemption documentée dans deny.toml.

Gate 7 — Licences et dépendances bannies

cargo deny check

Sortie attendue :

    Checking advisories
    Checking bans
    Checking licenses
    Checking sources

Aucune violation ne doit apparaître. Gitrust interdit les licences GPL et AGPL dans les dépendances de production.

Checklist sécurité (features touchant auth/secrets/permissions)

Pour tout changement touchant l’authentification, les tokens, les permissions ou les secrets :

  • Secrets jamais en clair en base — hash SHA-256 ou bcrypt systématique.
  • Secrets jamais loggés — vérifiez tracing::info/debug, Display et Debug impls.
  • Comparaison en temps constant — subtle::ConstantTimeEq pour la validation de secrets (pas de ==).
  • Rate limiting présent sur les endpoints d’authentification.
  • Token CSRF sur tous les formulaires POST/PUT/DELETE.
  • Vérification d’ownership côté serveur (anti-IDOR) — user_id vérifié sur chaque ressource accédée.
  • Expiration obligatoire sur tout token ou session.
  • Zeroize — les types contenant des secrets implémentent Zeroize/ZeroizeOnDrop.
  • Path traversal — validation des chemins disque (pas de .., /, \ dans les slugs).

Checklist pre-merge finale

  • Gate 1 : cargo fmt --all -- --check → 0 diff
  • Gate 2 : cargo clippy --workspace -- -D warnings → 0 warning
  • Gate 3 : cargo test --workspace → 0 échec
  • Gate 4 : npm run test:e2e → 0 échec (si UI modifiée)
  • Gate 5 : CSS rebuild commité (si templates modifiés)
  • Gate 6 : cargo audit → 0 vulnérabilité
  • Gate 7 : cargo deny check → 0 violation
  • Aucun secret en clair dans le diff (git diff --staged | grep -i "password\|secret\|token\|key")
  • Aucune modification dans crates/rustwarden-core/ (framework read-only)
  • Checklist sécurité §ci-dessus validée (si applicable)

Vérification des secrets dans le diff

Avant tout commit, scannez votre diff :

git diff --staged | grep -iE "(password|secret|token|api_key|private_key)\s*=" | grep -v "test\|example\|dummy\|placeholder"

Si la commande retourne des lignes, examinez-les une par une pour confirmer qu’il ne s’agit pas de vraies valeurs sensibles.

Voir aussi

Référence

La référence est une documentation exhaustive et précise. Elle décrit le comportement exact des API, des schémas, et des services. Consultez-la quand vous avez besoin de savoir ce qu’une fonction accepte, ce qu’une table contient, ou ce qu’un endpoint retourne.

Diátaxis — mode information : la référence ne vous apprend pas — elle vous informe. Chaque entrée est complète, précise, et sans contexte superflu.

Documents de référence

Services API interne

Signatures complètes de tous les services Rust disponibles dans gitrust-core et rustwarden-core : AuthService, UserService, RepositoryService, IssueService, LabelService, TotpService, ResourceService, et plus.


Schéma base de données

ERD Mermaid complet, définition de chaque table avec types Rust/SeaORM, contraintes, index, et règles de clé étrangère. Ordre des migrations inclus.


API REST v1

Tous les endpoints /api/v1/ : authentification, utilisateurs, dépôts, issues, pull requests, pipelines CI. Format requête/réponse JSON, codes d’erreur, pagination, rate limiting, exemples curl.


Hooks git post-receive

Implémentation du trait RustwardenHooks dans gitrust. Événements on_user_registered, on_user_deleted, on_resource_shared, on_resource_unshared avec leurs effets de bord. Séquence receive-pack, dispatch webhook/CI, tests locaux.


API rustwarden-core

Extracteurs Axum (AuthUser, OptionalUser, AuthenticatedSession), middleware JWT/CSRF/rate-limit, JwtService, trait RustwardenHooks, i18n, EmailQueueService. Règles d’extension du framework.


Conventions de lecture

  • Les signatures de méthode suivent la syntaxe Rust : fn nom(arg: Type) -> Result<ReturnType, Error>.
  • Les types Option<T> indiquent un champ nullable en base ou un paramètre optionnel.
  • Les colonnes marquées PK sont des clés primaires UUID générées côté base (gen_random_uuid()).
  • TIMESTAMPTZ = timestamp avec fuseau horaire, stocké en UTC.
  • Vous cherchez comment utiliser un service dans votre code ? → Guides pratiques
  • Vous voulez comprendre pourquoi ces services sont structurés ainsi ? → Explications

Architecture par crates

Référence structurelle de gitrust : crates, modules, routes, dépendances et composants CI.

Dépendances entre crates

graph LR
    RW["rustwarden-core"]

    Git["gitrust-git"]
    Core["gitrust-core"]
    Hooks["gitrust-hooks"]
    SSH["gitrust-ssh"]
    SshGuard["gitrust-ssh-guard"]
    Web["gitrust-web"]
    Bin["gitrust (binaire)"]

    Core --> RW
    Git -.->|"indépendant<br/>(git2, tokio)"| RW

    Hooks --> Core
    Hooks --> RW

    SSH --> Core
    SSH --> Git
    SSH --> SshGuard

    SshGuard -.->|"SeaORM bans/ACL/events"| Core

    Web --> Core
    Web --> Git
    Web -.->|"CI pages"| Git
    Web -.->|"admin ACL/ban via Extension"| SshGuard

    Bin --> Core
    Bin --> Git
    Bin --> SSH
    Bin --> SshGuard
    Bin --> Web
    Bin --> Hooks
    Bin --> RW

    subgraph "Externes CI (Phase 5b)"
        Dagger["Dagger CLI"]
        CiEng["ci-engine (Python)"]
        Syft["Syft"]
        DTrack["Dependency-Track"]
    end

    Core -.->|"CiWorker<br/>sous-processus"| Dagger
    Dagger -.-> CiEng
    Core -.-> Syft
    Syft -.-> DTrack

Crates — Responsabilités

gitrust-core

Cœur métier. Ne dépend que de rustwarden-core et des crates Rust standards.

ModuleRôle
configGitrustConfig::from_env() — ports SSH, chemin repos, limites
errorGitrustError — erreurs domaine Git + IntoResponse HTTP
rolesenum Role { Reader, Developer, Maintainer, Owner } avec Ord
typesNewtypes validés : RepoSlug, TeamSlug, Fingerprint, TokenHash
models/Entités SeaORM : repository, ssh_key, team, team_member, team_repository_access, ci_pipeline, ci_job, ci_log, ci_config, ci_variable
migrations/Migrations SeaORM (tables gitrust) combinées avec les migrations core
services/SshKeyService, RepositoryService, TeamService, AccessService, CiService, CiVariableService, CiDetectionService, CiWorker, NotificationService
dto/Structures d’entrée/sortie pour les services

gitrust-git

Opérations Git sur le système de fichiers. Utilise git2 (libgit2). Pas de dépendance vers la base de données.

ModuleRôle
errorsenum GitError (Git, RepoNotFound, RefNotFound, PathTraversal, …)
bare_repoInit, open, delete, exists + validation chemin canonique sous base
branch / tagListing branches (BranchInfo) et tags (TagInfo, annotated/lightweight)
referenceresolve_ref (branch -> tag -> SHA -> revparse)
tree_browserlist_tree (dirs first, sorted), TreeEntry, EntryKind
blob_readerread_blob -> Text/Binary, détection binaire (null bytes)
commit_loglist_commits (paginé), find_commit, CommitInfo
readmefind_readme (README.md > README > readme.md > …)
pack_protocoladvertise_refs, serve_pack (async, git-upload-pack/git-receive-pack)

gitrust-ssh

Serveur SSH basé sur russh. Authentification par clé publique.

Module (prévu)Rôle
serverDémarrage, génération clé hôte Ed25519, wrapper du TcpListener par SecureListener (ssh-guard)
authAuthentification par fingerprint -> SshKeyService, appel à AuthTracker.record_auth_attempt après chaque tentative
sessionHandler SSH (exec, shell), porte ClientIdentity du listener jusqu’aux décisions auth
command_handlerParsing git-upload-pack/git-receive-pack

gitrust-ssh-guard

Couche de durcissement SSH intercalée entre le TcpListener et russh. Voir la page dédiée Crate gitrust-ssh-guard pour le détail des modules et l’API publique. Synthèse :

ModuleRôle
configGuardConfig::from_env() — lecture SSH_GUARD_*, sélection DeploymentProfile, validation
runtimeGuardHandles::build(db) — assemblage partageable entre serveur SSH et routeur admin
listenerSecureListener wrappe TcpListener : extraction IP réelle (PROXY v1/v2), ACL, flood, retour AcceptOutcome
proxyParseur PROXY protocol v1/v2 avec timeout
identityClientIdentity — IP + session_id + user/fingerprint enrichis pendant l’auth
trackerAuthTracker — persiste, émet, dispatche aux détecteurs
banBanManager + EffectiveStatus — priorité deny > auto_ban > allow > default
eventsGuardEvent — schéma JSON stable (tag = “event”) pour fail2ban / SIEM
sinksTracingSink, FileSink, MultiSink — destination des événements
detector/{brute_force,user_enumeration,key_scanning,connection_flood}4 motifs d’attaque, push-based
store/{memory,postgres,hybrid}Backend bans/ACL/events — hybrid (RAM + write-through) par défaut

gitrust-web

Interface web SSR (Server-Side Rendering) avec Askama (templates compilés) + HTMX (interactions dynamiques). Fonctionne en mode headless() : les pages UI du framework (rustwarden-ui) ne sont pas montées, gitrust-web réimplémente toutes les pages avec un design spécifique (sidebar contextuelle, navigation Git, DaisyUI).

Architecture des routes :

gitrust (port 4000)
├── /api/v1/auth/*          ← Routes API du framework (JSON)
│   ├── POST /login            AuthService::authenticate_user
│   ├── POST /register         UserService::create_user
│   ├── POST /refresh          RefreshTokenService (rotation JWT)
│   ├── POST /logout           JwtBlacklistService
│   ├── GET  /me               Claims extraction
│   ├── POST /forgot-password  PasswordResetService::request_reset
│   ├── POST /reset-password   PasswordResetService::reset_password
│   ├── GET  /verify-email/{t} EmailValidationService::verify_email
│   └── POST /resend-verif     EmailValidationService::resend
│
├── /api/v1/*                ← API REST Gitrust (JSON, auth JWT/PAT)
│   ├── /user, /user/repos     Profil utilisateur
│   ├── /repos/{o}/{r}         Détail dépôt, branches, tags, commits
│   ├── /repos/{o}/{r}/issues  CRUD issues + commentaires
│   ├── /repos/{o}/{r}/pulls   CRUD PRs + merge
│   └── /repos/{o}/{r}/ci/*   Pipelines CI (list, trigger, cancel, logs)
│
├── /*                       ← Pages SSR gitrust (HTML)
│   ├── /login, /register       Pages formulaires (design gitrust)
│   ├── /dashboard, /teams      Pages utilisateur
│   ├── /settings/*             Profile, SSH keys, sessions, notifications
│   ├── /notifications          Liste notifications + SSE stream
│   ├── /admin/*                Administration (+ admin CI)
│   ├── /{owner}/{repo}/*       Navigation dépôt Git
│   └── /{owner}/{repo}/ci/*    Pages CI (pipelines, logs, config, variables)
│
└── /static/*                ← Assets locaux (CSS, JS, HTMX)

Les routes API du framework sont montées via framework_api_routes() dans routes.rs. Elles sont utilisées par :

  • Les liens dans les emails (vérification, reset password)
  • Le refresh token (rotation JWT, session longue durée)
  • L’accès programmatique (GET /me, POST /logout)

Les pages SSR et les routes API coexistent : les formulaires HTML soumettent aux handlers gitrust (SSR), tandis que les services framework génèrent des liens vers les routes API.

ModuleRôle
routes.rsRouteur principal : framework API + API REST Gitrust + pages SSR + fichiers statiques
handlers/Handlers Axum SSR (un fichier par domaine) + handlers API JSON
templates.rsStructs Askama + SidebarContext + RepoNav
helpers.rsrequire_auth, require_admin, sidebar_*, resolve_repo_access
templates/Templates HTML (Askama, extends base.html)
static_files.rsServeDir pour /static

gitrust-hooks

Implémentation de RustwardenHooks pour réagir aux événements du framework. Les hooks sont injectés dans le routeur Axum via Arc<dyn RustwardenHooks> + axum::Extension (dans main.rs).

HookAction
on_user_registeredCrée le répertoire utilisateur sur le FS (anti path-traversal)
on_user_deletedSupprime le répertoire utilisateur + bare repos sur le FS
on_resource_sharedAudit log si resource_type = “repository”
on_resource_unsharedAudit log si resource_type = “repository”

Notifications

Système de notification à deux canaux : in-app (SSE temps réel + page) et email (worker SMTP du framework).

Événement (pipeline échoue, PR commentée, etc.)
    │
    ▼
NotificationService::notify(user_id, event_type, title, body, link)
    │
    ├── INSERT INTO notifications (in-app)
    │     → broadcast::Sender → SSE /notifications/stream
    │     → Badge header mis à jour en temps réel (HTMX)
    │
    └── if user.email_on_{event} = true:
          EmailQueueService::enqueue() (framework SMTP worker)

Les préférences de notification sont par utilisateur (email on/off par type d’événement).


CI/CD — Modes et composants

Système CI hybride à deux niveaux, activé via CI_ENABLED=true.

Modes

ModeDétectionExécution
Easy.gitrust-ci.yml à la racine du repoModule Dagger Python générique (ci-engine) interprète le YAML
Power.dagger/ à la racine du repodagger call -m .dagger/ exécute le module utilisateur directement
AucunNi l’un ni l’autrePas de CI pour ce repo

Architecture CI

git push (HTTP ou SSH)
    │
    ▼
receive-pack handler
    │
    ├── CiDetectionService::detect(bare_repo, commit_sha)
    │     → Easy / Power / None
    │
    ├── CiService::create_pipeline(repo_id, sha, mode)
    │
    └── mpsc::Sender.send(job) ──► CiWorker (tokio::spawn)
                                       │
                                       ├── 1. Validation statique
                                       │     Easy: schéma YAML, whitelist images/packages
                                       │     Power: analyse code .dagger/ (si activé)
                                       │
                                       ├── 2. SBOM Gate (si CI_SBOM_ENABLED)
                                       │     syft scan → sbom.cdx.json
                                       │     → Dependency-Track (si CI_DTRACK_ENABLED)
                                       │     → PASS/FAIL selon politique
                                       │
                                       ├── 3. Résolution variables (héritage team → repo)
                                       │     CiVariableService::resolve_for_pipeline()
                                       │
                                       ├── 4. Exécution Dagger (isolé, timeout, cgroups)
                                       │     Easy: dagger call -m ci-engine test --profile=...
                                       │     Power: dagger call -m .dagger/ <function>
                                       │
                                       └── 5. Rapport + audit
                                             Status, logs (broadcast SSE), ci_audit_log

Composants externes (tous optionnels sauf Dagger)

ComposantRôleVariable de contrôle
Dagger EngineExécution des pipelines dans des containers isolésCI_ENABLED
ci-engine (Python)Moteur générique pour Easy ModeCI_DAGGER_MODULE_PATH
SyftGénération SBOM CycloneDXCI_SBOM_ENABLED
Dependency-TrackAnalyse de vulnérabilités sur les SBOMCI_DTRACK_ENABLED

Variables et secrets CI

Héritage team → repo : les variables définies au niveau team sont partagées entre tous les repos de l’équipe. Le repo peut surcharger une clé team avec sa propre valeur. Les secrets sont chiffrés en base (AES-256-GCM) et masqués dans les logs (***).


Sécurité (ANSSI PA-074)

DirectiveScope
#![forbid(unsafe_code)]gitrust-core, gitrust-web, gitrust-hooks, gitrust-ssh-guard
#![deny(unsafe_code)]gitrust-git, gitrust-ssh (FFI libgit2/russh)
deny(clippy::unwrap_used, expect_used, panic)Tous les crates
deny(clippy::indexing_slicing)Tous les crates
deny(clippy::mem_forget)Tous les crates
Zeroize on dropTokenHash (ANSSI R23)
RSA minimum 4096 bitsSshKeyService
Path traversal validationRepositoryService, gitrust-git
Slug validation + noms réservésRepoSlug, TeamSlug

Crate gitrust-ssh-guard

Référence structurelle de la crate de durcissement SSH. Pour la motivation et le rationale de design, voir Conception de ssh-guard. Pour l’exploitation côté admin, voir Configurer ssh-guard.


1. Position dans l’arborescence

crates/
├── gitrust-ssh-guard/
│   ├── Cargo.toml
│   └── src/
│       ├── lib.rs                 (re-exports publics)
│       ├── config.rs              (SSH_GUARD_*, profils, validation)
│       ├── runtime.rs             (GuardHandles : assemblage)
│       ├── listener.rs            (SecureListener, AcceptOutcome)
│       ├── proxy.rs               (parseur PROXY protocol v1/v2)
│       ├── identity.rs            (ClientIdentity)
│       ├── tracker.rs             (AuthTracker, dispatch détecteurs)
│       ├── ban.rs                 (BanManager, EffectiveStatus)
│       ├── events.rs              (GuardEvent, GuardEventSink)
│       ├── sinks.rs               (TracingSink, FileSink, MultiSink)
│       ├── detector/
│       │   ├── mod.rs
│       │   ├── brute_force.rs
│       │   ├── user_enumeration.rs
│       │   ├── key_scanning.rs
│       │   └── connection_flood.rs
│       └── store/
│           ├── mod.rs             (trait GuardStore)
│           ├── entities.rs        (modèles SeaORM)
│           ├── memory.rs          (MemoryStore — DashMap)
│           ├── postgres.rs        (PostgresStore — write-through)
│           └── hybrid.rs          (HybridStore — RAM chaude + DB)

2. Tableau des modules

ModuleRôleTypes principaux
configLecture des env vars SSH_GUARD_*, sélection du profil de déploiement, validation de cohérence (ex. : un profil proxifié sans trusted_proxies est rejeté au démarrage).GuardConfig, DeploymentProfile, ProxyProtocol, StoreBackend, LogFormat, LogTarget, DetectorThreshold, ConfigError
runtimeAssemble tous les composants à partir de GuardConfig + connexion DatabaseConnection. Le binaire en construit une seule instance et la partage entre le serveur SSH et le routeur Axum admin.GuardHandles, BuildError
listenerWrappe un tokio::net::TcpListener : extraction d’IP réelle (PROXY ou peer_addr), consultation de l’ACL/ban, rate-limit flood, puis remise du TcpStream au handler russh.SecureListener, AcceptOutcome, AcceptError, ProxyListenerConfig
proxyParseur PROXY protocol v1 (texte HAProxy legacy) et v2 (binaire nginx stream / HAProxy moderne) avec timeout dédié.parse_header, ParsedHeader, ProxyError
identityIdentité d’un client SSH au fil de la session : IP, session_id (UUID), nom d’utilisateur et fingerprint de clé renseignés au moment de l’auth.ClientIdentity
trackerReçoit les événements d’auth émis par gitrust-ssh, les persiste dans le store, les diffuse au sink, puis appelle on_event sur chaque détecteur actif.AuthTracker, AuthOutcome
banCompose ACL admin (allow/deny) et bans actifs en un statut effectif. Pose les bans auto (TTL) et manuels (CIDR), gère le unban.BanManager, EffectiveStatus
eventsDéfinition stable du schéma JSON émis. Toute évolution incompatible doit créer un nouveau variant plutôt que renommer un champ existant.GuardEvent, GuardEventSink, AuthMethod, BanReason, DropReason
sinksImplémentations de GuardEventSink : tracing (stderr / journald), fichier en append (ligne JSON), fan-out vers plusieurs sinks.TracingSink, FileSink, MultiSink, build_sink
detector::brute_forceCompte les AuthFailed par IP dans la fenêtre, déclenche auto_ban(BruteForce) au seuil.BruteForceDetector
detector::user_enumerationCompte les usernames distincts essayés par IP dans la fenêtre.UserEnumerationDetector
detector::key_scanningCompte les fingerprints de clé distincts essayés par IP dans la fenêtre.KeyScanningDetector
detector::connection_floodToken bucket GCRA (governor) keyé par IP. Pas de ban persistant : drop immédiat au listener.ConnectionFloodDetector
storeTrait abstrait pour bans, ACL et événements d’auth. Trois implémentations interchangeables.GuardStore, MemoryStore, PostgresStore, HybridStore, BanEntry, AclEntry, AclKind, RehydrateStats, StoreError

3. Surface API publique (lib.rs)

#![allow(unused)]
fn main() {
// Assemblage et runtime
pub use runtime::{BuildError, GuardHandles};

// Configuration
pub use config::{
    ConfigError, DeploymentProfile, DetectorThreshold, GuardConfig,
    LogFormat, LogTarget, ProxyProtocol, StoreBackend,
};

// Listener
pub use listener::{AcceptError, AcceptOutcome, ProxyListenerConfig, SecureListener};

// Identité et tracking
pub use identity::ClientIdentity;
pub use tracker::{AuthOutcome, AuthTracker};

// Décision de ban
pub use ban::{BanManager, EffectiveStatus};

// Détecteurs
pub use detector::{
    BruteForceDetector, ConnectionFloodDetector,
    KeyScanningDetector, UserEnumerationDetector,
};

// Événements et sinks
pub use events::{AuthMethod, BanReason, DropReason, GuardEvent, GuardEventSink};
pub use sinks::{build_sink, FileSink, MultiSink, TracingSink};

// Stockage
pub use store::{
    AclEntry, AclKind, BanEntry, GuardStore, HybridStore,
    MemoryStore, PostgresStore, RehydrateStats, StoreError,
};
}

Lints actifs sur la crate (alignés ANSSI PA-074) :

#![allow(unused)]
#![forbid(unsafe_code)]
#![deny(
    fn main() {
clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::mem_forget
)]
}

4. GuardHandles — point d’entrée du runtime

GuardHandles est l’objet construit une fois au démarrage du binaire, puis cloné (tous les champs sont Arc<…>) là où il est nécessaire :

  • vers gitrust_ssh::server::start_server pour que SecureListener et AuthTracker partagent les mêmes BanManager / GuardStore ;
  • vers le routeur Axum via axum::Extension, pour que les actions admin (ajouter une IP en denylist, lever un ban) soient immédiatement visibles par le listener — sans attendre un rehydrate au prochain redémarrage.
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct GuardHandles {
    pub config: GuardConfig,
    pub store: Arc<dyn GuardStore>,
    pub sink: Arc<dyn GuardEventSink>,
    pub ban_manager: Arc<BanManager>,
    pub tracker: Arc<AuthTracker>,
    pub flood: Option<Arc<ConnectionFloodDetector>>,
    pub proxy_config: Option<ProxyListenerConfig>,
}

impl GuardHandles {
    pub async fn build(db: DatabaseConnection) -> Result<Self, BuildError>;
}
}

build enchaîne :

  1. GuardConfig::from_env() (lecture + validation).
  2. build_sink(&config) selon LogTarget.
  3. Construction du store selon SSH_GUARD_STORE_BACKEND :
    • memoryMemoryStore (rien en DB) ;
    • postgresPostgresStore (chaque écriture en DB) ;
    • hybridHybridStore (RAM chaude + flush write-through périodique + rehydrate au boot pour repeupler la mémoire depuis la DB).
  4. Instanciation du BanManager.
  5. Instanciation des détecteurs (chaque détecteur dont le seuil est désactivé — u32::MAX — n’est pas instancié).
  6. Construction de l’AuthTracker avec les détecteurs actifs.
  7. Construction de ProxyListenerConfig si proxy_protocol != Disabled.

5. SecureListener — séquence d’accept

#![allow(unused)]
fn main() {
pub enum AcceptOutcome {
    Accepted { stream: TcpStream, identity: ClientIdentity },
    Dropped  { ip: IpAddr,        reason:   DropReason     },
}

impl SecureListener {
    pub async fn accept(&self) -> Result<AcceptOutcome, AcceptError>;
}
}

Étapes appliquées à chaque connexion entrante :

  1. tcp.accept()(stream, peer).
  2. resolve_real_ip(stream, peer.ip()) :
    • profil direct (pas de proxy_config) → IP = peer.ip() ;
    • profil proxifié → vérifie que peer.ip()trusted_proxies, puis parse l’en-tête PROXY pour extraire l’IP cliente réelle ;
    • en mode strict, un parsing en échec retourne Err(DropReason::ProxyHeaderInvalid|Missing|UntrustedProxy) ; en mode souple, fallback sur peer.ip() avec warn.
  3. ban_manager.effective_status(ip) :
    • DeniedByAclDropped { reason: Banned } ;
    • BannedAuto(_)Dropped { reason: Banned } ;
    • AllowListedAccepted immédiat (bypass flood) ;
    • Normal → étape 4.
  4. Si flood actif : flood.check(ip). Sur false → Dropped { reason: FloodLimit } (les events sont déjà émis par flood.check).
  5. Sinon : construction du ClientIdentity, émission de ConnectionAccepted, retour Accepted.

Toute erreur du store est traitée en fail-open (warn + on laisse passer), pour ne pas couper le service quand PostgreSQL est indisponible.


6. BanManager — priorités d’ACL

#![allow(unused)]
fn main() {
pub enum EffectiveStatus {
    Normal,
    AllowListed,
    DeniedByAcl,
    BannedAuto(Box<BanEntry>),
}
}

Priorité de résolution dans effective_status(ip) :

  1. acl_match(ip) == Some(Deny)DeniedByAcl.
  2. bans_covering(ip) non vide → BannedAuto(ban).
  3. acl_match(ip) == Some(Allow)AllowListed.
  4. Sinon → Normal.

Un appel auto_ban(ip, reason) est no-op dans trois cas :

  • dry_run actif (un événement IpBanned est tout de même émis pour fail2ban / observabilité) ;
  • l’IP est allowlistée ;
  • un ban auto actif couvre déjà l’IP (évite le spam d’événements IpBanned).

Un manual_ban(cidr, reason, ttl) accepte un CIDR (pas seulement un /32) et est permanent si ttl=None. unban(ban_id, by) retire le ban et émet IpUnbanned.


7. AuthTracker — pivot de la chaîne d’événements

#![allow(unused)]
fn main() {
pub enum AuthOutcome { Success, Failure }

impl AuthTracker {
    pub async fn record_auth_attempt(
        &self,
        identity: &ClientIdentity,
        method:   AuthMethod,
        outcome:  AuthOutcome,
    );
}
}

Pour chaque appel :

  1. Construit GuardEvent::AuthSucceeded ou AuthFailed selon outcome.
  2. store.record_event(&event) — alimente la table consultée par les détecteurs.
  3. sink.emit(&event) — alimente fail2ban / observabilité.
  4. Pour chaque détecteur configuré : detector.on_event(&event).await.

Côté gitrust-ssh, le handler russh appelle record_auth_attempt après chaque tentative d’authentification (clé publique, password, keyboard-interactive).


8. Schéma JSON des événements

Tous les GuardEvent se sérialisent avec #[serde(tag = "event", rename_all = "snake_case")]. La forme stable est :

{"event":"<nom_variant>","ts":"...","ip":"...", ...}

Pour le détail des champs par variant et leur usage côté admin (fail2ban, SIEM), voir Événements ssh-guard (JSON).

Convention de stabilité :

  • ajouter un nouveau variant n’est pas un breaking change pour les consommateurs ;
  • ajouter un champ optionnel à un variant existant ne l’est pas non plus ;
  • renommer un champ ou un variant est un breaking change et nécessite un nouveau nom.

9. Backends de stockage (GuardStore)

BackendVie des bans/ACLLatence lectureCas d’usage
MemoryStorePerdues au restartDashMap, lock-freeTests, profil private, dev
PostgresStorePersistantesRound-trip DB par lectureAudits stricts, instances multi-noeuds (sans cache RAM)
HybridStoreRAM chaude + write-through DB, rehydrate au bootDashMapDéfaut recommandé : performance + persistance

Le trait GuardStore expose : acl_match, bans_covering, is_banned, insert_ban, unban, list_active_bans, insert_acl, record_event, count_auth_failures, count_distinct_users, count_distinct_keys.


10. Intégration côté gitrust-ssh

Pseudo-code condensé du wiring effectif (crates/gitrust-ssh/src/server.rs) :

#![allow(unused)]
fn main() {
let secure = SecureListener::new_with_proxy(
    tcp_listener,
    guard.ban_manager.clone(),
    guard.flood.clone(),
    guard.sink.clone(),
    guard.proxy_config.clone(),
);

loop {
    match secure.accept().await? {
        AcceptOutcome::Accepted { stream, identity } => {
            let session = GitSession::new_with_guard(
                db.clone(), ssh_config.clone(), ci_tx.clone(),
                identity, guard.tracker.clone(),
            );
            tokio::spawn(async move { russh::server::run_stream(/* ... */).await });
        }
        AcceptOutcome::Dropped { .. } => {
            // L'événement ConnectionDropped a déjà été émis par le listener.
        }
    }
}
}

Côté GitSession (handler russh) : à chaque tentative d’auth, l’identité est enrichie (set_username, set_fingerprint) puis tracker.record_auth_attempt(&identity, method, outcome) est appelé.


11. Tests

Toute la crate utilise des tests unitaires #[tokio::test] avec un MemoryStore et un RecorderSink qui capture les GuardEvent. Ordre de grandeur : 80+ tests couvrant chaque détecteur, la priorité d’ACL, le mode dry_run, l’idempotence du ban, l’expiration TTL, les cas d’erreur du parseur PROXY, la séparation des budgets de flood par IP.

Les tests d’intégration de bout en bout (PROXY v2 binaire émis par un client de test, déclenchement réel du seuil brute-force depuis gitrust-ssh) vivent dans crates/gitrust-ssh/tests/.


12. Voir aussi

Services et API interne — Référence

Ce document liste tous les services publics de gitrust (rustwarden-core + gitrust-core), leurs signatures Rust, les erreurs possibles, et quand les utiliser.

Architecture générale

Handler Axum → Service (logique métier) → SeaORM (base de données)

Conventions communes :

  • Les services sont des structs sans état avec des méthodes associées async fn.
  • Premier paramètre toujours db: &DatabaseConnection.
  • Type de retour toujours Result<T, AppError> (rustwarden-core) ou Result<T, GitrustError> (gitrust-core).
  • Aucun unwrap() / expect() en production.
  • Aucun SQL brut — tout passe par SeaORM.

Services rustwarden-core (framework)

Importés via use rustwarden_core::prelude::* pour les exports du prélude, ou via leur chemin complet.

AuthService

Module : rustwarden_core::services::auth_service

MéthodeSignatureDescription
verify_credentials(db, identifier: &str, password: &str) → Result<user::Model>Vérifie les identifiants. identifier accepte un username ou un email. Utilise un dummy hash si l’utilisateur n’existe pas (protection timing attack SEC-C1).
authenticate_user(db, identifier: &str, password: &str) → Result<user::Model>Authentification de haut niveau. Appelle verify_credentials et logue must_change_password.

UserService

Module : rustwarden_core::services::user_serviceExport : prélude

MéthodeSignatureDescription
create_user(db, username, email, password_hash, must_change_password) → Result<user::Model>Crée un utilisateur. Le mot de passe doit être pré-haché via PasswordService::hash_password.
get_user_by_id(db, user_id: Uuid) → Result<Option<user::Model>>Recherche par ID.
get_user_by_username(db, username: &str) → Result<Option<user::Model>>Recherche par nom d’utilisateur.
get_user_by_email(db, email: &str) → Result<Option<user::Model>>Recherche par email.
list_all_users(db) → Result<Vec<user::Model>>Liste tous les utilisateurs.
get_user_roles(db, user_id: Uuid) → Result<Vec<String>>Retourne les noms des rôles.
assign_role_to_user(db, user_id, role_id) → Result<user_role::Model>Assigne un rôle.
update_profile(db, user_id, username: Option<String>, email: Option<String>) → Result<user::Model>Met à jour le profil. Valide l’unicité.
change_password(db, user_id, old_password, new_password, current_token_id) → Result<(Uuid, String)>Initie un changement de mot de passe (demande pendante). Retourne (request_id, plain_token).

PasswordService

Module : rustwarden_core::services::password_service

MéthodeSignatureDescription
hash_passwordasync (password: &str) → Result<String>Hash bcrypt via spawn_blocking. Coût configurable via BCRYPT_COST (défaut : 12).
verify_passwordasync (password: &str, hash: &str) → Result<bool>Vérification bcrypt via spawn_blocking.
generate_secure_password() → StringGénère un mot de passe de 20 caractères avec mix garanti.

TotpService

Module : rustwarden_core::services::totp_service

MéthodeDescription
setup_totp(db, user_id, username, issuer)Génère un secret TOTP + QR code URI.
verify_and_enable(db, user_id, code)Vérifie un code et active le 2FA. Retourne les 10 codes de secours.
verify_code(db, user_id, code)Vérifie un code TOTP (6 chiffres).
verify_backup_code(db, user_id, code)Vérifie un code de secours (8 chars, usage unique).
disable_totp(db, user_id)Désactive le 2FA.
create_challenge(db, user_id)Crée un challenge 2FA (token temporaire, 5 min, max 5 tentatives).
validate_challenge(db, challenge_token)Valide un challenge.
consume_challenge(db, challenge_token)Marque un challenge comme consommé.

RoleService / PermissionService

Module : rustwarden_core::services::role_serviceExport : prélude

MéthodeDescription
RoleService::create_role(db, name, description)Crée un rôle.
RoleService::get_role_by_name(db, name)Recherche par nom.
RoleService::list_all_roles(db)Liste tous les rôles.
RoleService::initialize_default_roles(db)Seed admin, user, app_manager. Idempotent.
PermissionService::get_permissions_for_user(db, user_id)Union des permissions de tous les rôles.
PermissionService::set_role_permissions(db, role_id, perm_ids)Remplace atomiquement les permissions d’un rôle.
PermissionService::initialize_default_permissions(db)Seed les 12 permissions par défaut. Idempotent.

ResourceService

Module : rustwarden_core::services::resource_serviceExport : prélude

Système générique de registre de ressources et de partage.

Hiérarchie des permissions : read < write < admin

MéthodeDescription
register(db, resource_type, resource_id, owner_id)Enregistre une ressource dans le registre.
set_public(db, resource_type, resource_id, is_public)Bascule la visibilité publique.
unregister(db, resource_type, resource_id)Supprime du registre (cascade sur les partages).
share(db, resource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_id)Partage une ressource. Met à jour si déjà partagé.
revoke_share(db, share_id)Révoque un partage par ID.
user_can_access(db, user_id, resource_type, resource_id, required_level)Vérifie l’accès : owner → public+read → partagé.
effective_permission(db, user_id, resource_type, resource_id)Retourne "owner", le niveau de partage, "read" (public), ou None.
accessible_by_user(db, user_id, resource_type)Toutes les ressources accessibles (possédées + partagées + publiques).

RefreshTokenService

Module : rustwarden_core::services::refresh_token_service

MéthodeDescription
create_refresh_token(db, user_id, expiration_days)Crée un refresh token. Révoque les plus anciens si > 10 actifs (SEC-M4).
validate_refresh_token(db, token)Valide un token (hash SHA-256, existence, non révoqué, non expiré).
revoke_all_tokens_for_user(db, user_id)Révoque toutes les sessions (logout, changement de mot de passe).
list_user_sessions(db, user_id)Liste les sessions actives.
revoke_session(db, token_id)Révoque une session spécifique.

AppSettingsService

Module : rustwarden_core::services::app_settings_service

MéthodeDescription
get_setting(db, key)Lire un paramètre.
set_setting(db, key, value, updated_by)Créer ou mettre à jour un paramètre.
get_bool(db, key, default)Lire un booléen avec valeur par défaut.
initialize_default_settings(db)Seed ~30 paramètres par défaut. Idempotent.

Services gitrust-core

RepositoryService

Module : gitrust_core::services::repository_service

MéthodeSignatureDescription
create(db, owner_id, slug, description, is_public) → Result<repository::Model>Crée un dépôt + répertoire bare sur disque. Enregistre dans ResourceService.
find_by_owner_and_slug(db, owner_username, slug) → Result<Option<repository::Model>>Recherche par owner/slug (chemin URL).
list_by_owner(db, owner_id) → Result<Vec<repository::Model>>Dépôts d’un propriétaire.
update(db, repo_id, owner_id, description, default_branch) → Result<repository::Model>Met à jour les métadonnées. Anti-IDOR.
delete(db, repo_id, owner_id) → Result<()>Supprime DB + bare repo sur disque. Anti-IDOR.
mark_non_empty(db, repo_id) → Result<()>Passe is_empty = false après le premier push.

SshKeyService

Module : gitrust_core::services::ssh_key_service

MéthodeSignatureDescription
create(db, user_id, title, key_data) → Result<ssh_key::Model>Parse la clé, calcule le fingerprint SHA256, valide le type (ed25519, rsa ≥ 4096 bits, ecdsa-p256/p384).
list_by_user(db, user_id) → Result<Vec<ssh_key::Model>>Liste les clés d’un utilisateur.
find_by_fingerprint(db, fingerprint) → Result<Option<ssh_key::Model>>Recherche par empreinte (utilisé par le serveur SSH).
delete(db, key_id, user_id) → Result<()>Suppression avec vérification d’ownership.
update_last_used(db, key_id) → Result<()>Met à jour last_used_at à chaque authentification SSH.

IssueService

Module : gitrust_core::services::issue_service

MéthodeDescription
create(db, repo_id, author_id, title, body, label_ids, subject_names)Numéro auto-incrémenté par dépôt.
list(db, repo_id, state_filter, label_filter, page, per_page)Liste paginée avec filtres.
find_by_number(db, repo_id, number)Par numéro d’issue.
close(db, issue_id, closed_by)Ferme l’issue + audit.
reopen(db, issue_id)Rouvre l’issue + audit.
enrich_issue(db, issue)Enrichit avec username auteur, labels, nombre de commentaires.

LabelService

Module : gitrust_core::services::label_service

MéthodeDescription
create_classification(db, owner_id, name, color, description)Label scope owner (tous ses dépôts).
create_subject(db, repo_id, name, color)Label scope dépôt. Couleur défaut #6b7280.
find_or_create_subject(db, repo_id, name)Trouve ou crée (pour création inline dans les issues).
list_classification_by_owner(db, owner_id)Labels classification de l’owner.
list_subject_by_repo(db, repo_id)Labels subject du dépôt.
search_subject(db, repo_id, query, limit)Recherche préfixe (autocomplete HTMX).
assign_to_issue(db, issue_id, label_id)Association issue ↔ label.

TeamService

Module : gitrust_core::services::team_service

MéthodeDescription
create(db, owner_id, slug, description)Crée une équipe.
add_member(db, team_id, user_id, role)Ajoute un membre.
remove_member(db, team_id, user_id)Retire un membre.
grant_repo_access(db, team_id, repo_id, permission)Accorde l’accès à un dépôt (read/write/admin).
revoke_repo_access(db, team_id, repo_id)Révoque l’accès.

ImportService

Module : gitrust_core::services::import_service

MéthodeDescription
create_job(db, owner_id, source_url, target_slug)Crée un job pending dans import_jobs.
mark_running(db, job_id)Passe en running + started_at.
update_progress(db, job_id, received, total, bytes)Met à jour les compteurs de progression.
mark_success(db, job_id, repo_id)Lie le job au dépôt créé.
mark_failed(db, job_id, error_message)Enregistre l’échec.
mark_cancelled(db, job_id)Annulation.

Gestion des erreurs

AppError (rustwarden-core)

VarianteHTTPUsage
AppError::NotFound(msg)404Ressource introuvable
AppError::Validation(msg)400Données invalides
AppError::Forbidden403Accès refusé (RBAC)
AppError::Unauthorized401Non authentifié
AppError::TokenExpired401JWT expiré
AppError::TokenReused401Refresh token réutilisé (attaque détectée)
AppError::Conflict(msg)409Contrainte d’unicité violée
AppError::Internal(msg)500Erreur interne

GitrustError (gitrust-core)

Mêmes variantes que AppError avec GitrustError::Database(sea_orm::DbErr) en plus pour les erreurs SeaORM non mappées.


Patterns communs

Accès à un service depuis un handler

#![allow(unused)]
fn main() {
use gitrust_core::services::issue_service::IssueService;

async fn list_issues_handler(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
    let issues = IssueService::list(&db, repo_id, "open", None, 1, 30).await?;
    // ...
}
}

Vérification d’ownership systématique (anti-IDOR)

#![allow(unused)]
fn main() {
// Toujours passer owner_id au service — le service vérifie en interne
let repo = RepositoryService::find_by_owner_and_slug(&db, &owner, &repo_slug).await?;
// Puis vérifier que l'utilisateur courant peut accéder
if !ResourceService::user_can_access(&db, user.user_id, "repository", repo.id, "read").await? {
    return Err(AppError::Forbidden);
}
}

Voir aussi

Schéma de la base de données — Référence développeur

Ce document décrit le schéma complet de la base de données gitrust, orienté développeur : types SeaORM côté Rust, enums, contraintes de modèle, et ordre des migrations.

Pour la vue admin (sauvegardes, supervision), consultez le manuel d’administration.

ERD — Diagramme entité-relation

erDiagram
    %% ===== Tables rustwarden-core =====
    users {
        uuid id PK
        string username UK
        string email UK
        string password_hash
        boolean must_change_password
        boolean email_verified
        timestamptz last_login_at
        timestamptz created_at
        timestamptz updated_at
    }

    resources {
        uuid id PK
        string resource_type
        uuid resource_id
        uuid owner_id FK
        boolean is_public
        timestamptz created_at
    }

    resource_shares {
        uuid id PK
        uuid resource_id FK
        uuid shared_with_user_id FK
        string permission_level
        uuid shared_by_user_id FK
        timestamptz created_at
    }

    user_totp {
        uuid id PK
        uuid user_id FK
        text encrypted_secret
        boolean enabled
        text backup_codes_json
        timestamptz enabled_at
        timestamptz created_at
        timestamptz updated_at
    }

    %% ===== Tables gitrust-core =====
    repositories {
        uuid id PK
        uuid owner_id FK
        string slug
        string description
        string disk_path
        string default_branch
        boolean is_empty
        timestamptz created_at
        timestamptz updated_at
    }

    ssh_keys {
        uuid id PK
        uuid user_id FK
        string title
        string fingerprint UK
        string key_type
        text key_data
        timestamptz last_used_at
        timestamptz created_at
    }

    teams {
        uuid id PK
        uuid owner_id FK
        string slug
        string description
        timestamptz created_at
        timestamptz updated_at
    }

    team_members {
        uuid id PK
        uuid team_id FK
        uuid user_id FK
        string role
        timestamptz created_at
    }

    team_repository_access {
        uuid id PK
        uuid team_id FK
        uuid repository_id FK
        string permission
        timestamptz created_at
    }

    personal_access_tokens {
        uuid id PK
        uuid user_id FK
        string name
        string token_hash UK
        string scopes
        timestamptz expires_at
        timestamptz last_used_at
        timestamptz created_at
    }

    issues {
        uuid id PK
        uuid repository_id FK
        integer number
        uuid author_id FK
        string title
        text body
        string state
        uuid closed_by FK
        timestamptz created_at
        timestamptz updated_at
    }

    issue_comments {
        uuid id PK
        uuid issue_id FK
        uuid author_id FK
        text body
        timestamptz created_at
        timestamptz updated_at
    }

    labels {
        uuid id PK
        uuid owner_id FK
        uuid repository_id FK
        string name
        string color
        string description
        string label_type
        timestamptz created_at
    }

    issue_labels {
        uuid id PK
        uuid issue_id FK
        uuid label_id FK
        timestamptz created_at
    }

    pull_requests {
        uuid id PK
        uuid repository_id FK
        integer number
        uuid author_id FK
        string title
        text body
        string source_branch
        string target_branch
        string state
        timestamptz merged_at
        uuid merged_by FK
        string merge_commit_sha
        timestamptz created_at
        timestamptz updated_at
    }

    pr_comments {
        uuid id PK
        uuid pull_request_id FK
        uuid author_id FK
        text body
        timestamptz created_at
    }

    import_jobs {
        uuid id PK
        uuid repository_id FK
        uuid owner_id FK
        text source_url
        text target_slug
        string status
        string phase
        int received_objects
        int total_objects
        bigint received_bytes
        text error_message
        timestamptz started_at
        timestamptz finished_at
        int duration_ms
        timestamptz created_at
        timestamptz updated_at
    }

    %% Relations
    users ||--o{ repositories : "possède"
    users ||--o{ ssh_keys : "enregistre"
    users ||--o{ teams : "crée"
    users ||--o{ resources : "possède"
    users ||--o| user_totp : "configure"
    repositories ||--o| resources : "enregistre dans"
    resources ||--o{ resource_shares : "partage"
    teams ||--o{ team_members : "contient"
    teams ||--o{ team_repository_access : "accède à"
    repositories ||--o{ team_repository_access : "accessible par"
    repositories ||--o{ issues : "contient"
    issues ||--o{ issue_comments : "a"
    issues ||--o{ issue_labels : "étiquetée"
    labels ||--o{ issue_labels : "appliquée"
    repositories ||--o{ pull_requests : "contient"
    pull_requests ||--o{ pr_comments : "a"
    users ||--o{ personal_access_tokens : "crée"
    users ||--o{ import_jobs : "lance"

Tables rustwarden-core (framework — read-only pour gitrust)

Ces tables sont gérées exclusivement par le framework rustwarden-core. Ne les modifiez jamais directement depuis gitrust.

TableRôle
usersComptes utilisateurs
roles / permissions / role_permissions / user_rolesRBAC global
resources / resource_sharesRegistre générique de ressources et partages
refresh_tokens / jwt_blacklistGestion JWT
app_settingsConfiguration applicative clé-valeur
audit_logsJournal d’audit
user_totp / totp_challengesAuthentification 2FA TOTP
email_queue / email_delivery_statusFile d’emails asynchrone
password_reset_tokens / password_change_requestsFlux de changement de mot de passe

Tables gitrust-core

repositories

ColonneType Rust (SeaORM)Contrainte PGNotes
idUuidPK
owner_idUuidFK → users ON DELETE CASCADE
slugStringVARCHAR(64) NOT NULLNewtype RepoSlug à la frontière
descriptionOption<String>VARCHAR(500) NULL
disk_pathStringVARCHAR(512) NOT NULLChemin absolu du bare repo
default_branchStringVARCHAR(255) NOT NULL DEFAULT ‘main’
is_emptyboolBOOLEAN NOT NULL DEFAULT truePasse à false après le premier push
created_atDateTimeUtcTIMESTAMPTZ NOT NULL
updated_atDateTimeUtcTIMESTAMPTZ NOT NULL

Index : UNIQUE(owner_id, slug), INDEX(owner_id)

Lien ResourceService : chaque dépôt est enregistré dans resources avec resource_type = "repository" et resource_id = repositories.id.

ssh_keys

ColonneType RustContrainte PGNotes
idUuidPK
user_idUuidFK → users CASCADE
titleStringVARCHAR(255) NOT NULL
fingerprintStringVARCHAR(128) UNIQUE NOT NULLSHA256, format SHA256:xxx
key_typeStringVARCHAR(32) NOT NULLssh-ed25519, ssh-rsa, ecdsa-sha2-nistp256/384
key_dataStringTEXT NOT NULLFormat authorized_keys complet
last_used_atOption<DateTimeUtc>TIMESTAMPTZ NULLMis à jour par le serveur SSH
created_atDateTimeUtcTIMESTAMPTZ NOT NULL

Validation : RSA ≥ 4096 bits obligatoire. Fingerprint calculé à l’insertion.

teams et team_members

ColonneType RustNotes
teams.slugStringNewtype TeamSlug ; UNIQUE(owner_id, slug)
team_members.roleString"read" (défaut), "write", "admin"
team_repository_access.permissionString"read", "write", "admin"

personal_access_tokens

ColonneType RustNotes
token_hashStringSHA-256 du token en clair. Le token en clair n’est retourné qu’à la création.
scopesStringEspace-séparé : "repo:read repo:write issues:write"
expires_atOption<DateTimeUtc>NULL = pas d’expiration

issues

ColonneType RustNotes
numberi32Auto-incrémenté par dépôt (pas global). UNIQUE(repository_id, number)
stateString"open" (défaut) ou "closed"
bodyStringMarkdown brut

labels

ColonneType RustNotes
label_typeString"classification" (scope owner) ou "subject" (scope repo)
owner_idOption<Uuid>Non-null pour classification, null pour subject
repository_idOption<Uuid>Non-null pour subject, null pour classification
colorStringHex #RRGGBB, validé à l’insertion

Index : UNIQUE(owner_id, repository_id, name, label_type)

pull_requests

ColonneType RustNotes
numberi32Auto-incrémenté par dépôt. UNIQUE(repository_id, number)
stateString"open", "closed", "merged"
merge_commit_shaOption<String>SHA40, null avant merge

import_jobs

ColonneType RustNotes
statusStringState machine : pendingrunningsuccess/failed/cancelled
phaseOption<String>"cloning", "resolving", "finalizing"
repository_idOption<Uuid>Null pendant le clonage, rempli au succès uniquement

Ordre des migrations

#NomTables créées
1m20260305_000001_initial_schemausers, refresh_tokens, jwt_blacklist, audit_logs (core)
2m20260306_000002_create_app_settings_tableapp_settings (core)
3m20260309_000003_create_permissions_tablesroles, permissions, role_permissions, user_roles (core)
4m20260309_000004_create_resources_tablesresources, resource_shares (core)
5m20260310_000005_create_oauth_accounts_tableoauth_accounts (core)
6m20260325_000001_create_repositoriesrepositories
7m20260325_000002_create_ssh_keysssh_keys
8m20260325_000003_create_teamsteams
9m20260325_000004_create_team_membersteam_members
10m20260325_000005_create_team_repository_accessteam_repository_access
11m20260327_000006_create_personal_access_tokenspersonal_access_tokens
12m20260327_000007_create_issuesissues
13m20260327_000008_create_issue_commentsissue_comments
14m20260327_000009_create_labelslabels, issue_labels
15m20260327_000010_add_label_typeALTER labels (label_type)
16m20260327_000011_labels_owner_scopeALTER labels (owner_id, repository_id nullable)
17m20260327_000012_create_pull_requestspull_requests
18m20260327_000013_create_pr_commentspr_comments
19m20260409_000006_create_totp_tablesuser_totp, totp_challenges (core)
20+m20260416_000022_create_import_jobsimport_jobs

Toutes les migrations sont exécutées par AppMigrator (combine core + gitrust) dans gitrust_core::migrations::run_migrations().

Voir aussi

Règles QA & conformité ANSSI

Ce document définit les règles de qualité et de sécurité applicables à tout le projet gitrust. Chaque lot doit satisfaire ces règles avant merge.


1. Gates de compilation obligatoires

Toute modification doit passer ces 4 gates sans erreur :

GateCommandeCritère
Formatagecargo fmt --all -- --checkZéro diff
Lintingcargo clippy --workspace -- -D warningsZéro warning
Tests unitairescargo test --workspace100% pass
Build CSSnpx tailwindcss -i static/css/input.css -o static/css/style.css --minifySi templates modifiés

Gates supplémentaires (à mettre en place)

GateOutilRôle
Audit dépendancescargo auditDétection CVE dans les deps
Licences & banscargo deny checkLicences compatibles, pas de crate bannie
Secrets dans le codeRecherche de patterns sensiblesPas de token/mot de passe en dur

2. Règles de sécurité (ANSSI PA-074)

2.1 Lints Rust obligatoires (déjà en place)

#![allow(unused)]
#![forbid(unsafe_code)]                    // core, web, hooks
#![deny(unsafe_code)]                      // git, ssh (FFI nécessaire)
#![deny(clippy::unwrap_used)]
#![deny(clippy::expect_used)]
#![deny(clippy::panic)]
#![deny(clippy::indexing_slicing)]
#![deny(clippy::mem_forget)]
fn main() {
}

2.2 Checklist sécurité par feature

Avant toute implémentation touchant l’authentification, les secrets ou les permissions, vérifier :

  • Secrets jamais en clair en base — hash SHA-256 ou bcrypt
  • Secrets jamais loggés — vérifier tracing::info/debug/warn, Display et Debug impls ne révèlent pas le secret
  • Comparaison constant-timesubtle::ConstantTimeEq pour toute validation de secret (pas de == sur des hash)
  • Rate-limiting — endpoint d’authentification protégé
  • CSRF — token valide sur toute action mutante (POST/PUT/DELETE)
  • Ownership vérifiée (anti-IDOR) — le user ne peut agir que sur ses propres ressources (vérifier user_id côté serveur)
  • Expiration obligatoire — tout token/session a une durée de vie max
  • Audit log — création, révocation, utilisation suspecte tracées
  • Zeroize — types sensibles implémentent Zeroize/ZeroizeOnDrop
  • Path traversal — validation des chemins disque (pas de .., /, \)

2.3 Marqueurs SEC-

Le code utilise des commentaires // SEC-XX pour tracer les décisions de sécurité. Tout nouveau contrôle doit être marqué avec le prochain numéro disponible dans sa catégorie :

PréfixeCatégorieExemples
SEC-CCryptographieTiming attack, CSRF, PKCE
SEC-HHTTP/HeadersX-Forwarded-For, cookies, nonce
SEC-LLogique métierHashing, validation, defaults
SEC-MMémoire/sessionsRate-limit DoS, refresh tokens

3. Tests requis par catégorie

3.1 Matrice de couverture

CatégorieQuand appliquerExemples
UnitaireLogique pure (validation, parsing, conversion)RepoSlug::new("../evil") → erreur
IntégrationService avec DB (CRUD, contraintes, transactions)PatService::validate token expiré → None
HandlerEndpoint HTTP (status, redirect, CSRF, auth)POST sans CSRF → 403
E2E PlaywrightFlow utilisateur completCréer token → copier → cloner un dépôt
Sécurité négatifTout bypass imaginableToken user A sur ressource user B → 401

3.2 Règles de tests

  1. Tests d’intégration sur vraie DB — pas de mocks pour la couche persistence (les mocks masquent les bugs de migration)
  2. Tests négatifs obligatoires — pour chaque chemin heureux, tester au moins : input invalide, non-authentifié, non-autorisé, expiré, révoqué
  3. Tests E2E en français — cohérent avec l’UI (locale fr-FR)
  4. Pas de sleep() dans les tests — utiliser des retry/poll avec timeout
  5. Données de test isolées — chaque test crée ses propres données (pas de dépendance sur l’ordre d’exécution)

4. Règles de code

4.1 Framework boundary

  • Ne jamais modifier crates/rustwarden-core/
  • Réutiliser les services framework avant d’implémenter (auth, users, sessions, ResourceService, i18n, middleware)
  • Si un service manque, l’étendre côté gitrust (wrappers, trait impls)

4.2 Assets

  • Zéro CDN — tous les CSS/JS servis depuis static/
  • La CSP du framework bloque les domaines externes

4.3 Gestion d’erreurs

  • GitrustError avec IntoResponse pour le mapping HTTP
  • Pas de .unwrap() / .expect() / panic!() / [index]
  • Errors utilisateur : messages génériques (pas de leak d’info interne)

4.4 Validation aux frontières

  • Valider les inputs utilisateur (formulaires, query params, headers)
  • Ne pas re-valider entre services internes
  • Newtypes avec validation à la construction (RepoSlug, TeamSlug, Fingerprint, TokenHash)

5. Checklist pré-merge

Avant chaque merge de lot :

  • cargo fmt --all -- --check passe
  • cargo clippy --workspace -- -D warnings passe
  • cargo test --workspace — tous les tests passent
  • Tests E2E Playwright passent (npm run test:e2e)
  • CSS rebuild si templates modifiés
  • Aucun secret en clair dans le code ou les logs
  • Checklist sécurité §2.2 validée (si applicable)
  • Pas de modification dans crates/rustwarden-core/

API REST v1 — Référence

L’API REST gitrust est accessible sous le préfixe /api/v1/. Elle accepte et retourne du JSON. Elle partage les mêmes services que l’interface web SSR.

Documentation interactive : GET /api/docs sur toute instance gitrust.

Authentification

Deux méthodes sont acceptées sur tous les endpoints authentifiés :

Cookie JWT (navigateur) — défini automatiquement par le serveur après login :

Cookie: jwt_token=<JWT>

Bearer PAT (clients API, CI, scripts) — Personal Access Token créé depuis /settings/tokens :

Authorization: Bearer <PAT>

Les PAT supportent des scopes : repo:read, repo:write, issues:read, issues:write, pulls:read, pulls:write, ci:read, ci:write.

Un endpoint qui requiert repo:write rejette un PAT avec seulement repo:read avec 403 Forbidden.

Format des erreurs

Toutes les erreurs retournent ce format JSON :

{
    "error": "not_found",
    "message": "Le dépôt 'alice/myrepo' est introuvable",
    "status": 404
}

Codes d’erreur standards :

errorHTTPCause
unauthorized401Token absent, expiré ou invalide
forbidden403Permissions insuffisantes
not_found404Ressource inexistante
validation_error400Corps de requête invalide
conflict409Contrainte d’unicité violée
rate_limited429Trop de requêtes
internal_error500Erreur serveur

Pagination

Tous les endpoints de liste acceptent ?page=N&per_page=N (défaut : page=1, per_page=30, max per_page=100).

Headers de réponse :

X-Total-Count: 142
X-Page: 2
X-Per-Page: 30

Rate limiting

En cas de dépassement : 429 Too Many Requests avec Retry-After: <secondes>.


Endpoints

Authentification

POST /api/v1/auth/login

// Requête
{ "username": "alice", "password": "SecurePass123!" }

// Réponse 200 (sans 2FA)
{
    "access_token": "<JWT>",
    "refresh_token": "<token>",
    "token_type": "Bearer",
    "user": { "id": "...", "username": "alice", "email": "alice@example.com" }
}

// Réponse 200 (avec 2FA activé)
{ "requires_2fa": true, "challenge_token": "abc123..." }

POST /api/v1/auth/2fa/verify

// Requête
{ "challenge_token": "abc123...", "code": "123456" }

// Réponse 200
{ "access_token": "...", "refresh_token": "...", "user": { ... } }

POST /api/v1/auth/refresh

// Requête
{ "refresh_token": "<token>" }

// Réponse 200
{ "access_token": "<nouveau JWT>", "refresh_token": "<nouveau token>" }

GET /api/v1/auth/me

// Réponse 200
{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "username": "alice",
    "email": "alice@example.com",
    "email_verified": true,
    "roles": ["user"]
}

Utilisateur courant

GET /api/v1/user

Profil de l’utilisateur authentifié. Identique à GET /api/v1/auth/me.

GET /api/v1/user/repos

Liste les dépôts accessibles par l’utilisateur courant (possédés + partagés).

// Réponse 200
{
    "items": [
        {
            "id": "...",
            "owner": "alice",
            "slug": "myrepo",
            "description": "Mon premier dépôt",
            "default_branch": "main",
            "is_empty": false,
            "is_public": false,
            "created_at": "2026-03-25T10:00:00Z"
        }
    ],
    "total": 1,
    "page": 1,
    "per_page": 30
}

Dépôts

GET /api/v1/repos/{owner}/{repo}

// Réponse 200
{
    "id": "...",
    "owner": "alice",
    "slug": "myrepo",
    "description": "Mon dépôt",
    "default_branch": "main",
    "is_empty": false,
    "is_public": true,
    "created_at": "2026-03-25T10:00:00Z",
    "updated_at": "2026-04-01T12:00:00Z"
}

GET /api/v1/repos/{owner}/{repo}/branches

// Réponse 200
{
    "items": [
        { "name": "main", "is_default": true, "commit_sha": "abc123..." },
        { "name": "feat/42-fix", "is_default": false, "commit_sha": "def456..." }
    ],
    "total": 2, "page": 1, "per_page": 30
}

GET /api/v1/repos/{owner}/{repo}/commits

Paramètres optionnels : ?ref=main&page=1&per_page=30

// Réponse 200
{
    "items": [
        {
            "sha": "abc123...",
            "message": "feat: ajouter la signature HMAC",
            "author": { "name": "Alice", "email": "alice@example.com" },
            "committed_at": "2026-04-01T09:00:00Z"
        }
    ],
    "total": 42, "page": 1, "per_page": 30
}

Issues

GET /api/v1/repos/{owner}/{repo}/issues

Paramètres : ?state=open&page=1&per_page=30

// Réponse 200
{
    "items": [
        {
            "id": "...",
            "number": 1,
            "title": "Bug dans le parser",
            "state": "open",
            "author": "alice",
            "labels": [
                { "name": "bug", "color": "#d73a4a", "type": "classification" }
            ],
            "comment_count": 3,
            "created_at": "2026-03-27T08:00:00Z"
        }
    ],
    "total": 5, "page": 1, "per_page": 30
}

POST /api/v1/repos/{owner}/{repo}/issues

// Requête
{ "title": "Nouveau bug", "body": "Description en Markdown..." }

// Réponse 201
{ "id": "...", "number": 6, "title": "Nouveau bug", "state": "open", ... }

PATCH /api/v1/repos/{owner}/{repo}/issues/{num}

// Requête (tous les champs optionnels)
{ "state": "closed" }

// Réponse 200
{ "id": "...", "number": 1, "state": "closed", ... }

Pull Requests

GET /api/v1/repos/{owner}/{repo}/pulls

Paramètres : ?state=open&page=1&per_page=30

POST /api/v1/repos/{owner}/{repo}/pulls

// Requête
{
    "title": "feat: ajouter la signature HMAC",
    "body": "Closes #42",
    "source_branch": "feat/42-hmac",
    "target_branch": "main"
}

// Réponse 201
{ "id": "...", "number": 3, "state": "open", ... }

POST /api/v1/repos/{owner}/{repo}/pulls/{num}/merge

// Requête
{ "merge_method": "merge" }
// merge_method: "merge" | "squash" | "rebase"

// Réponse 200
{ "merged": true, "merge_commit_sha": "abc123..." }

CI Pipelines

GET /api/v1/repos/{owner}/{repo}/ci/pipelines

Paramètres : ?page=1&per_page=30

// Réponse 200
{
    "items": [
        {
            "id": "...",
            "status": "success",
            "trigger": "push",
            "ref": "main",
            "commit_sha": "abc123...",
            "duration_ms": 45000,
            "created_at": "2026-04-01T09:00:00Z"
        }
    ],
    "total": 12, "page": 1, "per_page": 30
}

POST /api/v1/repos/{owner}/{repo}/ci/pipelines/trigger

Déclenche un pipeline manuellement.

// Requête
{ "ref": "main" }

// Réponse 201
{ "id": "...", "status": "pending", ... }

GET /api/v1/repos/{owner}/{repo}/ci/pipelines/{id}/logs

// Réponse 200
{
    "items": [
        { "line": 1, "content": "$ cargo build --release", "stream": "stdout", "timestamp": "..." },
        { "line": 2, "content": "   Compiling gitrust v0.1.0", "stream": "stdout", "timestamp": "..." }
    ],
    "total": 248, "page": 1, "per_page": 100
}

Exemples clients

Lister les issues avec curl

curl -s \
  -H "Authorization: Bearer ${GITRUST_PAT}" \
  "https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues?state=open" \
  | jq '.items[].title'

Créer une issue avec Python

import httpx

client = httpx.Client(
    base_url="https://demo.gitrust.eu",
    headers={"Authorization": f"Bearer {PAT}"},
)

resp = client.post(
    "/api/v1/repos/alice/myrepo/issues",
    json={"title": "Bug trouvé", "body": "Description..."},
)
resp.raise_for_status()
print(resp.json()["number"])

Créer une issue avec Rust

#![allow(unused)]
fn main() {
use reqwest::Client;
use serde_json::json;

let client = Client::new();
let resp = client
    .post("https://demo.gitrust.eu/api/v1/repos/alice/myrepo/issues")
    .bearer_auth(&pat)
    .json(&json!({"title": "Bug", "body": "..."}))
    .send()
    .await?;

println!("Issue #{}", resp.json::<serde_json::Value>().await?["number"]);
}

Voir aussi

Hooks gitrust — Référence

Ce document décrit les hooks exposés par crates/gitrust-hooks/, quand ils sont appelés, leur payload, leurs effets de bord, et comment les tester localement.

Vue d’ensemble

gitrust-hooks implémente le trait RustwardenHooks de rustwarden-core. Ce trait est un mécanisme de lifecycle : le framework appelle les hooks aux moments clés du cycle de vie des ressources (inscription utilisateur, suppression, partage).

#![allow(unused)]
fn main() {
// crates/gitrust-hooks/src/lib.rs
#[async_trait]
impl RustwardenHooks for GitrustHooks {
    async fn on_user_registered(...) -> anyhow::Result<()>;
    async fn on_resource_shared(...) -> anyhow::Result<()>;
    async fn on_user_deleted(...) -> anyhow::Result<()>;
    async fn on_resource_unshared(...) -> anyhow::Result<()>;
}
}

Les hooks Git serveur (receive-pack, upload-pack) sont implémentés directement dans gitrust-web/src/handlers/git_http.rs et dans le serveur SSH (gitrust-ssh).

Hooks de lifecycle (RustwardenHooks)

on_user_registered

Déclencheur : après la création réussie d’un compte utilisateur (via /register ou l’API /api/v1/auth/register).

Payload :

#![allow(unused)]
fn main() {
async fn on_user_registered(
    db: &DatabaseConnection,
    user_id: Uuid,
    username: &str,
) -> anyhow::Result<()>
}

Effet de bord : crée le répertoire de dépôts de l’utilisateur sur le système de fichiers :

{REPOS_BASE_PATH}/{username}/

Validation de sécurité : le username est validé contre la traversal de chemin (.., /, \) avant toute opération disque. Un username suspect déclenche un log WARN et retourne une erreur — le compte n’est pas créé.

Idempotence : si le répertoire existe déjà, l’appel réussit silencieusement.


on_user_deleted

Déclencheur : juste avant la suppression d’un utilisateur depuis le panel admin.

Payload :

#![allow(unused)]
fn main() {
async fn on_user_deleted(
    db: &DatabaseConnection,
    user_id: Uuid,
    username: &str,
) -> anyhow::Result<()>
}

Effets de bord :

  1. Supprime le répertoire disque {REPOS_BASE_PATH}/{username}/ et tous les bare repos qu’il contient.
  2. Enregistre un événement user_deleted dans audit_logs (niveau WARN).

Note : la cascade DB (suppression des lignes repositories) est gérée par la contrainte FK ON DELETE CASCADE. Ce hook gère uniquement le système de fichiers.

En cas d’erreur disque (permissions, NFS indisponible), l’erreur est loggée en WARN mais ne bloque pas la suppression en base.


on_resource_shared

Déclencheur : après un appel réussi à ResourceService::share() — quand un dépôt est partagé avec un collaborateur.

Payload :

#![allow(unused)]
fn main() {
async fn on_resource_shared(
    db: &DatabaseConnection,
    resource_type: &str,
    resource_id: Uuid,
    shared_with_user_id: Uuid,
    permission_level: &str,
    shared_by_user_id: Uuid,
) -> anyhow::Result<()>
}

Effet de bord : si resource_type == "repository", enregistre l’événement REPO_SHARED dans audit_logs avec les détails du partage.


on_resource_unshared

Déclencheur : après un appel réussi à ResourceService::revoke_share() ou revoke_user_access().

Payload :

#![allow(unused)]
fn main() {
async fn on_resource_unshared(
    db: &DatabaseConnection,
    resource_type: &str,
    resource_id: Uuid,
    user_id: Uuid,
) -> anyhow::Result<()>
}

Effet de bord : si resource_type == "repository", enregistre l’événement REPO_UNSHARED dans audit_logs.

Hooks Git serveur (receive-pack / upload-pack)

Ces hooks ne sont pas dans gitrust-hooks mais dans les handlers HTTP et SSH.

Receive-pack (après un git push)

Déclencheur : POST /{owner}/{repo}/git-receive-pack (HTTP) ou connexion SSH suivie d’un git-receive-pack.

Séquence d’effets :

sequenceDiagram
    participant Client as git push
    participant Handler as receive_pack handler
    participant DB as Base de données
    participant CI as CI Worker
    participant WH as Webhook Dispatcher

    Client->>Handler: objets packés
    Handler->>DB: RepositoryService::mark_non_empty() si premier push
    Handler->>DB: CiService::create_pipeline() si .gitrust-ci.yml détecté
    Handler->>CI: mpsc::send(CiTask)
    Handler->>DB: WebhookService::list_by_repo_and_event("push")
    Handler->>WH: tokio::spawn → deliver(webhook, "push", payload)
    Handler-->>Client: 200 OK (pack-protocol)

Payload webhook push :

{
    "repository": {
        "id": "...",
        "slug": "myrepo",
        "owner": "alice"
    },
    "pusher": { "username": "alice" },
    "ref": "refs/heads/main",
    "before": "0000000000000000000000000000000000000000",
    "after": "abc123...",
    "commits": [
        {
            "sha": "abc123...",
            "message": "feat: ajouter la signature HMAC",
            "author": { "name": "Alice", "email": "alice@example.com" }
        }
    ]
}

Upload-pack (après un git fetch / git clone)

Déclencheur : GET /{owner}/{repo}/info/refs?service=git-upload-pack + POST /{owner}/{repo}/git-upload-pack.

Effets de bord : aucun effet de bord métier. Vérification des permissions d’accès (public → lecture libre ; privé → authentification requise).

Hooks SBOM (post-push)

Après chaque push sur un dépôt avec SBOM_ENABLED=true, un job SBOM est déclenché via SbomService :

  1. SbomService::trigger_scan(db, repo_id, commit_sha) crée un job dans ci_jobs.
  2. Le worker CI exécute syft sur le répertoire de travail.
  3. Le résultat est uploadé vers Dependency-Track via HTTP.
  4. Les findings critiques peuvent bloquer la CI selon la configuration sbom_block_on_violation.

Tester les hooks localement

Tester on_user_registered

#![allow(unused)]
fn main() {
// tests/hooks_test.rs
#[tokio::test]
async fn on_user_registered_creates_dir() {
    let tmp = tempfile::TempDir::new().unwrap();
    let hooks = GitrustHooks::new(tmp.path().to_path_buf());
    let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();

    hooks
        .on_user_registered(&db, Uuid::new_v4(), "alice")
        .await
        .unwrap();

    assert!(tmp.path().join("alice").exists());
}

#[tokio::test]
async fn on_user_registered_rejects_traversal() {
    let tmp = tempfile::TempDir::new().unwrap();
    let hooks = GitrustHooks::new(tmp.path().to_path_buf());
    let db = sea_orm::Database::connect("sqlite::memory:").await.unwrap();

    assert!(hooks
        .on_user_registered(&db, Uuid::new_v4(), "../etc/passwd")
        .await
        .is_err());
}
}

Simuler un push en local

Pour tester le cycle complet receive-pack → CI → webhook sans client réel :

# 1. Démarrer gitrust localement
cargo run

# 2. Créer un dépôt via l'UI, cloner en SSH ou HTTP
git clone http://localhost:4000/alice/myrepo.git

# 3. Ajouter un fichier et pusher
cd myrepo
echo "hello" > README.md
git add README.md && git commit -m "test push"
git push origin main

# 4. Vérifier les effets de bord dans les logs
# → Log "Created pipeline" si .gitrust-ci.yml présent
# → Log "Delivered webhook" si webhook configuré

Ajouter un nouveau hook

Pour réagir à un nouvel événement de lifecycle :

  1. Définissez la méthode dans le trait RustwardenHooks (dans rustwarden-core/src/hooks.rs).
  2. Implémentez la méthode dans GitrustHooks (dans gitrust-hooks/src/lib.rs).
  3. Appelez le hook depuis le service rustwarden-core approprié.
  4. Écrivez un test unitaire avec tempfile::TempDir et sea_orm::Database::connect("sqlite::memory:").

Règle : les hooks ne doivent jamais bloquer un flux utilisateur critique. En cas d’erreur non fatale (écriture disque, audit log), loggez en WARN et retournez Ok(()).

Voir aussi

API rustwarden-core — Référence

Ce document décrit les helpers, extracteurs Axum, middleware, et APIs d’extension fournis par le framework rustwarden-core que gitrust consomme.

Extracteurs Axum

AuthUser

Module : rustwarden_core::auth::extractors
Export : prélude

Extracteur qui valide le JWT depuis le cookie jwt_token ou le header Authorization: Bearer <token>. Retourne 401 Unauthorized si le token est absent, expiré, invalide, ou blacklisté.

#![allow(unused)]
fn main() {
use rustwarden_core::prelude::*;

async fn handler(user: AuthUser) -> impl IntoResponse {
    // Champs disponibles :
    let _id: Uuid = user.user_id;
    let _name: &str = &user.username;
    let _roles: &[String] = &user.roles;
    let _perms: &[String] = &user.permissions;
}
}

Méthodes de vérification :

MéthodeDescription
has_role(role: &str) → boolL’utilisateur a-t-il ce rôle ?
has_any_role(roles: &[&str]) → boolA-t-il au moins un des rôles ?
has_all_roles(roles: &[&str]) → boolA-t-il tous les rôles ?
has_permission(permission: &str) → boolA-t-il cette permission ? Le rôle admin retourne toujours true.

AuthenticatedSession

Module : rustwarden_core::auth::session

Extracteur similaire à AuthUser mais inclut les informations de session (device info, IP). Utilisé pour la page /settings/sessions.

#![allow(unused)]
fn main() {
async fn sessions_handler(
    State(db): State<DatabaseConnection>,
    session: AuthenticatedSession,
) -> impl IntoResponse {
    let current_token_id = session.token_id; // UUID du refresh token courant
    // Utilisé pour marquer "Current session" dans l'UI
}
}

OptionalUser

Extracteur qui ne rejette pas les requêtes non authentifiées — retourne Option<AuthUser>. Utilisé pour les pages publiques (dépôts publics, explore).

#![allow(unused)]
fn main() {
async fn public_page(user: OptionalUser) -> impl IntoResponse {
    match user.0 {
        Some(u) => format!("Bonjour {}", u.username),
        None => "Visiteur anonyme".to_owned(),
    }
}
}

Middleware

Middleware d’authentification JWT

Intégré automatiquement dans RustwardenApp::build(). Gère :

  • Lecture du JWT depuis Cookie: jwt_token=<JWT> ou Authorization: Bearer <JWT>.
  • Vérification de la signature HMAC-SHA256 avec la clé JWT_SECRET.
  • Vérification de l’expiration (exp).
  • Vérification de la blacklist (table jwt_blacklist).

Ce middleware ne bloque pas les requêtes anonymes — c’est l’extracteur AuthUser dans le handler qui décide si l’authentification est requise.

Middleware CSRF

Protège toutes les requêtes mutantes (POST, PUT, DELETE, PATCH) émises par des formulaires HTML. Le token CSRF est :

  • Généré à la connexion et stocké dans un cookie csrf_token (HttpOnly, SameSite=Strict).
  • Attendu dans le corps du formulaire sous le champ _csrf.
  • Absent ou invalide → 403 Forbidden.

Pour les endpoints API JSON (header Content-Type: application/json), la vérification CSRF est désactivée — l’authentification Bearer PAT/JWT suffit.

<!-- Dans tout formulaire POST -->
<input type="hidden" name="_csrf" value="{{ csrf_token }}">

Middleware de rate limiting

Basé sur une fenêtre glissante en mémoire (par IP ou par user_id). Configurable via les variables d’environnement :

RATE_LIMIT_LOGIN=10          # tentatives de login / 15 min / IP
RATE_LIMIT_API_READ=1000     # requêtes lecture / heure / user
RATE_LIMIT_API_WRITE=200     # requêtes écriture / heure / user

Claims — contenu du JWT

#![allow(unused)]
fn main() {
pub struct Claims {
    pub sub: Uuid,              // user_id
    pub username: String,
    pub roles: Vec<String>,
    pub permissions: Vec<String>,
    pub exp: i64,               // timestamp d'expiration
    pub iat: i64,               // timestamp d'émission
    pub iss: String,            // issuer (app_domain)
    pub jti: Uuid,              // JWT ID unique (pour blacklist)
}
}

JwtService

Module : rustwarden_core::auth::jwt
Export : prélude

MéthodeSignatureDescription
generate_token(config, user_id, username, roles, permissions) → Result<String>Génère un JWT signé HS256.
validate_token(config, token: &str) → Result<Claims>Valide et décode. Retourne TokenExpired si expiré.
extract_token_from_header(header: &str) → Result<&str>Extrait le token du header Authorization: Bearer <token>.

Trait RustwardenHooks

Interface à implémenter dans votre projet consommateur pour réagir aux événements du framework.

#![allow(unused)]
fn main() {
#[async_trait]
pub trait RustwardenHooks: Send + Sync {
    async fn on_user_registered(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        username: &str,
    ) -> anyhow::Result<()> {
        Ok(())  // défaut no-op
    }

    async fn on_user_deleted(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        username: &str,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    async fn on_resource_shared(
        &self,
        db: &DatabaseConnection,
        resource_type: &str,
        resource_id: Uuid,
        shared_with_user_id: Uuid,
        permission_level: &str,
        shared_by_user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }

    async fn on_resource_unshared(
        &self,
        db: &DatabaseConnection,
        resource_type: &str,
        resource_id: Uuid,
        user_id: Uuid,
    ) -> anyhow::Result<()> {
        Ok(())
    }
}
}

Enregistrement dans main.rs :

#![allow(unused)]
fn main() {
let hooks = Arc::new(GitrustHooks::new(repos_base_path.clone()));
let app = RustwardenApp::builder()
    .from_env()
    .hooks(hooks)
    .build()
    .await?;
}

RBAC API

Vérification des permissions dans un handler

#![allow(unused)]
fn main() {
async fn admin_handler(user: AuthUser) -> Result<impl IntoResponse, AppError> {
    if !user.has_role("admin") {
        return Err(AppError::Forbidden);
    }
    // ...
}
}

Vérification des permissions sur une ressource

#![allow(unused)]
fn main() {
use rustwarden_core::prelude::*;

async fn repo_handler(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
    // ... autres extracteurs
) -> Result<impl IntoResponse, AppError> {
    // Vérifier que l'utilisateur peut accéder en lecture
    if !ResourceService::user_can_access(
        &db, user.user_id, "repository", repo.id, "read"
    ).await? {
        return Err(AppError::Forbidden);
    }

    // Déterminer le niveau d'accès effectif
    match ResourceService::effective_permission(
        &db, user.user_id, "repository", repo.id
    ).await? {
        Some(ref p) if p == "owner" => { /* accès total */ }
        Some(ref p) if p == "write" => { /* écriture permise */ }
        Some(ref p) if p == "read"  => { /* lecture seule */ }
        _ => return Err(AppError::Forbidden),
    }
}
}

i18n — Internationalisation

Module : rustwarden_core::i18n

Gitrust supporte 6 langues : fr, en, de, es, it, pt.

Les fichiers de traduction sont dans crates/rustwarden-core/locales/{lang}.yml et crates/gitrust-web/locales/{lang}.yml.

Dans un template Askama :

<!-- Clé de traduction avec paramètre -->
<h1>{{ t!("repository.title", repo_name = repo.slug) }}</h1>

<!-- Clé simple -->
<button>{{ t!("actions.save") }}</button>

Dans un handler (pour les messages d’erreur i18n) :

#![allow(unused)]
fn main() {
use rustwarden_core::i18n::I18n;

let message = I18n::translate(&user_lang, "errors.forbidden", &[]);
}

La langue est déterminée dans l’ordre : préférences utilisateur (table user_preferences) → cookie lang → header Accept-Language.


Email templating

Module : rustwarden_core::services::email_service

Pour envoyer un email depuis gitrust, utilisez EmailQueueService::enqueue (envoi asynchrone) :

#![allow(unused)]
fn main() {
use rustwarden_core::services::email_queue_service::EmailQueueService;

EmailQueueService::enqueue(
    &db,
    &to_email,
    Some(&to_name),
    &from_email,
    Some(&from_name),
    "Votre dépôt a été importé",
    &html_body,
    &text_body,
    "import_success",
).await?;
}

Les templates d’email sont dans crates/rustwarden-core/templates/emails/ (format HTML + texte brut).


Étendre rustwarden-core

Règle absolue : ne modifiez jamais le code de crates/rustwarden-core/. C’est un framework partagé entre plusieurs projets (gitrust, PasterMan, etc.).

Pour étendre une fonctionnalité :

  1. Wrappers dans gitrust-core : créez un service wrapper dans crates/gitrust-core/src/services/ qui appelle le service rustwarden-core et ajoute la logique spécifique gitrust.
  2. Trait impls dans gitrust-hooks : implémentez RustwardenHooks pour réagir aux événements du framework.
  3. Feature flags : certaines fonctionnalités rustwarden-core sont derrière des features Cargo (oauth, totp, etc.) — activez-les dans Cargo.toml.

Voir aussi

Explications

Les explications répondent à la question « pourquoi ? ». Elles décrivent les décisions d’architecture, les modèles conceptuels, et les compromis qui ont façonné gitrust. Consultez-les quand vous avez besoin de comprendre un système avant de le modifier.

Diátaxis — mode compréhension : les explications ne vous apprennent pas à faire quelque chose de précis. Elles vous donnent un modèle mental juste, qui rend les autres actions plus sûres et plus cohérentes.

Les explications disponibles

Sécurité et accès

PageCe qu’elle explique
Modèle de permissions RBACLes 4 rôles, les trois sources d’accès, le calcul du rôle effectif par maximum
Fonctionnement du 2FA TOTPRFC 6238, chiffrement AES-256-GCM du secret, codes de secours bcrypt, challenges temporaires

Interface et architecture

PageCe qu’elle explique
Décisions UI : SSR, HTMX, DaisyUIPourquoi pas React, comment HTMX remplace des SPA pour 90 % des cas, budget bundle
Patron worker asyncmpsc + Semaphore + SSE, durabilité via table SQL, récupération au redémarrage

Données et organisation

PageCe qu’elle explique
Hiérarchie des labels à deux niveauxClassification (scope owner) vs subject (scope dépôt), filtrage cross-dépôt, schéma DB

CI et outillage

PageCe qu’elle explique
CI Dagger : Easy Mode vs Power ModeDétection .gitrust-ci.yml / .dagger/, quand basculer, philosophie anti-YAML de Dagger

Quand lire une explication

Lisez une explication quand :

  • Vous vous apprêtez à modifier un système existant et vous voulez éviter de casser un invariant non documenté dans le code.
  • Vous rencontrez une décision qui vous semble étrange (ex : pourquoi le rôle effectif est-il le maximum et non le minimum ?) et vous voulez comprendre le raisonnement.
  • Vous voulez proposer une alternative architecturale et vous avez besoin du contexte des compromis déjà évalués.

Vue d’ensemble de l’architecture

Gitrust est une plateforme d’hébergement Git auto-hébergée construite sur le framework rustwarden-core. L’application expose deux points d’entrée réseau (HTTP + SSH) et persiste ses données dans PostgreSQL.

Vue d’ensemble

graph TB
    subgraph Clients
        Browser["Navigateur Web"]
        GitCLI["Git CLI (ssh/https)"]
    end

    subgraph "Gitrust Binary (src/main.rs)"
        Main["main()"]
    end

    subgraph "Crates Gitrust"
        Web["gitrust-web<br/><i>Routes HTTP, templates,<br/>handlers Axum</i>"]
        SSH["gitrust-ssh<br/><i>Serveur SSH (russh),<br/>auth par clé, sessions</i>"]
        SshGuard["gitrust-ssh-guard<br/><i>SecureListener, détecteurs,<br/>BanManager, AuthTracker</i>"]
        Hooks["gitrust-hooks<br/><i>impl RustwardenHooks<br/>(on_user_registered, ...)</i>"]
        Core["gitrust-core<br/><i>Models, services, migrations,<br/>rôles, types, DTOs</i>"]
        Git["gitrust-git<br/><i>Bare repos, tree browser,<br/>pack protocol (git2)</i>"]
    end

    subgraph "Framework"
        RW["rustwarden-core<br/><i>Auth, users, JWT, sessions,<br/>ResourceService, i18n</i>"]
    end

    subgraph "Stockage"
        PG[(PostgreSQL)]
        FS[("Système de fichiers<br/>bare repos .git")]
    end

    subgraph "CI/CD (optionnel)"
        CiWorker["CiWorker<br/><i>tokio::spawn, mpsc,<br/>sous-processus Dagger</i>"]
        Dagger["Dagger Engine<br/><i>Containers isolés,<br/>cache, exécution</i>"]
        CiEngine["ci-engine<br/><i>Module Dagger Python<br/>(Easy Mode)</i>"]
        Syft["Syft (optionnel)<br/><i>Génération SBOM<br/>CycloneDX</i>"]
        DTrack["Dependency-Track<br/>(optionnel)<br/><i>Analyse vulnérabilités</i>"]
    end

    Browser -->|HTTP :4000| Web
    GitCLI -->|SSH :2222| SshGuard
    SshGuard -->|"AcceptOutcome::Accepted"| SSH
    GitCLI -->|HTTPS :4000| Web

    Main --> Web
    Main --> SSH
    Main --> SshGuard
    Main --> Hooks
    Main -.->|"si CI_ENABLED"| CiWorker

    Web --> Core
    Web --> Git
    Web -.->|"admin ACL/ban"| SshGuard
    SSH --> Core
    SSH --> Git
    SSH --> SshGuard
    SshGuard --> Core
    Hooks --> Core

    Core --> RW
    Core --> PG
    Git --> FS

    CiWorker --> Core
    CiWorker -->|"dagger call"| Dagger
    Dagger -->|"Easy Mode"| CiEngine
    Dagger -->|"Power Mode"| FS
    CiWorker -.->|"si CI_SBOM_ENABLED"| Syft
    Syft -.->|"si CI_DTRACK_ENABLED"| DTrack

Démarrage de l’application

Le diagramme suivant détaille la séquence d’initialisation depuis main() jusqu’au démarrage des serveurs HTTP et SSH.

sequenceDiagram
    participant Main as main()
    participant Builder as RustwardenBuilder
    participant App as RustwardenApp
    participant Mig as AppMigrator
    participant DB as PostgreSQL
    participant SMTP as Serveur SMTP

    Main->>Main: GitrustConfig::from_env()
    Main->>Main: Arc<GitrustHooks> (hooks FS)

    Main->>Builder: builder().from_env()
    Note over Builder: Charge .env, init tracing
    Main->>Builder: .headless().auto_migrate(false)
    Main->>Builder: .merge_routes(app_routes(hooks))
    Note over Builder: Routes = framework API<br/>+ pages SSR gitrust<br/>+ Extension(hooks)
    Main->>Builder: .build().await
    Builder->>DB: Connexion pool
    Builder->>DB: Migrations rustwarden-core
    Builder->>SMTP: EmailQueueProcessor.start()
    Note over SMTP: Worker de fond : dépile<br/>email_queue toutes les 30s
    Builder-->>Main: RustwardenApp

    Main->>Mig: run_migrations(app.database())
    Mig->>DB: core_migrations() + gitrust_migrations()
    Note over DB: Tables: users, resources,<br/>repositories, ssh_keys,<br/>teams, team_members,<br/>team_repository_access
    Mig-->>Main: Ok

    Main->>Main: tokio::spawn(SSH server :2222)

    alt CI_ENABLED=true
        Main->>Main: CiWorker::start(config.ci)
        Note over Main: Vérifie dagger dans PATH<br/>Si CI_SBOM_ENABLED: vérifie syft<br/>Si CI_DTRACK_ENABLED: vérifie API DT
        Main->>Main: tokio::spawn(CiWorker loop)
        Note over Main: Écoute mpsc channel<br/>pour les jobs CI
    end

    Main->>App: app.run().await
    Note over App: HTTP :4000 démarre

Principes de conception

Séparation framework / métier gitrust

Gitrust est construit au-dessus de rustwarden-core, un framework Rust d’authentification et de gestion d’utilisateurs. Cette séparation est intentionnelle :

  • rustwarden-core gère : auth JWT, sessions, users, roles génériques, i18n, SMTP, OAuth, audit log générique.
  • gitrust-core gère : tout ce qui est spécifique à la forge Git — dépôts, clés SSH, équipes, permissions RBAC à 4 niveaux, CI, import de dépôts.

La règle fondamentale : ne jamais modifier crates/rustwarden-core/. Toute extension passe par des wrappers, des hooks (RustwardenHooks) ou des traits implémentés côté gitrust.

Mode headless

gitrust-web opère en mode headless() : les pages UI standard de rustwarden (login, register, settings) sont désactivées et réimplémentées entièrement dans gitrust-web/templates/. Cela permet un design cohérent (sidebar Git, DaisyUI) sans compromis avec l’UI générique du framework.

SSR + HTMX, zéro CDN

L’interface est rendue côté serveur via Askama (templates Rust compilés). HTMX gère les interactions dynamiques (mises à jour partielles de page, SSE pour les notifications et les logs CI). Aucun asset externe : tout CSS/JS est servi depuis static/ — imposé par la Content Security Policy du framework.

Bare repos sur le système de fichiers

gitrust-git est découplé de la base de données. Les opérations Git (navigation d’arbre, lecture de blobs, pack protocol) travaillent directement sur les bare repos ({GIT_REPOS_BASE_PATH}/{owner}/{slug}.git/) via libgit2. La base ne stocke que les métadonnées (nom, description, visibilité, ownership). Ce découplage permet de remplacer ou tester gitrust-git indépendamment.

CI hybride : Dagger comme abstraction d’exécution

Plutôt que d’implémenter un runner CI from scratch, gitrust délègue l’exécution à Dagger, qui garantit l’isolation (containers), la reproductibilité (cache) et la portabilité (local ou runner distant via SSH+rsync). Le CiWorker est une tâche Tokio dans le processus principal — pas un daemon séparé — ce qui simplifie le déploiement (un seul binaire).


Pour aller plus loin

Flux de requêtes

Diagrammes de séquence couvrant les opérations principales de gitrust : création de dépôt, clone et push SSH, enregistrement de clé SSH, résolution des permissions, navigation web, inscription utilisateur.

1. Création d’un dépôt (Web)

sequenceDiagram
    actor User as Utilisateur
    participant Web as gitrust-web
    participant RepoSvc as RepositoryService
    participant ResSvc as ResourceService
    participant DB as PostgreSQL
    participant Git as gitrust-git
    participant FS as Système de fichiers

    User->>Web: POST /new {name, description}
    Web->>Web: Extraire user_id (session JWT)

    Web->>RepoSvc: create(db, owner_id, username, base_path, input)
    RepoSvc->>RepoSvc: RepoSlug::from_name(input.name)
    RepoSvc->>DB: SELECT ... WHERE owner_id AND slug
    DB-->>RepoSvc: None (pas de doublon)

    RepoSvc->>RepoSvc: compute_disk_path(base, owner, slug)
    Note over RepoSvc: Validation anti path-traversal

    RepoSvc->>ResSvc: register(db, "repository", repo_id, owner_id)
    ResSvc->>DB: INSERT INTO resources
    DB-->>ResSvc: Ok

    RepoSvc->>DB: INSERT INTO repositories
    DB-->>RepoSvc: repository::Model

    RepoSvc-->>Web: RepositoryOutput

    Web->>Git: BareRepo::init(disk_path)
    Git->>FS: git init --bare
    FS-->>Git: Ok
    Git-->>Web: Ok

    Web-->>User: 302 Redirect /{owner}/{slug}

2. Clone SSH (git clone)

sequenceDiagram
    actor Dev as Développeur
    participant Client as Git CLI
    participant SSH as gitrust-ssh
    participant Auth as SshKeyService
    participant Access as AccessService
    participant Git as gitrust-git
    participant DB as PostgreSQL
    participant FS as Bare repo (.git)

    Dev->>Client: git clone ssh://git@host:2222/alice/my-repo.git
    Client->>SSH: Connexion TCP :2222
    SSH-->>Client: Banner SSH + échange clés

    Client->>SSH: Auth publickey (signature)
    SSH->>Auth: find_by_fingerprint(db, fingerprint)
    Auth->>DB: SELECT FROM ssh_keys WHERE fingerprint = ?
    DB-->>Auth: ssh_key::Model {user_id, ...}
    Auth-->>SSH: user_id

    SSH->>Auth: update_last_used(db, key_id)
    Auth->>DB: UPDATE ssh_keys SET last_used_at = now()

    SSH-->>Client: Auth OK

    Client->>SSH: exec "git-upload-pack 'alice/my-repo.git'"
    SSH->>SSH: CommandHandler::parse("git-upload-pack alice/my-repo.git")
    Note over SSH: Extraction owner=alice, repo=my-repo

    SSH->>Access: effective_role(db, user_id, repo_id)
    Access->>DB: Check ownership + shares + teams
    DB-->>Access: Role::Developer
    Access-->>SSH: can_read = true

    SSH->>Git: pack_protocol::advertise_refs(repo_path)
    Git->>FS: Lecture refs
    FS-->>Git: refs/heads/main, ...
    Git-->>SSH: advertise output

    SSH-->>Client: refs advertisement
    Client->>SSH: want/have negotiation

    SSH->>Git: pack_protocol::serve_pack(repo_path, wants, haves)
    Git->>FS: Pack objects
    FS-->>Git: pack data
    Git-->>SSH: pack stream

    SSH-->>Client: pack data
    Client-->>Dev: Dépôt cloné avec succès

3. Push SSH (git push)

sequenceDiagram
    actor Dev as Développeur
    participant Client as Git CLI
    participant SSH as gitrust-ssh
    participant Access as AccessService
    participant Git as gitrust-git
    participant Hooks as gitrust-hooks
    participant DB as PostgreSQL
    participant FS as Bare repo (.git)

    Note over Client,SSH: Session SSH déjà authentifiée

    Client->>SSH: exec "git-receive-pack 'alice/my-repo.git'"
    SSH->>SSH: CommandHandler::parse(...)
    Note over SSH: Extraction owner=alice, repo=my-repo

    SSH->>Access: effective_role(db, user_id, repo_id)
    Access->>DB: Check ownership + shares + teams
    DB-->>Access: Role::Developer
    Access-->>SSH: can_push = true

    SSH->>Git: pack_protocol::advertise_refs(repo_path)
    Git->>FS: Lecture refs
    Git-->>SSH: refs advertisement

    SSH-->>Client: refs
    Client->>SSH: pack data + commands

    SSH->>Git: pack_protocol::serve_pack(repo_path, receive-pack)
    Git->>FS: Écriture objets + update refs
    FS-->>Git: Ok
    Git-->>SSH: Ok

    SSH->>DB: UPDATE repositories SET is_empty = false, updated_at = now()

    SSH-->>Client: Push OK
    Client-->>Dev: Push réussi

4. Enregistrement d’une clé SSH

sequenceDiagram
    actor User as Utilisateur
    participant Web as gitrust-web
    participant Svc as SshKeyService
    participant DB as PostgreSQL

    User->>Web: POST /settings/keys {title, key_data}
    Web->>Web: Extraire user_id (session)

    Web->>Svc: create(db, user_id, input)

    Svc->>Svc: parse_public_key(key_data)
    Note over Svc: Extraire type + décoder base64

    Svc->>Svc: validate_key_type_and_size(type, bytes)
    Note over Svc: ed25519 OK<br/>RSA >= 4096 bits<br/>ecdsa-p256/p384 OK

    Svc->>Svc: compute_fingerprint(bytes)
    Note over Svc: SHA256(key_bytes) -> base64-no-pad

    Svc->>DB: SELECT FROM ssh_keys WHERE fingerprint = ?
    DB-->>Svc: None (pas de doublon)

    Svc->>DB: INSERT INTO ssh_keys
    DB-->>Svc: ssh_key::Model

    Svc-->>Web: SshKeyOutput
    Web-->>User: 200 Clé ajoutée (fingerprint affiché)

5. Résolution des permissions (AccessService)

sequenceDiagram
    participant Caller as Handler / SSH Session
    participant AS as AccessService
    participant RS as ResourceService
    participant DB as PostgreSQL

    Caller->>AS: effective_role(db, user_id, repo_id)

    AS->>RS: effective_permission(db, user_id, "repository", repo_id)
    RS->>DB: SELECT FROM resources WHERE type AND id
    DB-->>RS: resource {owner_id, is_public}

    alt user_id == owner_id
        RS-->>AS: "owner"
    else Share exists
        RS->>DB: SELECT FROM resource_shares WHERE resource AND user
        DB-->>RS: share {permission_level}
        RS-->>AS: permission_level
    else Resource is public
        RS-->>AS: "read"
    else No access
        RS-->>AS: None
    end

    AS->>AS: Role::from_permission(individual_level)

    AS->>DB: SELECT team_members + team_repository_access<br/>WHERE user_id AND repository_id
    DB-->>AS: Vec<team_permission>

    AS->>AS: team_role = max(team_permissions)
    AS->>AS: effective = max(individual_role, team_role)

    AS-->>Caller: Role (Reader / Developer / Maintainer / Owner)

6. Navigation dans un dépôt (Web)

sequenceDiagram
    actor User as Utilisateur
    participant Web as gitrust-web
    participant Ext as RepoExtractor
    participant Access as AccessService
    participant Git as gitrust-git
    participant DB as PostgreSQL
    participant FS as Bare repo

    User->>Web: GET /alice/my-repo/tree/main/src
    Web->>Ext: RepoContext::extract(owner=alice, repo=my-repo, ref=main, path=src)

    Ext->>DB: SELECT user WHERE username = 'alice'
    DB-->>Ext: user {id}

    Ext->>DB: SELECT repository WHERE owner_id AND slug
    DB-->>Ext: repository::Model

    Ext->>Access: effective_role(db, visitor_id, repo_id)
    Access-->>Ext: Role::Reader

    Ext-->>Web: RepoContext {repo, role, ref, path}

    Web->>Git: tree_browser::list_tree(repo_path, "main", "src")
    Git->>FS: Resolve ref main -> commit -> tree -> src/
    FS-->>Git: Vec<TreeEntry>

    Git-->>Web: entries (fichiers + dossiers)

    Web->>Git: readme::find_readme(repo_path, "main", "src")
    Git->>FS: Cherche README.md, README, readme.md
    FS-->>Git: Option<BlobContent>
    Git-->>Web: Option<readme_content>

    Web->>Web: Render template tree.html
    Web-->>User: HTML (arbre + README)

7. Inscription utilisateur + création répertoire

sequenceDiagram
    actor User as Nouvel utilisateur
    participant Web as rustwarden-core (auth)
    participant Hook as GitrustHooks
    participant DB as PostgreSQL
    participant FS as Système de fichiers

    User->>Web: POST /register {username, email, password}
    Web->>DB: INSERT INTO users
    DB-->>Web: user {id, username}

    Web->>Hook: on_user_registered(db, user_id, username)
    Hook->>Hook: Valider username (anti path-traversal)
    Hook->>FS: create_dir_all(repos_base_path / username)
    FS-->>Hook: Ok
    Hook-->>Web: Ok

    Web-->>User: 302 Redirect /login

Comprendre le modèle de permissions RBAC de gitrust

Ce que vous allez comprendre

  • Analyser comment les 4 rôles (Reader, Developer, Maintainer, Owner) s’appliquent à un dépôt.
  • Évaluer la différence entre accès direct et accès via équipe, et comment le rôle effectif est calculé.
  • Comparer le modèle gitrust avec les ACL Unix (user/group/other).

Le problème concret

Vous avez un dépôt privé alice/myproject. Alice souhaite que :

  • Bob puisse lire le code (pas pusher).
  • L’équipe backend-devs puisse pusher.
  • Charlie, membre de backend-devs, soit quand même restreint en lecture seule.

Sans un modèle de permissions précis, impossible de savoir quel accès Charlie obtient réellement.

L’analogie

Le modèle gitrust ressemble aux ACL Unix, mais à deux dimensions.

Sur Unix, chaque fichier a trois niveaux : user (propriétaire), group, other. Le niveau effectif est le premier qui correspond, dans cet ordre.

Gitrust fonctionne de manière similaire, avec deux axes :

Axe UnixAxe gitrust
useraccès individuel direct (resource_shares)
groupaccès via équipe (team_repository_access)
otheraccès public (resources.is_public)

La différence clé : gitrust prend le maximum entre l’accès individuel et l’accès équipe, pas le premier correspondant.

Le modèle

Les 4 rôles et leur hiérarchie

graph LR
    R[Reader] --> D[Developer]
    D --> M[Maintainer]
    M --> O[Owner]
RôlePermissions cumulatives
ReaderClone, browse (lecture seule)
DeveloperReader + push sur branches non protégées
MaintainerDeveloper + gestion dépôt (settings, collaborateurs, labels, protection de branches)
OwnerMaintainer + suppression, transfert

La hiérarchie est strictement cumulative : un Maintainer a toutes les permissions d’un Developer, qui a toutes celles d’un Reader.

Sources d’accès

Un utilisateur peut accéder à un dépôt par trois chemins indépendants :

flowchart TD
    User[Utilisateur]

    subgraph "Accès individuel"
        Owner["Owner (resources.owner_id)"]
        Share["resource_shares\n(read / write / admin)"]
        Public["Accès public\n(resources.is_public = true)"]
    end

    subgraph "Accès équipe"
        TeamMember["team_members\n(l'utilisateur est membre)"]
        TeamAccess["team_repository_access\n(permission de l'équipe sur le dépôt)"]
    end

    Effective["Rôle effectif\n= max(individuel, équipe)"]

    User --> Owner
    User --> Share
    User --> Public
    User --> TeamMember
    TeamMember --> TeamAccess

    Owner -->|"owner (full)"| Effective
    Share -->|"read/write/admin"| Effective
    Public -->|"read only"| Effective
    TeamAccess -->|"read/write/admin"| Effective

Calcul du rôle effectif

Le rôle effectif d’un utilisateur sur un dépôt est le maximum de tous ses accès :

rôle_effectif = max(
    owner_directement ? owner : none,
    resource_shares.permission_level,
    is_public ? read : none,
    max(team_repository_access.permission pour chaque équipe dont il est membre)
)

Exemple — réponse au problème concret :

UtilisateurAccès individuelAccès via équipeRôle effectif
Aliceownerowner
Bobread (partagé directement)read
Charlieread (partagé directement)write (via backend-devs)write (max)
Dave (membre backend-devs)writewrite

Charlie obtient write car le max(read, write) = write. Pour restreindre Charlie en lecture seule malgré son appartenance à backend-devs, il faudrait le retirer de l’équipe ou utiliser une protection de branche.

Arbre de décision

flowchart TD
    Q1{L'utilisateur\nest-il owner ?}
    Q1 -->|Oui| Full[Accès owner\n(toutes permissions)]
    Q1 -->|Non| Q2{resource_shares\nexiste pour cet utilisateur ?}
    Q2 -->|Oui| Q3{Dépôt public ?}
    Q2 -->|Non| Q3
    Q3 -->|Oui| Q4[Accès public = read]
    Q3 -->|Non| Q4b[Pas d'accès public]
    Q4 --> Q5{Membre d'une équipe\navec accès au dépôt ?}
    Q4b --> Q5
    Q5 -->|Oui| Calc["Rôle = max(share, public, team)"]
    Q5 -->|Non| Calc2["Rôle = max(share, public)"]
    Calc --> Result[Rôle effectif final]
    Calc2 --> Result

Mapping vers les niveaux ResourceService

ResourceService (rustwarden-core) utilise les niveaux "read", "write", "admin". Le mapping gitrust est :

Niveau ResourceServiceRôle gitrust
"read"Reader
"write"Developer
"admin"Maintainer
owner (ownership directe)Owner

Alternatives et compromis

Pourquoi pas un modèle RBAC pur avec des rôles nommés par dépôt ? Gitea/Forgejo utilisent des rôles nommés (reporter, developer, maintainer). Gitrust utilise des niveaux numériques (read < write < admin) héritables, ce qui simplifie le calcul du maximum mais rend impossible l’attribution de permissions non hiérarchiques (ex : « peut créer des issues mais pas pusher »).

Pourquoi pas des ACL par fichier ? La granularité fichier (comme dans Perforce) crée une complexité de maintenance considérable pour des équipes de 3-20 personnes. Les protections de branches couvrent 90 % des besoins de contrôle granulaire.

Vérifier votre compréhension

  1. Alice est owner de myrepo. Elle partage le dépôt avec Bob en read. L’équipe ops a accès en admin. Bob rejoint ops. Quel est le rôle effectif de Bob ?

  2. Un visiteur anonyme accède à un dépôt dont resources.is_public = true. Peut-il pusher ? Pourquoi ?

Pour aller plus loin

Comprendre le fonctionnement du 2FA TOTP dans gitrust

Ce que vous allez comprendre

  • Décrire le flux cryptographique TOTP (RFC 6238) de la génération du secret à la vérification du code.
  • Analyser les choix de sécurité : chiffrement du secret, codes de secours, challenges temporaires.
  • Évaluer pourquoi le 2FA ne protège pas contre le vol de session et comment gitrust atténue ce risque.

Le problème concret

Alice utilise un mot de passe fort. Mais si sa base de mots de passe est compromise, ou si un attaquant intercepte son mot de passe (phishing), il peut se connecter à son compte gitrust et pousser du code malveillant. Un second facteur garantit que la possession du mot de passe seul ne suffit pas.

L’analogie

TOTP ressemble à un générateur de codes de distributeur bancaire qui change toutes les 30 secondes. La banque et le distributeur partagent un secret commun (la clé de votre carte). À partir de ce secret et de l’heure actuelle, les deux parties calculent indépendamment le même code. Personne n’a besoin de transmettre le code sur le réseau.

La différence avec un SMS OTP : le secret n’est jamais transmis après la configuration initiale. Un attaquant qui intercepte votre réseau ne voit jamais le code à venir.

Le modèle

Génération du secret (RFC 6238)

sequenceDiagram
    participant User as Utilisateur
    participant Server as Serveur gitrust
    participant App as App authenticator

    User->>Server: POST /settings/security/2fa/setup
    Server->>Server: Générer secret aléatoire 20 octets (CSPRNG)
    Server->>Server: Chiffrer AES-256-GCM → stocker dans user_totp
    Server-->>User: { secret_base32, otpauth_uri, qr_code_data_uri }
    User->>App: Scanner le QR code
    App->>App: Stocker le secret localement
    User->>Server: POST /settings/security/2fa/verify { code: "123456" }
    Server->>Server: Déchiffrer secret, calculer TOTP, vérifier fenêtre ±1
    Server-->>User: { backup_codes: [...] } — activé

L’URI OTPAuth encode tout ce qu’une app authenticator a besoin :

otpauth://totp/gitrust:alice?secret=JBSWY3DPEHPK3PXP&issuer=gitrust&algorithm=SHA1&digits=6&period=30

Calcul du code TOTP

TOTP est HOTP (RFC 4226) avec un compteur basé sur le temps :

counter = floor(unix_timestamp / 30)
hmac    = HMAC-SHA1(secret, counter_as_8_bytes_big_endian)
offset  = hmac[19] & 0x0f
code    = (hmac[offset..offset+4] & 0x7fffffff) % 1_000_000

Le code est valide pendant 30 secondes. Gitrust accepte une fenêtre de tolérance ±1 période (90 secondes totales) pour compenser les légères dérives d’horloge entre l’appareil de l’utilisateur et le serveur.

Flux de login avec 2FA activé

sequenceDiagram
    participant C as Client
    participant S as Serveur

    C->>S: POST /api/v1/auth/login { username, password }
    S->>S: verify_credentials() → OK
    S->>S: is_totp_enabled(user_id) → true
    S->>S: create_challenge(user_id) → challenge_token (5 min, max 5 tentatives)
    S-->>C: { requires_2fa: true, challenge_token: "abc..." }
    C->>S: POST /api/v1/auth/2fa/verify { challenge_token, code }
    S->>S: validate_challenge() → non expiré, < 5 tentatives
    S->>S: verify_code() ou verify_backup_code()
    S->>S: consume_challenge()
    S-->>C: { access_token, refresh_token, user }

Le challenge expire après 5 minutes. Maximum 5 tentatives par challenge. Un challenge échoué 5 fois force une nouvelle authentification complète.

Stockage sécurisé du secret

Le secret TOTP n’est jamais stocké en clair en base de données. Il est chiffré avec AES-256-GCM via CryptoService avant insertion dans user_totp.encrypted_secret.

#![allow(unused)]
fn main() {
// À l'activation
let encrypted = CryptoService::encrypt(&secret_base32)?;
// → stocké : "enc:base64(nonce||ciphertext)"

// À la vérification
let secret = CryptoService::decrypt(&user_totp.encrypted_secret)?;
}

La clé de chiffrement est ENCRYPTION_KEY (32 octets, variable d’environnement). Si elle est absente, le TOTP ne peut pas être activé.

Codes de secours

À l’activation du 2FA, 10 codes de secours de 8 caractères alphanumériques sont générés. Ils sont stockés hachés bcrypt dans user_totp.backup_codes_json (JSON chiffré).

Chaque code est à usage unique : une fois utilisé, il est marqué consommé. L’utilisateur peut régénérer un nouveau jeu de 10 codes depuis /settings/security.

Format : XXXXXXXX (ex : A3B7K2M9) — reconnaissable par sa longueur (8 chars vs 6 chiffres pour TOTP).

La détection est automatique dans verify_backup_code : si le code contient des lettres ou fait 8 caractères, c’est un code de secours ; sinon c’est un code TOTP.

Force 2FA par l’administrateur

Un administrateur peut forcer le 2FA sur toute l’instance via AppSettingsService::set_setting("force_2fa_enabled", "true"). Quand ce flag est actif :

  • Les utilisateurs sans 2FA sont redirigés vers la page de configuration à chaque login.
  • Les endpoints API retournent 403 avec {"error":"totp_required"} tant que le 2FA n’est pas configuré.

Alternatives et compromis

Pourquoi TOTP et pas WebAuthn/passkeys ? WebAuthn offre une protection supérieure contre le phishing (le domaine est lié cryptographiquement). Gitrust prévoit de l’ajouter en phase future. TOTP a été choisi pour sa compatibilité universelle (toutes les apps authenticator, hors connexion, pas de dépendance matérielle).

Pourquoi pas SMS OTP ? Les SMS OTP sont vulnérables au SIM swapping et à l’interception SS7. Gitrust ne les implémente pas.

La fenêtre de ±1 période réduit-elle la sécurité ? Marginalement. Elle augmente la fenêtre d’attaque de 30 à 90 secondes, mais un code TOTP reste à 6 chiffres (1/1 000 000 de probabilité de deviner), et les 5 tentatives max par challenge rendent le brute-force infaisable.

Vérifier votre compréhension

  1. Un utilisateur est dans un fuseau horaire décalé de 45 secondes par rapport au serveur. Sa tentative de connexion TOTP échoue systématiquement. Pourquoi, et comment diagnostiquer ?

  2. L’ENCRYPTION_KEY du serveur change (rotation). Que se passe-t-il pour les utilisateurs ayant déjà activé le 2FA ? Quelle procédure de migration faudrait-il mettre en place ?

Pour aller plus loin

Comprendre la hiérarchie des labels à deux niveaux

Ce que vous allez comprendre

  • Analyser pourquoi gitrust utilise deux types de labels indépendants plutôt qu’un seul système plat.
  • Évaluer l’impact du filtrage combinatoire sur la recherche d’issues.
  • Décrire comment l’UI différencie visuellement les deux types.

Le problème concret

Sur une forge Git classique, les labels forment une liste plate par dépôt. Quand vous avez 50 dépôts avec chacun leurs propres labels bug, feature, documentation, vous ne pouvez pas filtrer « toutes les issues de type bug dans tous mes dépôts » sans dupliquer les labels à la main dans chaque dépôt.

De plus, deux équipes qui utilisent le même dépôt veulent des tags libres pour annoter leurs domaines (auth, ci, parser) sans que ces tags exigent une validation préalable par un mainteneur.

L’analogie

Pensez aux tags Flickr à deux niveaux : un album appartient à une catégorie fixe définie par l’organisateur (Voyage, Famille, Travail), et peut recevoir des tags libres ajoutés par quiconque (paris, 2026, coucher-de-soleil). Les deux systèmes coexistent sans se mélanger. La catégorie donne la structure ; les tags libres donnent la granularité.

Le modèle

Deux types, deux scopes

graph TB
    subgraph "Classification labels (scope owner)"
        C1[bug]
        C2[feature]
        C3[documentation]
        C4[security]
    end

    subgraph "Subject labels (scope dépôt)"
        S1[auth]
        S2[parser]
        S3[ci-dagger]
        S4[performance]
    end

    subgraph "Issue #42"
        I1["bug (classification)"]
        I2["auth (subject)"]
    end

    C1 --> I1
    S1 --> I2
CaractéristiqueClassificationSubject
ScopeOwner (tous ses dépôts)Dépôt unique
Qui créeOwner, Maintainer uniquementTout utilisateur connecté
Création inlineNon (CRUD dédié /labels)Oui (champ autocomplete dans l’issue)
Style visuelBadge plein (fond coloré)Badge outline (contour coloré)
CouleurDéfinie par l’auteurDéfaut #6b7280, personnalisable
Colonne DBowner_id non-null, repository_id nullrepository_id non-null, owner_id null

Filtrage combinatoire

L’intérêt principal des classification labels est le filtrage cross-dépôt. Sur le tableau de bord d’Alice (/alice) :

Filtrer par classification: bug
→ Affiche toutes les issues "bug" dans tous les dépôts d'Alice
  (alice/myrepo, alice/api-client, alice/cli-tool)

Un subject label seul ne permet pas ce filtrage car il est scopé à un seul dépôt.

La combinaison des deux offre un filtrage à deux dimensions :

bug (classification) × auth (subject)
→ Toutes les issues bug liées à l'authentification

Assignation sur une issue

La page de détail d’une issue présente deux blocs distincts :

Bloc classification (géré par Owner/Maintainer uniquement) :

  • Checkboxes pour les labels de classification de l’owner
  • Bouton « Save labels »
  • Envoyé dans label_ids[] au POST /{o}/{r}/issues/{num}/labels

Bloc subject tags (géré par l’auteur ou Owner/Maintainer) :

  • Champ texte avec autocomplete HTMX (déclenché après 3 caractères, délai 300ms)
  • Tab sur une suggestion → sélectionne le label existant
  • Tab sur du texte libre → crée le label subject à la soumission (find_or_create_subject)
  • Badges removable (×)
  • Envoyé dans subject_names (comma-separated) au même endpoint

Schéma DB

-- Un seul champ label_type discrimine les deux cas
labels (
    id           UUID PK,
    owner_id     UUID NULL,      -- non-null pour classification
    repository_id UUID NULL,     -- non-null pour subject
    name         VARCHAR(50),
    color        VARCHAR(7),
    description  VARCHAR(255) NULL,
    label_type   VARCHAR(20),    -- 'classification' ou 'subject'
    ...
)

-- Index d'unicité composite
UNIQUE(owner_id, repository_id, name, label_type)

La contrainte d’unicité garantit qu’un owner ne peut pas avoir deux labels classification bug, et qu’un dépôt ne peut pas avoir deux subject tags auth.

Alternatives et compromis

Pourquoi pas un seul système de tags libres ? Les tags libres offrent une flexibilité maximale mais aucune standardisation cross-dépôt. Deux développeurs utilisant bug et Bug créent deux labels distincts.

Pourquoi pas des labels en arbre (hiérarchiques) ? Un arbre de labels (comme les tags JIRA avec sous-catégories) serait plus expressif mais beaucoup plus complexe à afficher dans des badges inline. La combinaison classification × subject couvre 95 % des besoins à coût d’implémentation minimal.

Compromis choisi : la classification donne la rigueur (contrôlée par les mainteneurs, partagée cross-dépôt) ; le subject donne la liberté (créé par n’importe qui, spécifique au dépôt). Chacun fait ce que l’autre ne fait pas.

Vérifier votre compréhension

  1. Un développeur crée un subject tag bug dans son dépôt. L’owner a aussi un classification label bug. Comment l’UI les distingue-t-elle visuellement ? Peuvent-ils coexister sur la même issue ?

  2. Alice crée un classification label security sur son compte. Elle a 15 dépôts. Sans aucune action supplémentaire, dans combien de dépôts ce label est-il disponible ?

Pour aller plus loin

Comprendre la CI Dagger : Easy Mode vs Power Mode

Ce que vous allez comprendre

  • Analyser pourquoi Dagger a été choisi comme moteur CI plutôt qu’une CI YAML classique.
  • Comparer Easy Mode (.gitrust-ci.yml) et Power Mode (.dagger/) et évaluer quand basculer de l’un à l’autre.
  • Décrire les garanties d’isolation et de reproductibilité offertes par Dagger.

Le problème concret

Une CI traditionnelle (GitHub Actions, GitLab CI) exécute des étapes dans un environnement partagé. Deux problèmes fréquents :

  1. Manque de reproductibilité : « ça passe sur ma machine mais pas en CI » parce que les versions d’outils diffèrent.
  2. Couplage fort : un pipeline GitLab CI est indissociable de GitLab. Migrer vers une autre plateforme = réécrire tous les pipelines.

L’analogie

Dagger, c’est comme Docker pour les pipelines CI : chaque étape s’exécute dans un container isolé, avec des dépendances déclarées et des caches portables. Le pipeline peut tourner identiquement sur votre machine, sur le serveur gitrust, ou sur n’importe quel autre runner qui a Docker installé.

L’analogie avec Docker/Kubernetes est directe :

  • Docker résout « ça marche sur ma machine » pour les applications.
  • Dagger résout « ça marche sur ma machine » pour les pipelines.

Le modèle

Architecture gitrust CI

flowchart LR
    Push[git push] --> Hook[receive-pack\nhook]
    Hook --> Detect{Détection\nfichier CI}
    Detect -->|".gitrust-ci.yml"| Easy[Easy Mode\nDagger générique]
    Detect -->|".dagger/"| Power[Power Mode\nModule utilisateur]
    Detect -->|Aucun| None[Pas de CI]
    Easy --> Worker[CI Worker\nSémaphore N]
    Power --> Worker
    Worker --> Dagger[dagger call ...]
    Dagger --> Logs[Logs DB + SSE]
    Logs --> UI[Page pipeline\n/{owner}/{repo}/ci]

La détection est prioritaire : si .dagger/ et .gitrust-ci.yml coexistent, le Power Mode prend la priorité.

Easy Mode — .gitrust-ci.yml

Un fichier YAML simple décrit le pipeline. Le moteur Dagger générique de la plateforme l’interprète.

# .gitrust-ci.yml — exemple pour un projet Rust
build:
  command: cargo build --release
  cache:
    - path: ~/.cargo/registry
      key: cargo-registry

test:
  command: cargo test --workspace
  requires_build: true

checks:
  - name: fmt
    command: cargo fmt --all -- --check
  - name: clippy
    command: cargo clippy --workspace -- -D warnings

Avantages :

  • Accessible à tout utilisateur sans connaissance de Dagger.
  • Standardisé : tous les projets Easy Mode ont la même structure.
  • Zéro configuration côté plateforme.

Limites :

  • Pas de logique conditionnelle complexe.
  • Pas d’accès au Daggerverse (modules Dagger communautaires).
  • Pas de tests unitaires du pipeline lui-même.
  • Pas de composition (appeler un module depuis un autre).

Power Mode — .dagger/

Le dépôt contient un vrai module Dagger (Go, Python, ou TypeScript) :

.dagger/
├── dagger.json          # manifest du module
└── main.go              # fonctions Dagger
// .dagger/main.go — exemple Go
package main

import (
    "dagger/gitrust-ci/internal/dagger"
)

type GitrustCi struct{}

func (m *GitrustCi) Test(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("rust:1.77-slim").
        WithMountedDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"cargo", "test", "--workspace"}).
        Stdout(ctx)
}

Gitrust exécute : dagger call -m .dagger/ test --source=.

Avantages :

  • Logique conditionnelle arbitraire (Go/Python/TS complet).
  • Accès au Daggerverse : réutiliser des modules existants (Helm, SBOM, Trivy…).
  • Tests unitaires du pipeline possible (fonctions Dagger sont du vrai code).
  • Composition : un module peut appeler un autre module.
  • Isolation totale garantie par les containers Dagger.
  • Type-safety : le compilateur Go/TS vérifie les appels.

Limites :

  • Courbe d’apprentissage : il faut écrire du code Go/Python/TS.
  • Chaque équipe maintient son propre module.

Quand basculer de Easy Mode vers Power Mode ?

flowchart TD
    Q1{Pipeline simple ?\nbuild + test + lint}
    Q1 -->|Oui| Easy[Rester en Easy Mode]
    Q1 -->|Non| Q2{Logique conditionnelle ?\nex: déployer seulement sur main}
    Q2 -->|Oui| Power[Passer en Power Mode]
    Q2 -->|Non| Q3{Composition avec\nd'autres modules Dagger ?}
    Q3 -->|Oui| Power
    Q3 -->|Non| Q4{Tests du pipeline\nlui-même ?}
    Q4 -->|Oui| Power
    Q4 -->|Non| Easy

Règle pratique : si votre .gitrust-ci.yml commence à ressembler à un programme avec des if/else imbriqués, c’est le moment de passer en Power Mode.

Tableau comparatif

CritèreEasy Mode (YAML)Power Mode (Dagger natif)
AccessibilitéExcellentCourbe d’apprentissage
PuissanceLimitéeIllimitée
Testabilité du pipelineNulleTotale
Composition (Daggerverse)NonOui
PortabilitéVia le moteur gitrustNative (tout runner Dagger)
Alignement philosophie DaggerPartiel (YAML = anti-pattern)Total
Maintenance plateformeLe moteur évolueChaque repo est autonome

Position philosophique de Dagger

Dagger a été créé explicitement contre le modèle YAML des CI traditionnelles par l’équipe fondatrice de Docker. Le YAML Easy Mode de gitrust est un compromis assumé : on sacrifie la pureté philosophique pour l’accessibilité des 90 % de cas simples. Le YAML Easy Mode ne doit jamais essayer de tout couvrir — dès que la complexité augmente, le Power Mode est la bonne réponse.

Alternatives et compromis

Pourquoi pas GitHub Actions ou GitLab CI directement ? Ces systèmes sont couplés à leur plateforme. Un pipeline GitHub Actions ne tourne pas localement sans outil tiers (act). Dagger tourne identiquement en local, sur le runner gitrust, et sur GitHub Actions si nécessaire.

Pourquoi pas Jenkins/Tekton ? L’objectif de gitrust est l’auto-hébergement simple pour des équipes de 3-20 personnes. Ces outils ajoutent une complexité d’administration disproportionnée.

Vérifier votre compréhension

  1. Un développeur a un .gitrust-ci.yml avec 15 étapes et des skip_if conditionnels simulés via des scripts shell. Doit-il rester en Easy Mode ? Justifiez.

  2. Quelle propriété de Dagger garantit qu’un pipeline Power Mode qui passe en local passera aussi sur le runner gitrust ? Comment cette propriété est-elle implémentée techniquement ?

Pour aller plus loin

Comprendre les décisions UI : SSR, HTMX et DaisyUI

Ce que vous allez comprendre

  • Analyser pourquoi gitrust utilise le rendu côté serveur (SSR) plutôt qu’un framework JavaScript front-end.
  • Évaluer comment HTMX ajoute de l’interactivité sans introduire de build front-end.
  • Décrire les contraintes qui ont orienté le choix de DaisyUI comme bibliothèque de composants.

Le problème concret

Une forge Git moderne doit proposer des interfaces réactives : autocomplétion, chargement partiel, mises à jour en temps réel (logs CI, progression d’import). Les approches habituelles imposent soit un SPA React/Vue avec son pipeline de build, soit un back-end qui sert une API JSON distincte de ses vues.

Pour une équipe de 3 personnes maintenant un outil en Rust, les coûts de ces approches sont disproportionnés :

  • Un SPA impose une deuxième codebase (TypeScript), deux langages de test, deux pipelines CI.
  • Un back-end JSON-only perd les avantages du rendu serveur (SEO, accessibilité, temps de chargement initial).

L’analogie

SSR + HTMX fonctionne comme un serveur HTML des années 2000 avec superpouvoirs : le serveur produit du HTML complet que le navigateur affiche directement (SSR classique), mais HTMX permet au navigateur de remplacer des fragments de la page sans recharger le tout. C’est la différence entre remplacer une page entière de livre et coller un post-it sur un paragraphe.

DaisyUI est la couche de présentation : elle apporte des composants CSS cohérents (boutons, badges, modales) sans JavaScript, uniquement via des classes Tailwind.

Le modèle

Architecture UI de gitrust

flowchart LR
    Browser[Navigateur]
    Axum[Axum SSR\nAskama templates]
    HTMX[htmx.js\n~14 KB gzippé]
    DaisyUI[DaisyUI + Tailwind\nbundle CSS < 300 KB]
    AlpineJS[Alpine.js\n~15 KB gzippé]

    Browser -->|"GET /alice/myrepo"| Axum
    Axum -->|"HTML complet"| Browser
    Browser -->|"hx-get=/fragment"| Axum
    Axum -->|"Fragment HTML"| Browser

    Browser --- HTMX
    Browser --- DaisyUI
    Browser --- AlpineJS

Aucun CDN externe. Tous les assets sont servis localement depuis static/. Le navigateur ne fait jamais de requête vers un domaine tiers.

Rendu côté serveur — Askama

Gitrust utilise Askama, un moteur de templates Rust compilé. Les templates sont vérifiés au moment de la compilation : une clé manquante ou un type incompatible est une erreur de build, pas une erreur à l’exécution.

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/templates.rs
#[derive(Template)]
#[template(path = "repository/show.html")]
pub struct RepositoryShowTemplate {
    pub repo: RepositoryView,
    pub branches: Vec<BranchSummary>,
    pub current_user: Option<UserView>,
    pub csrf_token: String,
}
}

Le handler construit la struct, passe-la au template, et Askama sérialise le HTML :

#![allow(unused)]
fn main() {
async fn repo_show(/* ... */) -> impl IntoResponse {
    let tmpl = RepositoryShowTemplate {
        repo: repo_view,
        branches,
        current_user: user.map(|u| u.into()),
        csrf_token: generate_csrf(&session),
    };
    Html(tmpl.render().unwrap())
}
}

Avantage clé : le compilateur Rust garantit que chaque champ exposé au template existe et a le bon type. Impossible d’afficher un champ None non géré.

HTMX — interactivité sans JavaScript custom

HTMX étend HTML avec des attributs hx-*. Le navigateur envoie des requêtes HTTP et remplace des fragments DOM avec la réponse.

Autocomplétion des subject labels

<!-- templates/issues/partials/subject_tags.html -->
<input
  type="text"
  name="tag_query"
  hx-get="/{{ owner }}/{{ repo }}/labels/search"
  hx-trigger="input changed delay:300ms, keyup[key=='Tab']"
  hx-target="#tag-suggestions"
  hx-swap="innerHTML"
  placeholder="Ajouter un tag..."
/>
<ul id="tag-suggestions"></ul>

Le handler correspondant retourne un fragment HTML, pas du JSON :

#![allow(unused)]
fn main() {
async fn search_subject_labels(
    Path((owner, repo)): Path<(String, String)>,
    Query(params): Query<HashMap<String, String>>,
    State(db): State<DatabaseConnection>,
) -> impl IntoResponse {
    let query = params.get("tag_query").map(|s| s.as_str()).unwrap_or("");
    let labels = LabelService::search_subject_labels(&db, &repo_id, query).await?;
    let tmpl = LabelSuggestionsTemplate { labels };
    Html(tmpl.render().unwrap())
}
}

Logs CI en temps réel (SSE)

Pour les logs de pipeline qui s’affichent en continu, gitrust utilise les Server-Sent Events exposés par Axum, consommés via hx-ext="sse" :

<div
  hx-ext="sse"
  sse-connect="/{{ owner }}/{{ repo }}/ci/{{ pipeline_id }}/stream"
  sse-swap="message"
  hx-swap="beforeend"
  id="log-output"
>
</div>

Le serveur envoie des événements data: <ligne HTML>\n\n. HTMX les append dans #log-output sans aucun JavaScript custom.

DaisyUI — composants CSS, zéro JavaScript

DaisyUI est une bibliothèque de composants construite sur Tailwind CSS. Ses composants (bouton, badge, dropdown, modal) s’activent uniquement via des classes CSS et des attributs HTML natifs (<details>, <input type="checkbox">).

<!-- Badge classification (fond plein) -->
<span class="badge badge-primary">{{ label.name }}</span>

<!-- Badge subject (contour) -->
<span class="badge badge-outline" style="border-color: {{ label.color }}">
  {{ label.name }}
</span>

<!-- Dropdown natif DaisyUI -->
<details class="dropdown">
  <summary class="btn btn-sm">Assignees</summary>
  <ul class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
    {% for collaborator in collaborators %}
    <li><a hx-post="..." hx-vals='{"user_id": "{{ collaborator.id }}"}'>
      {{ collaborator.username }}
    </a></li>
    {% endfor %}
  </ul>
</details>

Aucun composant DaisyUI ne nécessite d’initialisation JavaScript. Les modales utilisent <input type="checkbox" id="modal-toggle"> avec CSS :checked pour afficher/masquer.

Alpine.js — état local léger

Pour les rares cas nécessitant de l’état côté client (toggle d’un panneau, validation inline), gitrust utilise Alpine.js (~15 KB) plutôt que React ou Vue.

<!-- Toggle d'un panneau de settings -->
<div x-data="{ open: false }">
  <button @click="open = !open">Paramètres avancés</button>
  <div x-show="open" x-transition>
    <!-- contenu du panneau -->
  </div>
</div>

Alpine.js est réservé aux comportements purement visuels sans appel réseau. Dès qu’un appel serveur est nécessaire, c’est HTMX qui prend le relais.

Budget bundle et contraintes

AssetTaille gzippée
htmx.min.js~14 KB
alpine.min.js~15 KB
gitrust.css (DaisyUI + Tailwind purged)~280 KB
Total< 310 KB

Le CSS est généré lors du build via npx tailwindcss --input ... --output ... --minify. Le résultat est commité dans static/css/gitrust.css pour éviter une dépendance Node.js en production. La reconstruction est déclenchée uniquement quand les templates changent (gate QA : make css-rebuild).

Quand utiliser chaque outil

flowchart TD
    Q1{Interaction nécessite\nun appel serveur ?}
    Q1 -->|Oui| Q2{Contenu retourné ?}
    Q1 -->|Non| Alpine[Alpine.js\nx-data, x-show, @click]
    Q2 -->|Fragment HTML| HTMX[HTMX\nhx-get / hx-post]
    Q2 -->|Flux continu| SSE[HTMX + SSE\nhx-ext='sse']
    Q2 -->|JSON uniquement| Fetch[fetch() natif\n(rare, éviter)]

Alternatives et compromis

Pourquoi pas React/Vue/Svelte ? Ces frameworks nécessitent un pipeline de build (Vite, Webpack), une API JSON séparée, et des compétences TypeScript en plus de Rust. Pour une forge auto-hébergée ciblant 3-20 développeurs, ce coût n’est pas justifié.

Pourquoi pas Inertia.js (SSR + SPA hybride) ? Inertia demande un adaptateur côté serveur. Il n’existe pas d’adaptateur Rust/Axum stable. De plus, Inertia reste JavaScript-first.

Pourquoi pas Turbo (Hotwire) ? Turbo est excellent mais son modèle Frame/Stream est plus complexe que les attributs HTMX pour des équipes qui démarrent. HTMX est plus proche de HTML pur.

Compromis assumé : HTMX ne peut pas gérer des interactions très complexes (éditeur de code en ligne, diagrammes interactifs). Si gitrust devait ajouter un éditeur Monaco, un composant React isolé serait envisageable — mais ce n’est pas une décision prise aujourd’hui.

Vérifier votre compréhension

  1. Un développeur propose d’ajouter une feature de « preview Markdown en temps réel » dans l’éditeur de description d’issue. Il suggère d’installer React juste pour ce composant. Quelles sont les alternatives possibles avec la stack existante ? Évaluez leurs compromis.

  2. La page /alice/myrepo/issues charge 100 issues et est lente. Un développeur propose de migrer vers une SPA React avec pagination côté client. Quelle solution HTMX permettrait d’obtenir le même résultat sans changer de stack ?

Pour aller plus loin

Comprendre le patron worker async de gitrust

Ce que vous allez comprendre

  • Analyser pourquoi les opérations longues (import, export, envoi de mails) ne peuvent pas s’exécuter dans le cycle requête/réponse HTTP.
  • Décrire comment le patron worker async de gitrust combine mpsc, Semaphore, et SSE pour traiter des tâches longues avec retour de progression.
  • Évaluer les garanties de durabilité offertes par le modèle « état en base + canal en mémoire ».

Le problème concret

L’import d’un dépôt GitHub peut prendre 30 secondes. Une requête HTTP a un timeout. Si le serveur redémarre pendant l’import, la tâche est perdue. Si 50 utilisateurs lancent un import simultanément sans limite, le serveur sature.

Le même problème se pose pour l’envoi de mails, la génération de SBOM, ou toute opération dont la durée dépasse quelques secondes.

L’analogie

Le patron ressemble à une cuisine de restaurant avec un tableau de commandes :

  • Le serveur (handler HTTP) prend la commande, l’inscrit sur le tableau (DB), et dit au client « votre numéro est le 42, suivez l’avancement ici ».
  • La cuisine (worker) lit le tableau en continu, choisit la prochaine commande, la prépare derrière un comptoir limité à N plans de travail (Semaphore).
  • Le tableau lumineux (SSE) indique l’état en temps réel au client sans qu’il ait besoin de redemander.

Si la cuisine ferme brutalement (crash), les commandes « en cours » sur le tableau sont remises en attente au redémarrage — elles ne disparaissent pas.

Le modèle

Vue d’ensemble du patron

flowchart LR
    Handler[Handler HTTP\nPOST /import]
    DB[(Table\nimport_jobs)]
    Channel[mpsc::channel\nJobId]
    Worker[NotifyWorker\ntokio::spawn]
    Semaphore[Semaphore\nmax N slots]
    Executor[execute_job()\nlogique métier]
    SSE[SSE Handler\nGET /import/:id/stream]
    Client[Navigateur\nhx-ext='sse']

    Handler -->|"INSERT status=Pending"| DB
    Handler -->|"tx.send(job_id)"| Channel
    Channel -->|"rx.recv()"| Worker
    Worker -->|"acquire()"| Semaphore
    Semaphore -->|"permit"| Executor
    Executor -->|"UPDATE status=..."| DB
    DB -->|"SELECT"| SSE
    SSE -->|"data: <html>"| Client

La table de persistance

Chaque type de job a sa propre table. Le schéma canonique pour un import :

CREATE TABLE import_jobs (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL REFERENCES users(id),
    repository_id   UUID REFERENCES repositories(id),
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    -- 'pending' | 'running' | 'completed' | 'failed'
    source_url      TEXT NOT NULL,
    error_message   TEXT,
    progress_pct    SMALLINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    started_at      TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ
);

Invariant fondamental : le statut en base est la source de vérité. Si le processus redémarre, un job running sans process vivant est remis à pending au démarrage.

L’état en mémoire : le canal mpsc

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/workers/import_worker.rs

use tokio::sync::mpsc;

#[derive(Clone)]
pub struct ImportJobSender(pub mpsc::Sender<Uuid>);

pub struct ImportWorker {
    db: DatabaseConnection,
    rx: mpsc::Receiver<Uuid>,
    semaphore: Arc<Semaphore>,
}

impl ImportWorker {
    pub fn new(db: DatabaseConnection) -> (Self, ImportJobSender) {
        let (tx, rx) = mpsc::channel(100); // buffer de 100 IDs en attente
        let semaphore = Arc::new(Semaphore::new(4)); // max 4 imports simultanés
        let worker = Self { db, rx, semaphore };
        (worker, ImportJobSender(tx))
    }

    pub async fn run(mut self) {
        while let Some(job_id) = self.rx.recv().await {
            let db = self.db.clone();
            let permit = Arc::clone(&self.semaphore)
                .acquire_owned()
                .await
                .expect("semaphore closed");

            tokio::spawn(async move {
                let _permit = permit; // libéré quand le spawn se termine
                if let Err(e) = execute_import(&db, job_id).await {
                    tracing::error!(?job_id, error = %e, "import job failed");
                    let _ = ImportJobRepository::mark_failed(&db, job_id, &e.to_string()).await;
                }
            });
        }
    }
}
}

Le Semaphore borne la concurrence. Même si 100 IDs arrivent dans le canal, seuls 4 execute_import s’exécutent en parallèle. Les autres attendent leur tour dans le canal.

Le handler HTTP — découpler la réponse de l’exécution

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/routes/import.rs

pub async fn post_import(
    State(db): State<DatabaseConnection>,
    State(sender): State<ImportJobSender>,
    user: AuthUser,
    Form(form): Form<ImportForm>,
) -> Result<impl IntoResponse, AppError> {
    // 1. Valider et persister
    let job = ImportJobRepository::create(&db, user.user_id, &form.source_url).await?;

    // 2. Envoyer l'ID dans le canal (non-bloquant grâce au buffer)
    sender.0.send(job.id).await.map_err(|_| AppError::Internal("worker unavailable".into()))?;

    // 3. Répondre immédiatement avec la page de suivi
    Ok(Redirect::to(&format!("/import/{}/progress", job.id)))
}
}

Le handler retourne avant que l’import ait commencé. Le navigateur est redirigé vers une page de progression.

Le worker — exécution avec transitions d’état

#![allow(unused)]
fn main() {
async fn execute_import(db: &DatabaseConnection, job_id: Uuid) -> anyhow::Result<()> {
    // Transition : pending → running
    ImportJobRepository::mark_running(db, job_id).await?;

    // Récupérer le job
    let job = ImportJobRepository::find(db, job_id).await?
        .ok_or_else(|| anyhow!("job not found: {}", job_id))?;

    // Étape 1/3 — cloner
    ImportJobRepository::update_progress(db, job_id, 10).await?;
    let temp_dir = git_clone(&job.source_url).await?;

    // Étape 2/3 — créer le dépôt gitrust
    ImportJobRepository::update_progress(db, job_id, 50).await?;
    let repo = RepositoryService::create_from_import(db, &job, &temp_dir).await?;

    // Étape 3/3 — finaliser
    ImportJobRepository::update_progress(db, job_id, 90).await?;
    finalize_import(db, job.id, repo.id).await?;

    // Transition : running → completed
    ImportJobRepository::mark_completed(db, job_id, repo.id).await?;
    Ok(())
}
}

Chaque update_progress écrit en base. Si le process redémarre entre deux étapes, la progression est connue — mais le job doit être relancé depuis zéro (pas de reprise partielle dans ce modèle).

SSE — retour de progression vers le navigateur

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/routes/import.rs

pub async fn stream_import_progress(
    Path(job_id): Path<Uuid>,
    State(db): State<DatabaseConnection>,
    user: AuthUser,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = async_stream::stream! {
        loop {
            let job = ImportJobRepository::find(&db, job_id).await
                .unwrap_or(None);

            match job {
                None => {
                    yield Ok(Event::default().data("<p>Job introuvable.</p>"));
                    break;
                }
                Some(ref j) if j.status == "completed" => {
                    yield Ok(Event::default()
                        .data(format!("<p>Import terminé — <a href='/{}/{}'>Voir le dépôt</a></p>",
                            j.owner_login, j.repo_slug)));
                    break;
                }
                Some(ref j) if j.status == "failed" => {
                    yield Ok(Event::default()
                        .data(format!("<p class='text-error'>Erreur : {}</p>",
                            j.error_message.as_deref().unwrap_or("inconnue"))));
                    break;
                }
                Some(ref j) => {
                    yield Ok(Event::default()
                        .data(format!("<progress class='progress' value='{}' max='100'></progress>",
                            j.progress_pct)));
                    tokio::time::sleep(Duration::from_millis(500)).await;
                }
            }
        }
    };

    Sse::new(stream).keep_alive(KeepAlive::default())
}
}

Le template HTMX consomme ce flux :

<!-- templates/import/progress.html -->
<div
  hx-ext="sse"
  sse-connect="/import/{{ job_id }}/stream"
  sse-swap="message"
  hx-swap="innerHTML"
  id="progress-container"
>
  <progress class="progress" value="0" max="100"></progress>
</div>

Récupération au redémarrage

Au démarrage de l’application, gitrust remet en pending les jobs bloqués en running :

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/main.rs (dans la fonction de démarrage)

async fn recover_stuck_jobs(db: &DatabaseConnection, sender: &ImportJobSender) {
    let stuck = ImportJobRepository::find_by_status(db, "running").await
        .unwrap_or_default();

    for job in stuck {
        tracing::warn!(?job.id, "recovering stuck import job");
        let _ = ImportJobRepository::reset_to_pending(db, job.id).await;
        let _ = sender.0.send(job.id).await;
    }
}
}

Cette fonction s’exécute une fois au démarrage, avant que le serveur HTTP accepte des connexions.

Enregistrement dans main.rs

#![allow(unused)]
fn main() {
// crates/gitrust-web/src/main.rs

let (import_worker, import_sender) = ImportWorker::new(db.clone());

// Démarrer le worker dans un task dédié
tokio::spawn(async move { import_worker.run().await });

// Récupérer les jobs bloqués
recover_stuck_jobs(&db, &import_sender).await;

// Injecter le sender dans l'état Axum
let app = Router::new()
    // ... routes ...
    .with_state(AppState { db, import_sender, /* ... */ });
}

Décisions et compromis

Durabilité : base de données vs queue dédiée

ApprocheDurabilitéComplexité opérationnelle
Table SQL (gitrust)Au niveau PostgreSQLNulle (déjà présent)
Redis + Bull/SidekiqHaute (AOF/RDB)Redis à opérer
RabbitMQ / NATSTrès hauteBroker supplémentaire
In-memory seulementNulle (perdu au restart)Nulle

Gitrust choisit la table SQL : la durabilité est suffisante (perte maximale = 1 transaction), et PostgreSQL est déjà requis. Un broker externe serait disproportionné pour une forge auto-hébergée à petite échelle.

Concurrence : Semaphore vs pool de workers

Le Semaphore(N) est plus simple qu’un pool de threads ou de tâches fixes. Il permet au runtime Tokio de gérer l’ordonnancement. L’inconvénient : si un job prend 30 secondes et que N=4, les 5e et 6e jobs attendent dans le canal, pas dans un pool observable.

Pour des jobs très longs (> 5 minutes), un pool de workers avec heartbeat serait plus robuste — mais ce cas ne se présente pas dans gitrust aujourd’hui.

SSE vs WebSocket vs polling

MéthodeCas d’usage
Polling HTMX (hx-trigger="every 2s")Statut simple, peu de mises à jour
SSE (gitrust)Flux unidirectionnel continu (logs, progression)
WebSocketBidirectionnel (chat, collaboration temps réel)

SSE est suffisant pour tous les workers actuels de gitrust (import, CI logs). WebSocket ajouterait une complexité d’état bidirectionnel non nécessaire.

Vérifier votre compréhension

  1. Le serveur gitrust redémarre pendant l’import d’un dépôt. Le job était en statut running avec progress_pct = 60. Que se passe-t-il exactement au redémarrage ? L’utilisateur verra-t-il la progression reprendre à 60 % ou à 0 % ?

  2. Le Semaphore est initialisé à 4 slots. 10 utilisateurs lancent un import simultanément. Combien de tokio::spawn s’exécutent en parallèle ? Où attendent les 6 jobs restants ? Que se passe-t-il si le serveur redémarre à cet instant ?

Pour aller plus loin

Conception de ssh-guard

Ce que vous allez comprendre

  • Identifier les défauts de sécurité SSH de gitrust avant l’introduction de ssh-guard et le périmètre exact que cette nouvelle crate couvre.
  • Analyser le design async/event-driven choisi : pourquoi un listener wrapper, pourquoi des détecteurs push-based, pourquoi le store hybride.
  • Évaluer les compromis (fail-open, dry-run, idempotence, priorité ACL) et savoir comment les ajuster.

Public : contributeurs au code de gitrust-ssh et gitrust-ssh-guard. Pour une recette d’exploitation, voir Configurer ssh-guard.


1. Le problème concret

Avant ssh-guard, le serveur SSH russh intégré à gitrust avait quatre angles morts opérationnels :

  1. IP cliente faussée derrière nginx stream. Le déploiement type (« nginx en frontal sur :22, proxy_protocol on; vers 127.0.0.1:2222 ») rendait peer_addr() = 127.0.0.1 côté gitrust. Conséquence : tous les rate-limits par IP étaient inutilisables, les logs n’identifiaient personne, fail2ban était impossible à brancher.
  2. Aucune détection de motifs d’attaque. La couche russh rejette une auth invalide, mais ne corrèle rien : 1 000 tentatives en 30 secondes ressemblent à 1 000 lignes de log indépendantes.
  3. Format de logs instable. Les messages d’erreur de russh évoluent entre versions, ce qui rendait les filtres fail2ban fragiles (cassés à chaque mise à jour).
  4. Pas de levier admin. Aucune ACL CIDR (allowlist d’un VPN, denylist d’un AS abusif) ne pouvait être appliquée à l’extérieur du firewall système, donc inopérante pour la majorité des admins.

ssh-guard adresse exactement ces quatre points — pas plus. Toute la couche TLS, OAuth, JWT, RBAC reste hors de son périmètre.


2. L’analogie

Imaginez une porte d’immeuble (le TcpListener) et derrière elle un agent d’accueil (russh) qui contrôle les badges. ssh-guard est le sas qui s’intercale entre les deux. Il fait trois choses, dans cet ordre :

  1. Lire la pièce d’identité correctement, qu’elle soit présentée directement ou tendue par un coursier (le proxy) — c’est la résolution d’IP réelle.
  2. Consulter une liste blanche/noire affichée à l’intérieur du sas — c’est l’ACL admin.
  3. Compter les visiteurs récents par identité et activer une alarme si quelqu’un sonne 5 fois en 5 minutes — c’est la détection.

Le sas n’inspecte pas les badges (c’est le rôle de russh). Il décide juste qui a le droit d’arriver jusqu’à l’agent d’accueil.


3. Le modèle

Vie d’une connexion

flowchart TD
    A[Connexion TCP entrante] --> B[peer_addr du socket]
    B --> C{proxy_config présent ?}
    C -- Non, profil direct --> E[ip_réelle = peer_addr]
    C -- Oui, profil proxifié --> D{peer_addr dans<br/>trusted_proxies ?}
    D -- Non --> X1[Drop : UntrustedProxy]
    D -- Oui --> P[Parsing PROXY v1/v2]
    P -- OK --> E2[ip_réelle = ip_cliente du header]
    P -- Échec strict --> X2[Drop : ProxyHeaderInvalid/Missing]
    P -- Échec souple --> E3[fallback peer_addr + warn]

    E --> F[BanManager.effective_status]
    E2 --> F
    E3 --> F

    F --> G{ACL Deny ?}
    G -- Oui --> X3[Drop : Banned]
    G -- Non --> H{Ban auto actif ?}
    H -- Oui --> X3
    H -- Non --> I{ACL Allow ?}
    I -- Oui --> J[Bypass flood,<br/>Accept]
    I -- Non --> K{Flood actif ?}
    K -- Oui : check_key OK --> L[Accept]
    K -- Oui : refusé --> X4[Drop : FloodLimit]
    K -- Non --> L

    L --> M[ClientIdentity créé,<br/>handshake russh]
    M --> N[Tentatives auth]
    N --> O[AuthTracker.record_auth_attempt]
    O --> O1[Persiste dans store]
    O --> O2[Émet event JSON sur sink]
    O --> O3[Notifie chaque détecteur]
    O3 --> P1[BruteForce / UserEnum / KeyScan]
    P1 --> Q{Seuil atteint ?}
    Q -- Oui --> R[BanManager.auto_ban]
    R --> S[Insert ban + event IpBanned]
    Q -- Non --> Z[Fin]

Trois plans qui interagissent

PlanComposantsCaractéristique principale
Synchrone, hot pathSecureListener.accept, BanManager.effective_status, ConnectionFloodDetector.checkLock-free (DashMap, GCRA), aucun appel DB tant que le store est memory ou hybrid
Asynchrone, pushAuthTracker.record_auth_attempt → détecteurs → BanManager.auto_banTâches Tokio courtes, jamais bloquantes
PersistantHybridStore, PostgresStoreWrite-through périodique, rehydrate au boot

L’invariant clé : le hot path ne touche jamais directement la DB. Si PostgreSQL tombe, le listener continue à servir avec sa vue mémoire des bans/ACL, et la prochaine écriture admin sera mise en attente (HybridStore) ou échouera proprement (Postgres direct).


4. Les décisions de conception

4.1 Listener wrapper plutôt que middleware russh

ssh-guard intercale SecureListener entre TcpListener et russh. Trois raisons :

  • Beaucoup de drops (flood, ban) doivent se faire avant d’allouer un handler russh (RAM, threads). Un middleware russh coûterait des allocations inutiles à chaque scan de port.
  • L’extraction d’IP via PROXY protocol nécessite de peeker les premiers octets du TcpStream avant que russh ne le voie — un middleware de niveau session ne peut pas faire ça.
  • Découplage strict : ssh-guard ne dépend pas de russh (testable en standalone, réutilisable si on remplace la lib SSH).

4.2 Push-based detectors

Chaque détecteur expose async fn on_event(event: &GuardEvent). L’AuthTracker les appelle après chaque événement. Avantages :

  • Pas de scheduler de fond à entretenir. Le détecteur se réveille uniquement quand il y a quelque chose à analyser.
  • Idempotence facile : un second appel après franchissement du seuil retombe sur un auto_ban no-op (déjà banni).
  • Test déterministe : on injecte des événements synthétiques et on observe le sink — pas de tokio::time::sleep à attendre.

Coût : chaque détecteur fait une lecture du store pour calculer son agrégat. C’est acceptable car on ne déclenche qu’aux échecs d’auth (rare) et que l’agrégat est borné par la fenêtre.

4.3 Priorité ACL : deny > auto_ban > allow > default

L’ordre est exécuté tel quel dans BanManager.effective_status. Conséquences :

  • deny admin > tout : un opérateur peut brûler une IP même si elle a été allowlistée par erreur ailleurs.
  • auto_ban > allow : un ban auto déjà posé ne saute pas si on allowliste après coup. Pour le lever, il faut explicitement unban. C’est un choix conservateur : un allow ne doit pas effacer la trace d’un comportement passé.
  • allow > default : une IP allowlistée bypasse les détecteurs (mais ses événements restent loggés pour audit).

L’invariant auto_ban no-op si IP allowlistée est un garde-fou de défense en profondeur : si un détecteur oublie un jour de consulter l’ACL avant d’appeler auto_ban, le BanManager refuse quand même.

4.4 Fail-open sur erreur de store

Quand BanManager.effective_status reçoit Err(StoreError), le listener log warn et laisse passer. C’est un compromis :

  • Pour : une panne PostgreSQL n’arrête pas le service Git en lecture/push (les opérateurs ne sont pas réveillés à 3h du matin pour une indisponibilité partielle).
  • Contre : pendant la fenêtre de panne, un attaquant pourrait passer (mais l’auth russh reste opérationnelle, donc l’attaquant ne devient utilisateur que s’il connaît une clé valide).

Le compromis a été pris sciemment : il est documenté, et on peut le renverser en remplaçant Ok(...) par return Ok(self.drop_reason(ip, DropReason::Banned).await) dans la branche Err si une instance préfère fail-closed.

4.5 Dry-run avec émission d’événements

SSH_GUARD_DRY_RUN=true désactive la persistance des bans mais continue à émettre les événements IpBanned. C’est le mode parfait pour :

  • valider un nouveau seuil en prod sans risquer de bannir un utilisateur ;
  • alimenter fail2ban (qui ferait le ban réel via UFW/iptables) tout en laissant ssh-guard observer ;
  • répliquer le comportement d’un détecteur dans un environnement de pré-prod.

4.6 Stockage hybride par défaut

Trois backends, mais le défaut hybrid est presque toujours le bon :

  • les lectures hot path passent par DashMap (latence µs) ;
  • chaque écriture est synchrone côté mémoire + asynchrone vers PostgreSQL (write-through) ;
  • au boot, rehydrate repeuple la mémoire depuis la DB en une seule transaction. Si la DB est vide ou inaccessible, on démarre avec une mémoire vide et un warn (fail-open au boot aussi).

Le mode memory est réservé aux tests et au profil private (réseau interne, on accepte de perdre l’historique au restart). Le mode postgres direct existe pour les rares cas où une instance multi-noeuds n’a pas de cache RAM cohérent et veut tout passer par la DB.

4.7 Schéma JSON stable

#[serde(tag = "event", rename_all = "snake_case")] produit {"event":"<nom>","ts":"...","ip":"...",...}. La règle inviolable :

  • ajouter un nouveau variant ou un nouveau champ optionnel : OK ;
  • renommer un variant ou un champ existant : breaking change → nouveau nom + period de transition.

C’est ce qui rend les filtres fail2ban et les requêtes Loki stables dans le temps. Un test (event_name_matches_serde_tag) garantit que le nom Rust et le tag JSON ne divergent pas.


5. Ce que ssh-guard ne fait pas (volontairement)

Hors périmètrePourquoiCouvert par
Inspection des payloads SSHNiveau TCP uniquement, ssh-guard n’a pas la clé d’hôterussh lui-même
Authentification utilisateurDomaine de gitrust-core::SshKeyServicegitrust-ssh
Limites de débit applicatives (push trop gros)Concerne le pack-protocol, pas la connexiongitrust-git
Distribution multi-noeudsPas d’algorithme de gossip, pas de RaftÀ traiter par le store backend (Postgres partagé)
GeoIP / blocage par paysDécision politique, pas techniqueReverse-proxy ou firewall en amont

6. Implications pour les contributeurs

Quand ajouter un détecteur

Si un nouveau motif d’attaque émerge (ex. : « attaques par chronométrie sur les fingerprints »), suivre ce squelette :

  1. Ajouter un fichier src/detector/<nom>.rs calqué sur brute_force.rs.
  2. Implémenter pub async fn on_event(&self, event: &GuardEvent) qui filtre les événements pertinents et lit l’agrégat via GuardStore.
  3. Ajouter une variante BanReason::<Nom> et un GuardEvent::<Nom>Detected { ... } dans events.rs. Mettre à jour event_name et le test event_name_matches_serde_tag.
  4. Câbler le détecteur dans runtime.rs::build (instanciation conditionnelle si threshold.is_disabled()).
  5. Câbler dans tracker.rs (ajout d’un Option<Arc<NouveauDetector>> + appel dans record_auth_attempt).
  6. Documenter le nouveau seuil dans config.rs (env var + preset par profil) et dans la page admin de référence.

Quand modifier le format d’événement

Ne pas. Si un champ doit changer de type ou de sens, créer un nouveau variant et garder l’ancien jusqu’à période de transition explicite. Le test de stabilité du tag est là pour casser la PR si quelqu’un le tente sans s’en rendre compte.

Quand changer une priorité ACL

Pratiquement jamais. La priorité actuelle (deny > auto_ban > allow > default) est un consensus défensif. Si une PR la modifie, exiger un dossier d’opportunité avec attaques traitées et nouveaux compromis acceptés.


7. Vérifier votre compréhension

  1. Une connexion arrive avec un en-tête PROXY v2 valide depuis 192.168.10.5. La config est SSH_GUARD_PROFILE=nginx (donc trusted_proxies = 127.0.0.1/32, ::1/128). Quel est l’AcceptOutcome ? Pourquoi ?
  2. Une IP est dans la denylist admin et vient d’être bannie automatiquement pour brute force. Quel EffectiveStatus retourne effective_status ? L’ordre des deux lookups dans le code change-t-il quelque chose ?
  3. PostgreSQL tombe pendant 30 minutes. Une attaque brute-force démarre depuis une IP nouvelle. Que se passe-t-il : (a) au moment de l’accept, (b) au moment où le détecteur lit count_auth_failures ?

8. Pour aller plus loin

Bibliothèque de templates gitrust

Bienvenue dans l’atelier gitrust. Chaque fichier ici est standalone et copiable tel quel — un fichier, un cas d’usage. Les commentaires internes suffisent à l’adapter. Pour les explications pédagogiques, référez-vous aux manuels qui pointent vers ces templates.

Convention : copiez le fichier qui correspond à votre cas, adaptez les valeurs marquées CHANGE_ME, puis supprimez les commentaires si vous le souhaitez. Ne modifiez pas les fichiers dans ce dépôt — travaillez toujours sur une copie locale.


Fichiers d’environnement (env/)

FichierCas d’usage
env/dev-local.envInstance de développement local sur localhost:4000 — PostgreSQL local, pas de TLS, debug activé
env/production-mono-machine.envProduction mono-machine derrière reverse-proxy — PostgreSQL, SMTP, Redis sessions, TLS délégué
env/production-ci-heavy.envProduction avec CI runners distants intensifs — tuning pool de connexions, rate limiting ajusté
env/tests-e2e.envEnvironnement E2E Playwright — base éphémère, seed de données, SMTP mock
env/ssh-guard-direct.envBloc SSH_GUARD_* pour gitrust exposé Internet sans reverse-proxy SSH (profil direct)
env/ssh-guard-nginx.envBloc SSH_GUARD_* pour gitrust derrière nginx stream avec PROXY protocol v2 (profil nginx)

Stacks Docker (docker/)

FichierCas d’usage
docker/docker-compose.minimal.ymlStack minimale : gitrust + PostgreSQL uniquement — idéale pour l’évaluation et les tutoriels
docker/docker-compose.production.ymlStack production : gitrust + PostgreSQL + Redis sessions + relay SMTP Postfix
docker/docker-compose.with-ci-runner.ymlStack production + CI runner Dagger en sidecar — pour instances avec builds fréquents
docker/podman-quadlet.containerAlternative Podman Quadlet — déploiement rootless systemd-native sans Docker daemon

Reverse-proxy (reverse-proxy/)

FichierCas d’usage
reverse-proxy/nginx-tls.confNginx avec TLS Let’s Encrypt — virtual host gitrust, headers de sécurité, proxy WebSocket SSE
reverse-proxy/caddy-CaddyfileCaddyfile — TLS automatique, configuration minimale, idéal pour les petites instances
reverse-proxy/traefik-dynamic.ymlTraefik config dynamique — intégration labels Docker, middlewares rate-limit et auth

Systemd (systemd/)

FichierCas d’usage
systemd/gitrust.serviceService systemd avec hardening AppArmor/seccomp — pour déploiement binaire natif sans Docker
systemd/gitrust-backup.timerTimer systemd de sauvegarde quotidienne — déclenche backup.sh à 2h00 avec journalisation

Configurations CI (.gitrust-ci.yml) (ci/)

FichierCas d’usage
ci/rust.gitrust-ci.ymlPipeline CI pour projet Rust — fmt, clippy, test, audit de dépendances, SBOM
ci/go.gitrust-ci.ymlPipeline CI pour projet Go — vet, staticcheck, test, build multi-arch
ci/python.gitrust-ci.ymlPipeline CI pour projet Python — ruff, mypy, pytest, packaging wheel
ci/node.gitrust-ci.ymlPipeline CI pour projet Node.js — ESLint, Vitest, build, audit npm
ci/dagger-power-mode/dagger.jsonSquelette Dagger Power Mode — module Go minimal à étendre pour pipelines complexes
ci/dagger-power-mode/main.goModule Dagger Go — fonctions build/test/publish prêtes à adapter

Sécurité (security/)

FichierCas d’usage
security/fail2ban-gitrust-ssh-guard.confJail + filtre fail2ban consommant le flux JSON stable de gitrust-ssh-guard (durcissement SSH)

Scripts de sauvegarde et restauration (backup/)

FichierCas d’usage
backup/backup.shSauvegarde complète — pg_dump + rsync des dépôts bare + copie des clés SSH host
backup/restore.shRestauration complète depuis une archive de sauvegarde — avec vérification d’intégrité

Clients API d’exemple (api-clients/)

Ces exemples illustrent l’authentification par Personal Access Token (PAT) et les opérations CRUD de base sur l’API REST gitrust v1.

FichierCas d’usage
api-clients/python-create-repo.pyClient Python — créer un dépôt via l’API v1 avec httpx et PAT
api-clients/rust-list-repos.rsClient Rust — lister les dépôts avec pagination via reqwest et PAT
api-clients/curl-pat-auth.shShell curl — exemples d’authentification PAT, GET/POST/DELETE sur les endpoints principaux

Scripts de migration (migration/)

FichierCas d’usage
migration/from-gitea.shImport en masse depuis Gitea — dépôts, membres, issues via le worker d’import gitrust
migration/from-forgejo.shImport en masse depuis Forgejo — même mécanique, adaptée à l’API Forgejo

Comment utiliser ces templates depuis les manuels

Les manuels narratifs pointent vers ces fichiers depuis leurs sections how-to. Par exemple :

  • administration_manual/how-to/configurer-smtp.md« Copiez template/env/production-mono-machine.env puis… »
  • administration_manual/tutorials/01-installation-docker.md« La stack s’appuie sur template/docker/docker-compose.minimal.yml »
  • developer_manual/how-to/implementer-endpoint-api.md« Testez avec template/api-clients/curl-pat-auth.sh »

Les templates ne contiennent pas d’explication pédagogique — tout le contexte se trouve dans les manuels.