Gitfed
bastien-mrq/gitfed / docs / ARCHITECTURE.en.md
ARCHITECTURE.en.md Code Preview
**Languages:** [Français](ARCHITECTURE.md) · English

# Architecture

How gitfed's code is organized, and why. For the identity/federation model
itself, see [`HOW_IT_WORKS.en.md`](HOW_IT_WORKS.en.md); for deployment
steps, see [`deploy/k8s/README.md`](../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`](../deploy/k8s/README.md).

## 6. What protects the instance in production

See [`docs/security/AUDIT.md`](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`](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.