bastien-mrq/gitfed / docs / ARCHITECTURE.md
ARCHITECTURE.md
Code Aperçu
**Langues :** Français · [English](ARCHITECTURE.en.md)
# Architecture
Comment le code de gitfed est organisé, et pourquoi. Pour le modèle
d'identité/fédération lui-même, voir [`HOW_IT_WORKS.md`](HOW_IT_WORKS.md) ;
pour la marche à suivre de déploiement, voir
[`deploy/k8s/README.md`](../deploy/k8s/README.md).
---
## 1. Vue d'ensemble : quatre binaires, un cœur partagé
```
cmd/
gitfed-server/ démon : serveur SSH + endpoint /.well-known
gitfed-web/ interface web (navigation publique + self-service + admin)
gitfed-tui/ outil d'administration en terminal
gitfed-renew-cert/ outil client de renouvellement de certificat
```
`gitfed-server` est le seul processus qui touche directement la base de
données et les dépôts git. `gitfed-web` et `gitfed-tui` ne réimplémentent
rien : ils pilotent le même cœur métier via l'interface `admin.Ops`
(§3) — soit en parlant à un `gitfed-server` déjà lancé via un socket,
soit, s'il n'est pas lancé, en ouvrant directement la base.
`gitfed-renew-cert` est complètement à part : un petit client autonome,
pensé pour tourner sur la machine d'un·e utilisateur·ice (cron/systemd
timer), qui ne parle qu'en SSH à une instance distante.
## 2. Les paquets `internal/`
| Paquet | Rôle |
|---|---|
| `ca` | Génère la clé d'autorité de certification de l'instance et signe les certificats utilisateurs. |
| `ssh` | Le serveur SSH lui-même : authentification (clé nue ou certificat), vérification de révocation, exécution de `git-upload-pack`/`git-receive-pack`. |
| `federation` | Découverte `.well-known`, garde-fous anti-SSRF (`wellknown.go`), magasin de confiance et sa politique (`resolver.go`). |
| `acl` | Les règles d'autorisation par dépôt (public/privé, rôles read/write/admin). |
| `gitexec` | Tout ce qui invoque `git` en sous-processus : init, lecture d'arbre/fichier à HEAD, résolution de branche par défaut, historique/diffs de commits, et la plomberie branches/fusion derrière les merge requests (§8). |
| `store` | Persistance bbolt : utilisateurs, dépôts, ACL, sessions, magasin de confiance, journal d'audit, révocations, dépôts épinglés, notifications fédérées, merge requests et leurs commentaires. |
| `admin` | L'interface `Ops` : chaque opération d'administration ou de self-service, dans un seul endroit, implémentée une fois. |
| `adminrpc` | Protocole JSON-sur-socket-Unix qui expose `admin.Ops` à un client distant (voir §3). |
| `opsconnect` | Choisit automatiquement le mode socket-vivant ou base-directe selon qu'un `gitfed-server` tourne déjà. |
| `i18n` | Dictionnaires de traduction (français/anglais) de l'interface web et fonction de résolution de langue. |
| `config` | Chargement du fichier de configuration de l'instance (`gitfed.json`). |
| `version` | Numéro de version, injecté à la compilation. |
## 3. Pourquoi un socket RPC d'administration
bbolt (la base embarquée utilisée par `store`) impose un **verrou exclusif
mono-écrivain** sur son fichier : un seul processus peut l'ouvrir en
écriture à la fois. Si `gitfed-web` ouvrait la base directement pendant que
`gitfed-server` tourne déjà, l'un des deux échouerait à démarrer, ou pire,
les deux se disputeraient le verrou.
La solution : `gitfed-server` expose son `admin.Ops` sur un socket Unix
local (`data/admin.sock`), via un petit protocole JSON maison
(`internal/adminrpc`). `gitfed-web` et `gitfed-tui` s'y connectent comme
client au lieu de rouvrir la base — c'est `internal/opsconnect` qui décide,
au démarrage, s'il faut passer par ce socket (mode « live ») ou ouvrir la
base directement (mode « hors-ligne », utilisé par `gitfed-tui` quand aucun
serveur ne tourne, par exemple pour créer le tout premier compte).
Un piège corrigé au passage : les erreurs qui traversent ce socket perdent
leur identité si on n'y prend pas garde — `errors.New("not found")` côté
serveur redevient une simple chaîne de caractères côté client, qui ne peut
plus être comparée avec `== store.ErrNotFound`. `adminrpc.Client` reconstruit
donc explicitement les erreurs sentinelles connues par correspondance de
message plutôt que de les laisser passer telles quelles.
## 4. L'interface web (`cmd/gitfed-web`)
Chaque page est un handler HTTP qui : vérifie les droits d'accès via
`admin.Ops`, exécute un petit template Go pour produire le corps de la
page, puis appelle `server.render()` qui enveloppe ce corps dans le
"shell" commun (barre de navigation, pied de page, feuille de style,
sprite d'icônes SVG) défini dans `render.go`.
Points notables :
- **Pas de framework JS.** Tout le rendu est fait côté serveur avec
`html/template` ; le peu de JavaScript (menu profil, bascule d'onglets,
copie dans le presse-papiers) est un unique bloc inline, dont le hash
SHA-256 est épinglé dans la politique CSP (voir §6).
- **i18n par fonction de template.** Chaque page passe sa langue courante
(résolue par cookie, puis `Accept-Language`, voir `lang.go`) dans les
données du template ; les chaînes de caractères passent par
`{{t .Lang "clé"}}`, qui va chercher la traduction dans `internal/i18n`.
- **Authentification web indépendante de git.** Le mot de passe web
(haché bcrypt, coût 12) n'ouvre qu'une session opaque côté web — il
n'intervient jamais dans l'authentification SSH/git, qui repose
uniquement sur les clés et certificats (voir `HOW_IT_WORKS.md`).
## 5. Topologie de déploiement
```
┌─────────────────────────── Pod (1 réplique) ───────────────────────────┐
│ │
│ conteneur "server" conteneur "web" │
│ gitfed-server gitfed-web │
│ ├─ SSH :2222 (hostPort) ├─ HTTP :8088 │
│ ├─ well-known :8443 └─ socket data/admin.sock (client) │
│ └─ socket data/admin.sock (serveur) │
│ │
│ volume "data" (PVC) monté dans les deux conteneurs : │
│ base bbolt, dépôts bare, clé de CA, clé d'hôte SSH, socket admin │
└──────────────────────────────────────────────────────────────────────┘
```
- **Une seule réplique, toujours.** bbolt (verrou mono-écrivain), le socket
admin et le port SSH exposé en `hostPort` sont tous, par nature, des
ressources à instance unique — `deployment.yaml` fixe `replicas: 1` et
`strategy: Recreate` plutôt qu'un rolling update.
- **Deux conteneurs, un seul pod.** Ils partagent le même volume de
données ; le conteneur `web` attend, via une boucle shell, que
`data/admin.sock` existe avant de démarrer.
- **SSH sur `hostPort: 2222`**, pas 22 — le port 22 du nœud est déjà pris
par le sshd de la VPS elle-même.
- **`gitfed-web` seul est exposé par une Ingress** (Traefik + cert-manager
déjà en place sur le cluster pour un autre projet) ; c'est sûr de
l'exposer publiquement car l'authentification web est réelle (mot de
passe + session), pas un simple accès admin non protégé.
Le détail pas-à-pas (DNS, premier déploiement, amorçage du compte admin)
est dans [`deploy/k8s/README.md`](../deploy/k8s/README.md).
## 6. Ce qui protège l'instance en production
Voir [`docs/security/AUDIT.md`](security/AUDIT.md) pour le détail complet,
mais en résumé, ce qui est en place structurellement :
- **Écriture uniquement par SSH** — `git-receive-pack` n'est jamais exposé
autrement, authentifié par clé ou certificat fédéré uniquement (voir §7
pour la seule exception HTTP, strictement en lecture).
- **Anti-SSRF au moment de la connexion**, pas seulement à la validation du
nom de domaine (`internal/federation/wellknown.go`) — protège aussi
contre le DNS-rebinding.
- **CSP stricte** avec script inline épinglé par hash, en-têtes de
durcissement standards, vérification d'origine sur les requêtes de
mutation (défense CSRF en profondeur, en complément de `SameSite=Lax`).
- **Révocation de certificat immédiate**, consultée à chaque authentification
SSH (`internal/ssh/server.go`).
- **Limitation de débit** sur le login web, par compte et par IP.
## 7. Clone HTTPS anonyme pour les dépôts publics
Depuis la version 0.8.0, `cmd/gitfed-web/handlers_git_http.go` implémente
une petite partie du protocole « smart HTTP » de git
(`git-upload-pack` uniquement) pour que
`git clone https://<domaine>/<owner>/<dépôt>.git` fonctionne sans compte ni
clé SSH — voir [`HOW_IT_WORKS.md`](HOW_IT_WORKS.md) §8 pour le détail
utilisateur. Trois garanties structurelles :
- **Lecture seule, point final.** Il n'y a tout simplement aucune route
`git-receive-pack` en HTTP — pas de vérification à contourner, le code
pour écrire n'existe pas sur ce chemin.
- **`repo.Public` revérifié à chaque requête**, jamais mis en cache — un
dépôt qui redevient privé cesse instantanément d'être clonable en HTTP,
et un dépôt privé ou inexistant renvoie exactement le même 404, comme
partout ailleurs dans l'app (`canView`).
- **Route au niveau racine** (`/{owner}/{repo}.git/...`) plutôt que sous un
préfixe dédié, pour que l'URL de clone ressemble à ce que tout le monde
attend. Le mux de Go priorise toujours les routes littérales plus
spécifiques, donc ça ne peut pas masquer une autre page.
Cette route POST est explicitement exemptée de la vérification
same-origin CSRF (`sameOriginPOST`) : un client `git` n'envoie jamais
d'en-tête `Origin`/`Referer`. Ce n'est pas une brèche CSRF — cette requête
ne porte aucun cookie de session et ne modifie aucun état.
## 8. Merge requests, et pourquoi une fusion déclenchée depuis le web ne rouvre pas le §6
Le §6 dit que les écritures ne se font que par SSH. Les merge requests
(`/repo-mrs`, `/repo-mr`, `cmd/gitfed-web/handlers_merge_requests.go`)
permettent à un·e utilisateur·ice connecté·e de fusionner une branche dans
une autre depuis l'interface web, ce qui ressemble à une exception — ça
n'en est pas une, pour une raison précise : **le navigateur n'envoie
jamais de donnée git.** Une merge request ne stocke qu'un titre, une
description, deux noms de branche et un statut (`store.MergeRequest`) ; le
diff affiché sur sa page est calculé en direct à partir des pointes
actuelles des deux branches (`gitexec.BranchDiff`), jamais mis en cache.
Cliquer sur « Fusionner » n'envoie rien — ça dit à `gitfed-server`
« combine ces deux refs que tu as déjà », et chaque commit que l'une ou
l'autre ref pointe n'est arrivé dans le dépôt que via un vrai push
authentifié par SSH au départ. Rien qui ressemble à `git-receive-pack`
n'est joignable par ce chemin.
L'autorisation passe par le même verrou `CheckAccess(repo, principal,
RoleWrite)` qu'un `git push` par SSH traverse déjà — une fusion web
n'ouvre aucune capacité nouvelle, puisque n'importe quel collaborateur en
écriture pourrait atteindre le même état final en clonant par SSH, en
fusionnant en local, puis en repoussant. Le seul compromis honnête :
l'identité de l'auteur du commit de fusion vient de la session web
(connexion par mot de passe), pas d'un certificat SSH — mais c'est déjà
vrai de toute autre écriture web (supprimer un dépôt, changer sa
visibilité, accorder/révoquer un collaborateur), donc ce n'est pas une
nouvelle catégorie de confiance.
La fusion elle-même (`gitexec.MergeBranches`) ne touche jamais l'état
réellement extrait (checked-out) d'une branche :
- Elle tourne dans un `git worktree add --detach` jetable, supprimé (`git
worktree remove --force`) qu'elle réussisse, entre en conflit, ou
échoue — la ref de la branche cible n'est touchée qu'à la toute
dernière étape.
- Un conflit (`git merge` sort en erreur avec des fichiers non fusionnés)
est détecté *avant* toute écriture — `CheckMergeable` tente la même
fusion dans son propre worktree jetable uniquement pour rapporter les
fichiers en conflit, puis le jette.
- La seule vraie mutation est `git update-ref refs/heads/<cible> <nouveau>
<ancien>` — un compare-and-swap, pas un écrasement aveugle. Si `<cible>`
a bougé entre le calcul de la fusion et cet appel (un `git push`
concurrent, le plus probable), la mise à jour est rejetée plutôt que de
silencieusement perdre ce push.