Gitfed
bastien-mrq/gitfed / docs / ARCHITECTURE.en.md
ARCHITECTURE.en.md Code Preview

Languages: Français · English

Architecture

How gitfed's code is organized, and why. For the identity/federation model itself, see HOW_IT_WORKS.en.md; for deployment steps, see deploy/k8s/README.md.


1. Overview: four binaries, one shared core

cmd/
  gitfed-server/       daemon: SSH server + /.well-known endpoint
  gitfed-web/           web UI (public browsing + self-service + admin)
  gitfed-tui/            terminal admin tool
  gitfed-renew-cert/     client-side certificate renewal tool

gitfed-server is the only process that touches the database and the git repos directly. gitfed-web and gitfed-tui don't reimplement anything: they drive the same business core through the admin.Ops interface (§3) — either by talking to an already-running gitfed-server over a socket, or, if it isn't running, by opening the database directly. gitfed-renew-cert is entirely separate: a small standalone client, meant to run on a user's own machine (cron/systemd timer), which only ever speaks SSH to a remote instance.

2. The internal/ packages

Package Role
ca Generates the instance's certificate authority key and signs user certificates.
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, 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.
i18n Translation dictionaries (French/English) for the web UI and the language-resolution helper.
config Loads the instance's configuration file (gitfed.json).
version Version number, injected at build time.

3. Why there's an admin RPC socket

bbolt (the embedded database store uses) enforces an exclusive, single-writer lock on its file: only one process can have it open for writing at a time. If gitfed-web opened the database directly while gitfed-server was already running, one of the two would fail to start — or worse, they'd fight over the lock.

The fix: gitfed-server exposes its admin.Ops over a local Unix socket (data/admin.sock), through a small homegrown JSON protocol (internal/adminrpc). gitfed-web and gitfed-tui connect to it as clients instead of reopening the database — internal/opsconnect decides, at startup, whether to go through this socket ("live" mode) or open the database directly ("offline" mode, used by gitfed-tui when no server is running, e.g. to create the very first account).

One gotcha fixed along the way: errors crossing this socket lose their identity if you're not careful — errors.New("not found") on the server side comes back as a plain string on the client side, which can no longer be compared with == store.ErrNotFound. adminrpc.Client therefore explicitly reconstructs known sentinel errors by matching the message, rather than letting them pass through as-is.

4. The web UI (cmd/gitfed-web)

Every page is an HTTP handler that: checks access rights via admin.Ops, runs a small Go template to produce the page body, then calls server.render(), which wraps that body in the common "shell" (nav bar, footer, stylesheet, SVG icon sprite) defined in render.go.

Notable points:

  • No JS framework. All rendering is done server-side with html/template; the little JavaScript there is (profile menu, tab switching, clipboard copy) is a single inline block, whose SHA-256 hash is pinned in the CSP policy (see §6).
  • i18n via a template function. Every page passes its current language (resolved from a cookie, then Accept-Language, see lang.go) into the template data; strings go through {{t .Lang "key"}}, which looks up the translation in internal/i18n.
  • Web authentication is independent of git. The web password (bcrypt hashed, cost 12) only opens an opaque web-side session — it's never involved in SSH/git authentication, which relies solely on keys and certificates (see HOW_IT_WORKS.en.md).

5. Deployment topology

┌─────────────────────────── Pod (1 replica) ────────────────────────────┐
│                                                                          │
│   "server" container               "web" container                     │
│   gitfed-server                    gitfed-web                           │
│   ├─ SSH :2222 (hostPort)          ├─ HTTP :8088                        │
│   ├─ well-known :8443              └─ socket data/admin.sock (client)   │
│   └─ socket data/admin.sock (server)                                    │
│                                                                          │
│   "data" volume (PVC) mounted in both containers:                       │
│   bbolt database, bare repos, CA key, SSH host key, admin socket        │
└──────────────────────────────────────────────────────────────────────┘
  • Always a single replica. bbolt (single-writer lock), the admin socket, and the SSH port exposed via hostPort are all, by nature, single-instance resources — deployment.yaml sets replicas: 1 and strategy: Recreate instead of a rolling update.
  • Two containers, one pod. They share the same data volume; the web container waits, via a shell loop, for data/admin.sock to exist before starting.
  • SSH on hostPort: 2222, not 22 — the node's port 22 is already taken by the VPS's own sshd.
  • Only gitfed-web is exposed via an Ingress (Traefik + cert-manager, already in place on the cluster for another project); it's safe to expose it publicly because web authentication is real (password + session), not an unprotected admin-only access point.

The step-by-step detail (DNS, first deployment, bootstrapping the admin account) is in deploy/k8s/README.md.

6. What protects the instance in production

See docs/security/AUDIT.md (French) for the full detail, but in short, what's structurally in place:

  • Writes only ever happen over SSH — git-receive-pack is never exposed any other way, authenticated by key or federated certificate only (see §7 for the one HTTP exception, strictly read-only).
  • Anti-SSRF at connection time, not just at domain-name validation (internal/federation/wellknown.go) — also protects against DNS-rebinding.
  • Strict CSP with the inline script pinned by hash, standard hardening headers, origin checking on mutating requests (CSRF defense-in-depth, on top of SameSite=Lax).
  • Immediate certificate revocation, checked on every SSH authentication (internal/ssh/server.go).
  • Rate limiting on the web login, per account and per IP.

7. Anonymous HTTPS clone for public repos

As of 0.8.0, cmd/gitfed-web/handlers_git_http.go implements a small slice of git's "smart HTTP" protocol (git-upload-pack only) so that git clone https://<domain>/<owner>/<repo>.git works with no account and no SSH key — see HOW_IT_WORKS.en.md §8 for the user-facing explanation. Three structural guarantees:

  • Read-only, full stop. There simply is no git-receive-pack route over HTTP — nothing to bypass, the code to write doesn't exist on this path.
  • repo.Public is re-checked on every request, never cached — a repo that goes back to private instantly stops being cloneable over HTTP, and a private or nonexistent repo returns the exact same 404, the same as everywhere else in the app (canView).
  • Routed at the site root (/{owner}/{repo}.git/...) rather than under a dedicated prefix, so the clone URL looks like what people expect. Go's mux always prefers more specific literal routes, so this can't shadow any other page.

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.