docs/plans/2026-09-27-server-hardening.md
3687 lines · 117467 bytes
32 symbols in this file
Server hardening implementation planGlobal constraintsDecisions (from the brief)Findings from the code that shape this planOrder and dependenciesFile mapMR 1: explicit TLS minimum (branch `https-tls-minimum`, closes #281)Task 1.1: `serverTLS` sets TLS 1.2 on both TLS modesMR 2: mail requires TLS to a remote relay (branch `mail-require-tls`, closes #280)Task 2.1: config `mail.require_tls` and `mail.tls`Task 2.2: `mail.Send` refuses plaintext when TLS is required; implicit TLSMR 3: mirrors connect only to an address checked at sync time (branch `mirror-pin-address`, closes #279)Task 3.1: `webhook.CheckAddrs`Task 3.2: mirror sync resolves, checks and pinsMR 4: authenticated hook socket (branch `hook-socket-auth`, closes #282)Task 4.1: `push_tokens` table and store methodsTask 4.2: hookd requires 0600, the daemon's uid, and a live push tokenTask 4.3: sshd mints the token; the hook sends itMR 5: audited refusals and a hash-chained audit log (branch `audit-refusals-chain`, closes #275)Task 5.1: chained audit rows, the journal line, verificationTask 5.2: refused mutating commands are audited, rate-limited per actorTask 5.3: refused pushes, the daemon's journal, `gitbayd admin audit verify`MR 6: one limit on pack generation (branch `pack-limit`, closes #262)Task 6.1: `internal/packlimit`Task 6.2: config knobsTask 6.3: SSH transports acquire a slot and die with their clientTask 6.4: smart HTTP and git:// acquire a slot; ls-refs does notTask 6.5: one limiter for the daemon; benchmark script; docsRunbook for the operator (cmc)Release notes for whoever tags theseDecisions and remaining questionsSelf-review
1# Server hardening implementation plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close #281, #280, #279, #282, #275 and #262 from the
62026-09-27 architecture review: an explicit TLS floor, mail that
7refuses plaintext to a remote relay, mirror syncs that connect only to
8an address checked at sync time, an authenticated hook socket, audited
9refusals with a tamper-evident audit log, and a shared limit on git
10pack generation.
11
12**Architecture:** Six MRs, each small enough to review alone. The TLS
13and mail changes are local to `cmd/gitbayd/main.go` and
14`internal/mail`. Mirror sync resolves and checks the host itself, then
15pins git's connection to those addresses with `http.curloptResolve`.
16The hook socket gets mode 0600, a Linux peer-uid check behind build
17tags, and a per-push token that sshd stores (hashed) in a new
18`push_tokens` table and hookd requires. Audit rows gain a SHA-256
19chain over an immutable actor column, refusals of mutating commands
20are recorded through a per-actor limiter, and the daemon writes each
21row to its log. A new `internal/packlimit` package holds one limiter
22shared by the SSH, smart HTTP and git:// transports.
23
24**Tech stack:** Go 1.27, SQLite (modernc, `_txlock=immediate`),
25`golang.org/x/crypto/ssh`, `net/smtp`, `crypto/tls`, `syscall`
26(Linux `SO_PEERCRED`), git ≥ 2.37 (`http.curloptResolve`), cobra.
27
28**Spec:** the issue texts of #262, #275, #279, #280, #281, #282 on
29krz/gitbay, and the decisions recorded in the brief for this plan set
30(copied under "Decisions" below).
31
32## Global constraints
33
34- Each MR on its own branch off `main`. Commits are signed (the repo
35 refuses unsigned), messages reference issues (`Ref #N`, and
36 `Closes #N` on the commit that finishes one). No attribution to any
37 assistant, model or AI anywhere: commits, MR bodies, comments.
38- MR: `gitbay mr create --source <branch> --target main --title "..."`;
39 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
40 delete the branch locally and on the remote. Behind main → rebase,
41 force-push, merge again.
42- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
43 packages, and at most the one e2e test being written
44 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
45- Registries that fail CI when a new thing lacks its row: top-level route
46 word in `internal/policy/names.go`; new page template in the width map
47 of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
48 command in `readArgs` in `e2e/readonly_test.go`; new control command
49 needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
50 a command reading stdin needs `ReadsStdin: true`.
51 This plan adds no route, no template and no control command:
52 `gitbayd admin audit verify` is a host-local cobra command, not a
53 registry entry.
54- New migrations: the highest today is 0059. Six plans are written in
55 parallel, so numbers are pre-assigned: plan 1 uses 0060–0064, plan 2
56 0065–0068, plan 3 0069–0071, plan 4 0072–0074, plan 5 0075–0077,
57 plan 6 0078–0079. Whoever lands second renumbers to the next free
58 number at execution time. Migrations come in `.up.sql`/`.down.sql`
59 pairs. Hand-written SQL, no ORM. This plan uses 0069
60 (`push_tokens`, MR 4) and 0070 (`audit_chain`, MR 5); 0071 is unused.
61- Secrets travel on stdin, never argv; never logged or echoed. The
62 push token is minted per receive-pack and passed only in the hook's
63 environment; only its SHA-256 is stored.
64- Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model,
65 CI, Users, Performance, and the `Architecture/` folder with its
66 Known-Gaps table and controls matrix). Update the page in the same MR
67 that changes the behaviour it describes, and close the matching
68 Known-Gaps row (`Architecture/10-Known-Gaps.org`) and controls row
69 (`Architecture/09-Controls.org`).
70- Writing style: plain, direct, no hype; code comments match the
71 surrounding density. Comments and docs state facts, never
72 before/after narration.
73- The worktree may carry another session's edits; work in a fresh
74 worktree per MR (`git worktree add ../gitbay-<branch> -b <branch> main`).
75- `CHANGELOG.org` is written at release time (`CHANGELOG: vX.Y.Z`
76 commits), not in these MRs. The release notes each MR needs are
77 listed at the end of this plan.
78
79## Decisions (from the brief)
80
81- #275: audit refused mutating commands (rate-limited per actor); each
82 audit row carries a hash of the previous row, `gitbayd admin audit
83 verify` checks the chain, and rows are also written to the journal
84 (a slog line on the daemon's stderr, which the unit's journal
85 collects).
86- #282: chmod 0600, SO_PEERCRED uid check, and a per-push token in the
87 hook environment that hookd requires.
88- #279: resolve and check before each sync and pin the address for git.
89
90## Findings from the code that shape this plan
91
92- Mirror URLs are http/https only. `runMirrorAdd`
93 (`internal/control/mirrorcmd.go:58`) calls `webhook.ValidateURL`,
94 which refuses any other scheme (`internal/webhook/webhook.go:31-33`).
95 There is no ssh mirror URL to pin. Sync (`internal/mirror/mirror.go:76-116`)
96 runs `git fetch|push <url>` with a replaced environment (no proxy
97 variables), so the pin is `-c http.curloptResolve=<host>:<port>:<addrs>`
98 plus `-c http.followRedirects=false` (git's default follows a
99 redirect on the first request, which would reach an unchecked
100 host). Sync also refuses a non-http(s) scheme, for rows that
101 predate the save-time check.
102- Hook environment is set in exactly one place,
103 `runGit` (`internal/sshd/sshd.go:441-447`). `runGit` runs in the
104 daemon (embedded SSH) and in `gitbayd shell` (system SSH mode,
105 `cmd/gitbayd/system.go:97`), a separate process. The push token must
106 therefore be visible across processes: it goes in SQLite, not in
107 daemon memory.
108- In both SSH modes the hook runs as the uid git runs as, which is the
109 daemon's (`User=gitbay`, `deploy/cloud-init.yaml:210`; system mode
110 logs in as the account that owns `<root>`). So the peer-uid rule is
111 "equal to `os.Getuid()`".
112- `audit_log.actor_id` is `REFERENCES users(id) ON DELETE SET NULL`
113 (`0001_init.up.sql:186`). Hashing it would break the chain when an
114 account is deleted, so migration 0070 adds `actor_ref`, the id as
115 written, and the hash covers that.
116- Retention deletes the oldest audit rows (`internal/store/retention.go:75`).
117 Verification therefore takes the first remaining chained row's
118 `prev_hash` as given.
119- Audit rows are written from three kinds of process: the daemon,
120 `gitbayd shell`, and `gitbayd admin …`. The chain is computed inside
121 one `BEGIN IMMEDIATE` transaction (the store's DSN sets
122 `_txlock=immediate`, `internal/store/store.go:47`), which serialises
123 writers across processes. The journal line is written only where
124 stderr is the journal: `gitbayd serve`.
125- Pack generation runs in four places: SSH `gitutil.Transport`
126 (`internal/gitutil/gitutil.go:39`), smart-HTTP `uploadPack`
127 (`internal/httpd/smart.go:122`), git:// (`internal/gitd/gitd.go:71-77`),
128 and SSH `git-upload-archive`. The info/refs advertisement
129 (`smart.go:89`) and protocol-v2 `ls-refs` POSTs are ref listings and
130 stay outside the limit. Web archives are already bounded by
131 `archiveTimeout` and `MaxArchiveBytes` (`internal/gitutil/read.go:124-130`)
132 and stay outside. receive-pack stays outside: killing or queueing
133 it risks losing post-receive, which runs after the client has its
134 report.
135- `done` in `handleSession` (`internal/sshd/sshd.go:263-270`) closes
136 when the client closes the channel *or* the server stops. A queued
137 clone should give up on either; a running clone should be killed
138 only when the client left, never on a restart drain.
139- Dispatcher tests call `Dispatch` with a nil `Store` and expect
140 refusals (`internal/control/control_test.go:192-231`). Auditing a
141 refusal must tolerate a nil store.
142
143## Order and dependencies
144
145| # | Branch | Closes | Migration | Depends on |
146|---|---|---|---|---|
147| 1 | `https-tls-minimum` | #281 | — | — |
148| 2 | `mail-require-tls` | #280 | — | — |
149| 3 | `mirror-pin-address` | #279 | — | — |
150| 4 | `hook-socket-auth` | #282 | 0069 | — |
151| 5 | `audit-refusals-chain` | #275 | 0070 | — |
152| 6 | `pack-limit` | #262 | — | MR 5 (both edit `sshd.Exec`) |
153
154Cross-plan overlaps, all textual (rebase, no design dependency):
155
156- Plan 1 (credentials-and-sessions, #256) closes connections when a key
157 is removed; it edits `internal/sshd/sshd.go`, as do MRs 4, 5 and 6.
158- Plan 4 (data-at-rest-and-backup, #273) decrypts `m.Token` in
159 `internal/mirror/mirror.go`'s `sync`; MR 3 edits the same function.
160 Whichever lands second keeps both: decryption of the token and the
161 resolve-check-pin block.
162- Plan 5 (web-ux, #261 doc drift) may edit the `[limits]` section of
163 `Admin.org`, which MR 6 also edits (its "reserved, not yet enforced"
164 line for `max_pack_bytes`/`ssh_auth_rate` is stale; leave that line
165 to #261 unless it has not landed when MR 6 merges).
166
167## File map
168
169| File | MR | Responsibility |
170|---|---|---|
171| `cmd/gitbayd/main.go` | 1, 5, 6 | `serverTLS`; `st.AuditJournal`; `admin audit verify` wiring; one `packlimit.Limiter` |
172| `cmd/gitbayd/tls_test.go` | 1 | TLS floor |
173| `internal/config/config.go` | 2, 6 | `Mail.RequireTLS`, `Mail.TLS`, `Mail.TLSRequired`; `Limits.Pack*`, `PackLimits` |
174| `internal/config/config_test.go` | 2, 6 | validation and defaults |
175| `internal/mail/mail.go`, `mail_test.go` | 2 | require TLS, implicit TLS |
176| `internal/webhook/webhook.go` | 3 | `CheckAddrs` |
177| `internal/mirror/mirror.go`, `mirror_test.go` | 3 | resolve, check, pin |
178| `internal/store/migrations/0069_push_tokens.*.sql` | 4 | table |
179| `internal/store/pushtokens.go`, `pushtokens_test.go` | 4 | create, look up, delete |
180| `internal/store/retention.go` | 4 | sweep expired push tokens |
181| `internal/hookd/hookd.go`, `peercred_linux.go`, `peercred_other.go`, `socket_test.go` | 4 | 0600, peer uid, token check |
182| `internal/sshd/sshd.go` | 4, 5, 6 | mint token; audit refused push; acquire pack slot |
183| `cmd/gitbayd/hook.go` | 4 | send the token |
184| `internal/store/migrations/0070_audit_chain.*.sql` | 5 | chain columns |
185| `internal/store/audit.go`, `auditchain_test.go` | 5 | chained append, journal, verify |
186| `internal/control/control.go`, `auditrefusal.go`, `auditrefusal_test.go` | 5 | refusal auditing |
187| `cmd/gitbayd/auditverify.go` | 5 | `gitbayd admin audit verify` |
188| `internal/sshd/refusal_test.go` | 5, 6 | refused push audited; busy clone |
189| `e2e/audit_test.go` | 5 | `TestAuditChainVerify` |
190| `internal/packlimit/packlimit.go`, `packlimit_test.go` | 6 | the limiter |
191| `internal/gitutil/gitutil.go` | 6 | `Transport` takes a context |
192| `internal/httpd/smart.go` (`Server`, `New`, `uploadPack`), `packlimit_test.go` | 6 | limit on POST upload-pack, `ls-refs` outside |
193| `internal/gitd/gitd.go`, `gitd_test.go` | 6 | limit on git:// |
194| `cmd/gitbayd/system.go` | 5, 6 | `Exec` signature |
195| `deploy/clonebench.sh` | 6 | concurrent-clone benchmark |
196| `.gitbay/wiki/Admin.org`, `Performance.org`, `Threat-Model.org`, `Architecture/03-Deployment.org`, `04-Trust-Boundaries.org`, `06-Data-and-Cryptography.org`, `09-Controls.org`, `10-Known-Gaps.org` | 1–6 | docs per MR |
197
198---
199
200# MR 1: explicit TLS minimum (branch `https-tls-minimum`, closes #281)
201
202### Task 1.1: `serverTLS` sets TLS 1.2 on both TLS modes
203
204**Files:**
205- Create: `cmd/gitbayd/tls.go`
206- Create: `cmd/gitbayd/tls_test.go`
207- Modify: `cmd/gitbayd/main.go:241-242` (`case "files"`), `:301-302` (`case "acme"`)
208- Modify: `.gitbay/wiki/Admin.org` (`** [http]`, lines 72-80),
209 `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (HTTPS row, line 59),
210 `.gitbay/wiki/Architecture/10-Known-Gaps.org` (#281 row)
211
212**Interfaces:**
213- Produces: `func serverTLS(c *tls.Config) *tls.Config` in package `main` — sets `MinVersion = tls.VersionTLS12` on `c` (a new config when nil) and returns it.
214
215- [ ] **Step 1: Write the failing test**
216
217`cmd/gitbayd/tls_test.go`:
218
219```go
220package main
221
222import (
223 "crypto/tls"
224 "testing"
225)
226
227// The floor is stated in code rather than inherited from the Go
228// release the binary was built with (#281).
229func TestServerTLSMinimum(t *testing.T) {
230 if got := serverTLS(nil).MinVersion; got != tls.VersionTLS12 {
231 t.Fatalf("files mode: MinVersion %#x, want %#x", got, tls.VersionTLS12)
232 }
233 // autocert's config carries the ALPN protocols TLS-ALPN-01 needs;
234 // setting the floor must keep them.
235 base := &tls.Config{NextProtos: []string{"h2", "http/1.1", "acme-tls/1"}}
236 got := serverTLS(base)
237 if got.MinVersion != tls.VersionTLS12 || len(got.NextProtos) != 3 {
238 t.Fatalf("acme mode: %+v", got)
239 }
240}
241```
242
243- [ ] **Step 2: Run it and see it fail**
244
245Run: `go test ./cmd/gitbayd -run TestServerTLSMinimum -count=1`
246Expected: build failure, `undefined: serverTLS`.
247
248- [ ] **Step 3: Implement**
249
250`cmd/gitbayd/tls.go`:
251
252```go
253package main
254
255import "crypto/tls"
256
257// serverTLS sets the HTTPS listener's protocol floor: TLS 1.2 and 1.3,
258// with Go's default cipher suites.
259func serverTLS(c *tls.Config) *tls.Config {
260 if c == nil {
261 c = &tls.Config{}
262 }
263 c.MinVersion = tls.VersionTLS12
264 return c
265}
266```
267
268In `cmd/gitbayd/main.go`, `case "files":` becomes:
269
270```go
271 case "files":
272 hs.TLSConfig = serverTLS(nil)
273 errCh <- hs.ListenAndServeTLS(cfg.HTTP.CertFile, cfg.HTTP.KeyFile)
274```
275
276and in `case "acme":` replace `hs.TLSConfig = m.TLSConfig()` with:
277
278```go
279 hs.TLSConfig = serverTLS(m.TLSConfig())
280```
281
282`ListenAndServeTLS` clones `TLSConfig` and loads the certificate files
283into the clone, so setting it in files mode changes nothing else.
284
285- [ ] **Step 4: Run the test and the package**
286
287Run: `go test ./cmd/gitbayd -count=1 && go vet ./cmd/gitbayd`
288Expected: PASS.
289
290- [ ] **Step 5: Docs**
291
292`Admin.org`, append to the `** [http]` bullet list, after the `=off=` bullet:
293
294```org
295- The HTTPS listener accepts TLS 1.2 and 1.3 only (=serverTLS= in
296 =cmd/gitbayd/tls.go=), with the default cipher suites of the Go
297 release the binary was built with. =openssl s_client -connect
298 <host>:443 -tls1_1= fails the handshake.
299```
300
301`Architecture/06-Data-and-Cryptography.org`, HTTPS row:
302
303```org
304| HTTPS | TLS 1.2 minimum (=cmd/gitbayd/tls.go=), ACME or operator certificates; HSTS one year |
305```
306
307`Architecture/10-Known-Gaps.org`: delete the `#281` row.
308
309- [ ] **Step 6: Commit**
310
311```bash
312git add cmd/gitbayd/tls.go cmd/gitbayd/tls_test.go cmd/gitbayd/main.go \
313 .gitbay/wiki/Admin.org .gitbay/wiki/Architecture/06-Data-and-Cryptography.org \
314 .gitbay/wiki/Architecture/10-Known-Gaps.org
315git commit -S -m "https: TLS 1.2 minimum, set explicitly
316
317Closes #281"
318```
319
320- [ ] **Step 7: MR and merge**
321
322```bash
323git push -u origin https-tls-minimum
324gitbay mr create --source https-tls-minimum --target main --title "https: TLS 1.2 minimum, set explicitly"
325```
326
327After CI is green: `gitbay mr merge <n> --strategy ff`, then
328`git branch -d https-tls-minimum && git push origin --delete https-tls-minimum`.
329
330---
331
332# MR 2: mail requires TLS to a remote relay (branch `mail-require-tls`, closes #280)
333
334### Task 2.1: config `mail.require_tls` and `mail.tls`
335
336**Files:**
337- Modify: `internal/config/config.go:211-216` (`Mail`), `Validate` (after the `[mail] from` check, line 406-408)
338- Test: `internal/config/config_test.go`
339
340**Interfaces:**
341- Produces: `Mail.RequireTLS *bool` (`toml:"require_tls,omitempty"`), `Mail.TLS string` (`toml:"tls,omitempty"`, `""`/`"starttls"`/`"implicit"`), `func (m Mail) TLSRequired() bool`.
342
343- [ ] **Step 1: Write the failing tests**
344
345Append to `internal/config/config_test.go`:
346
347```go
348func TestMailTLSRequired(t *testing.T) {
349 off, on := false, true
350 for _, tc := range []struct {
351 m Mail
352 want bool
353 }{
354 {Mail{SMTPHost: "smtp.example.com:587"}, true},
355 {Mail{SMTPHost: "smtp.example.com"}, true},
356 {Mail{SMTPHost: "localhost:25"}, false},
357 {Mail{SMTPHost: "localhost"}, false},
358 {Mail{SMTPHost: "127.0.0.1:25"}, false},
359 {Mail{SMTPHost: "[::1]:25"}, false},
360 {Mail{SMTPHost: "smtp.example.com:587", RequireTLS: &off}, false},
361 {Mail{SMTPHost: "127.0.0.1:25", RequireTLS: &on}, true},
362 } {
363 if got := tc.m.TLSRequired(); got != tc.want {
364 t.Errorf("%+v: TLSRequired = %v, want %v", tc.m, got, tc.want)
365 }
366 }
367}
368```
369
370Add one case to the `cases` table in `TestContradictions`:
371
372```go
373 {
374 "unknown mail.tls",
375 minimal + "\n[mail]\nsmtp_host = \"mx.example\"\nfrom = \"gitbay@example\"\ntls = \"ssl\"\n",
376 "mail.tls must be starttls or implicit",
377 },
378```
379
380- [ ] **Step 2: Run them and see them fail**
381
382Run: `go test ./internal/config -run 'TestMailTLSRequired|TestContradictions' -count=1`
383Expected: build failure, `unknown field RequireTLS` / `TLSRequired undefined`.
384
385- [ ] **Step 3: Implement**
386
387`Mail` in `internal/config/config.go`:
388
389```go
390type Mail struct {
391 SMTPHost string `toml:"smtp_host"` // host:port (port defaults to 587, 465 with tls = "implicit")
392 From string `toml:"from"`
393 SMTPUser string `toml:"smtp_user,omitempty"`
394 SMTPPass string `toml:"smtp_pass,omitempty"`
395 // RequireTLS fails delivery when the relay does not offer STARTTLS,
396 // instead of sending in clear. Unset, it is on for any relay but
397 // localhost or a loopback address (TLSRequired).
398 RequireTLS *bool `toml:"require_tls,omitempty"`
399 // TLS is "starttls" (the default, also when empty) or "implicit":
400 // TLS from the first byte, as relays on port 465 expect.
401 TLS string `toml:"tls,omitempty"`
402}
403
404// TLSRequired reports whether mail must not go to the relay in clear.
405func (m Mail) TLSRequired() bool {
406 if m.RequireTLS != nil {
407 return *m.RequireTLS
408 }
409 host := m.SMTPHost
410 if h, _, err := net.SplitHostPort(host); err == nil {
411 host = h
412 }
413 host = strings.Trim(host, "[]")
414 if host == "localhost" {
415 return false
416 }
417 ip := net.ParseIP(host)
418 return ip == nil || !ip.IsLoopback()
419}
420```
421
422In `Validate`, after the `[mail] from is required` check:
423
424```go
425 if t := c.Mail.TLS; t != "" && t != "starttls" && t != "implicit" {
426 errs = append(errs, fmt.Errorf("mail.tls must be starttls or implicit, got %q", t))
427 }
428```
429
430- [ ] **Step 4: Run the package**
431
432Run: `go test ./internal/config -count=1`
433Expected: PASS.
434
435- [ ] **Step 5: Commit**
436
437```bash
438git add internal/config/config.go internal/config/config_test.go
439git commit -S -m "config: mail.require_tls and mail.tls
440
441Ref #280"
442```
443
444### Task 2.2: `mail.Send` refuses plaintext when TLS is required; implicit TLS
445
446**Files:**
447- Modify: `internal/mail/mail.go` (whole `Send`, package comment lines 1-3)
448- Create: `internal/mail/mail_test.go`
449
450**Interfaces:**
451- Consumes: `config.Mail.TLSRequired()`, `config.Mail.TLS` (Task 2.1).
452- Produces: unexported `var rootCAs *x509.CertPool` (tests set it); `Send` signature unchanged.
453
454- [ ] **Step 1: Write the failing tests**
455
456`internal/mail/mail_test.go`:
457
458```go
459package mail
460
461import (
462 "bufio"
463 "crypto/tls"
464 "crypto/x509"
465 "fmt"
466 "net"
467 "net/http"
468 "net/http/httptest"
469 "strings"
470 "sync"
471 "testing"
472
473 "gitbay.org/gitbay/internal/config"
474)
475
476// fakeRelay is an SMTP server that never offers STARTTLS. Given a TLS
477// config it speaks TLS from the first byte, as a port-465 relay does.
478type fakeRelay struct {
479 addr string
480 mu sync.Mutex
481 data []string
482}
483
484func startRelay(t *testing.T, tlsCfg *tls.Config) *fakeRelay {
485 t.Helper()
486 ln, err := net.Listen("tcp", "127.0.0.1:0")
487 if err != nil {
488 t.Fatal(err)
489 }
490 if tlsCfg != nil {
491 ln = tls.NewListener(ln, tlsCfg)
492 }
493 t.Cleanup(func() { ln.Close() })
494 f := &fakeRelay{addr: ln.Addr().String()}
495 go func() {
496 for {
497 conn, err := ln.Accept()
498 if err != nil {
499 return
500 }
501 go f.serve(conn)
502 }
503 }()
504 return f
505}
506
507func (f *fakeRelay) serve(conn net.Conn) {
508 defer conn.Close()
509 r := bufio.NewReader(conn)
510 fmt.Fprint(conn, "220 fake\r\n")
511 var body strings.Builder
512 inData := false
513 for {
514 line, err := r.ReadString('\n')
515 if err != nil {
516 return
517 }
518 line = strings.TrimRight(line, "\r\n")
519 switch {
520 case inData && line == ".":
521 f.mu.Lock()
522 f.data = append(f.data, body.String())
523 f.mu.Unlock()
524 inData = false
525 fmt.Fprint(conn, "250 ok\r\n")
526 case inData:
527 body.WriteString(line + "\n")
528 case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
529 fmt.Fprint(conn, "250-fake\r\n250 SIZE 1000000\r\n")
530 case line == "DATA":
531 inData = true
532 fmt.Fprint(conn, "354 go\r\n")
533 case line == "QUIT":
534 fmt.Fprint(conn, "221 bye\r\n")
535 return
536 default:
537 fmt.Fprint(conn, "250 ok\r\n")
538 }
539 }
540}
541
542func (f *fakeRelay) delivered() int {
543 f.mu.Lock()
544 defer f.mu.Unlock()
545 return len(f.data)
546}
547
548func mailCfg(host string) config.Config {
549 var cfg config.Config
550 cfg.Mail.SMTPHost, cfg.Mail.From = host, "gitbay@example.test"
551 return cfg
552}
553
554func TestRequireTLSRefusesPlaintextRelay(t *testing.T) {
555 relay := startRelay(t, nil)
556 cfg := mailCfg(relay.addr)
557 on := true
558 cfg.Mail.RequireTLS = &on
559 err := Send(cfg, "a@example.test", "subject", "body")
560 if err == nil || !strings.Contains(err.Error(), "STARTTLS") {
561 t.Fatalf("Send = %v, want a refusal naming STARTTLS", err)
562 }
563 if n := relay.delivered(); n != 0 {
564 t.Fatalf("%d message(s) sent in clear", n)
565 }
566}
567
568// A loopback relay has no network to cross; the default leaves it in
569// clear, which is what the e2e suite's fake relay relies on.
570func TestLoopbackRelayDefaultsToPlaintext(t *testing.T) {
571 relay := startRelay(t, nil)
572 if err := Send(mailCfg(relay.addr), "a@example.test", "subject", "body"); err != nil {
573 t.Fatal(err)
574 }
575 if n := relay.delivered(); n != 1 {
576 t.Fatalf("delivered %d, want 1", n)
577 }
578}
579
580func TestImplicitTLS(t *testing.T) {
581 ts := httptest.NewTLSServer(http.NotFoundHandler())
582 defer ts.Close()
583 pool := x509.NewCertPool()
584 pool.AddCert(ts.Certificate())
585 prev := rootCAs
586 rootCAs = pool
587 defer func() { rootCAs = prev }()
588
589 relay := startRelay(t, &tls.Config{Certificates: ts.TLS.Certificates})
590 cfg := mailCfg(relay.addr)
591 cfg.Mail.TLS = "implicit"
592 on := true
593 cfg.Mail.RequireTLS = &on
594 if err := Send(cfg, "a@example.test", "subject", "body"); err != nil {
595 t.Fatal(err)
596 }
597 if n := relay.delivered(); n != 1 {
598 t.Fatalf("delivered %d, want 1", n)
599 }
600}
601```
602
603`httptest`'s certificate carries `127.0.0.1` as an IP SAN, so
604verification against `ServerName: "127.0.0.1"` passes.
605
606- [ ] **Step 2: Run them and see them fail**
607
608Run: `go test ./internal/mail -count=1`
609Expected: build failure, `undefined: rootCAs`.
610
611- [ ] **Step 3: Implement**
612
613`internal/mail/mail.go`:
614
615```go
616// Package mail sends transactional email over SMTP: verification codes and
617// invites. The connection is encrypted with STARTTLS, or with TLS from the
618// first byte when mail.tls = "implicit"; a relay that offers neither gets
619// nothing unless mail.require_tls is off. PLAIN auth when credentials are
620// configured.
621package mail
622
623import (
624 "crypto/tls"
625 "crypto/x509"
626 "fmt"
627 "net"
628 "net/smtp"
629 "strings"
630 "time"
631
632 "gitbay.org/gitbay/internal/config"
633)
634
635// rootCAs verifies the relay's certificate; nil is the system pool.
636var rootCAs *x509.CertPool
637
638// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port.
639func Send(cfg config.Config, to, subject, body string) error {
640 m := cfg.Mail
641 if m.SMTPHost == "" || m.From == "" {
642 return fmt.Errorf("[mail] smtp_host and from must be configured")
643 }
644 implicit := m.TLS == "implicit"
645 host := m.SMTPHost
646 if !strings.Contains(host, ":") {
647 if implicit {
648 host += ":465"
649 } else {
650 host += ":587"
651 }
652 }
653 hostname, _, _ := net.SplitHostPort(host)
654 tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs}
655
656 msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf(
657 "From: %s\nTo: %s\nSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n",
658 m.From, to, subject, time.Now().Format(time.RFC1123Z), body))
659
660 c, err := dial(host, hostname, implicit, tlsCfg)
661 if err != nil {
662 return fmt.Errorf("smtp dial %s: %w", host, err)
663 }
664 defer c.Close()
665 if !implicit {
666 if ok, _ := c.Extension("STARTTLS"); ok {
667 if err := c.StartTLS(tlsCfg); err != nil {
668 return fmt.Errorf("starttls: %w", err)
669 }
670 } else if m.TLSRequired() {
671 return fmt.Errorf("%s does not offer STARTTLS and mail.require_tls is on; not sending in clear", host)
672 }
673 }
674 if m.SMTPUser != "" {
675 if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil {
676 return fmt.Errorf("smtp auth: %w", err)
677 }
678 }
679 if err := c.Mail(m.From); err != nil {
680 return err
681 }
682 if err := c.Rcpt(to); err != nil {
683 return err
684 }
685 w, err := c.Data()
686 if err != nil {
687 return err
688 }
689 if _, err := w.Write([]byte(msg)); err != nil {
690 return err
691 }
692 if err := w.Close(); err != nil {
693 return err
694 }
695 return c.Quit()
696}
697
698// dial opens the SMTP session: plain TCP for STARTTLS, or TLS from the
699// first byte.
700func dial(addr, hostname string, implicit bool, tlsCfg *tls.Config) (*smtp.Client, error) {
701 if !implicit {
702 return smtp.Dial(addr)
703 }
704 conn, err := tls.Dial("tcp", addr, tlsCfg)
705 if err != nil {
706 return nil, err
707 }
708 c, err := smtp.NewClient(conn, hostname)
709 if err != nil {
710 conn.Close()
711 return nil, err
712 }
713 return c, nil
714}
715```
716
717- [ ] **Step 4: Run the package**
718
719Run: `go test ./internal/mail ./internal/config -count=1 && go vet ./internal/mail`
720Expected: PASS.
721
722- [ ] **Step 5: Docs**
723
724`Admin.org`, `** [mail]` becomes:
725
726```org
727** [mail]
728- =smtp_host= (host:port; 587 assumed, 465 with =tls = "implicit"=),
729 =from=, optional =smtp_user= / =smtp_pass=. Required for invite/open
730 registration and self-service =email add=; in closed mode you may omit
731 it entirely and assert addresses by hand (below).
732- =tls= — =starttls= (default) or =implicit= (TLS from the first byte,
733 for relays on 465).
734- =require_tls= — with =starttls=, a relay that does not offer STARTTLS
735 gets no mail: delivery fails and retries, and the admin page's Mail
736 table shows the error. Unset, it is on for every relay except
737 =localhost= and loopback addresses; set =false= to allow plaintext
738 to a remote relay.
739```
740
741`Architecture/03-Deployment.org`, SMTP relay row:
742
743```org
744| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
745```
746
747`Architecture/06-Data-and-Cryptography.org`, SMTP row:
748
749```org
750| SMTP | STARTTLS required unless the relay is local (=mail.require_tls=), or implicit TLS (=mail.tls=) |
751```
752
753`Architecture/09-Controls.org`, "SMTP credentials protected in transit" row:
754
755```org
756| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) |
757```
758
759`Architecture/10-Known-Gaps.org`: delete the `#280` row.
760
761- [ ] **Step 6: Commit, MR, merge**
762
763```bash
764git add internal/mail .gitbay/wiki/Admin.org .gitbay/wiki/Architecture/03-Deployment.org \
765 .gitbay/wiki/Architecture/06-Data-and-Cryptography.org \
766 .gitbay/wiki/Architecture/09-Controls.org .gitbay/wiki/Architecture/10-Known-Gaps.org
767git commit -S -m "mail: require TLS to a non-local relay; implicit TLS option
768
769Closes #280"
770git push -u origin mail-require-tls
771gitbay mr create --source mail-require-tls --target main --title "mail: require TLS to a non-local relay"
772```
773
774Before merging, run the runbook's #280 check (bay1's relay must offer
775STARTTLS or be local). Merge `--strategy ff` after CI, delete the branch
776both places.
777
778---
779
780# MR 3: mirrors connect only to an address checked at sync time (branch `mirror-pin-address`, closes #279)
781
782### Task 3.1: `webhook.CheckAddrs`
783
784**Files:**
785- Modify: `internal/webhook/webhook.go` (add after `isForbidden`, line 55)
786- Create: `internal/webhook/webhook_test.go`
787
788**Interfaces:**
789- Produces: `func CheckAddrs(host string, ips []net.IP, allowLocal bool) error`.
790
791- [ ] **Step 1: Write the failing test**
792
793```go
794package webhook
795
796import (
797 "net"
798 "strings"
799 "testing"
800)
801
802func TestCheckAddrs(t *testing.T) {
803 public := []net.IP{net.ParseIP("203.0.113.5")}
804 mixed := []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("10.1.2.3")}
805 if err := CheckAddrs("git.example", public, false); err != nil {
806 t.Fatalf("public: %v", err)
807 }
808 if err := CheckAddrs("git.example", mixed, false); err == nil || !strings.Contains(err.Error(), "10.1.2.3") {
809 t.Fatalf("mixed: %v", err)
810 }
811 if err := CheckAddrs("git.example", mixed, true); err != nil {
812 t.Fatalf("allow_local: %v", err)
813 }
814}
815```
816
817- [ ] **Step 2: Run it and see it fail**
818
819Run: `go test ./internal/webhook -run TestCheckAddrs -count=1`
820Expected: `undefined: CheckAddrs`.
821
822- [ ] **Step 3: Implement**
823
824```go
825// CheckAddrs refuses host when any of its resolved addresses is
826// loopback, private or link-local, unless allowLocal. A caller resolves
827// immediately before connecting and connects only to the addresses it
828// checked.
829func CheckAddrs(host string, ips []net.IP, allowLocal bool) error {
830 if allowLocal {
831 return nil
832 }
833 for _, ip := range ips {
834 if isForbidden(ip) {
835 return fmt.Errorf("%s resolves to private or local address %s; refusing (SSRF)", host, ip)
836 }
837 }
838 return nil
839}
840```
841
842- [ ] **Step 4: Run it**
843
844Run: `go test ./internal/webhook -count=1`
845Expected: PASS.
846
847- [ ] **Step 5: Commit**
848
849```bash
850git add internal/webhook
851git commit -S -m "webhook: CheckAddrs for callers that resolve before connecting
852
853Ref #279"
854```
855
856### Task 3.2: mirror sync resolves, checks and pins
857
858**Files:**
859- Modify: `internal/mirror/mirror.go:30-44` (`Worker`, `New`), `:76-116` (`sync`)
860- Create: `internal/mirror/mirror_test.go`
861
862**Interfaces:**
863- Consumes: `webhook.CheckAddrs` (Task 3.1).
864- Produces: `Worker.Lookup func(ctx context.Context, host string) ([]net.IP, error)` (set by `New`); unexported `pinArgs(u *url.URL, ips []net.IP) []string`.
865
866- [ ] **Step 1: Write the failing tests**
867
868`internal/mirror/mirror_test.go`:
869
870```go
871package mirror
872
873import (
874 "context"
875 "net"
876 "net/http/cgi"
877 "net/http/httptest"
878 "net/url"
879 "os"
880 "os/exec"
881 "path/filepath"
882 "slices"
883 "strings"
884 "testing"
885
886 "gitbay.org/gitbay/internal/config"
887 "gitbay.org/gitbay/internal/control"
888 "gitbay.org/gitbay/internal/store"
889)
890
891func git(t *testing.T, dir string, args ...string) string {
892 t.Helper()
893 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
894 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+t.TempDir(),
895 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
896 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
897 out, err := cmd.CombinedOutput()
898 if err != nil {
899 t.Fatalf("git %v: %v\n%s", args, err, out)
900 }
901 return strings.TrimSpace(string(out))
902}
903
904// upstream serves a bare repository with one commit on main over smart
905// HTTP and returns its URL and that commit.
906func upstream(t *testing.T) (string, string) {
907 t.Helper()
908 parent := t.TempDir()
909 bare := filepath.Join(parent, "remote.git")
910 work := filepath.Join(parent, "work")
911 git(t, parent, "init", "-q", "--bare", "--initial-branch=main", bare)
912 git(t, parent, "init", "-q", "--initial-branch=main", work)
913 git(t, work, "commit", "-q", "--allow-empty", "-m", "one")
914 git(t, work, "push", "-q", bare, "main")
915 sha := git(t, work, "rev-parse", "HEAD")
916 execPath := git(t, parent, "--exec-path")
917 srv := httptest.NewServer(&cgi.Handler{
918 Path: filepath.Join(execPath, "git-http-backend"),
919 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
920 })
921 t.Cleanup(srv.Close)
922 return srv.URL + "/remote.git", sha
923}
924
925// local returns a store with alice/app, its bare repository under root,
926// and the pull mirror row for url.
927func local(t *testing.T, root, mirrorURL string) (*store.Store, store.Mirror, string) {
928 t.Helper()
929 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
930 if err != nil {
931 t.Fatal(err)
932 }
933 t.Cleanup(func() { st.Close() })
934 if err := st.MigrateUp(); err != nil {
935 t.Fatal(err)
936 }
937 uid, err := st.CreateUser("alice", false)
938 if err != nil {
939 t.Fatal(err)
940 }
941 repoID, err := st.CreateRepo("user", uid, "app", "public")
942 if err != nil {
943 t.Fatal(err)
944 }
945 dir := control.RepoDir(root, "alice", "app")
946 os.MkdirAll(filepath.Dir(dir), 0o755)
947 git(t, root, "init", "-q", "--bare", dir)
948 if _, err := st.AddMirror(repoID, "pull", mirrorURL, "", ""); err != nil {
949 t.Fatal(err)
950 }
951 due, err := st.DueMirrors(900)
952 if err != nil || len(due) != 1 {
953 t.Fatalf("due mirrors: %v %v", due, err)
954 }
955 return st, due[0], dir
956}
957
958// mirror.test does not resolve; the fetch works only because git was
959// pinned to the address the worker looked up and checked.
960func TestSyncConnectsToTheCheckedAddress(t *testing.T) {
961 remote, sha := upstream(t)
962 u, _ := url.Parse(remote)
963 root := t.TempDir()
964 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
965 var cfg config.Config
966 cfg.Server.Root = root
967 cfg.Webhooks.AllowLocal = true
968 var asked []string
969 w := &Worker{St: st, Cfg: cfg, Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
970 asked = append(asked, host)
971 return []net.IP{net.ParseIP("127.0.0.1")}, nil
972 }}
973 if err := w.sync(m); err != nil {
974 t.Fatal(err)
975 }
976 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
977 t.Fatalf("main = %s, want %s", got, sha)
978 }
979 if !slices.Equal(asked, []string{"mirror.test"}) {
980 t.Fatalf("looked up %v", asked)
981 }
982}
983
984// The URL passed the check when it was saved; the answer at sync time
985// is what counts.
986func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
987 root := t.TempDir()
988 st, m, _ := local(t, root, "https://mirror.test/x.git")
989 var cfg config.Config
990 cfg.Server.Root = root
991 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
992 return []net.IP{net.ParseIP("10.0.0.7")}, nil
993 }}
994 err := w.sync(m)
995 if err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
996 t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err)
997 }
998}
999
1000func TestPinArgs(t *testing.T) {
1001 u, _ := url.Parse("https://git.example/x.git")
1002 got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")})
1003 want := []string{"-c", "http.followRedirects=false",
1004 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
1005 if !slices.Equal(got, want) {
1006 t.Fatalf("https: %q", got)
1007 }
1008 u, _ = url.Parse("http://git.example:8080/x.git")
1009 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
1010 t.Fatalf("http with port: %q", got)
1011 }
1012 // An address literal is its own resolution; there is nothing to pin.
1013 u, _ = url.Parse("https://203.0.113.5/x.git")
1014 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
1015 t.Fatalf("literal: %q", got)
1016 }
1017}
1018```
1019
1020- [ ] **Step 2: Run them and see them fail**
1021
1022Run: `go test ./internal/mirror -count=1`
1023Expected: build failure, `unknown field Lookup` / `undefined: pinArgs`.
1024
1025- [ ] **Step 3: Implement**
1026
1027`Worker` and `New`:
1028
1029```go
1030type Worker struct {
1031 St *store.Store
1032 Cfg config.Config
1033 Tick time.Duration
1034 // Lookup resolves a mirror's host immediately before each sync.
1035 Lookup func(ctx context.Context, host string) ([]net.IP, error)
1036}
1037
1038func New(st *store.Store, cfg config.Config) *Worker {
1039 tick := 10 * time.Second
1040 if v := os.Getenv("GITBAY_MIRROR_TICK"); v != "" {
1041 if d, err := time.ParseDuration(v); err == nil {
1042 tick = d
1043 }
1044 }
1045 return &Worker{St: st, Cfg: cfg, Tick: tick,
1046 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
1047 return net.DefaultResolver.LookupIP(ctx, "ip", host)
1048 }}
1049}
1050```
1051
1052`sync` from the top through the argv; the askpass block is unchanged
1053and the timeout context moves above the lookup so the lookup shares it:
1054
1055```go
1056func (w *Worker) sync(m store.Mirror) error {
1057 repo, err := w.St.RepoByID(m.RepoID)
1058 if err != nil {
1059 return err
1060 }
1061 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
1062 u, err := url.Parse(m.URL)
1063 if err != nil {
1064 return err
1065 }
1066 if u.Scheme != "https" && u.Scheme != "http" {
1067 return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme)
1068 }
1069
1070 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
1071 defer cancel()
1072 // The URL was checked when saved, but DNS can answer differently
1073 // now. Check what it resolves to at sync time, then let git connect
1074 // to exactly those addresses.
1075 ips, err := w.Lookup(ctx, u.Hostname())
1076 if err != nil {
1077 return fmt.Errorf("resolving %s: %w", u.Hostname(), err)
1078 }
1079 if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil {
1080 return err
1081 }
1082
1083 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root}
1084 if m.Token != "" {
1085 // (askpass block unchanged)
1086 }
1087
1088 args := append(pinArgs(u, ips), "-C", dir)
1089 if m.Direction == "push" {
1090 // Branches and tags only: internal refs (merge-requests) stay home.
1091 args = append(args, "push", "--prune", m.URL,
1092 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
1093 } else {
1094 args = append(args, "fetch", "--prune", m.URL,
1095 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
1096 }
1097 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
1098 cmd.Env = env
1099 if out, err := cmd.CombinedOutput(); err != nil {
1100 return fmt.Errorf("git %s: %v: %.300s", m.Direction, err, out)
1101 }
1102 return nil
1103}
1104
1105// pinArgs keeps git on the addresses just checked: curl's resolve list
1106// pins the host, and with redirects off a server cannot send git on to
1107// a host nobody checked. An address literal needs no pin.
1108func pinArgs(u *url.URL, ips []net.IP) []string {
1109 args := []string{"-c", "http.followRedirects=false"}
1110 host := u.Hostname()
1111 if net.ParseIP(host) != nil {
1112 return args
1113 }
1114 port := u.Port()
1115 if port == "" {
1116 port = "443"
1117 if u.Scheme == "http" {
1118 port = "80"
1119 }
1120 }
1121 addrs := make([]string, len(ips))
1122 for i, ip := range ips {
1123 if ip.To4() == nil {
1124 addrs[i] = "[" + ip.String() + "]"
1125 } else {
1126 addrs[i] = ip.String()
1127 }
1128 }
1129 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
1130}
1131```
1132
1133Imports gain `net`, `net/url`, `strings`, and
1134`gitbay.org/gitbay/internal/webhook`. `mirror` does not import
1135`webhook` today; `webhook` imports only `store`, so there is no cycle.
1136The `// (askpass block unchanged)` line stands for lines 84-97 kept as
1137they are; do not type it literally.
1138
1139- [ ] **Step 4: Run the package and the existing mirror e2e**
1140
1141Run: `go test ./internal/mirror ./internal/webhook -count=1 && go vet ./internal/mirror`
1142Expected: PASS.
1143
1144Run: `go test ./e2e -run TestMirrors -count=1`
1145Expected: PASS (its URLs are `http://127.0.0.1:<port>/…`, address
1146literals, so they take the no-pin branch; redirects are not used).
1147
1148- [ ] **Step 5: Docs**
1149
1150`Admin.org`, `** [mirrors]` last line becomes:
1151
1152```org
1153 Mirror URLs pass the same SSRF rules as webhook targets, when saved
1154 and again before every sync; git then connects only to the addresses
1155 that were checked (=http.curloptResolve=) and does not follow
1156 redirects, so a mirror of a renamed repository fails until its URL
1157 is updated. Needs git 2.37 or later on the server.
1158```
1159
1160`Threat-Model.org`, the paragraph under `* Network-facing request forgery`:
1161
1162```org
1163Anything that makes the *server* open an outbound connection to a
1164user-supplied address — webhook delivery, GitHub-history import
1165=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
1166must be http/https and, unless =webhooks.allow_local= is set, the
1167resolved address must not be loopback, private, or link-local. The
1168webhook dialer re-checks at connect time, and the mirror worker
1169resolves and checks before each sync and pins git to the checked
1170addresses, so a DNS answer that changes after validation still cannot
1171reach private space. Redirects are never followed.
1172```
1173
1174`Architecture/03-Deployment.org`, Mirror URLs row:
1175
1176```org
1177| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
1178```
1179
1180`Architecture/09-Controls.org`, SSRF row:
1181
1182```org
1183| SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=) |
1184```
1185
1186`Architecture/10-Known-Gaps.org`: delete the `#279` row.
1187
1188- [ ] **Step 6: Commit, MR, merge**
1189
1190```bash
1191git add internal/mirror .gitbay/wiki/Admin.org .gitbay/wiki/Threat-Model.org \
1192 .gitbay/wiki/Architecture/03-Deployment.org .gitbay/wiki/Architecture/09-Controls.org \
1193 .gitbay/wiki/Architecture/10-Known-Gaps.org
1194git commit -S -m "mirror: check the address before each sync and pin git to it
1195
1196Closes #279"
1197git push -u origin mirror-pin-address
1198gitbay mr create --source mirror-pin-address --target main --title "mirror: check the address before each sync and pin git to it"
1199```
1200
1201Before merging, the runbook's #279 check (git ≥ 2.37 on bay1). Merge
1202`--strategy ff` after CI, delete the branch both places.
1203
1204---
1205
1206# MR 4: authenticated hook socket (branch `hook-socket-auth`, closes #282)
1207
1208### Task 4.1: `push_tokens` table and store methods
1209
1210**Files:**
1211- Create: `internal/store/migrations/0069_push_tokens.up.sql`, `0069_push_tokens.down.sql`
1212- Create: `internal/store/pushtokens.go`, `internal/store/pushtokens_test.go`
1213- Modify: `internal/store/retention.go:47-54` (`expired` list)
1214
1215**Interfaces:**
1216- Produces:
1217 - `type PushToken struct { RepoID, UserID int64; Scope string }`
1218 - `func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error)` — returns the raw token; stores `HashToken(token)`; expires in 24h.
1219 - `func (s *Store) PushTokenByHash(hash string) (PushToken, error)` — `ErrNotFound` when absent or expired.
1220 - `func (s *Store) DeletePushToken(token string) error` — takes the raw token.
1221
1222- [ ] **Step 1: Write the failing test**
1223
1224`internal/store/pushtokens_test.go`:
1225
1226```go
1227package store
1228
1229import (
1230 "errors"
1231 "testing"
1232 "time"
1233)
1234
1235func TestPushTokens(t *testing.T) {
1236 s := open(t)
1237 if err := s.MigrateUp(); err != nil {
1238 t.Fatal(err)
1239 }
1240 uid, err := s.CreateUser("alice", false)
1241 if err != nil {
1242 t.Fatal(err)
1243 }
1244 repoID, err := s.CreateRepo("user", uid, "app", "public")
1245 if err != nil {
1246 t.Fatal(err)
1247 }
1248 token, err := s.CreatePushToken(repoID, uid, "full")
1249 if err != nil {
1250 t.Fatal(err)
1251 }
1252 got, err := s.PushTokenByHash(HashToken(token))
1253 if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) {
1254 t.Fatalf("lookup = %+v, %v", got, err)
1255 }
1256 if err := s.DeletePushToken(token); err != nil {
1257 t.Fatal(err)
1258 }
1259 if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) {
1260 t.Fatalf("after delete: %v", err)
1261 }
1262
1263 // A token whose receive-pack never cleaned up is swept after a day.
1264 stale, err := s.CreatePushToken(repoID, uid, "full")
1265 if err != nil {
1266 t.Fatal(err)
1267 }
1268 swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour))
1269 if err != nil || swept["push_tokens"] != 1 {
1270 t.Fatalf("sweep = %v, %v", swept, err)
1271 }
1272 if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) {
1273 t.Fatalf("after sweep: %v", err)
1274 }
1275}
1276```
1277
1278- [ ] **Step 2: Run it and see it fail**
1279
1280Run: `go test ./internal/store -run TestPushTokens -count=1`
1281Expected: build failure, `s.CreatePushToken undefined`.
1282
1283- [ ] **Step 3: Implement**
1284
1285`0069_push_tokens.up.sql`:
1286
1287```sql
1288-- One row per receive-pack in flight. The hook names its push by the
1289-- token; hookd answers only a live one. Only the SHA-256 is stored.
1290CREATE TABLE push_tokens (
1291 token_hash TEXT PRIMARY KEY,
1292 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
1293 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1294 scope TEXT NOT NULL,
1295 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
1296 expires_at TEXT NOT NULL
1297);
1298```
1299
1300`0069_push_tokens.down.sql`:
1301
1302```sql
1303DROP TABLE push_tokens;
1304```
1305
1306`internal/store/pushtokens.go`:
1307
1308```go
1309package store
1310
1311import (
1312 "database/sql"
1313 "errors"
1314 "time"
1315)
1316
1317// PushToken is the receive-pack a hook request speaks for.
1318type PushToken struct {
1319 RepoID int64
1320 UserID int64
1321 Scope string
1322}
1323
1324// pushTokenTTL bounds a row whose receive-pack died before deleting it.
1325const pushTokenTTL = 24 * time.Hour
1326
1327// CreatePushToken records a token for one receive-pack and returns it.
1328func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) {
1329 token, hash, err := NewToken()
1330 if err != nil {
1331 return "", err
1332 }
1333 _, err = s.DB.Exec(
1334 "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
1335 hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL)))
1336 if err != nil {
1337 return "", err
1338 }
1339 return token, nil
1340}
1341
1342func (s *Store) PushTokenByHash(hash string) (PushToken, error) {
1343 var t PushToken
1344 err := s.DB.QueryRow(
1345 "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?",
1346 hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope)
1347 if errors.Is(err, sql.ErrNoRows) {
1348 return PushToken{}, ErrNotFound
1349 }
1350 return t, err
1351}
1352
1353func (s *Store) DeletePushToken(token string) error {
1354 _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token))
1355 return err
1356}
1357```
1358
1359`internal/store/retention.go`, the `expired` list gains a row:
1360
1361```go
1362 {"web_sessions", "expires_at <= ?"},
1363 {"login_tokens", "expires_at <= ?"},
1364 {"email_tokens", "expires_at <= ?"},
1365 {"push_tokens", "expires_at <= ?"},
1366```
1367
1368- [ ] **Step 4: Run the package**
1369
1370Run: `go test ./internal/store -count=1`
1371Expected: PASS, including `TestMigrateUpDown`.
1372
1373- [ ] **Step 5: Commit**
1374
1375```bash
1376git add internal/store
1377git commit -S -m "store: push tokens for receive-pack
1378
1379Ref #282"
1380```
1381
1382### Task 4.2: hookd requires 0600, the daemon's uid, and a live push token
1383
1384**Files:**
1385- Modify: `internal/hookd/hookd.go:34-50` (constants, `Request`), `:90-128` (`Serve`, `handle`)
1386- Create: `internal/hookd/peercred_linux.go`, `internal/hookd/peercred_other.go`
1387- Create: `internal/hookd/socket_test.go`
1388
1389**Interfaces:**
1390- Consumes: `store.CreatePushToken`, `store.PushTokenByHash`, `store.HashToken` (Task 4.1).
1391- Produces: `hookd.EnvToken = "GITBAY_PUSH_TOKEN"`; `Request.Token string` (`json:"token"`); unexported `checkPeer(net.Conn) error`.
1392
1393- [ ] **Step 1: Write the failing tests**
1394
1395`internal/hookd/socket_test.go`:
1396
1397```go
1398package hookd
1399
1400import (
1401 "os"
1402 "path/filepath"
1403 "strings"
1404 "testing"
1405
1406 "gitbay.org/gitbay/internal/config"
1407 "gitbay.org/gitbay/internal/store"
1408)
1409
1410func serveSocket(t *testing.T) (sock string, st *store.Store, repoID, uid int64) {
1411 t.Helper()
1412 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
1413 if err != nil {
1414 t.Fatal(err)
1415 }
1416 t.Cleanup(func() { st.Close() })
1417 if err := st.MigrateUp(); err != nil {
1418 t.Fatal(err)
1419 }
1420 if uid, err = st.CreateUser("alice", false); err != nil {
1421 t.Fatal(err)
1422 }
1423 if repoID, err = st.CreateRepo("user", uid, "app", "public"); err != nil {
1424 t.Fatal(err)
1425 }
1426 var cfg config.Config
1427 cfg.Server.Root = t.TempDir()
1428 stop, err := Serve(cfg, st)
1429 if err != nil {
1430 t.Fatal(err)
1431 }
1432 t.Cleanup(func() { stop() })
1433 return SocketPath(cfg.Server.Root), st, repoID, uid
1434}
1435
1436func TestSocketIsOwnerOnly(t *testing.T) {
1437 sock, _, _, _ := serveSocket(t)
1438 fi, err := os.Stat(sock)
1439 if err != nil {
1440 t.Fatal(err)
1441 }
1442 if fi.Mode().Perm() != 0o600 {
1443 t.Fatalf("mode %v, want 0600", fi.Mode().Perm())
1444 }
1445}
1446
1447// A request speaks for a receive-pack sshd started, and only for the
1448// repository, account and scope that push was started with (#282).
1449func TestHookRequestNeedsItsPushToken(t *testing.T) {
1450 sock, st, repoID, uid := serveSocket(t)
1451 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full"}
1452
1453 resp, err := Ask(sock, req, nil)
1454 if err != nil {
1455 t.Fatal(err)
1456 }
1457 if resp.Allow || !strings.Contains(resp.Message, "not started by this server") {
1458 t.Fatalf("no token: %+v", resp)
1459 }
1460
1461 token, err := st.CreatePushToken(repoID, uid, "full")
1462 if err != nil {
1463 t.Fatal(err)
1464 }
1465 req.Token = token
1466 if resp, err = Ask(sock, req, nil); err != nil || !resp.Allow {
1467 t.Fatalf("with token: %+v, %v", resp, err)
1468 }
1469
1470 other, err := st.CreateUser("mallory", false)
1471 if err != nil {
1472 t.Fatal(err)
1473 }
1474 forged := req
1475 forged.UserID = other
1476 if resp, err = Ask(sock, forged, nil); err != nil || resp.Allow {
1477 t.Fatalf("token for another account: %+v, %v", resp, err)
1478 }
1479
1480 if err := st.DeletePushToken(token); err != nil {
1481 t.Fatal(err)
1482 }
1483 if resp, err = Ask(sock, req, nil); err != nil || resp.Allow {
1484 t.Fatalf("finished push: %+v, %v", resp, err)
1485 }
1486}
1487```
1488
1489The peer-uid check is exercised by the same test on Linux (CI on
1490bay1): the test process is the daemon's uid, so a refusal there fails
1491the "with token" case.
1492
1493- [ ] **Step 2: Run them and see them fail**
1494
1495Run: `go test ./internal/hookd -run 'TestSocketIsOwnerOnly|TestHookRequestNeedsItsPushToken' -count=1`
1496Expected: build failure, `unknown field Token in struct literal`.
1497
1498- [ ] **Step 3: Implement**
1499
1500`internal/hookd/hookd.go`, constants and `Request`:
1501
1502```go
1503const (
1504 EnvSocket = "GITBAY_HOOK_SOCKET"
1505 EnvRepoID = "GITBAY_REPO_ID"
1506 EnvUserID = "GITBAY_USER_ID"
1507 EnvScope = "GITBAY_KEY_SCOPE"
1508 // EnvToken names the receive-pack this hook runs under. sshd mints
1509 // it per push; hookd answers only a request carrying a live one
1510 // whose repository, account and scope match the request's.
1511 EnvToken = "GITBAY_PUSH_TOKEN"
1512)
1513
1514type Request struct {
1515 Hook string `json:"hook"` // pre-receive | post-receive
1516 RepoID int64 `json:"repo_id"`
1517 UserID int64 `json:"user_id"`
1518 // Scope is the pushing key's scope. The user id alone is the account
1519 // the key belongs to, and a deploy key grants nothing outside its
1520 // binding, so anything acting on another repository needs this too.
1521 Scope string `json:"scope"`
1522 Token string `json:"token"`
1523 Updates []policy.RefUpdate `json:"updates"`
1524}
1525```
1526
1527`Serve`, after `net.Listen`:
1528
1529```go
1530 ln, err := net.Listen("unix", path)
1531 if err != nil {
1532 return nil, err
1533 }
1534 // Listen creates the socket under the process umask. Hooks run as
1535 // the daemon's own user; nobody else has a reason to connect.
1536 if err := os.Chmod(path, 0o600); err != nil {
1537 ln.Close()
1538 return nil, err
1539 }
1540```
1541
1542`handle`:
1543
1544```go
1545func (s *Server) handle(conn net.Conn) {
1546 defer conn.Close()
1547 dec := json.NewDecoder(conn)
1548 enc := json.NewEncoder(conn)
1549 if err := checkPeer(conn); err != nil {
1550 slog.Warn("hook socket: refused connection", "err", err)
1551 enc.Encode(Response{Allow: false, Message: "hook socket: " + err.Error()})
1552 return
1553 }
1554 var req Request
1555 if err := dec.Decode(&req); err != nil {
1556 enc.Encode(Response{Allow: false, Message: "bad hook request"})
1557 return
1558 }
1559 if msg := s.authorize(req); msg != "" {
1560 enc.Encode(Response{Allow: false, Message: msg})
1561 return
1562 }
1563 switch req.Hook {
1564 case "pre-receive":
1565 s.preReceive(req, dec, enc)
1566 case "post-receive":
1567 s.postReceive(req)
1568 enc.Encode(Response{Allow: true})
1569 default:
1570 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
1571 }
1572}
1573
1574// authorize ties a request to a receive-pack sshd started: its token
1575// must be live and name the same repository, account and key scope.
1576func (s *Server) authorize(req Request) string {
1577 if req.Token == "" {
1578 return "push not started by this server"
1579 }
1580 tok, err := s.st.PushTokenByHash(store.HashToken(req.Token))
1581 if err != nil {
1582 return "push not started by this server"
1583 }
1584 if tok.RepoID != req.RepoID || tok.UserID != req.UserID || tok.Scope != req.Scope {
1585 return "push token does not match this request"
1586 }
1587 return ""
1588}
1589```
1590
1591`internal/hookd/peercred_linux.go`:
1592
1593```go
1594//go:build linux
1595
1596package hookd
1597
1598import (
1599 "fmt"
1600 "net"
1601 "os"
1602 "syscall"
1603)
1604
1605// checkPeer refuses a connection from any uid but the daemon's: git,
1606// and so every hook, runs as the daemon's user.
1607func checkPeer(conn net.Conn) error {
1608 uc, ok := conn.(*net.UnixConn)
1609 if !ok {
1610 return fmt.Errorf("not a unix socket connection")
1611 }
1612 raw, err := uc.SyscallConn()
1613 if err != nil {
1614 return err
1615 }
1616 var cred *syscall.Ucred
1617 var credErr error
1618 if err := raw.Control(func(fd uintptr) {
1619 cred, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
1620 }); err != nil {
1621 return err
1622 }
1623 if credErr != nil {
1624 return credErr
1625 }
1626 if int(cred.Uid) != os.Getuid() {
1627 return fmt.Errorf("peer uid %d is not the daemon's (%d)", cred.Uid, os.Getuid())
1628 }
1629 return nil
1630}
1631```
1632
1633`internal/hookd/peercred_other.go`:
1634
1635```go
1636//go:build !linux
1637
1638package hookd
1639
1640import "net"
1641
1642// checkPeer reads peer credentials on Linux only; elsewhere the
1643// socket's 0600 mode is the boundary.
1644func checkPeer(net.Conn) error { return nil }
1645```
1646
1647- [ ] **Step 4: Run the package, and vet for Linux**
1648
1649Run: `go test ./internal/hookd -count=1 && GOOS=linux go vet ./internal/hookd`
1650Expected: PASS; vet clean for both build-tag files.
1651
1652- [ ] **Step 5: Commit**
1653
1654```bash
1655git add internal/hookd
1656git commit -S -m "hookd: 0600 socket, peer uid check, push token required
1657
1658Ref #282"
1659```
1660
1661### Task 4.3: sshd mints the token; the hook sends it
1662
1663**Files:**
1664- Modify: `internal/sshd/sshd.go:441-464` (`runGit`, env and transport)
1665- Modify: `cmd/gitbayd/hook.go:170-178` (`hookd.Ask` request)
1666
1667**Interfaces:**
1668- Consumes: `store.CreatePushToken`, `store.DeletePushToken`, `hookd.EnvToken`, `hookd.Request.Token`.
1669
1670- [ ] **Step 1: Implement in sshd**
1671
1672In `runGit`, after the quota block (line 463) and before
1673`gitutil.Transport`:
1674
1675```go
1676 if write {
1677 // hookd answers only a hook that names this receive-pack.
1678 token, err := st.CreatePushToken(repo.ID, user.ID, scope)
1679 if err != nil {
1680 fmt.Fprintln(stderr, "internal error")
1681 return protocol.ExitFailure
1682 }
1683 defer st.DeletePushToken(token)
1684 env = append(env, hookd.EnvToken+"="+token)
1685 }
1686```
1687
1688The token is deleted when `Transport` returns, which is after
1689post-receive: receive-pack runs post-receive before it exits.
1690
1691- [ ] **Step 2: Implement in the hook**
1692
1693`cmd/gitbayd/hook.go`, the request becomes:
1694
1695```go
1696 resp, err := hookd.Ask(sock, hookd.Request{
1697 Hook: args[0],
1698 RepoID: repoID,
1699 UserID: userID,
1700 Scope: os.Getenv(hookd.EnvScope),
1701 Token: os.Getenv(hookd.EnvToken),
1702 Updates: updates,
1703 }, func(emit func(hookd.RawCommit) error) error {
1704 return streamIncomingCommits(updates, emit)
1705 })
1706```
1707
1708- [ ] **Step 3: Build, vet, unit tests; one push e2e**
1709
1710Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/hookd ./cmd/gitbayd -count=1`
1711Expected: PASS.
1712
1713Run: `go test ./e2e -run TestAuditAndHardening -count=1`
1714Expected: PASS. It pushes over SSH (including an oversized push
1715refused by receive-pack), so pre-receive and post-receive both go
1716through the token check end to end. This is the one e2e run for this
1717MR; CI runs the rest of the push tests.
1718
1719- [ ] **Step 4: Docs**
1720
1721`Architecture/03-Deployment.org`, hook.sock row:
1722
1723```org
1724| =<root>/hook.sock= | Unix socket | gitbayd | on | mode 0600; peer uid must be the daemon's (Linux); per-push token | =internal/hookd/hookd.go= |
1725```
1726
1727`Architecture/04-Trust-Boundaries.org`, TB5 row:
1728
1729```org
1730| TB5 | Z3 → Z1 hook socket | ref updates, repository id, user id, key scope, push token, commit objects | the socket is mode 0600 and, on Linux, refuses a peer whose uid is not the daemon's; a request must carry the token sshd minted for its receive-pack (stored hashed in =push_tokens=) and name the same repository, account and scope. The daemon then decides with =policy.CheckPush= and =sig.VerifyCommit= (=internal/hookd/hookd.go=) |
1731```
1732
1733`Architecture/04-Trust-Boundaries.org`, step 2 of the push sequence
1734(line 57): append ", and a push token" after "key scope" in the list
1735of what `git receive-pack` runs with.
1736
1737`Architecture/10-Known-Gaps.org`: delete the `#282` row.
1738
1739- [ ] **Step 5: Commit, MR, merge**
1740
1741```bash
1742git add internal/sshd/sshd.go cmd/gitbayd/hook.go .gitbay/wiki/Architecture
1743git commit -S -m "sshd: mint a push token per receive-pack; hook sends it
1744
1745Closes #282"
1746git push -u origin hook-socket-auth
1747gitbay mr create --source hook-socket-auth --target main --title "hookd: authenticate the hook socket"
1748```
1749
1750Merge `--strategy ff` after CI, delete the branch both places. Deploy
1751with no push in flight (see runbook): a receive-pack started by the old
1752daemon has no token and its post-receive is refused by the new one.
1753
1754---
1755
1756# MR 5: audited refusals and a hash-chained audit log (branch `audit-refusals-chain`, closes #275)
1757
1758### Task 5.1: chained audit rows, the journal line, verification
1759
1760**Files:**
1761- Create: `internal/store/migrations/0070_audit_chain.up.sql`, `0070_audit_chain.down.sql`
1762- Modify: `internal/store/audit.go:1-19` (`Audit`)
1763- Modify: `internal/store/store.go:23-30` (`Store` gains `AuditJournal`)
1764- Create: `internal/store/auditchain_test.go`
1765
1766**Interfaces:**
1767- Produces:
1768 - `Store.AuditJournal *slog.Logger` — when set, every audit row is also logged there.
1769 - `type AuditChain struct { Rows, Unchained int; First, Last int64; LastHash string; BrokenAt int64; Reason string }`
1770 - `func (s *Store) VerifyAuditChain() (AuditChain, error)`
1771 - `Audit` signature unchanged.
1772
1773- [ ] **Step 1: Write the failing tests**
1774
1775`internal/store/auditchain_test.go`:
1776
1777```go
1778package store
1779
1780import (
1781 "bytes"
1782 "log/slog"
1783 "strings"
1784 "testing"
1785)
1786
1787func chainStore(t *testing.T) *Store {
1788 t.Helper()
1789 s := open(t)
1790 if err := s.MigrateUp(); err != nil {
1791 t.Fatal(err)
1792 }
1793 return s
1794}
1795
1796func TestAuditChainIntact(t *testing.T) {
1797 s := chainStore(t)
1798 s.Audit(0, "a", map[string]any{"n": 1})
1799 s.Audit(0, "b", nil)
1800 s.Audit(0, "c", map[string]any{"n": 3})
1801 res, err := s.VerifyAuditChain()
1802 if err != nil {
1803 t.Fatal(err)
1804 }
1805 if res.Rows != 3 || res.BrokenAt != 0 || res.First != 1 || res.Last != 3 || len(res.LastHash) != 64 {
1806 t.Fatalf("%+v", res)
1807 }
1808}
1809
1810func TestAuditChainDetectsAnEditedRow(t *testing.T) {
1811 s := chainStore(t)
1812 for _, a := range []string{"a", "b", "c"} {
1813 s.Audit(0, a, nil)
1814 }
1815 if _, err := s.DB.Exec("UPDATE audit_log SET action = 'x' WHERE id = 2"); err != nil {
1816 t.Fatal(err)
1817 }
1818 res, err := s.VerifyAuditChain()
1819 if err != nil {
1820 t.Fatal(err)
1821 }
1822 if res.BrokenAt != 2 || !strings.Contains(res.Reason, "contents") {
1823 t.Fatalf("%+v", res)
1824 }
1825}
1826
1827func TestAuditChainDetectsARemovedRow(t *testing.T) {
1828 s := chainStore(t)
1829 for _, a := range []string{"a", "b", "c"} {
1830 s.Audit(0, a, nil)
1831 }
1832 if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 2"); err != nil {
1833 t.Fatal(err)
1834 }
1835 res, err := s.VerifyAuditChain()
1836 if err != nil {
1837 t.Fatal(err)
1838 }
1839 if res.BrokenAt != 3 || !strings.Contains(res.Reason, "previous hash") {
1840 t.Fatalf("%+v", res)
1841 }
1842}
1843
1844// Retention removes the oldest rows, and deleting an account nulls
1845// actor_id; neither is tampering.
1846func TestAuditChainSurvivesRetentionAndAccountDeletion(t *testing.T) {
1847 s := chainStore(t)
1848 uid, err := s.CreateUser("alice", false)
1849 if err != nil {
1850 t.Fatal(err)
1851 }
1852 s.Audit(0, "a", nil)
1853 s.Audit(uid, "b", nil)
1854 s.Audit(0, "c", nil)
1855 if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 1"); err != nil {
1856 t.Fatal(err)
1857 }
1858 if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", uid); err != nil {
1859 t.Fatal(err)
1860 }
1861 res, err := s.VerifyAuditChain()
1862 if err != nil {
1863 t.Fatal(err)
1864 }
1865 if res.BrokenAt != 0 || res.First != 2 || res.Last != 3 {
1866 t.Fatalf("%+v", res)
1867 }
1868}
1869
1870// Rows written before migration 0070 carry no hash; the chain starts
1871// after them, and a hashless row after that start is a break.
1872func TestAuditChainLegacyRows(t *testing.T) {
1873 s := chainStore(t)
1874 if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('legacy')"); err != nil {
1875 t.Fatal(err)
1876 }
1877 s.Audit(0, "a", nil)
1878 res, err := s.VerifyAuditChain()
1879 if err != nil {
1880 t.Fatal(err)
1881 }
1882 if res.Unchained != 1 || res.BrokenAt != 0 || res.First != 2 {
1883 t.Fatalf("%+v", res)
1884 }
1885 if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('injected')"); err != nil {
1886 t.Fatal(err)
1887 }
1888 if res, _ = s.VerifyAuditChain(); res.BrokenAt != 3 {
1889 t.Fatalf("hashless row after the chain: %+v", res)
1890 }
1891}
1892
1893func TestAuditJournal(t *testing.T) {
1894 s := chainStore(t)
1895 var buf bytes.Buffer
1896 s.AuditJournal = slog.New(slog.NewTextHandler(&buf, nil))
1897 s.Audit(0, "cmd repo create", map[string]any{"argv": []string{"a/b"}})
1898 line := buf.String()
1899 for _, want := range []string{"msg=audit", "action=\"cmd repo create\"", "id=1", "hash="} {
1900 if !strings.Contains(line, want) {
1901 t.Fatalf("journal line %q lacks %q", line, want)
1902 }
1903 }
1904}
1905```
1906
1907- [ ] **Step 2: Run them and see them fail**
1908
1909Run: `go test ./internal/store -run 'TestAuditChain|TestAuditJournal' -count=1`
1910Expected: build failure, `s.VerifyAuditChain undefined`.
1911
1912- [ ] **Step 3: Implement**
1913
1914`0070_audit_chain.up.sql`:
1915
1916```sql
1917-- Each row carries the hash of the row before it. actor_ref is the actor
1918-- id as written: actor_id is set to NULL when the account is deleted,
1919-- and the hash must not change with it. Rows written before this
1920-- migration keep an empty hash; the chain starts after them.
1921ALTER TABLE audit_log ADD COLUMN actor_ref INTEGER NOT NULL DEFAULT 0;
1922ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
1923ALTER TABLE audit_log ADD COLUMN hash TEXT NOT NULL DEFAULT '';
1924UPDATE audit_log SET actor_ref = COALESCE(actor_id, 0);
1925```
1926
1927`0070_audit_chain.down.sql`:
1928
1929```sql
1930ALTER TABLE audit_log DROP COLUMN hash;
1931ALTER TABLE audit_log DROP COLUMN prev_hash;
1932ALTER TABLE audit_log DROP COLUMN actor_ref;
1933```
1934
1935`internal/store/store.go`, `Store` gains:
1936
1937```go
1938 // AuditJournal, when set, receives a copy of every audit row. The
1939 // daemon sets it to its own logger, whose output the service
1940 // journal keeps outside the database.
1941 AuditJournal *slog.Logger
1942```
1943
1944(`store.go` imports `log/slog`.)
1945
1946`internal/store/audit.go`, replacing `Audit` (lines 5-19) and adding
1947the chain helpers; `AuditEntry`, `AuditFilter` and `AuditEntries` stay
1948as they are:
1949
1950```go
1951package store
1952
1953import (
1954 "crypto/sha256"
1955 "database/sql"
1956 "encoding/hex"
1957 "encoding/json"
1958 "errors"
1959 "time"
1960)
1961
1962// Audit appends to the security feed. Events are the product feed; this
1963// records who did what, from where, for an operator. actorID 0 means the
1964// host admin (gitbayd admin commands) or an unauthenticated source.
1965func (s *Store) Audit(actorID int64, action string, data map[string]any) {
1966 raw, err := json.Marshal(data)
1967 if err != nil {
1968 raw = []byte("{}")
1969 }
1970 id, createdAt, hash, err := s.appendAudit(actorID, action, string(raw), time.Now())
1971 if s.AuditJournal == nil {
1972 return
1973 }
1974 if err != nil {
1975 s.AuditJournal.Error("audit: append", "action", action, "err", err)
1976 return
1977 }
1978 s.AuditJournal.Info("audit", "id", id, "actor", actorID, "action", action,
1979 "data", string(raw), "created_at", createdAt, "hash", hash)
1980}
1981
1982// appendAudit writes one row and its chain hash in one transaction. The
1983// store begins every transaction IMMEDIATE, so two writers — the daemon
1984// and a gitbayd admin command, say — cannot both read the same last
1985// hash.
1986func (s *Store) appendAudit(actorID int64, action, data string, now time.Time) (int64, string, string, error) {
1987 tx, err := s.DB.Begin()
1988 if err != nil {
1989 return 0, "", "", err
1990 }
1991 defer tx.Rollback()
1992 var prev string
1993 err = tx.QueryRow("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prev)
1994 if err != nil && !errors.Is(err, sql.ErrNoRows) {
1995 return 0, "", "", err
1996 }
1997 var actor any
1998 if actorID != 0 {
1999 actor = actorID
2000 }
2001 createdAt := fmtTime(now)
2002 res, err := tx.Exec(
2003 "INSERT INTO audit_log (actor_id, actor_ref, action, data_json, created_at, prev_hash) VALUES (?, ?, ?, ?, ?, ?)",
2004 actor, actorID, action, data, createdAt, prev)
2005 if err != nil {
2006 return 0, "", "", err
2007 }
2008 id, err := res.LastInsertId()
2009 if err != nil {
2010 return 0, "", "", err
2011 }
2012 hash := auditHash(prev, id, actorID, action, createdAt, data)
2013 if _, err := tx.Exec("UPDATE audit_log SET hash = ? WHERE id = ?", hash, id); err != nil {
2014 return 0, "", "", err
2015 }
2016 return id, createdAt, hash, tx.Commit()
2017}
2018
2019// auditHash covers every column an operator reads, plus the previous
2020// row's hash. A JSON array keeps field boundaries unambiguous.
2021func auditHash(prev string, id, actor int64, action, createdAt, data string) string {
2022 b, _ := json.Marshal([]any{prev, id, actor, action, createdAt, data})
2023 sum := sha256.Sum256(b)
2024 return hex.EncodeToString(sum[:])
2025}
2026
2027// AuditChain is what VerifyAuditChain found.
2028type AuditChain struct {
2029 Rows int // rows read
2030 Unchained int // rows from before migration 0070, which carry no hash
2031 First int64 // first chained row; its prev_hash is taken as given, since retention may have removed the row it names
2032 Last int64
2033 LastHash string
2034 BrokenAt int64 // 0 when the chain is intact
2035 Reason string
2036}
2037
2038// VerifyAuditChain recomputes every row's hash in id order and stops at
2039// the first row that does not match. It cannot see rows removed from
2040// the end of the table; the journal copy covers those.
2041func (s *Store) VerifyAuditChain() (AuditChain, error) {
2042 rows, err := s.DB.Query(`SELECT id, actor_ref, action, data_json, created_at, prev_hash, hash
2043 FROM audit_log ORDER BY id`)
2044 if err != nil {
2045 return AuditChain{}, err
2046 }
2047 defer rows.Close()
2048 var res AuditChain
2049 for rows.Next() {
2050 var (
2051 id, actor int64
2052 action, data, createdAt, prev, hash string
2053 )
2054 if err := rows.Scan(&id, &actor, &action, &data, &createdAt, &prev, &hash); err != nil {
2055 return res, err
2056 }
2057 res.Rows++
2058 switch {
2059 case hash == "" && res.First == 0:
2060 res.Unchained++
2061 continue
2062 case hash == "":
2063 res.BrokenAt, res.Reason = id, "row has no hash after the chain began"
2064 case res.First != 0 && prev != res.LastHash:
2065 res.BrokenAt, res.Reason = id, "previous hash does not match: a row before it was removed or changed"
2066 case auditHash(prev, id, actor, action, createdAt, data) != hash:
2067 res.BrokenAt, res.Reason = id, "row contents do not match its hash"
2068 }
2069 if res.BrokenAt != 0 {
2070 return res, nil
2071 }
2072 if res.First == 0 {
2073 res.First = id
2074 }
2075 res.Last, res.LastHash = id, hash
2076 }
2077 return res, rows.Err()
2078}
2079```
2080
2081The previous `Audit` ignored every error; it still returns nothing,
2082and reports an append failure only where there is a journal to report
2083it to.
2084
2085- [ ] **Step 4: Run the package**
2086
2087Run: `go test ./internal/store -count=1`
2088Expected: PASS, `TestMigrateUpDown` and `TestSweep*` included (the
2089retention sweep still deletes by `created_at`).
2090
2091- [ ] **Step 5: Commit**
2092
2093```bash
2094git add internal/store
2095git commit -S -m "store: hash-chained audit rows, journal copy, chain verification
2096
2097Ref #275"
2098```
2099
2100### Task 5.2: refused mutating commands are audited, rate-limited per actor
2101
2102**Files:**
2103- Create: `internal/control/auditrefusal.go`, `internal/control/auditrefusal_test.go`
2104- Modify: `internal/control/control.go:153-191` (`Dispatch` from the scope check to the end)
2105
2106**Interfaces:**
2107- Produces: `func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any)` — records at most `refusalsPerMinute` (10) rows per actor per minute, then one `refused.throttled` row for the rest of that minute; a nil store records nothing.
2108- Dispatch audit actions: `"cmd <path>"` on success (unchanged), `"refused <path>"` on exit 4 or exit 3, for commands that are not `ReadOnly`, with data `{"argv", "source", "exit"}`.
2109
2110- [ ] **Step 1: Write the failing tests**
2111
2112`internal/control/auditrefusal_test.go`:
2113
2114```go
2115package control
2116
2117import (
2118 "testing"
2119
2120 "gitbay.org/gitbay/internal/protocol"
2121 "gitbay.org/gitbay/internal/store"
2122)
2123
2124func TestRefusedWritesAreAudited(t *testing.T) {
2125 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
2126 st, repo, _ := newQueueTestRepo(t)
2127 bobID, err := st.CreateUser("bob", false)
2128 if err != nil {
2129 t.Fatal(err)
2130 }
2131 bob := store.User{ID: bobID, Username: "bob"}
2132
2133 c, _ := pruneCtx(st, t.TempDir(), bob)
2134 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied {
2135 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
2136 }
2137 // A refused read is not a write attempt.
2138 c, _ = pruneCtx(st, t.TempDir(), bob)
2139 if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied {
2140 t.Fatalf("audit: exit %d", code)
2141 }
2142 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10})
2143 if err != nil {
2144 t.Fatal(err)
2145 }
2146 if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" {
2147 t.Fatalf("entries: %+v", got)
2148 }
2149}
2150
2151func TestRefusalAuditIsRateLimited(t *testing.T) {
2152 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
2153 st, repo, _ := newQueueTestRepo(t)
2154 bobID, err := st.CreateUser("bob", false)
2155 if err != nil {
2156 t.Fatal(err)
2157 }
2158 for range refusalsPerMinute + 5 {
2159 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
2160 Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"})
2161 }
2162 refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100})
2163 throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100})
2164 if len(refused) != refusalsPerMinute || len(throttled) != 1 {
2165 t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled))
2166 }
2167}
2168```
2169
2170`AuditEntries` with prefix `"refused "` (trailing space) excludes
2171`refused.throttled`.
2172
2173- [ ] **Step 2: Run them and see them fail**
2174
2175Run: `go test ./internal/control -run 'TestRefusedWritesAreAudited|TestRefusalAuditIsRateLimited' -count=1`
2176Expected: build failure, `undefined: refusals`.
2177
2178- [ ] **Step 3: Implement the limiter**
2179
2180`internal/control/auditrefusal.go`:
2181
2182```go
2183package control
2184
2185import (
2186 "sync"
2187 "time"
2188
2189 "gitbay.org/gitbay/internal/store"
2190)
2191
2192// refusalsPerMinute bounds audit rows for refused writes per actor. A
2193// probe is what these rows record, and a loop of probes must not grow
2194// the table without bound.
2195const refusalsPerMinute = 10
2196
2197type refusalLimiter struct {
2198 mu sync.Mutex
2199 seen map[int64]*refusalWindow
2200}
2201
2202type refusalWindow struct {
2203 start time.Time
2204 n int
2205}
2206
2207var refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
2208
2209// allow reports whether to record this refusal, and whether it is the
2210// first one past the limit in the current minute.
2211func (l *refusalLimiter) allow(actor int64, now time.Time) (record, firstDropped bool) {
2212 l.mu.Lock()
2213 defer l.mu.Unlock()
2214 if len(l.seen) > 4096 {
2215 for k, w := range l.seen {
2216 if now.Sub(w.start) >= time.Minute {
2217 delete(l.seen, k)
2218 }
2219 }
2220 }
2221 w := l.seen[actor]
2222 if w == nil || now.Sub(w.start) >= time.Minute {
2223 w = &refusalWindow{start: now}
2224 l.seen[actor] = w
2225 }
2226 w.n++
2227 return w.n <= refusalsPerMinute, w.n == refusalsPerMinute+1
2228}
2229
2230// AuditRefused records a refused attempt to change something. Past the
2231// per-actor limit it records one refused.throttled row a minute and
2232// drops the rest. Dispatcher tests run without a store.
2233func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any) {
2234 if st == nil {
2235 return
2236 }
2237 switch record, first := refusals.allow(actorID, time.Now()); {
2238 case record:
2239 st.Audit(actorID, action, data)
2240 case first:
2241 st.Audit(actorID, "refused.throttled", map[string]any{"limit_per_minute": refusalsPerMinute})
2242 }
2243}
2244```
2245
2246- [ ] **Step 4: Route every Dispatch refusal through it**
2247
2248In `internal/control/control.go`, everything in `Dispatch` from the
2249scope check (line 154) to the end moves into `runChecked`, and
2250`Dispatch` ends:
2251
2252```go
2253 c.Argv = args
2254 code := runChecked(c, cmd, args)
2255 if !cmd.ReadOnly {
2256 data := map[string]any{"argv": auditArgs(args), "source": c.Source}
2257 switch code {
2258 case protocol.ExitOK:
2259 // Every successful mutating command lands in the audit log.
2260 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), data)
2261 case protocol.ExitDenied, protocol.ExitNotFound:
2262 // So does every refused one: probing leaves a trace.
2263 data["exit"] = code
2264 AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path), data)
2265 }
2266 }
2267 return code
2268}
2269
2270// runChecked applies the dispatcher's own gates, then runs the command.
2271func runChecked(c *Ctx, cmd Command, args []string) int {
2272 // A runner-scoped key reaches the runner protocol and nothing else, so
2273 // the key a CI host holds cannot administer the instance.
2274 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
2275 return c.fail(protocol.ExitDenied, "this key's scope (%s) does not allow control commands; use a key added with --scope full", c.Scope)
2276 }
2277 if c.ReadOnly && !cmd.ReadOnly {
2278 return c.fail(protocol.ExitDenied, "this token is read-only; %s modifies state — mint one with --scope full", joinPath(cmd.Path))
2279 }
2280 // The SSH listener refuses a disabled account before it gets here; the
2281 // API and the web reach Dispatch directly, so the check lives here too.
2282 if c.User.Disabled {
2283 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
2284 }
2285 // The admin noun is gated here as well as in each handler, so a new
2286 // admin command that forgets requireInstanceAdmin is still refused.
2287 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
2288 return c.fail(protocol.ExitDenied, "admin commands are for instance admins; ask one")
2289 }
2290 if c.User.Pending && !pendingAllowed(cmd.Path) {
2291 return c.fail(protocol.ExitDenied,
2292 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
2293 }
2294 if code := limitWrites(c, cmd); code >= 0 {
2295 return code
2296 }
2297 if !cmd.ReadsStdin {
2298 c.Stdin = emptyReader{}
2299 }
2300 return cmd.Run(c, args)
2301}
2302```
2303
2304The gates and their messages are unchanged; only their position moves.
2305A successful command's audit data keeps exactly `argv` and `source`.
2306
2307- [ ] **Step 5: Run the package**
2308
2309Run: `go test ./internal/control -count=1`
2310Expected: PASS, including `TestAdminNounGatedInDispatch` and
2311`TestRefusalsHonourJSON` (nil store: `AuditRefused` returns early).
2312
2313- [ ] **Step 6: Commit**
2314
2315```bash
2316git add internal/control
2317git commit -S -m "control: audit refused mutating commands, rate-limited per actor
2318
2319Ref #275"
2320```
2321
2322### Task 5.3: refused pushes, the daemon's journal, `gitbayd admin audit verify`
2323
2324**Files:**
2325- Modify: `internal/sshd/sshd.go:355-360` (`Exec`, git transport case)
2326- Create: `internal/sshd/refusal_test.go`
2327- Create: `cmd/gitbayd/auditverify.go`
2328- Modify: `cmd/gitbayd/main.go:138-142` (`serveCmd`, after `openStore`), `:416` (`admin audit` registration)
2329- Modify: `e2e/audit_test.go` (new test `TestAuditChainVerify`)
2330
2331**Interfaces:**
2332- Consumes: `control.AuditRefused` (Task 5.2), `store.AuditJournal`, `store.VerifyAuditChain` (Task 5.1).
2333- Produces: `func auditVerifyCmd() *cobra.Command` in package `main`.
2334
2335- [ ] **Step 1: Write the failing sshd test**
2336
2337`internal/sshd/refusal_test.go`:
2338
2339```go
2340package sshd
2341
2342import (
2343 "bytes"
2344 "path/filepath"
2345 "strings"
2346 "testing"
2347
2348 "gitbay.org/gitbay/internal/config"
2349 "gitbay.org/gitbay/internal/control"
2350 "gitbay.org/gitbay/internal/protocol"
2351 "gitbay.org/gitbay/internal/store"
2352)
2353
2354// execFixture: alice owns the public alice/app; bob has no grant on it.
2355func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
2356 t.Helper()
2357 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
2358 if err != nil {
2359 t.Fatal(err)
2360 }
2361 t.Cleanup(func() { st.Close() })
2362 if err := st.MigrateUp(); err != nil {
2363 t.Fatal(err)
2364 }
2365 alice, err := st.CreateUser("alice", false)
2366 if err != nil {
2367 t.Fatal(err)
2368 }
2369 if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
2370 t.Fatal(err)
2371 }
2372 bobID, err := st.CreateUser("bob", false)
2373 if err != nil {
2374 t.Fatal(err)
2375 }
2376 bob, err := st.UserByID(bobID)
2377 if err != nil {
2378 t.Fatal(err)
2379 }
2380 cfg := config.Default()
2381 cfg.Server.Root = t.TempDir()
2382 return cfg, st, bob
2383}
2384
2385func TestRefusedPushIsAudited(t *testing.T) {
2386 cfg, st, bob := execFixture(t)
2387 var out, errOut bytes.Buffer
2388 code := Exec(cfg, st, bob, "full", "SHA256:test", control.Term{}, "git-receive-pack alice/app",
2389 strings.NewReader(""), &out, &errOut, nil, nil)
2390 if code != protocol.ExitDenied {
2391 t.Fatalf("exit %d: %s", code, errOut.String())
2392 }
2393 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
2394 if err != nil || len(got) != 1 || got[0].Actor != "bob" || !strings.Contains(got[0].Data, "alice/app") {
2395 t.Fatalf("entries %+v, %v", got, err)
2396 }
2397}
2398```
2399
2400- [ ] **Step 2: Run it and see it fail**
2401
2402Run: `go test ./internal/sshd -run TestRefusedPushIsAudited -count=1`
2403Expected: FAIL, `entries [] <nil>`.
2404
2405- [ ] **Step 3: Implement in `Exec`**
2406
2407```go
2408 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
2409 if user.Pending {
2410 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
2411 return protocol.ExitDenied
2412 }
2413 code := runGit(cfg, st, user, scope, argv, stdin, stdout, stderr)
2414 if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) {
2415 control.AuditRefused(st, user.ID, "refused git-receive-pack",
2416 map[string]any{"argv": argv[1:], "source": source})
2417 }
2418 return code
2419```
2420
2421Run: `go test ./internal/sshd -count=1`
2422Expected: PASS.
2423
2424- [ ] **Step 4: Write the failing e2e test**
2425
2426Append to `e2e/audit_test.go` (imports gain `path/filepath` — already
2427there — and `gitbay.org/gitbay/internal/store`):
2428
2429```go
2430// The audit log is a hash chain: gitbayd admin audit verify passes on
2431// an untouched log and names the first row that was edited (#275).
2432func TestAuditChainVerify(t *testing.T) {
2433 t.Parallel()
2434 inst := startInstance(t)
2435 aliceKey := inst.newKey(t, "alice")
2436 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
2437 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
2438 t.Fatal("repo create failed")
2439 }
2440 if out := inst.admin(t, "admin", "audit", "verify"); !strings.Contains(out, "intact") {
2441 t.Fatalf("verify: %s", out)
2442 }
2443
2444 st, err := store.Open(filepath.Join(inst.root, "gitbay.db"))
2445 if err != nil {
2446 t.Fatal(err)
2447 }
2448 var id int64
2449 if err := st.DB.QueryRow("SELECT id FROM audit_log WHERE action = 'cmd repo create'").Scan(&id); err != nil {
2450 t.Fatal(err)
2451 }
2452 if _, err := st.DB.Exec("UPDATE audit_log SET data_json = '{}' WHERE id = ?", id); err != nil {
2453 t.Fatal(err)
2454 }
2455 st.Close()
2456 out := inst.forgedAdminErr(t, "admin", "audit", "verify")
2457 if !strings.Contains(out, fmt.Sprintf("row %d", id)) {
2458 t.Fatalf("verify after edit: %s", out)
2459 }
2460}
2461```
2462
2463(`fmt` is added to the file's imports.)
2464
2465- [ ] **Step 5: Run it and see it fail**
2466
2467Run: `go build ./... && go test ./e2e -run TestAuditChainVerify -count=1`
2468Expected: FAIL, `gitbayd [admin audit verify]: exit status 2` — the
2469host `audit` command reads `verify` as an unknown argument.
2470
2471- [ ] **Step 6: Implement the verify command and the journal**
2472
2473`cmd/gitbayd/auditverify.go`:
2474
2475```go
2476package main
2477
2478import (
2479 "fmt"
2480
2481 "github.com/spf13/cobra"
2482
2483 "gitbay.org/gitbay/internal/config"
2484)
2485
2486// auditVerifyCmd recomputes the audit log's hash chain. A break names
2487// the first row that does not match; rows removed from the end of the
2488// log leave no break, and the journal copy is the record for those.
2489func auditVerifyCmd() *cobra.Command {
2490 return &cobra.Command{
2491 Use: "verify",
2492 Short: "check the audit log's hash chain",
2493 Args: cobra.NoArgs,
2494 RunE: func(cmd *cobra.Command, args []string) error {
2495 cfg, err := config.Load(configPath)
2496 if err != nil {
2497 return err
2498 }
2499 st, err := openStore(cfg)
2500 if err != nil {
2501 return err
2502 }
2503 defer st.Close()
2504 res, err := st.VerifyAuditChain()
2505 if err != nil {
2506 return err
2507 }
2508 fmt.Printf("read %d rows (%d from before the chain)\n", res.Rows, res.Unchained)
2509 if res.BrokenAt != 0 {
2510 return fmt.Errorf("chain broken at row %d: %s", res.BrokenAt, res.Reason)
2511 }
2512 if res.Last == 0 {
2513 fmt.Println("no chained rows yet")
2514 return nil
2515 }
2516 fmt.Printf("chain intact from row %d to row %d\nlast hash %s\n", res.First, res.Last, res.LastHash)
2517 return nil
2518 },
2519 }
2520}
2521```
2522
2523`cmd/gitbayd/main.go`, in `adminCmd`, replace the `hostCmd("audit …")`
2524entry in `admin.AddCommand(…)` with a variable built before the call:
2525
2526```go
2527 auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit")
2528 auditCmd.AddCommand(auditVerifyCmd())
2529```
2530
2531and pass `auditCmd` in its place. cobra resolves `verify` as the child
2532before the parent's passthrough arguments are considered, so
2533`gitbayd admin audit --limit 5` is unchanged.
2534
2535In `serveCmd`, after `defer st.Close()`:
2536
2537```go
2538 // The daemon's stderr is the service journal: a copy of each
2539 // audit row outside the database the daemon can write.
2540 st.AuditJournal = slog.Default()
2541```
2542
2543`gitbayd shell` and host `gitbayd admin` commands leave it unset:
2544their stderr is the SSH client or the operator's terminal (open
2545question 1).
2546
2547- [ ] **Step 7: Run it**
2548
2549Run: `go build ./... && go vet ./... && go test ./cmd/gitbayd ./internal/sshd ./internal/control ./internal/store -count=1 && go test ./e2e -run TestAuditChainVerify -count=1`
2550Expected: PASS.
2551
2552- [ ] **Step 8: Docs**
2553
2554`Admin.org`, under `* Audit and account control`, the first paragraph
2555becomes:
2556
2557```org
2558The audit log is the security feed (events are the product feed): every
2559successful mutating command with its argv and source credential (SSH key
2560fingerprint or API), every refused one (exit 3 or 4) as =refused
2561<command>=, refused pushes as =refused git-receive-pack=, registrations,
2562admin actions, force-pushes, and auth failures/throttling. Refusals are
2563recorded up to ten a minute per account; past that, one
2564=refused.throttled= row stands for the rest of the minute. Secrets
2565never appear — they travel on stdin, never in argv.
2566
2567Each row carries the SHA-256 of the row before it. =gitbayd admin audit
2568verify= recomputes the chain and names the first row that was edited or
2569whose predecessor was removed; it prints the last hash, which an
2570operator can note elsewhere. Retention removing the oldest rows is not
2571a break. Rows removed from the end leave no break, so the daemon also
2572logs every row to its journal (=journalctl -u gitbayd -g '^.*msg=audit'=),
2573outside the database it writes. Rows written before the chain existed
2574are counted and skipped.
2575```
2576
2577and add to the command block:
2578
2579```org
2580gitbayd admin audit verify # check the hash chain; exit 1 names the first bad row
2581```
2582
2583`Architecture/09-Controls.org`, the two Logging rows:
2584
2585```org
2586| Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor (=internal/control/auditrefusal.go=) |
2587| Audit log tamper resistance | partial | hash chain checked by =gitbayd admin audit verify=; every row copied to the journal; the table itself is writable by the daemon user |
2588```
2589
2590`Architecture/06-Data-and-Cryptography.org`, the Audit and feed row
2591(line 21): append ", a hash chain (=prev_hash=, =hash=)" to its
2592description column.
2593
2594`Architecture/10-Known-Gaps.org`: delete the `#275` row.
2595
2596- [ ] **Step 9: Commit, MR, merge**
2597
2598```bash
2599git add internal/sshd cmd/gitbayd e2e/audit_test.go .gitbay/wiki
2600git commit -S -m "audit: refused pushes, journal copy, admin audit verify
2601
2602Closes #275"
2603git push -u origin audit-refusals-chain
2604gitbay mr create --source audit-refusals-chain --target main --title "audit: record refusals; hash-chain the log"
2605```
2606
2607Merge `--strategy ff` after CI, delete the branch both places.
2608
2609---
2610
2611# MR 6: one limit on pack generation (branch `pack-limit`, closes #262)
2612
2613### Task 6.1: `internal/packlimit`
2614
2615**Files:**
2616- Create: `internal/packlimit/packlimit.go`, `internal/packlimit/packlimit_test.go`
2617
2618**Interfaces:**
2619- Produces:
2620 - `var ErrBusy error`, `var ErrGone error`
2621 - `func New(max, per, queue int, wait time.Duration) *Limiter` — nil when `max <= 0` (no limit); `per <= 0` means no per-principal cap; `queue` is how many may wait (0: none).
2622 - `func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func(), err error)` — nil receiver never waits; `release` is idempotent.
2623
2624- [ ] **Step 1: Write the failing tests**
2625
2626```go
2627package packlimit
2628
2629import (
2630 "errors"
2631 "testing"
2632 "time"
2633)
2634
2635func TestGlobalCap(t *testing.T) {
2636 l := New(2, 0, 0, time.Second)
2637 r1, err1 := l.Acquire(nil, "a")
2638 r2, err2 := l.Acquire(nil, "b")
2639 if err1 != nil || err2 != nil {
2640 t.Fatal(err1, err2)
2641 }
2642 if _, err := l.Acquire(nil, "c"); !errors.Is(err, ErrBusy) {
2643 t.Fatalf("third with no queue: %v", err)
2644 }
2645 r1()
2646 r1() // a second release is a no-op
2647 r3, err := l.Acquire(nil, "c")
2648 if err != nil {
2649 t.Fatal(err)
2650 }
2651 if _, err := l.Acquire(nil, "d"); !errors.Is(err, ErrBusy) {
2652 t.Fatalf("double release freed two slots: %v", err)
2653 }
2654 r2()
2655 r3()
2656}
2657
2658func TestPerPrincipalCap(t *testing.T) {
2659 l := New(4, 1, 4, 50*time.Millisecond)
2660 ra, err := l.Acquire(nil, "a")
2661 if err != nil {
2662 t.Fatal(err)
2663 }
2664 defer ra()
2665 if _, err := l.Acquire(nil, "a"); !errors.Is(err, ErrBusy) {
2666 t.Fatalf("second for a: %v", err)
2667 }
2668 rb, err := l.Acquire(nil, "b")
2669 if err != nil {
2670 t.Fatalf("b blocked by a: %v", err)
2671 }
2672 rb()
2673}
2674
2675func TestWaiterGetsReleasedSlot(t *testing.T) {
2676 l := New(1, 0, 1, 5*time.Second)
2677 r1, _ := l.Acquire(nil, "a")
2678 got := make(chan error, 1)
2679 go func() {
2680 r, err := l.Acquire(nil, "b")
2681 if err == nil {
2682 r()
2683 }
2684 got <- err
2685 }()
2686 waitQueued(t, l, 1)
2687 r1()
2688 select {
2689 case err := <-got:
2690 if err != nil {
2691 t.Fatal(err)
2692 }
2693 case <-time.After(2 * time.Second):
2694 t.Fatal("waiter never got the slot")
2695 }
2696}
2697
2698func TestQueueIsBounded(t *testing.T) {
2699 l := New(1, 0, 1, 5*time.Second)
2700 r1, _ := l.Acquire(nil, "a")
2701 defer r1()
2702 go l.Acquire(nil, "b")
2703 waitQueued(t, l, 1)
2704 if _, err := l.Acquire(nil, "c"); !errors.Is(err, ErrBusy) {
2705 t.Fatalf("queue over its bound: %v", err)
2706 }
2707}
2708
2709// A principal cannot fill the queue on its own.
2710func TestPrincipalQueueIsBounded(t *testing.T) {
2711 l := New(1, 1, 8, 5*time.Second)
2712 r1, _ := l.Acquire(nil, "x")
2713 defer r1()
2714 go l.Acquire(nil, "a")
2715 waitQueued(t, l, 1)
2716 if _, err := l.Acquire(nil, "a"); !errors.Is(err, ErrBusy) {
2717 t.Fatalf("second waiter for a: %v", err)
2718 }
2719}
2720
2721func TestClientGoneWhileQueued(t *testing.T) {
2722 l := New(1, 0, 1, 5*time.Second)
2723 r1, _ := l.Acquire(nil, "a")
2724 defer r1()
2725 done := make(chan struct{})
2726 close(done)
2727 if _, err := l.Acquire(done, "b"); !errors.Is(err, ErrGone) {
2728 t.Fatalf("got %v, want ErrGone", err)
2729 }
2730}
2731
2732func TestWaitRunsOut(t *testing.T) {
2733 l := New(1, 0, 1, 20*time.Millisecond)
2734 r1, _ := l.Acquire(nil, "a")
2735 defer r1()
2736 if _, err := l.Acquire(nil, "b"); !errors.Is(err, ErrBusy) {
2737 t.Fatalf("got %v, want ErrBusy", err)
2738 }
2739}
2740
2741func TestNilLimiterNeverWaits(t *testing.T) {
2742 var l *Limiter
2743 if l = New(0, 1, 1, time.Second); l != nil {
2744 t.Fatal("max 0 should mean no limit")
2745 }
2746 r, err := l.Acquire(nil, "a")
2747 if err != nil {
2748 t.Fatal(err)
2749 }
2750 r()
2751}
2752
2753func waitQueued(t *testing.T, l *Limiter, n int) {
2754 t.Helper()
2755 deadline := time.Now().Add(2 * time.Second)
2756 for time.Now().Before(deadline) {
2757 l.mu.Lock()
2758 q := l.queued
2759 l.mu.Unlock()
2760 if q == n {
2761 return
2762 }
2763 time.Sleep(time.Millisecond)
2764 }
2765 t.Fatalf("queue never reached %d", n)
2766}
2767```
2768
2769- [ ] **Step 2: Run them and see them fail**
2770
2771Run: `go test ./internal/packlimit -count=1`
2772Expected: `no non-test Go files` / build failure.
2773
2774- [ ] **Step 3: Implement**
2775
2776`internal/packlimit/packlimit.go`:
2777
2778```go
2779// Package packlimit bounds concurrent git pack generation. upload-pack
2780// and upload-archive over SSH, smart HTTP and git:// draw on one
2781// budget: a global cap, a cap per principal (an account, or a client
2782// address on the anonymous transports), and a bounded queue whose
2783// waiters give up after a fixed wait or when the client goes away.
2784// Waiters are not served in order; the wait bounds how long any one
2785// of them waits.
2786package packlimit
2787
2788import (
2789 "errors"
2790 "sync"
2791 "time"
2792)
2793
2794var (
2795 ErrBusy = errors.New("the server is busy generating packs for other clients; try again in a minute")
2796 ErrGone = errors.New("client went away while queued")
2797)
2798
2799type Limiter struct {
2800 max, per, queue int
2801 wait time.Duration
2802
2803 mu sync.Mutex
2804 running int
2805 queued int
2806 held map[string]int // running, per principal
2807 waiting map[string]int // queued, per principal
2808 changed chan struct{} // closed and replaced on every release
2809}
2810
2811// New returns a limiter, or nil — no limit — when max is not positive.
2812func New(max, per, queue int, wait time.Duration) *Limiter {
2813 if max <= 0 {
2814 return nil
2815 }
2816 return &Limiter{max: max, per: per, queue: queue, wait: wait,
2817 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})}
2818}
2819
2820// Acquire takes a slot for principal, queueing when none is free.
2821// release is called once git has exited. done, when it closes, ends
2822// the wait.
2823func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func(), err error) {
2824 if l == nil {
2825 return func() {}, nil
2826 }
2827 l.mu.Lock()
2828 if l.fits(principal) {
2829 l.take(principal)
2830 l.mu.Unlock()
2831 return l.releaser(principal), nil
2832 }
2833 if l.queued >= l.queue || (l.per > 0 && l.waiting[principal] >= l.per) {
2834 l.mu.Unlock()
2835 return nil, ErrBusy
2836 }
2837 l.queued++
2838 l.waiting[principal]++
2839 l.mu.Unlock()
2840 defer func() {
2841 l.mu.Lock()
2842 l.queued--
2843 if l.waiting[principal]--; l.waiting[principal] == 0 {
2844 delete(l.waiting, principal)
2845 }
2846 l.mu.Unlock()
2847 }()
2848
2849 timer := time.NewTimer(l.wait)
2850 defer timer.Stop()
2851 for {
2852 l.mu.Lock()
2853 if l.fits(principal) {
2854 l.take(principal)
2855 l.mu.Unlock()
2856 return l.releaser(principal), nil
2857 }
2858 changed := l.changed
2859 l.mu.Unlock()
2860 select {
2861 case <-changed:
2862 case <-timer.C:
2863 return nil, ErrBusy
2864 case <-done:
2865 return nil, ErrGone
2866 }
2867 }
2868}
2869
2870func (l *Limiter) fits(principal string) bool {
2871 return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per)
2872}
2873
2874func (l *Limiter) take(principal string) {
2875 l.running++
2876 l.held[principal]++
2877}
2878
2879func (l *Limiter) releaser(principal string) func() {
2880 var once sync.Once
2881 return func() {
2882 once.Do(func() {
2883 l.mu.Lock()
2884 defer l.mu.Unlock()
2885 l.running--
2886 if l.held[principal]--; l.held[principal] == 0 {
2887 delete(l.held, principal)
2888 }
2889 close(l.changed)
2890 l.changed = make(chan struct{})
2891 })
2892 }
2893}
2894```
2895
2896- [ ] **Step 4: Run it, with the race detector**
2897
2898Run: `go test -race ./internal/packlimit -count=3`
2899Expected: PASS.
2900
2901- [ ] **Step 5: Commit**
2902
2903```bash
2904git add internal/packlimit
2905git commit -S -m "packlimit: global and per-principal limit with a bounded queue
2906
2907Ref #262"
2908```
2909
2910### Task 6.2: config knobs
2911
2912**Files:**
2913- Modify: `internal/config/config.go:187-209` (`Limits`), `Validate` (after the `max_snippets_per_user` check, line 344-346)
2914- Test: `internal/config/config_test.go`
2915
2916**Interfaces:**
2917- Produces: `Limits.PackConcurrency`, `PackPerPrincipal`, `PackQueue int`, `PackQueueWait string`; `func (l Limits) PackLimits() (max, per, queue int, wait time.Duration)`; constants `DefaultPackConcurrency = 3`, `DefaultPackPerPrincipal = 2`, `DefaultPackQueue = 32`, `DefaultPackQueueWait = time.Minute`.
2918
2919- [ ] **Step 1: Write the failing tests**
2920
2921```go
2922func TestPackLimits(t *testing.T) {
2923 max, per, queue, wait := Limits{}.PackLimits()
2924 if max != DefaultPackConcurrency || per != DefaultPackPerPrincipal || queue != DefaultPackQueue || wait != DefaultPackQueueWait {
2925 t.Fatalf("defaults: %d %d %d %s", max, per, queue, wait)
2926 }
2927 max, per, queue, wait = Limits{PackConcurrency: -1, PackPerPrincipal: -1, PackQueue: -1, PackQueueWait: "5s"}.PackLimits()
2928 if max != 0 || per != 0 || queue != 0 || wait != 5*time.Second {
2929 t.Fatalf("off: %d %d %d %s", max, per, queue, wait)
2930 }
2931 max, per, queue, _ = Limits{PackConcurrency: 8, PackPerPrincipal: 3, PackQueue: 64}.PackLimits()
2932 if max != 8 || per != 3 || queue != 64 {
2933 t.Fatalf("set: %d %d %d", max, per, queue)
2934 }
2935}
2936```
2937
2938(`config_test.go` imports gain `time`.) And one `TestContradictions` case:
2939
2940```go
2941 {
2942 "bad pack_queue_wait",
2943 minimal + "\n[limits]\npack_queue_wait = \"soon\"\n",
2944 "limits.pack_queue_wait",
2945 },
2946```
2947
2948- [ ] **Step 2: Run them and see them fail**
2949
2950Run: `go test ./internal/config -run 'TestPackLimits|TestContradictions' -count=1`
2951Expected: build failure, `PackLimits undefined`.
2952
2953- [ ] **Step 3: Implement**
2954
2955Add to `Limits`:
2956
2957```go
2958 // PackConcurrency caps git pack generation (upload-pack and
2959 // upload-archive) running at once across SSH, smart HTTP and git://.
2960 // PackPerPrincipal caps it per account, or per client address on the
2961 // anonymous transports. PackQueue is how many may wait for a slot,
2962 // for at most PackQueueWait ("60s"). For the three counts 0 takes the
2963 // default and a negative value turns that bound off.
2964 PackConcurrency int `toml:"pack_concurrency"`
2965 PackPerPrincipal int `toml:"pack_per_principal"`
2966 PackQueue int `toml:"pack_queue"`
2967 PackQueueWait string `toml:"pack_queue_wait"`
2968```
2969
2970After `DefaultWriteRate`:
2971
2972```go
2973// Pack generation defaults for a four-core host: a full clone of a large
2974// repository runs git at about 1.5 cores (Performance wiki page).
2975const (
2976 DefaultPackConcurrency = 3
2977 DefaultPackPerPrincipal = 2
2978 DefaultPackQueue = 32
2979 DefaultPackQueueWait = time.Minute
2980)
2981```
2982
2983After `Limits`:
2984
2985```go
2986// PackLimits resolves the pack_* settings for packlimit.New. A zero
2987// count is no bound.
2988func (l Limits) PackLimits() (max, per, queue int, wait time.Duration) {
2989 pick := func(v, def int) int {
2990 switch {
2991 case v == 0:
2992 return def
2993 case v < 0:
2994 return 0
2995 }
2996 return v
2997 }
2998 wait = DefaultPackQueueWait
2999 if d, err := time.ParseDuration(l.PackQueueWait); err == nil && d > 0 {
3000 wait = d
3001 }
3002 return pick(l.PackConcurrency, DefaultPackConcurrency),
3003 pick(l.PackPerPrincipal, DefaultPackPerPrincipal),
3004 pick(l.PackQueue, DefaultPackQueue), wait
3005}
3006```
3007
3008In `Validate`:
3009
3010```go
3011 if w := c.Limits.PackQueueWait; w != "" {
3012 if d, err := time.ParseDuration(w); err != nil || d <= 0 {
3013 errs = append(errs, fmt.Errorf("limits.pack_queue_wait %q must be a positive duration such as 60s", w))
3014 }
3015 }
3016```
3017
3018- [ ] **Step 4: Run the package**
3019
3020Run: `go test ./internal/config -count=1`
3021Expected: PASS.
3022
3023- [ ] **Step 5: Commit**
3024
3025```bash
3026git add internal/config
3027git commit -S -m "config: pack_concurrency, pack_per_principal, pack_queue, pack_queue_wait
3028
3029Ref #262"
3030```
3031
3032### Task 6.3: SSH transports acquire a slot and die with their client
3033
3034**Files:**
3035- Modify: `internal/gitutil/gitutil.go:35-56` (`Transport` takes a context)
3036- Modify: `internal/sshd/sshd.go:34-71` (`Server.packs`, `New`), `:299-313` (`runExec`), `:339-385` (`Exec`), `:387-468` (`runGit`)
3037- Modify: `cmd/gitbayd/system.go:97`
3038- Modify: `internal/sshd/sshd_test.go:65` (`New(cfg, st, nil)`)
3039- Modify: `internal/sshd/refusal_test.go` (Exec call gains `nil` packs; new busy test)
3040
3041**Interfaces:**
3042- Consumes: `packlimit.Limiter`, `packlimit.New` (Task 6.1).
3043- Produces:
3044 - `func Transport(ctx context.Context, service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error`
3045 - `func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error)`
3046 - `func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope, source string, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int`
3047
3048- [ ] **Step 1: Write the failing test**
3049
3050In `internal/sshd/refusal_test.go`, update `TestRefusedPushIsAudited`'s
3051call to `Exec(cfg, st, nil, bob, …)` and add (imports gain `time` and
3052`gitbay.org/gitbay/internal/packlimit`):
3053
3054```go
3055func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
3056 cfg, st, bob := execFixture(t)
3057 packs := packlimit.New(1, 0, 0, time.Second)
3058 hold, err := packs.Acquire(nil, "ip:elsewhere")
3059 if err != nil {
3060 t.Fatal(err)
3061 }
3062 defer hold()
3063 var out, errOut bytes.Buffer
3064 code := Exec(cfg, st, packs, bob, "full", "SHA256:test", control.Term{}, "git-upload-pack alice/app",
3065 strings.NewReader(""), &out, &errOut, nil, nil)
3066 if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
3067 t.Fatalf("exit %d: %q", code, errOut.String())
3068 }
3069}
3070```
3071
3072- [ ] **Step 2: Run it and see it fail**
3073
3074Run: `go test ./internal/sshd -run TestCloneRefusedWhenPackSlotsAreFull -count=1`
3075Expected: build failure, too many arguments to `Exec`.
3076
3077- [ ] **Step 3: Implement `Transport(ctx, …)`**
3078
3079```go
3080func Transport(ctx context.Context, service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64) error {
3081 // (argument building unchanged)
3082 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
3083 cmd.Env = append(os.Environ(), extraEnv...)
3084 cmd.Stdin = stdin
3085 cmd.Stdout = stdout
3086 cmd.Stderr = errW
3087 return cmd.Run()
3088}
3089```
3090
3091The doc comment gains: "ctx ending kills git."
3092
3093- [ ] **Step 4: Implement in sshd**
3094
3095`Server` gains `packs *packlimit.Limiter`; `New`:
3096
3097```go
3098func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error) {
3099 s := &Server{cfg: cfg, st: st, packs: packs, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
3100```
3101
3102`runExec` passes it: `return Exec(s.cfg, s.st, s.packs, user, …, done, s.stopping)`.
3103
3104`Exec` takes `packs` after `st` and passes `packs, done, stopping`
3105to `runGit`:
3106
3107```go
3108 code := runGit(cfg, st, packs, user, scope, argv, stdin, stdout, stderr, done, stopping)
3109```
3110
3111`runGit` signature:
3112
3113```go
3114func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope string, argv []string,
3115 stdin io.Reader, stdout, stderr io.Writer, done, stopping <-chan struct{}) int {
3116```
3117
3118and after the pull-mirror refusal (line 439), before `dir :=`:
3119
3120```go
3121 // Pack generation shares one budget with smart HTTP and git://.
3122 // receive-pack stays outside it: its post-receive runs after the
3123 // client has its report, and must not be queued or killed.
3124 ctx := context.Background()
3125 if !write {
3126 release, err := packs.Acquire(done, "user:"+strconv.FormatInt(user.ID, 10))
3127 if err != nil {
3128 fmt.Fprintln(stderr, err)
3129 return protocol.ExitFailure
3130 }
3131 defer release()
3132 var cancel context.CancelFunc
3133 ctx, cancel = context.WithCancel(ctx)
3134 defer cancel()
3135 go func() {
3136 select {
3137 case <-done:
3138 // done closes on a restart too; a clone already running
3139 // finishes then. Only a departed client ends it.
3140 select {
3141 case <-stopping:
3142 default:
3143 cancel()
3144 }
3145 case <-ctx.Done():
3146 }
3147 }()
3148 }
3149```
3150
3151and the transport call becomes
3152`gitutil.Transport(ctx, service, dir, stdin, stdout, stderr, env, maxPack)`.
3153
3154`internal/sshd/sshd.go` imports `gitbay.org/gitbay/internal/packlimit`.
3155
3156`cmd/gitbayd/system.go:97`:
3157
3158```go
3159 // Each forced command is its own process, so there is no
3160 // shared pack budget in system mode (see Admin, [limits]).
3161 code := sshd.Exec(cfg, st, nil, user, key.Scope, key.Fingerprint, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil)
3162```
3163
3164`internal/sshd/sshd_test.go:65`: `srv, err := New(cfg, st, nil)`.
3165
3166`cmd/gitbayd/main.go:208`: `srv, err := sshd.New(cfg, st, nil)`, so
3167this commit builds; Task 6.5 passes the shared limiter.
3168
3169- [ ] **Step 5: Run the packages**
3170
3171Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil -count=1`
3172Expected: PASS.
3173
3174- [ ] **Step 6: Commit**
3175
3176```bash
3177git add internal/gitutil internal/sshd cmd/gitbayd/system.go cmd/gitbayd/main.go
3178git commit -S -m "sshd: upload-pack and upload-archive take a pack slot; killed when the client leaves
3179
3180Ref #262"
3181```
3182
3183### Task 6.4: smart HTTP and git:// acquire a slot; ls-refs does not
3184
3185**Files:**
3186- Modify: `internal/httpd/smart.go:25-38` (`Server.packs`, `New`), `:122-146` (`uploadPack`)
3187- Create: `internal/httpd/packlimit_test.go`
3188- Modify: `internal/gitd/gitd.go:22-27` (`Server.packs`, `New`), `:64-77` (`handle`)
3189- Create: `internal/gitd/gitd_test.go`
3190
3191**Interfaces:**
3192- Consumes: `packlimit` (Task 6.1).
3193- Produces: `func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server` in both `httpd` and `gitd`; unexported `lsRefs(br *bufio.Reader) bool` in `httpd`.
3194
3195- [ ] **Step 1: Write the failing tests**
3196
3197`internal/httpd/packlimit_test.go`:
3198
3199```go
3200package httpd
3201
3202import (
3203 "net/http"
3204 "net/http/httptest"
3205 "path/filepath"
3206 "strings"
3207 "testing"
3208 "time"
3209
3210 "gitbay.org/gitbay/internal/config"
3211 "gitbay.org/gitbay/internal/packlimit"
3212 "gitbay.org/gitbay/internal/store"
3213)
3214
3215func busyServer(t *testing.T) *Server {
3216 t.Helper()
3217 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
3218 if err != nil {
3219 t.Fatal(err)
3220 }
3221 t.Cleanup(func() { st.Close() })
3222 if err := st.MigrateUp(); err != nil {
3223 t.Fatal(err)
3224 }
3225 uid, err := st.CreateUser("alice", false)
3226 if err != nil {
3227 t.Fatal(err)
3228 }
3229 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
3230 t.Fatal(err)
3231 }
3232 packs := packlimit.New(1, 0, 0, time.Second)
3233 hold, err := packs.Acquire(nil, "ip:elsewhere")
3234 if err != nil {
3235 t.Fatal(err)
3236 }
3237 t.Cleanup(hold)
3238 var cfg config.Config
3239 cfg.Server.Root = t.TempDir()
3240 return &Server{cfg: cfg, st: st, packs: packs, stopping: make(chan struct{})}
3241}
3242
3243func post(s *Server, body string) *httptest.ResponseRecorder {
3244 r := httptest.NewRequest("POST", "/alice/app/git-upload-pack", strings.NewReader(body))
3245 r.SetPathValue("owner", "alice")
3246 r.SetPathValue("repo", "app")
3247 w := httptest.NewRecorder()
3248 s.uploadPack(w, r)
3249 return w
3250}
3251
3252func TestUploadPackBusyIs503(t *testing.T) {
3253 w := post(busyServer(t), "0000")
3254 if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
3255 t.Fatalf("status %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
3256 }
3257}
3258
3259// A protocol v2 ref listing generates no pack and is never queued.
3260func TestLsRefsBypassesTheLimit(t *testing.T) {
3261 w := post(busyServer(t), "0014command=ls-refs\n0000")
3262 if w.Code == http.StatusServiceUnavailable {
3263 t.Fatal("ls-refs was held to the pack limit")
3264 }
3265}
3266```
3267
3268The repository directory does not exist, so the ls-refs request's git
3269exits non-zero; the test asserts only that it was not refused.
3270
3271`internal/gitd/gitd_test.go`:
3272
3273```go
3274package gitd
3275
3276import (
3277 "fmt"
3278 "net"
3279 "path/filepath"
3280 "strings"
3281 "testing"
3282 "time"
3283
3284 "gitbay.org/gitbay/internal/config"
3285 "gitbay.org/gitbay/internal/packlimit"
3286 "gitbay.org/gitbay/internal/store"
3287)
3288
3289func TestBusyAnswersERR(t *testing.T) {
3290 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
3291 if err != nil {
3292 t.Fatal(err)
3293 }
3294 defer st.Close()
3295 if err := st.MigrateUp(); err != nil {
3296 t.Fatal(err)
3297 }
3298 uid, err := st.CreateUser("alice", false)
3299 if err != nil {
3300 t.Fatal(err)
3301 }
3302 repoID, err := st.CreateRepo("user", uid, "app", "public")
3303 if err != nil {
3304 t.Fatal(err)
3305 }
3306 if _, err := st.UpdateRepoSettings(repoID, func(rs *store.RepoSettings) { rs.GitDaemon = true }); err != nil {
3307 t.Fatal(err)
3308 }
3309 packs := packlimit.New(1, 0, 0, time.Second)
3310 hold, _ := packs.Acquire(nil, "ip:elsewhere")
3311 defer hold()
3312
3313 s := New(config.Config{Server: config.Server{Root: t.TempDir()}}, st, packs)
3314 client, server := net.Pipe()
3315 defer client.Close()
3316 go s.handle(server)
3317 req := "git-upload-pack /alice/app.git\x00host=x\x00"
3318 fmt.Fprintf(client, "%04x%s", len(req)+4, req)
3319 client.SetReadDeadline(time.Now().Add(5 * time.Second))
3320 line, err := readPktLine(client)
3321 if err != nil || !strings.HasPrefix(line, "ERR ") || !strings.Contains(line, "busy") {
3322 t.Fatalf("got %q, %v", line, err)
3323 }
3324}
3325```
3326
3327- [ ] **Step 2: Run them and see them fail**
3328
3329Run: `go test ./internal/httpd -run 'TestUploadPackBusyIs503|TestLsRefsBypassesTheLimit' -count=1; go test ./internal/gitd -run TestBusyAnswersERR -count=1`
3330Expected: build failures, `unknown field packs`.
3331
3332- [ ] **Step 3: Implement in httpd**
3333
3334`Server` gains `packs *packlimit.Limiter`; `New`:
3335
3336```go
3337func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server {
3338 proxies, _ := cfg.HTTP.TrustedProxyNets() // validated at config load
3339 return &Server{cfg: cfg, st: st, packs: packs, apiLimit: newAPILimiter(cfg.Limits.APIRate), proxies: proxies,
3340 stopping: make(chan struct{})}
3341}
3342```
3343
3344`uploadPack`, from the gzip block to the end:
3345
3346```go
3347 body := io.Reader(r.Body)
3348 if r.Header.Get("Content-Encoding") == "gzip" {
3349 gz, err := gzip.NewReader(body)
3350 if err != nil {
3351 http.Error(w, "bad gzip body", http.StatusBadRequest)
3352 return
3353 }
3354 defer gz.Close()
3355 body = gz
3356 }
3357 br := bufio.NewReader(body)
3358 if !lsRefs(br) {
3359 // Waiting ends when the client leaves or the daemon stops, so a
3360 // queued clone does not hold up a restart's drain.
3361 release, err := s.packs.Acquire(s.until(r), "ip:"+s.clientIP(r))
3362 if err != nil {
3363 if errors.Is(err, packlimit.ErrBusy) {
3364 w.Header().Set("Retry-After", "30")
3365 http.Error(w, err.Error(), http.StatusServiceUnavailable)
3366 }
3367 return
3368 }
3369 defer release()
3370 }
3371 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
3372 w.Header().Set("Cache-Control", "no-cache")
3373 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
3374 cmd := exec.CommandContext(r.Context(), toolpath.Look("git"), "upload-pack", "--stateless-rpc", dir)
3375 cmd.Env = append(os.Environ(), gitProtocolEnv(r)...)
3376 cmd.Stdin = br
3377 cmd.Stdout = w
3378 cmd.Run()
3379}
3380
3381// lsRefs reports whether a protocol v2 request is a ref listing, which
3382// generates no pack. Its first pkt-line is "command=ls-refs".
3383func lsRefs(br *bufio.Reader) bool {
3384 const want = "command=ls-refs"
3385 head, err := br.Peek(4 + len(want))
3386 return err == nil && string(head[4:]) == want
3387}
3388```
3389
3390Imports gain `bufio`, `errors`, and
3391`gitbay.org/gitbay/internal/packlimit`.
3392
3393- [ ] **Step 4: Implement in gitd**
3394
3395```go
3396type Server struct {
3397 cfg config.Config
3398 st *store.Store
3399 packs *packlimit.Limiter
3400}
3401
3402func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) *Server {
3403 return &Server{cfg: cfg, st: st, packs: packs}
3404}
3405```
3406
3407In `handle`, after the "repository not exported" check:
3408
3409```go
3410 host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
3411 release, err := s.packs.Acquire(nil, "ip:"+host)
3412 if err != nil {
3413 writeErr(conn, err.Error())
3414 return
3415 }
3416 defer release()
3417```
3418
3419`net.Pipe`'s address is `"pipe"`, which `SplitHostPort` rejects; `host`
3420is then empty and the principal is `"ip:"`, which is fine for the test.
3421
3422`cmd/gitbayd/main.go:225` and `:313`: `httpd.New(cfg, st, nil)` and
3423`gitd.New(cfg, st, nil)`, so this commit builds; Task 6.5 passes the
3424shared limiter.
3425
3426- [ ] **Step 5: Run the packages**
3427
3428Run: `go build ./... && go vet ./... && go test ./internal/httpd ./internal/gitd -count=1`
3429Expected: PASS.
3430
3431- [ ] **Step 6: Commit**
3432
3433```bash
3434git add internal/httpd internal/gitd cmd/gitbayd/main.go
3435git commit -S -m "httpd, gitd: pack generation takes a slot; ls-refs does not
3436
3437Ref #262"
3438```
3439
3440### Task 6.5: one limiter for the daemon; benchmark script; docs
3441
3442**Files:**
3443- Modify: `cmd/gitbayd/main.go` (before `sshd.New`, line 204-208; `httpd.New`, line 225; `gitd.New`, line 313)
3444- Create: `deploy/clonebench.sh`
3445- Modify: `.gitbay/wiki/Admin.org` (`** [limits]`), `.gitbay/wiki/Performance.org`,
3446 `.gitbay/wiki/Architecture/09-Controls.org`, `.gitbay/wiki/Architecture/10-Known-Gaps.org`
3447
3448**Interfaces:**
3449- Consumes: `config.Limits.PackLimits()` (Task 6.2), `packlimit.New` (Task 6.1), the three `New` signatures (Tasks 6.3, 6.4).
3450
3451- [ ] **Step 1: Wire the limiter**
3452
3453Before `errCh := make(chan error, 3)`:
3454
3455```go
3456 // One pack-generation budget for SSH, smart HTTP and git://.
3457 packs := packlimit.New(cfg.Limits.PackLimits())
3458```
3459
3460then `sshd.New(cfg, st, packs)`, `httpd.New(cfg, st, packs)`,
3461`gitd.New(cfg, st, packs).Serve(gln)`. Import
3462`gitbay.org/gitbay/internal/packlimit`.
3463
3464- [ ] **Step 2: Build, vet, touched packages**
3465
3466Run: `go build ./... && go vet ./... && go test ./cmd/gitbayd ./internal/sshd ./internal/httpd ./internal/gitd ./internal/packlimit ./internal/config ./internal/gitutil -count=1`
3467Expected: PASS.
3468
3469- [ ] **Step 3: Benchmark script**
3470
3471`deploy/clonebench.sh` (mode 0755):
3472
3473```sh
3474#!/bin/sh
3475# clonebench.sh <clone-url> <n>: start n full bare clones of <clone-url>
3476# at once and print each one's wall time and outcome, then the total.
3477# Run from a machine other than the server, against a public repository.
3478set -eu
3479url=$1
3480n=$2
3481dir=$(mktemp -d)
3482trap 'rm -rf "$dir"' EXIT
3483start=$(date +%s)
3484i=1
3485while [ "$i" -le "$n" ]; do
3486 (
3487 s=$(date +%s)
3488 if git clone --quiet --bare "$url" "$dir/$i.git" 2>"$dir/$i.err"; then
3489 echo "$i ok $(( $(date +%s) - s ))s"
3490 else
3491 echo "$i failed $(( $(date +%s) - s ))s: $(head -n 1 "$dir/$i.err")"
3492 fi
3493 ) &
3494 i=$((i + 1))
3495done
3496wait
3497echo "total $(( $(date +%s) - start ))s for $n clones"
3498```
3499
3500Run: `sh -n deploy/clonebench.sh`
3501Expected: no output (syntax ok).
3502
3503- [ ] **Step 4: Docs**
3504
3505`Admin.org`, `** [limits]`, add after the `max_bytes_per_user` bullet:
3506
3507```org
3508- =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32),
3509 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
3510 =git archive --remote=) over SSH, smart HTTP and git:// shares one
3511 budget: this many at once, this many per account (per client
3512 address when anonymous), and this many waiting for at most the wait.
3513 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
3514 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued
3515 client that disconnects leaves the queue; a running clone whose
3516 client disconnects is killed. Ref listings (info/refs, protocol v2
3517 =ls-refs=), pushes and web archives are outside the budget. For the
3518 three counts 0 means the default and a negative value turns that
3519 bound off. The defaults suit a four-core host; see [[Performance]].
3520 With =ssh.mode = "system"= each SSH session is its own process and
3521 SSH clones are not counted.
3522```
3523
3524`Performance.org`, the last paragraph of `* Why it holds` becomes:
3525
3526```org
3527The practical ceiling on this hardware is concurrent pack generation:
3528full clones of large repositories are CPU-bound in git itself (the 17s
3529clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many
3530run at once across SSH, HTTP and git://, with a queue behind it (see
3531[[Admin]], =[limits]=); the measurements below set its default.
3532```
3533
3534and a new section at the end:
3535
3536```org
3537* Concurrent clones
3538
3539Measured with =deploy/clonebench.sh https://gitbay.org/krz/gitbay.git <n>=
3540from a machine outside bay1 (four cores), before and after the pack
3541limit was deployed with its defaults (=pack_concurrency= 3,
3542=pack_per_principal= 2, =pack_queue= 32, =pack_queue_wait= 60s). All
3543clones in one run come from one address, so the per-principal cap
3544applies to them; the "limit off" run sets the counts to -1.
3545```
3546
3547The table itself is added by the operator from the runbook's #262
3548measurements, in the follow-up wiki MR described there.
3549
3550`Architecture/09-Controls.org`, the concurrency row:
3551
3552```org
3553| Concurrency limit on git pack generation | in place | global, per-principal, bounded queue across SSH, HTTP and git:// (=internal/packlimit=); not in system SSH mode |
3554```
3555
3556`Architecture/10-Known-Gaps.org`: delete the `#262` row. The question
3557row "How many concurrent clones does the host sustain?" stays until the
3558runbook's numbers are on the Performance page.
3559
3560- [ ] **Step 5: Commit, MR, merge**
3561
3562```bash
3563chmod 0755 deploy/clonebench.sh
3564git add cmd/gitbayd/main.go deploy/clonebench.sh .gitbay/wiki
3565git commit -S -m "gitbayd: one pack-generation limit for SSH, HTTP and git://
3566
3567Closes #262"
3568git push -u origin pack-limit
3569gitbay mr create --source pack-limit --target main --title "git: limit concurrent pack generation across HTTP and SSH"
3570```
3571
3572Run the runbook's #262 "before" measurement against production before
3573deploying this MR. Merge `--strategy ff` after CI, delete the branch
3574both places.
3575
3576---
3577
3578# Runbook for the operator (cmc)
3579
3580The classifier refuses root ssh to bay1 from an assistant session; these
3581steps are run by hand. Operator ssh is `ssh -p 2222 root@gitbay.org`.
3582
35831. **Before merging MR 2 (#280).** `grep -A6 '^\[mail\]' /etc/gitbay/config.toml`
3584 on bay1. If `smtp_host` is not `localhost`/loopback, check the relay
3585 offers STARTTLS: `openssl s_client -starttls smtp -connect <smtp_host> -brief </dev/null`
3586 must complete a handshake. If it does not, either set
3587 `require_tls = false` in `[mail]` before deploying (and record why
3588 on the Admin page), or switch to the relay's implicit-TLS port with
3589 `tls = "implicit"`. After deploying, `gitbay dashboard --json | jq .queues`
3590 shows no mail failures after the next notification.
35912. **Before merging MR 3 (#279).** `git --version` on bay1 must be
3592 2.37 or later (`http.curloptResolve`). If not, upgrade git first;
3593 mirrors fail with an unknown-config error otherwise. After
3594 deploying, `gitbay repo mirror sync krz/gitbay` then
3595 `gitbay repo mirror list krz/gitbay` shows the GitHub push mirror
3596 with no error.
35973. **Deploying MR 4 (#282).** Check `gitbay build list` and the
3598 journal for a push in progress, then `make deploy`. After it:
3599 `stat -c '%a %U' /var/lib/gitbay/hook.sock` shows `600 gitbay`;
3600 push a commit to a scratch repository and confirm it lands and its
3601 push event appears in `gitbay feed`.
36024. **After deploying MR 5 (#275).** On bay1, as the gitbay user:
3603 `sudo -u gitbay gitbayd --config /etc/gitbay/config.toml admin audit verify`
3604 prints "chain intact" with the unchained count equal to the rows
3605 written before the upgrade. `journalctl -u gitbayd -g 'msg=audit' -n 5`
3606 shows the rows the verify run's own session produced. Record the
3607 printed last hash somewhere off the host (a note in the
3608 password manager) if a manual anchor is wanted.
36095. **MR 6 (#262) benchmark.** From the laptop, before deploying MR 6:
3610 `for n in 1 2 4 8; do sh deploy/clonebench.sh https://gitbay.org/krz/gitbay.git $n; done`,
3611 and on bay1 `uptime` during the n=8 run. After deploying MR 6
3612 (defaults), repeat, and additionally n=16 and n=40 (40 exceeds
3613 concurrency + queue from one address with per-principal 2 and shows
3614 the refusals). Record per n: total wall time, slowest clone,
3615 refused count, load average. Put the table under
3616 `* Concurrent clones` in `.gitbay/wiki/Performance.org` on a branch
3617 `wiki-clone-benchmark`, remove the "How many concurrent clones"
3618 question row from `Architecture/10-Known-Gaps.org`, and open an MR
3619 with `Ref #262`. If the numbers show the web staying slow with 3
3620 concurrent clones, lower `DefaultPackConcurrency` in the same MR
3621 and say so on the Admin page.
36226. **After MR 1 (#281).** `openssl s_client -connect gitbay.org:443 -tls1_1 </dev/null`
3623 fails; `-tls1_2` and `-tls1_3` succeed.
3624
3625# Release notes for whoever tags these
3626
3627- mail: `mail.require_tls` defaults on for non-local relays; a relay
3628 without STARTTLS stops receiving mail unless `require_tls = false`.
3629 New `mail.tls = "implicit"` for port 465.
3630- mirror: git ≥ 2.37 required on the server; mirrors no longer follow
3631 redirects.
3632- hookd: pushes in flight across the upgrade lose their post-receive
3633 effects; deploy with none running.
3634- audit: schema 0070 adds the chain; rows before it are reported as
3635 unchained by `gitbayd admin audit verify`.
3636- limits: new `pack_*` settings with non-zero defaults; a burst of
3637 clones now queues and, past the queue, is refused with 503 / exit 1.
3638
3639# Decisions and remaining questions
3640
3641Decided 2026-09-28:
3642
3643- **receive-pack stays outside the pack limit.**
3644- **bay1's relay and git**, checked: git 2.47.3 (`http.curloptResolve`
3645 needs 2.37); relay is AWS mail manager on port 587 and negotiates
3646 STARTTLS (TLS 1.3, certificate verified). MRs 2 and 3 are not blocked;
3647 runbook steps 1 and 2 stay as the check for other operators.
3648
3649Remaining:
3650
36511. **System SSH mode.** With `ssh.mode = "system"` every session is a
3652 separate `gitbayd shell` process, so (a) the pack limiter cannot
3653 count SSH clones across sessions — this plan passes `nil` and says
3654 so on the Admin page — and (b) audit rows written there are not
3655 copied to the journal, because that process's stderr is the SSH
3656 client. The same applies to host `gitbayd admin …` commands. gitbay.org
3657 runs embedded mode; the plan documents the limit and adds nothing
3658 for system mode.
36592. **Default pack limits.** 3 / 2 / 32 / 60s are an estimate from the
3660 one measured full clone (~1.5 cores). The runbook's benchmark
3661 decides whether they stand.
3662
3663# Self-review
3664
3665- Coverage against the issues: #281 MinVersion + Admin (MR 1). #280
3666 require_tls default by locality, implicit TLS (MR 2). #279 resolve
3667 and check before each sync, pin for git, http(s) only per
3668 `ValidateURL` (MR 3). #282 chmod 0600, SO_PEERCRED behind build
3669 tags, per-push token minted in `runGit` and required by hookd, works
3670 in both SSH modes through SQLite (MR 4). #275 refusals audited with
3671 per-actor limit, hash chain with `actor_ref`, `gitbayd admin audit
3672 verify`, journal copy from the daemon (MR 5). #262 global and
3673 per-principal limit, bounded queue, cancellation while queued (done /
3674 request context / stop) and while running (SSH ctx, HTTP request
3675 context), ls-refs and info/refs outside, knobs with 4-core defaults,
3676 benchmark script and Performance section, results via runbook (MR 6).
3677- Signatures used across tasks: `packlimit.New(max, per, queue int, wait time.Duration)`
3678 and `Limits.PackLimits() (max, per, queue int, wait time.Duration)`
3679 match; `Acquire(done <-chan struct{}, principal string)` is called
3680 with `done` (SSH), `s.until(r)` (HTTP), `nil` (git://, tests).
3681 `Exec` gains `packs` in MR 6 only; MR 5's test call is updated in
3682 Task 6.3 Step 1. `CreatePushToken` returns the raw token and
3683 `DeletePushToken` takes the raw token in both sshd and tests.
3684- Migrations 0069/0070 are within plan 3's range; renumber if another
3685 plan has taken them by then.
3686- No new route, template, ReadOnly command, control command or stdin
3687 reader, so no registry rows.