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

Request flow

Server mode push

git push → /server/<provider>/<owner>/<repo>.git
             │
             ▼
     ServerRepositoryResolver
       • clone/fetch upstream repo locally
       • extract credentials from Authorization header
             │
             ▼
     ServerReceivePackFactory
       • assemble hook chain (see below)
             │
       ┌─────┴──────────────────────────────────┐
       │  Pre-receive hooks (ordered)            │
       │  1. PushStorePersistenceHook            │  record RECEIVED
       │  2. Validation hooks (see below)        │  emit per-step sideband messages
       │  3. PushStorePersistenceHook            │  record PENDING or BLOCKED
       │  4. ApprovalPreReceiveHook              │  block until approved / auto-approve
       └─────┬──────────────────────────────────┘
             │  (if approved)
       ┌─────┴──────────────────────────────────┐
       │  Post-receive hooks                     │
       │  1. ForwardingPostReceiveHook           │  push to upstream with dev's credentials
       │  2. PushStorePersistenceHook            │  record FORWARDED or ERROR
       └─────────────────────────────────────────┘

Authenticating a server mode request

Server mode cannot forward a request it has no credentials for, and a git client only sends credentials after a 401 challenge — so BasicAuthChallengeFilter has to decide, before the servlet runs, whether this request needs one.

Push is unambiguous: the push is forwarded upstream using the developer’s own token, so it is always challenged.

Fetch is not. The mirror is cloned from upstream on every open, so a fetch of a private repository must be able to carry credentials — but challenging every fetch makes public repositories unclonable by anyone who has no credential to offer, and a client that answers the challenge with an unrelated or expired token is rejected by providers such as GitHub even on a repository they would have served anonymously. Guessing in either direction breaks a real workflow.

So fogwall asks upstream instead. UpstreamAuthProbe issues the git advertisement itself — GET <repo>/info/refs?service=git-upload-pack, no credentials — and reads the answer: 200 means anonymous reads are served, anything else means they are not. That is provider-agnostic, needs no REST API and no per-provider visibility field, and follows the repository’s real visibility rather than a configured assumption.

Two properties keep it cheap and safe. Only an unauthenticated fetch probes at all — a request already carrying an Authorization header is passed straight through — and verdicts are cached per repository, so a burst of anonymous clones costs one upstream round trip. Any unclear answer (timeout, 404, 5xx) is treated as “credentials required”, because a probe that cannot reach upstream must never be the reason a repository becomes anonymously readable.

The transparent proxy needs none of this: it forwards to upstream directly, so upstream issues its own challenge. SSH authenticates by key before any git command runs.

Repository path derivation

Owner, name and slug all come from one place, RepoPath — the transparent proxy’s ParseGitRequestFilter, server mode’s ServerReceivePackFactory and RepositoryUrlRuleHook, the SSH transport’s route resolution, and RepoPermissionService’s OWNER/NAME targets each parse the request path through it rather than splitting the path themselves. A URL rule and a permission check evaluated for the same request have to compare against the same strings; a per-call-site split is how they drift apart, and a rule that silently matches a different repository than the permission check did is a containment failure rather than a cosmetic inconsistency.

The rule is that the repository name is the last path segment and the owner is everything before it, so a path is not capped at two segments — a GitLab subgroup project /group/subgroup/project has owner group/subgroup. Splitting at the first separator, or keeping only the first two segments, reads such a path as the subgroup itself. Every segment is validated, not just the first and last, because a nested owner is several segments and traversal in any one of them must not reach an upstream URL or a cache key. A path that does not parse yields nothing at all and each caller rejects: a partial reading of a repository path is never safe to authorize against.

RepoPathMatching is the companion half: RepoPath decides which part of a path a pattern is compared against, and RepoPathMatching decides how that comparison is made. Both the URL-rule evaluator and the permission service route through it, so neither can drift on the answer. Its one job today is case folding — every provider fogwall speaks to resolves owner/repo case-insensitively, so comparing case-sensitively let a recased path miss a DENY and fall through to a broader allow beneath it. Folding also takes GLOB off the host filesystem’s case rules, which otherwise made the same rule behave differently on Linux and macOS. Case is the only thing folded: the path’s shape — its leading slash and its segment boundaries — is still compared verbatim, because those are what the operator wrote in a URL rule.

Transparent proxy push

