1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
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.
|