From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree. --- README | 211 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 README (limited to 'README') diff --git a/README b/README new file mode 100644 index 0000000..14d3158 --- /dev/null +++ b/README @@ -0,0 +1,211 @@ +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. -- cgit v1.2.3