git push → /proxy/<provider>/<owner>/<repo>.git
             │
             ▼
     Servlet filter chain (ordered)
       ParseGitRequestFilter      extract pack metadata from packet lines
       EnrichPushCommitsFilter    clone/fetch upstream repo; unpack inflight pack into a per-request quarantine; walk commit range
       AllowApprovedPushFilter    prior-approved? skip validation, proxy directly
       UrlRuleAggregateFilter     evaluate ALLOW/DENY rules
       CheckUserPushPermissionFilter   resolve identity; check repo permissions
       CommitAttributionPolicyFilter  verify commit author/committer email
       [content validation filters — see below]
       ValidationSummaryFilter    collect all issues
       PushFinalizerFilter        save push record; wait for approval if required
             │
             ▼
     FogwallServlet (Jetty AsyncProxyServlet)
       • HTTP proxy pass-through to upstream
       • on response: update push record → FORWARDED or ERROR

Per-request object quarantine

Validation has to read a push’s objects before it can decide anything about them, but the mirror behind /proxy/<provider>/... is shared by every request for that repository. Unpacking straight into it means a rejected push leaves its content there permanently — including the content policy just refused.

QuarantineObjectStore gives each push its own scratch object store instead:

  • the quarantine Repository shares the mirror’s git directory, so it sees the mirror’s refs and can still answer “what does this push actually introduce”;
  • its object directory is a temporary directory, so every write lands there;
  • the mirror’s object directory is registered as an alternate, which is what lets thin-pack deltas resolve against objects the mirror already has.

Downstream filters receive the quarantine as GitRequestDetails.localRepository, so they see the union: mirror contents plus this push. EnrichPushCommitsFilter wraps the rest of the chain in try-finally and deletes the quarantine when the request ends.

In this mode nothing is ever promoted back into the mirror. An accepted push’s objects reach it the same way everything else does — by being fetched from upstream once they exist there — which keeps the mirror a reflection of upstream rather than an accumulation of everything anyone attempted. (Server mode differs; see below.) If a quarantine cannot be created the filter logs a warning and falls back to the mirror: the loss is disk hygiene, not a validation result, so it is not worth failing a push over.

This is the same shape as git’s own receive-pack quarantine (tmp_objdir, exposed to hooks as GIT_QUARANTINE_PATH): temporary object directory, real one as an alternate, hooks run against that view. JGit has no equivalent, hence the local implementation. Note it is roughly the inverse of a worktree — a worktree shares the object database and isolates the index and HEAD, whereas this shares refs and isolates objects, so a worktree would not help here.

The one deliberate departure from git’s version is the last step: git migrates objects into the real store on success, because for git the receive is authoritative and those objects have nowhere else to come from. Here the mirror is a cache of upstream, so not promoting is both simpler and a stronger guarantee.

Server mode quarantines too, with one difference. There JGit’s ReceivePack applies the ref updates to the shared git directory once the pre-receive hooks pass, so the objects those refs name have to be in the mirror by then — discarding them would leave the mirror pointing at objects that no longer exist. QuarantinePromotionHook runs as the last pre-receive hook and moves the objects across only when nothing has been rejected; if it fails, it rejects the push, because a half-promoted push is worse than a refused one. The HTTP path’s quarantine is torn down by QuarantineCleanupFilter on the server-mode mapping; the SSH path scopes it to SshGitReceiveCommand, which has no servlet request to hang it off.

So both modes discard a rejected push’s objects. The transparent proxy additionally never promotes, because it never applies ref updates. There, JGit’s ReceivePack owns the inserter and writes into the mirror before the pre-receive hooks run, so the same guarantee needs a different mechanism.

Both mirrors — server mode’s and the transparent proxy’s — are held by a LocalRepositoryCache, and the dashboard exposes each one for operator inspection and manual invalidation over /api/admin/cache (ROLE_ADMIN). The cache is in-memory and per-pod, so this is a per-pod operational view, not distributed state; invalidating an entry deletes its local clone (keeping the cache root) so the next access re-clones from upstream — the recovery path for a stale or poisoned mirror without a restart.

Concurrency. A mirror is shared across concurrent requests, so the cache coordinates access at three points. First clones are serialized on a per-repository lock (keyed on the cache key): threads racing on the first access to the same repo dedupe to one clone, while first clones of different repos run in parallel. Upstream refreshes for a repo are serialized on that repo’s own lock so two fetches never write the same bare repo at once. The serve path is deliberately left lock-free, though: a refresh (writer) may run while an upload/receive or content inspection (reader) uses the same mirror, and readers are not blocked. This is safe because a git fetch is additive — it never deletes the objects a concurrent reader is serving — so the worst normal outcome is that the reader sees a slightly stale snapshot and the client re-fetches. A read/write lock was rejected because, to be safe, it would either starve refreshes under sustained fetch traffic or tax the hot serve path; the full rationale (and the shallow-mirror cloneDepth=0 escape hatch for the one transient edge case) lives on LocalRepositoryCache. Separately, JGit’s process-global pack-window cache is tuned once at startup for a many-mirror server (larger packedGitLimit/open-file budget, mmap off) rather than its desktop-git defaults; these are engine internals, intentionally not fogwall config keys.