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_<KEY>_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.
