aboutsummaryrefslogtreecommitdiff
path: root/README
diff options
context:
space:
mode:
Diffstat (limited to 'README')
-rw-r--r--README211
1 files changed, 211 insertions, 0 deletions
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_<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.