aboutsummaryrefslogtreecommitdiff

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.