Gitfed
bastien-mrq/gitfed/ Commits/ 8492d2c

Update docs for merge requests, backup.sh, and stale hardening notes

ARCHITECTURE.{en,}.md gain a new §8 explaining why a web-triggered merge doesn't reopen the "writes only over SSH" guarantee from §6 (no git data crosses the browser, same write-role gate as SSH push, scratch worktree + compare-and-swap ref update) — the exact question worth documenting given how central that guarantee is elsewhere in these docs. HOW_IT_WORKS gets a matching §9, mirroring how §8/§7 already document the anonymous-clone exception. The internal/ package table's gitexec/store rows were also out of date (missing commits/diffs, pins, notifications, merge requests). READMEs: mention merge requests under gitfed-web, and point the Backups section at deploy/backup.sh instead of only describing the concept. deploy/k8s/README.md: the "Further hardening" list had drifted from reality — login rate limiting shipped a while ago and was still listed as missing; the NetworkPolicy note now says plainly that it exists but isn't applied, and wouldn't be enforced by k3s' default flannel CNI even if it were.

bastien-mrq 2026-07-28 22:34 commit 8492d2c552965971bd316681af7cbe558cb10ab9 parent c49db41f9574cc56a5ad92e46fc2d89fa8139dae
7 files changed +146 −13
M README.fr.md +7 −1
M README.md +8 −2
M deploy/k8s/README.md +10 −6
M docs/ARCHITECTURE.en.md +42 −2
M docs/ARCHITECTURE.md +47 −2
M docs/HOW_IT_WORKS.en.md +15 −0
M docs/HOW_IT_WORKS.md +17 −0
README.fr.md
diff --git a/README.fr.md b/README.fr.md index 9ef9dee..bb37e09 100644 --- a/README.fr.md +++ b/README.fr.md @@ -51,7 +51,8 @@ instance en fonctionnement. `/.well-known/gitfed.json`. - **`gitfed-web`** — l'interface web : un navigateur de dépôts public, et, derrière une connexion par mot de passe, la gestion autonome (vos propres - clés et dépôts) et une section admin, en français ou en anglais. + clés et dépôts), les merge requests avec revue et fil de discussion, et + une section admin, en français ou en anglais. - **`gitfed-tui`** — un outil d'administration en terminal ; le seul moyen d'amorcer le tout premier compte. - **`gitfed-renew-cert`** — un petit outil client pour renouveler votre @@ -119,6 +120,11 @@ et casse toutes les relations de confiance fédérées qui pointent vers elle — il n'y a pas de récupération possible en dehors de tout reconstruire depuis zéro. Sauvegardez le volume entier, pas seulement les données git. +Pour le déploiement Kubernetes, [`deploy/backup.sh`](deploy/backup.sh) +automatise ça — capture tout le volume dans une archive datée téléchargée +depuis le serveur, avec purge automatique. La procédure de restauration +est dans [`deploy/k8s/README.md`](deploy/k8s/README.md#backups). + ## Licence [GNU AGPLv3](LICENSE) — la licence à copyleft réseau : si vous faites
README.md
diff --git a/README.md b/README.md index 30bca6d..53b2d5f 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,9 @@ instance. - **`gitfed-server`** — SSH server (git operations + certificate issuance) and the `/.well-known/gitfed.json` federation endpoint. - **`gitfed-web`** — the web UI: a public repo browser, and, behind a - password login, self-service (your own keys and repos) and an admin - section, in French or English. + password login, self-service (your own keys and repos), merge requests + with review and a discussion thread, and an admin section, in French or + English. - **`gitfed-tui`** — a terminal admin tool; the only way to bootstrap the first account. - **`gitfed-renew-cert`** — a small client tool to renew your own @@ -113,6 +114,11 @@ breaks every federated trust relationship pointing at it — there's no recovery short of everyone re-establishing trust from scratch. Back up the whole volume, not just the git data. +For the Kubernetes deployment, [`deploy/backup.sh`](deploy/backup.sh) +automates this — snapshots the whole volume to a timestamped tarball +downloaded off the server, with pruning. The restore procedure is in +[`deploy/k8s/README.md`](deploy/k8s/README.md#backups). + ## License [GNU AGPLv3](LICENSE) — the network-copyleft license: if you run a modified
deploy/k8s/README.md
diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index 2eea4c4..cfc84fc 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -195,12 +195,16 @@ this repo works fine). ## Further hardening (not included here, worth doing later) -- A `NetworkPolicy` restricting which namespaces/pods can reach - `gitfed-wellknown`/`gitfed-web` at all, if your cluster runs anything - else you don't fully trust. +- **`deploy/k8s/networkpolicy.yaml` exists but isn't part of the "Apply" + step above, and k3s' bundled flannel CNI doesn't enforce `NetworkPolicy` + at all.** Applying it as-is is documentation of intent, not an active + control, until the cluster runs a policy-capable CNI (Cilium, Calico, + ...) — worth doing if this cluster ever hosts anything you don't fully + trust alongside gitfed. - Rotating `cert_ttl_hours` down and setting up `gitfed-renew-cert` on a timer for any users who script access, instead of the default 48h/manual `gitfed-cert` request. -- Rate limiting or a CAPTCHA on `/login` if this instance ever becomes a - public target — right now a wrong password just redirects back with an - error, no lockout or backoff. + +Login already has rate limiting (per-account and per-IP, see +`cmd/gitfed-web/ratelimit.go`) — it used to be listed here as missing; +it isn't anymore.
docs/ARCHITECTURE.en.md
diff --git a/docs/ARCHITECTURE.en.md b/docs/ARCHITECTURE.en.md index 9b86e03..ca7039e 100644 --- a/docs/ARCHITECTURE.en.md +++ b/docs/ARCHITECTURE.en.md @@ -35,8 +35,8 @@ speaks SSH to a remote instance. | `ssh` | The SSH server itself: authentication (bare key or certificate), revocation checking, running `git-upload-pack`/`git-receive-pack`. | | `federation` | `.well-known` discovery, anti-SSRF guards (`wellknown.go`), the trust store and its policy (`resolver.go`). | | `acl` | Per-repo authorization rules (public/private, read/write/admin roles). | -| `gitexec` | Everything that invokes `git` as a subprocess: init, reading a tree/file at HEAD, default-branch resolution. | -| `store` | bbolt persistence: users, repos, ACLs, sessions, trust store, audit log, revocations. | +| `gitexec` | Everything that invokes `git` as a subprocess: init, reading a tree/file at HEAD, default-branch resolution, commit history/diffs, and the branch/merge plumbing behind merge requests (§8). | +| `store` | bbolt persistence: users, repos, ACLs, sessions, trust store, audit log, revocations, pinned repos, federated notifications, merge requests and their comments. | | `admin` | The `Ops` interface: every admin or self-service operation, in one place, implemented once. | | `adminrpc` | The JSON-over-Unix-socket protocol that exposes `admin.Ops` to a remote client (see §3). | | `opsconnect` | Automatically picks live-socket vs. direct-store mode depending on whether a `gitfed-server` is already running. | @@ -164,3 +164,43 @@ This POST route is explicitly exempted from the same-origin CSRF check (`sameOriginPOST`): a `git` client never sends an `Origin`/`Referer` header. That's not a CSRF hole — the request carries no session cookie and mutates no state. + +## 8. Merge requests, and why a web-triggered merge doesn't reopen §6 + +§6 says writes only ever happen over SSH. Merge requests (`/repo-mrs`, +`/repo-mr`, `cmd/gitfed-web/handlers_merge_requests.go`) let a logged-in +user merge one branch into another from the web UI, which sounds like an +exception — it isn't, for a specific reason: **the browser never sends git +data.** A merge request stores nothing but a title, description, two +branch names and a status (`store.MergeRequest`); the diff shown on its +page is computed live from the two branches' current tips +(`gitexec.BranchDiff`), never cached. Clicking "Merge" doesn't upload +anything — it tells `gitfed-server` "combine these two refs you already +have," and every commit either ref points at only ever got into the repo +through a real SSH-authenticated push in the first place. Nothing resembling +`git-receive-pack` is reachable from this path. + +Authorization is the same `CheckAccess(repo, principal, RoleWrite)` gate a +`git push` over SSH already goes through — a web merge grants no new +capability, since any write collaborator could reach the identical end +state by cloning over SSH, merging locally, and pushing back. The one +honest trade-off: the merge commit's author identity comes from the web +session (password login), not an SSH certificate — but that's already true +of every other web-only write (deleting a repo, changing its visibility, +granting/revoking a collaborator), so this isn't a new category of trust. + +The merge itself (`gitexec.MergeBranches`) never touches a branch's real +checked-out state: + +- It runs inside a throwaway `git worktree add --detach`, discarded (`git + worktree remove --force`) whether it succeeds, conflicts, or errors — + the target branch's ref is untouched until the very last step. +- A conflict (`git merge` exits non-zero with files still unmerged) is + detected *before* anything is written back — `CheckMergeable` runs the + same attempt in its own throwaway worktree purely to report conflicting + files, then discards it. +- The one real mutation is `git update-ref refs/heads/<target> <new> <old>` + — compare-and-swap, not a blind overwrite. If `<target>` moved between + the merge being computed and this call (a concurrent `git push`, most + likely), the update is rejected instead of silently discarding that + push.
docs/ARCHITECTURE.md
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ad56dcb..09268d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,8 +36,8 @@ timer), qui ne parle qu'en SSH à une instance distante. | `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. | -| `store` | Persistance bbolt : utilisateurs, dépôts, ACL, sessions, magasin de confiance, journal d'audit, révocations. | +| `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à. | @@ -167,3 +167,48 @@ 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.
docs/HOW_IT_WORKS.en.md
diff --git a/docs/HOW_IT_WORKS.en.md b/docs/HOW_IT_WORKS.en.md index 4fcdc2b..327195a 100644 --- a/docs/HOW_IT_WORKS.en.md +++ b/docs/HOW_IT_WORKS.en.md @@ -157,3 +157,18 @@ write is possible this way (there is no `git-receive-pack` route over HTTP, literally none), and a repo that goes back to private instantly stops being reachable this way. See [`ARCHITECTURE.en.md`](ARCHITECTURE.en.md) §7 for the implementation detail. + +## 9. Special case: merging a branch from the web + +Merge requests let a write collaborator merge one branch into another from +the web UI — a "write" that doesn't go over SSH, which sounds like a +second exception to §2's identity model. It isn't one: the browser never +sends git data, it only tells the server to combine two branches it +already has, and every commit either branch points at only got into the +repo through a real SSH-authenticated push in the first place. Access is +gated by the exact same write-role check a `git push` already goes +through — the web button grants no capability a write collaborator +couldn't already reach by cloning, merging locally, and pushing back. See +[`ARCHITECTURE.en.md`](ARCHITECTURE.en.md) §8 for how the merge itself is +isolated from a branch's real state (scratch worktree, conflict detection +before any write, compare-and-swap ref update).
docs/HOW_IT_WORKS.md
diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index 32913ae..6ee7318 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -164,3 +164,20 @@ a pas de route `git-receive-pack` en HTTP, littéralement aucune), et un dépôt qui redevient privé cesse instantanément d'être accessible ainsi. Voir [`ARCHITECTURE.md`](ARCHITECTURE.md) §7 pour le détail d'implémentation. + +## 9. Cas particulier : fusionner une branche depuis le web + +Les merge requests permettent à un collaborateur en écriture de fusionner +une branche dans une autre depuis l'interface web — une « écriture » qui +ne passe pas par SSH, ce qui ressemble à une deuxième exception au modèle +d'identité du §2. Ça n'en est pas une : le navigateur n'envoie jamais de +donnée git, il dit seulement au serveur de combiner deux branches qu'il a +déjà, et chaque commit que l'une ou l'autre pointe n'est arrivé dans le +dépôt que via un vrai push authentifié par SSH au départ. L'accès passe +par exactement le même contrôle de rôle en écriture qu'un `git push` +traverse déjà — le bouton web n'ouvre aucune capacité qu'un collaborateur +en écriture n'aurait pas déjà en clonant, fusionnant en local, puis en +repoussant. Voir [`ARCHITECTURE.md`](ARCHITECTURE.md) §8 pour comment la +fusion elle-même est isolée de l'état réel d'une branche (worktree +jetable, détection de conflit avant toute écriture, mise à jour de ref en +compare-and-swap).