git-forge ========= A small self-hosted git forge: HTTP push and pull, cgit browsing with syntax highlighting, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. One binary, one data directory, one listener. git-forge does not reimplement git. It runs `git-http-backend` and `cgit` as CGI behind its own listener, authorises every request before exec'ing either, and supplies only what those two lack. Out, deliberately: pull requests, patch review, CI, hooks, forks, repository rename, inbound mail, SSH, attachments, milestones, assignees, and closing an issue from a commit message. Also out: JavaScript, a database, a reverse proxy, and a process supervisor. Limits: one process per datadir, `git grep` is the issue search, and a few thousand issues per repository is the ceiling. One push is bounded at 1 GiB of code and 16 MiB of wiki; the total is not. Eight cgit and eight git-http-backend children run at once and the rest queue. Build ----- Go 1.25 or later: go build `CGO_ENABLED=0` makes the binary static, which is what `Containerfile` does. Gating checks: gofmt -l . must print nothing go vet ./... must be clean go mod tidy -diff must print nothing go test ./... unit and integration tests, needs git; some skip without cgit ./test builds the image and drives a container end to end `./test` needs docker, git, curl, lynx, openssl, python3, and timeout. Checked by hand: OIDC against a real provider, and Mailgun against a real domain. Running outside the container needs `git`, `cgit`, and `highlight`. On Alpine `git-http-backend` is in the `git-daemon` package; without it every clone and push answers 500. `GIT_FORGE_CGIT` names `cgit.cgi` when it is not at `/usr/share/webapps/cgit/cgit.cgi`. Run --- ./git-forge -datadir /var/lib/git-forge The datadir comes from the flag, else `GIT_FORGE_DATADIR`, else `/var/lib/git-forge`, and never from the config. Everything missing is created on first start. There is no TLS. A push token is sent as Basic auth on every request, and the session cookie is `Secure` only when `base_url` is https. Keep the listener on localhost, or put something in front that terminates TLS and set `base_url` to the origin it serves. Administration is web only. With no accounts, git-forge prints a one time setup token to stdout and serves `/setup`; that form takes the token and creates the first admin. A restart before setup prints a new token, and once any account exists `/setup` answers 404. Bare metal on Alpine: `adduser -D -H forge`, install the binary and `git-forge.initd` as `/etc/init.d/git-forge`, and put secrets as `VAR=value` lines in `/etc/conf.d/git-forge`, mode 0600. The unit creates the datadir, runs as `forge`, and writes stdout to `/var/log/git-forge.log`. Configuration ------------- `{datadir}/config/forge.json`, plus environment for secrets. The config has no field for a secret: GIT_FORGE_COOKIE_KEY else generated into {datadir}/cookie.key GIT_FORGE_MAIL_MAILGUN_API_KEY GIT_FORGE_OIDC__CLIENT_SECRET Five fields have an environment override: `GIT_FORGE_LISTEN`, `GIT_FORGE_SITE_NAME`, `GIT_FORGE_SITE_DESC`, `GIT_FORGE_BASE_URL`, and `GIT_FORGE_CLIENT_IP_HEADER`. An override is an overlay: `/admin` rewrites the file without it. `site_name`, `site_desc`, `logo`, and `icon` are editable at `/admin`, which commits the change. `logo` and `icon` are image paths, relative to the config directory, served at `/logo` and `/favicon.ico`; empty means cgit's own. `mail.driver` is one of: none the default. Nothing is sent; the log names each dropped message by recipient and subject, never its body log whole messages to the log, for development. A set-password or reset link is a capability, and this driver logs it mailgun one form-encoded POST to `mail.mailgun.base_url`, with the key from the environment An OIDC provider takes an optional `allow`: addresses, or `@domain` for a whole domain. An empty list admits anyone the provider vouches for. It is checked at every login, so removing an entry revokes: "allow": ["carol@example.com", "@example.com"] `client_ip_header` is empty by default. Behind a proxy every request shares one address, so every per address rate limit becomes one bucket; naming the header the proxy sets is the way out. Naming one trusts it: the proxy must overwrite it, and nothing may reach the listener around the proxy. "client_ip_header": "CF-Connecting-IP" Rate limits are constants in the source. Startup validates `forge.json` and `repos.json` and exits nonzero on anything incoherent. Data directory -------------- config/ git repo with a working tree: forge.json, users.json, repos.json, and an uploaded logo and icon cookie.key generated secret, 0600, outside the config repo mailq/ spooled outbound mail, transient repos/ bare git repos, holding nothing git-forge wrote Back up with `tar` against a stopped service, or a filesystem snapshot. The datadir is credential material in full: `users.json` holds argon2id hashes, TOTP secrets, and recovery codes, and the config repo keeps their history. Exactly one process may serve a datadir: git-forge takes an exclusive `flock` on it and a second process refuses to start. Storage must be a real filesystem with atomic `rename()` and working lock files; FUSE-mounted object storage is not. Debugging --------- One log line per request on stdout. Query strings are not logged, because reset links and OIDC codes arrive in them, and the path is quoted, so a newline in one cannot write its own line. The config repo is the audit trail: cat {datadir}/config/repos.json git -C {datadir}/config log -p repos.json when did bob get write git -C {datadir}/config log --author=alice what has this admin done git -C {datadir}/config revert HEAD undo a fat-fingered grant The working tree is the source of truth; a commit that fails is logged and the request still succeeds. Issues and wiki pages are paths inside a git tree: git -C {datadir}/repos/infra/dns.issues.git show master:issues/7.json Repository state is derived from the tree: a directory ending in `.git` is a repository, anything else is a section one level deep. A directory with no entry in `repos.json` is served private with issues and wiki off, and shown with an adopt button at `/admin`; an entry with no directory is ignored. Both are logged at startup. A repository path is one or two components of lowercase letters, digits, dot, dash, and underscore. Neither component may be a name one of git-forge's own routes owns, and a second component may not be a cgit route either: `/infra/log` is the log of the repository `infra`. Every form refused with a 403 while everything else works means `base_url` is not the origin the browser uses: a form post whose `Origin` disagrees with it is refused. An image that does not load is one that is not this origin's. The security policy refuses remote images, so a badge in a README cannot tell a third party who read it. No script runs anywhere. Handing out access asks for the acting account's own authenticator code, at `/admin` and on a repository's settings alike, since what it hands out outlives the session. A code works once: a second action within the same thirty seconds waits for the next code. An admin without an authenticator is sent to enrol one. Changing the second factor asks for the password on a local account and the enrolled code on an external one. cgit is invoked per request with a generated config naming only the one repository. Those configs live in a temporary directory logged at startup and are not part of the backup. `git gc --auto` runs after a push and after every issue and wiki write, so a repository's on-disk shape changes when git's thresholds fire. Tokens ------ Git over HTTP takes a token, never a password: git cannot be asked for a second factor, and a token is separately scoped and separately revoked. A token is a credential, not a permission. Minting one at `/settings` asks only that the account can reach the repository; what it may do is decided on arrival, from the same grants the web uses. An anonymous git request for anything it may not read is answered 401, whether the repository exists or not, so the answer never says which private names are taken. An authenticated request without read answers 404, as the web does. Container --------- podman build -t git-forge . podman run -p 8080:8080 -v git-forge:/var/lib/git-forge git-forge Alpine, non-root, volume at `/var/lib/git-forge`, tini as PID 1 to reap git's detached gc. `/healthz` answers 200 and SIGTERM drains and exits. License ------- BSD 3-Clause, in `LICENSE`. cgit's stylesheet is served from the cgit package at runtime rather than copied into this tree: it is cgit's, under cgit's own licence.