Commit 4e0958a163

4e0958a1631b2a0a0b5cd3c342e09a565bf7191c

parent: fc5b1b67a2

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-28 05:23 UTC

plans: open issues from the architecture and UX reviews

Six plans: credentials and sessions, CI trust and build reporting, server hardening, data at rest and backup, web UX, CLI UX.

Ref #255 #256 #257 #258 #259 #260 #261 #262 #263 #264 #265 #266 #267 #268 #269 #270 #271 #273 #274 #275 #276 #277 #278 #279 #280 #281 #282 #283

Layout: unified · split

docs/plans/2026-09-27-ci-trust-and-build-reporting.md added +3250
@@ -0,0 +1,3250 @@
1# CI trust and build reporting 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:** Untrusted builds get a disposable home and never feed a
6trusted build (#255, #258); `ci/*` statuses belong to the build
7subsystem and merges can wait on named contexts (#258); a build can no
8longer share the runner's source address (#260); a failed build names
9its step, exit and duration on the CLI and the web (#266).
10
11**Architecture:** The claim payload gains an explicit `trusted` flag and
12the instance's public ssh destination. The runner picks the build home
13by trust (persistent per repository for trusted builds, fresh and
14removed for untrusted ones), keeps a loopback runner's builds off the
15host's loopback, and reports the failed step on `runner done`. The
16server refuses `ci/` contexts in `status set`, restricts tree and commit
17reuse to trusted builds on the same image, adds a `required_contexts`
18repository setting that `MergeGates` treats as pending until reported,
19stores the failed step and reason on the build, and cuts the log at its
20`$ <step>` lines for `build log --step` and the build page.
21
22**Tech Stack:** Go, SQLite (hand-written SQL), `html/template`, rootless
23podman with pasta, systemd.
24
25**Spec:** the issues themselves: krz/gitbay#255, #258, #260, #266 (texts
26in the session's `issues.txt`), plus the decisions recorded under
27"Decisions" below.
28
29## Global Constraints
30
31- Each MR on its own branch off `main`. Commits are signed (the repo
32 refuses unsigned), messages reference issues (`Ref #N`, and
33 `Closes #N` on the commit that finishes one). No attribution to any
34 assistant, model or AI anywhere: commits, MR bodies, comments. No
35 `Co-Authored-By` trailer.
36- MR: `gitbay mr create --source <branch> --target main --title "..."`;
37 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
38 delete the branch locally and on the remote. Behind main → rebase,
39 force-push, merge again.
40- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
41 packages, and at most the one e2e test being written
42 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
43- Registries that fail CI when a new thing lacks its row: top-level route
44 word in `internal/policy/names.go`; new page template in the width map
45 of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
46 command in `readArgs` in `e2e/readonly_test.go`; new control command
47 needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
48 a command reading stdin needs `ReadsStdin: true`. A new or changed
49 command `Summary` needs `go test ./cmd/gitbay -run TestSummariesAreCurrent -update`.
50- New migrations: the highest today is 0059. Six plans are written in
51 parallel, so numbers are pre-assigned: plan 1 uses 0060–0064, plan 2
52 (this one) 0065–0068, plan 3 0069–0071, plan 4 0072–0074, plan 5
53 0075–0077, plan 6 0078–0079. Whoever lands second renumbers to the
54 next free number at execution time. Migrations come in
55 `.up.sql`/`.down.sql` pairs. Hand-written SQL, no ORM. This plan uses
56 one: 0065.
57- Secrets travel on stdin, never argv; never logged or echoed.
58- Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model,
59 CI, Users, Performance, and the `Architecture/` folder with its
60 Known-Gaps table and controls matrix). Update the page in the same MR
61 that changes the behaviour it describes, and close the matching
62 Known-Gaps row.
63- Writing style: plain, direct, no hype; code comments match the
64 surrounding density. Comments and docs state facts, never
65 before/after narration.
66- **Deploy order for Parts 1, 3 and 4: `make deploy` (gitbayd) before
67 `make deploy-runner`.** A new runner reads `trusted` and `ssh` from the
68 claim and sends `--step`/`--reason` to `runner done`; an older server
69 omits the first two (the runner then treats every build as untrusted:
70 no secrets, disposable home) and refuses the flags with exit 2 (the
71 build stays running until the reaper fails it). An older runner
72 against a newer server works unchanged. The laptop runner is a brew
73 bottle built from a release tag, so it always trails the server.
74- **Runner changes are validated on a scratch repository before the
75 bay1 runner touches real repositories** (CLAUDE.md: every deploy that
76 skipped this took CI down). The procedure is in the runbook at the end
77 of this plan; Parts 1, 3 and 4 each have a validation section there.
78- `cmd/gitbay-runner` tests run on macOS and Linux; nothing here may
79 need podman to pass (`e2e/isolation_podman_test.go` is the only podman
80 test and skips without it).
81
82## Decisions
83
84- **#255.** The claim carries `"trusted": true|false` with no
85 `omitempty`; a runner that finds the field absent treats the build as
86 untrusted. Trusted homes move from `<workdir>/home/<owner>/<name>` to
87 `<workdir>/trusted-home/<owner>/<name>`, so a home written before this
88 change — which untrusted builds could write — is never read again,
89 even before the operator deletes it. An untrusted build's home is
90 `<workdir>/build-<id>-home`, created with `os.Mkdir` (so it is new and
91 empty) and removed after the build with a helper that first makes
92 every directory writable (the Go module cache leaves them 0555). No
93 persistent cache for untrusted builds: a fork's build downloads its
94 modules each time. The runner also drops secrets for an untrusted
95 build, though the server never sends any.
96- **#258.** `status set` refuses any context starting with `ci/`,
97 case-folded, with exit 4, before resolving the repository. Tree reuse
98 (`SuccessBuildForTree`) and the cancelled-build fallback
99 (`SuccessBuildFor`) consider trusted builds only, and tree reuse also
100 requires the same `image:` as the job declares. The same-commit dedupe
101 in `queueJobs` lets an untrusted build stand only for another
102 untrusted queue: a fork head that lands on a branch by fast-forward is
103 built again as trusted. `required_contexts` is a list in the
104 repository's settings JSON (no migration), set by
105 `repo settings require-contexts <owner/name> [<context>...]` (no
106 contexts clears it); it applies only while `require_checks` is on, and
107 the command says so on stderr when it is off. A missing required
108 context makes the combined check `pending` and appears as
109 `<context>=missing` in the unmet sentence and in `checks_missing`.
110- **#260.** Reading the code: the bay1 runner polls `git@127.0.0.1`
111 (`deploy/gitbay-runner.override.conf:78`); `buildSSH`
112 (`cmd/gitbay-runner/main.go:471-486`) sends podman builds to
113 `169.254.1.2`, and pasta's default gateway mapping lets a build reach
114 the host's loopback, where its connections arrive from `127.0.0.1`.
115 The limiter keys on the remote IP (`internal/sshd/ratelimit.go:86`,
116 used at `internal/sshd/sshd.go:122`). "Runner on the public address"
117 does not separate anything: a build can connect to the public address
118 too, and would then share the runner's source there instead. So the
119 runner stays on loopback and its builds lose loopback: under podman,
120 when the runner's remote is loopback, containers run with
121 `--network pasta:--no-map-gw`, and `GITBAY_SSH` is the instance's
122 public destination (`git@<site host>`), which the server sends in the
123 claim as `ssh`. A build then reaches the host only as an internet
124 client does. **Egress policy:** builds, trusted or not, keep outbound
125 internet access (a fork's merge request to a Go repository must fetch
126 its modules); they get no host loopback; they reach the forge's
127 public ports (22, 80, 443, and the admin sshd on 2222) exactly as
128 anyone on the internet can. No nftables rules. `-isolation none`
129 builds run on the host and share its loopback; that mode is for
130 instances where every repository is trusted and says so already.
131- **Finding for #260, recorded for the runbook.** On gitbay.org the
132 limiter's failure count is unreachable by an unknown key:
133 `authenticate` admits an unknown key as an anonymous `register`
134 session whenever `registration.mode` is not `closed`
135 (`internal/sshd/sshd.go:136-143`), and `fail` is only called on the
136 closed path (`sshd.go:145`). gitbay.org runs `registration = "open"`,
137 so the throttling test on bay1 is expected to show no throttling at
138 all; the separation matters for closed-registration instances. The
139 runbook still measures source addresses on bay1, which is the part of
140 #260 that holds on every instance.
141- **#266.** Duration is not stored: `Build.Elapsed()`
142 (`internal/store/builds.go:420`) already derives it from `started_at`
143 and `finished_at`. Migration 0065 adds `failed_step` (1-based, 0 for
144 "no step": success, or a failure before the first step) and
145 `failed_reason` (one line, at most 200 bytes). The runner writes
146 `step 3/3 failed: exit 1` and reports `runner done <id> failure --step
147 3 --reason 'exit 1'`; the reason is `exit <code>` for a command that
148 exited and the error text otherwise (`build timed out after 45m0s`,
149 `cancelled`, `git clone: exit 128`). The log format is otherwise
150 unchanged: sections are cut at the `$ <step>` line the runner already
151 writes before each step (`isolate.go:88`, `:186`), matched against the
152 build's own `steps` in order and only at a line start, so logs of
153 builds that ran before this change fold too. `build log --step`
154 takes `0` (setup, before the first step), a step number, or `failed`.
155 An invalid `--step` on `runner done` is recorded as 0 rather than
156 refused, so a runner/server mismatch never loses an outcome.
157
158## Order and dependencies
159
160| # | Branch | Closes | Migration | Needs |
161|---|---|---|---|---|
162| 1 | `ci-untrusted-home` | #255 | — | — |
163| 2 | `ci-status-trust` | #258 | — | — |
164| 3 | `runner-source-address` | Ref #260 (closed by the runbook result commit) | — | Part 1 merged (both edit `runRunnerNext`'s payload and `stepEnv`) |
165| 4 | `build-failure-report` | #266 | 0065 | Part 1 merged (both change `run()`); Part 3 merged (both change `runStepsPodman`) |
166
167#255 goes first. Parts 1–4 land and deploy in order; each runner deploy
168follows the scratch validation in the runbook.
169
170Other plans (all `docs/plans/2026-09-27-*.md`):
171
172- Plan 4 (data-at-rest-and-backup, #273) encrypts `build_secrets`; if it
173 changes `Store.BuildSecrets`, Part 1's edit to `runRunnerNext`
174 (`internal/control/build.go:543-564`) conflicts textually. Whoever
175 lands second rebases; no behavioural dependency.
176- Plan 3 (server-hardening, #275) audits refused mutating commands; the
177 `status set` refusal from Part 2 is one of them and needs nothing
178 from this plan.
179- Plan 5 (web-ux, #261) covers documentation drift. Two items seen here
180 and left alone: `deploy/gitbay-runner.override.conf:27-30` names
181 `cmc/ci-smoke`, which no longer exists; `cmd/gitbay-runner/main.go:513`
182 repeats its comment line.
183- Plan 1 (credentials-and-sessions, #256) closes a removed key's
184 connections; the runner's `runner log` session is one such
185 connection, and no code here depends on it.
186
187## File map
188
189| File | Part | Responsibility |
190|---|---|---|
191| `internal/control/build.go` | 1, 3, 4 | claim payload `trusted`, `ssh`; `runner done` flags; `build show` fields; `build log --step/--tail`; `queueJobs` trust rule (2) |
192| `internal/control/buildlog.go` (create) | 4 | `LogSection`, `SplitBuildLog`, `FailedSection`, `tailLines` |
193| `internal/control/status.go` | 2 | `ci/` refusal |
194| `internal/control/mr.go`, `output.go` | 2 | `require-contexts`, `MergeGates`, `GatesOut.ChecksMissing` |
195| `internal/control/repo.go` | 2 | `repo settings show` prints required contexts |
196| `internal/store/builds.go` | 2, 4 | trust and image on reuse; failed step columns |
197| `internal/store/repos.go` | 2 | `RepoSettings.RequiredContexts` |
198| `internal/store/migrations/0065_build_failure.{up,down}.sql` | 4 | columns |
199| `cmd/gitbay-runner/main.go` | 1, 3, 4 | `job` fields, `buildHome`, `removeTree`, `stepEnv`, `loopbackRemote`, `buildSSH`, `buildNetwork`, `failure`, `exitReason` |
200| `cmd/gitbay-runner/isolate.go` | 3, 4 | network flag; `runSteps` returns `*failure` |
201| `cmd/gitbay-runner/report.go` | 4 | `doneArgs` |
202| `cmd/gitbay/main.go`, `summaries_gen.go` | 2 | `require-contexts` pass-through |
203| `internal/httpd/builds.go`, `settings.go` | 2, 4 | step view; settings form mapping |
204| `internal/web/templates/build.html`, `mr.html`, `settings.html` | 2, 4 | steps, gates row, form |
205| `internal/web/static/style.css` | 4 | `pre.buildlog` wraps; step folds |
206| `e2e/readonly_test.go`, `e2e/mrweb_test.go`, `e2e/status_test.go`, `e2e/settingsweb_test.go`, `e2e/ci_test.go` | 2, 4 | contexts off `ci/`; refusal; settings; failed step |
207| `.gitbay/wiki/…` | all | as listed per task |
208
209---
210
211# Part 1: disposable home for untrusted builds (branch `ci-untrusted-home`, #255)
212
213### Task 1.1: the claim says whether a build is trusted
214
215**Files:**
216- Modify: `internal/control/build.go:554-564` (the claim payload in `runRunnerNext`)
217- Test: `internal/control/runnernext_test.go` (append)
218
219**Interfaces:**
220- Produces: the `runner next --json` payload gains `"trusted": <bool>`, always present.
221
222- [ ] **Step 1: Write the failing test**
223
224Append to `internal/control/runnernext_test.go`:
225
226```go
227// The claim says whether a build is trusted in so many words. A runner
228// must not infer it from secrets being absent: a trusted repository with
229// no secrets looks the same (#255).
230func TestRunnerNextSaysWhetherTrusted(t *testing.T) {
231 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
232 for _, trusted := range []bool{true, false} {
233 if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", trusted); err != nil {
234 t.Fatal(err)
235 }
236 c, out := runnerCtx(st, uid, root)
237 c.JSON = true
238 if code := runRunnerNext(c, []string{"--untrusted"}); code != protocol.ExitOK {
239 t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
240 }
241 want := fmt.Sprintf(`"trusted":%v`, trusted)
242 if !strings.Contains(out.String(), want) {
243 t.Fatalf("claim of a trusted=%v build lacks %s:\n%s", trusted, want, out.String())
244 }
245 }
246}
247```
248
249The first loop creates and claims the trusted build; the second creates
250the untrusted one, which is then the only pending build.
251
252- [ ] **Step 2: Run it and see it fail**
253
254Run: `go test ./internal/control -run TestRunnerNextSaysWhetherTrusted -count=1`
255Expected: FAIL, `claim of a trusted=true build lacks "trusted":true`.
256
257- [ ] **Step 3: Implement**
258
259Replace the payload at `internal/control/build.go:554-564` with:
260
261```go
262 d := struct {
263 ID int64 `json:"id"`
264 Repo string `json:"repo"`
265 Number int64 `json:"number"`
266 Job string `json:"job"`
267 SHA string `json:"sha"`
268 Ref string `json:"ref"`
269 Steps []string `json:"steps"`
270 Image string `json:"image,omitempty"`
271 // Trusted is always sent: a runner decides a build's home and
272 // secrets from it, and reads a missing field as untrusted (#255).
273 Trusted bool `json:"trusted"`
274 Secrets map[string]string `json:"secrets,omitempty"`
275 }{ID: b.ID, Repo: repo.Path(), Number: b.Number, Job: b.Job, SHA: b.SHA, Ref: b.Ref,
276 Steps: steps, Image: b.Image, Trusted: b.Trusted, Secrets: secrets}
277```
278
279- [ ] **Step 4: Run it and see it pass**
280
281Run: `go test ./internal/control -run 'TestRunnerNext' -count=1`
282Expected: PASS.
283
284- [ ] **Step 5: Commit**
285
286```bash
287git add internal/control/build.go internal/control/runnernext_test.go
288git commit -S -m "runner next: say whether the build is trusted
289
290Ref #255"
291```
292
293### Task 1.2: the runner's build home follows trust
294
295**Files:**
296- Modify: `cmd/gitbay-runner/main.go:12-30` (imports), `:32-42` (`job`), `:315-330` (`run`), `:437-463` (comment and `buildHomeFor`), `:488-511` (`stepEnv`)
297- Modify: `cmd/gitbay-runner/home_test.go` (rewrite), `cmd/gitbay-runner/env_test.go:47-60`
298
299**Interfaces:**
300- Consumes: the `trusted` claim field from Task 1.1.
301- Produces:
302 - `job.Trusted bool` (`json:"trusted"`)
303 - `func buildHome(workdir string, j job) (string, func(), error)` — the home and a cleanup to defer; replaces `buildHomeFor`.
304 - `func removeTree(dir string) error`
305
306- [ ] **Step 1: Write the failing tests**
307
308Replace `cmd/gitbay-runner/home_test.go` with:
309
310```go
311package main
312
313import (
314 "os"
315 "path/filepath"
316 "strings"
317 "testing"
318)
319
320// A trusted build's home is its repository's, kept between builds so
321// tool caches survive: the same repository gets the same directory back,
322// another repository a different one (#184).
323func TestTrustedHomeIsPerRepositoryAndKept(t *testing.T) {
324 work := t.TempDir()
325 a, done, err := buildHome(work, job{ID: 1, Repo: "alice/app", Trusted: true})
326 if err != nil {
327 t.Fatal(err)
328 }
329 done()
330 if _, err := os.Stat(a); err != nil {
331 t.Fatalf("trusted home removed after its build: %v", err)
332 }
333 b, done, err := buildHome(work, job{ID: 2, Repo: "bob/app", Trusted: true})
334 if err != nil {
335 t.Fatal(err)
336 }
337 done()
338 if a == b {
339 t.Fatalf("two repositories share a build home: %s", a)
340 }
341 again, done, _ := buildHome(work, job{ID: 3, Repo: "alice/app", Trusted: true})
342 done()
343 if again != a {
344 t.Fatalf("build home moved between builds: %s then %s", a, again)
345 }
346 for _, dir := range []string{a, b} {
347 rel, err := filepath.Rel(filepath.Join(work, "trusted-home"), dir)
348 if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
349 t.Fatalf("build home %s is not under %s/trusted-home", dir, work)
350 }
351 st, err := os.Stat(dir)
352 if err != nil {
353 t.Fatal(err)
354 }
355 if st.Mode().Perm() != 0o700 {
356 t.Fatalf("build home mode %o, want 0700", st.Mode().Perm())
357 }
358 }
359}
360
361// An untrusted build gets a home of its own, outside the trusted root,
362// removed when the build ends: nothing a fork's build writes reaches a
363// later build of the repository (#255).
364func TestUntrustedHomeIsDisposable(t *testing.T) {
365 work := t.TempDir()
366 trusted, done, err := buildHome(work, job{ID: 1, Repo: "alice/app", Trusted: true})
367 if err != nil {
368 t.Fatal(err)
369 }
370 done()
371 home, done, err := buildHome(work, job{ID: 2, Repo: "alice/app"})
372 if err != nil {
373 t.Fatal(err)
374 }
375 if home == trusted || strings.HasPrefix(home, filepath.Join(work, "trusted-home")) {
376 t.Fatalf("untrusted build got a trusted home: %s", home)
377 }
378 // What the Go module cache leaves behind: read-only directories.
379 cache := filepath.Join(home, "go", "pkg", "mod", "example.com", "m@v1")
380 if err := os.MkdirAll(cache, 0o755); err != nil {
381 t.Fatal(err)
382 }
383 if err := os.WriteFile(filepath.Join(cache, "go.mod"), []byte("module m\n"), 0o444); err != nil {
384 t.Fatal(err)
385 }
386 os.Chmod(cache, 0o555)
387 os.Chmod(filepath.Dir(cache), 0o555)
388 done()
389 if _, err := os.Stat(home); !os.IsNotExist(err) {
390 t.Fatalf("untrusted home left behind: %v", err)
391 }
392}
393
394// A repository path is server-validated, but a trusted home must still
395// never resolve outside the runner's home root.
396func TestBuildHomeRefusesTraversal(t *testing.T) {
397 if _, _, err := buildHome(t.TempDir(), job{Repo: "../../etc", Trusted: true}); err == nil {
398 t.Fatal("a traversing repository path produced a build home")
399 }
400}
401```
402
403In `cmd/gitbay-runner/env_test.go`, replace `TestStepEnvCarriesSecrets`
404(lines 47-60) with:
405
406```go
407// Secrets reach a trusted build's steps and never an untrusted one's,
408// whatever the claim carried: the trust flag decides, not whether any
409// secrets arrived (#255).
410func TestStepEnvCarriesSecrets(t *testing.T) {
411 secrets := map[string]string{"TOKEN": "s3cret"}
412 env := stepEnv(job{Trusted: true, Secrets: secrets}, "/tmp/buildhome", "git@x.test")
413 if !containsEnv(env, "TOKEN=s3cret") {
414 t.Error("a trusted build's secret did not reach the step")
415 }
416 for _, j := range []job{{}, {Secrets: secrets}} {
417 for _, e := range stepEnv(j, "/tmp/buildhome", "git@x.test") {
418 if strings.HasPrefix(e, "TOKEN=") {
419 t.Errorf("a secret reached an untrusted build: %q", e)
420 }
421 }
422 }
423}
424```
425
426- [ ] **Step 2: Run them and see them fail**
427
428Run: `go test ./cmd/gitbay-runner -count=1`
429Expected: build failure, `undefined: buildHome` and `unknown field Trusted in struct literal of type job`.
430
431- [ ] **Step 3: Implement**
432
433In `cmd/gitbay-runner/main.go`:
434
435Add `"io/fs"` to the imports (between `"io"` and `"log"`).
436
437Add the field to `job` (after `Image`):
438
439```go
440 Image string `json:"image"`
441 // Trusted is false for a merge request head from a fork, and when the
442 // server did not say: such a build gets no secrets and a home of its
443 // own (#255).
444 Trusted bool `json:"trusted"`
445 Secrets map[string]string `json:"secrets"`
446```
447
448Replace lines 315-330 of `run` (the build-home comment and the
449`buildHomeFor` call) with:
450
451```go
452 home, doneHome, err := buildHome(r.workdir, j)
453 if err != nil {
454 log.Printf("build %d: build home: %v", j.ID, err)
455 return false
456 }
457 defer doneHome()
458```
459
460and change line 433 to `env := stepEnv(j, home, r.buildSSH())`.
461
462Replace lines 437-463 (the `stepEnv` comment that sits above
463`buildHomeFor`, and `buildHomeFor`) with `buildHome` and `removeTree`;
464the `stepEnv` comment moves to `stepEnv` in the next block:
465
466```go
467// buildHome is a build's HOME and what to do with it when the build ends.
468//
469// Not the workspace, which is removed after every build: the Go module
470// cache and every other tool cache live under HOME. Not the runner's own
471// home either, where its SSH key and credential dotfiles are.
472//
473// A trusted build gets its repository's home,
474// <workdir>/trusted-home/<owner>/<name>, kept between builds so the
475// caches survive. One per repository: shared across repositories, a step
476// could poison a cache or plant a .gitconfig that another repository's
477// build would honour (#184). The root is not <workdir>/home, where homes
478// that untrusted builds could write were kept before #255, so none of
479// those is read again.
480//
481// An untrusted build gets <workdir>/build-<id>-home, new and empty,
482// removed when the build ends. The container mounts HOME read-write, so
483// a home a fork's build could write is a cache a stranger controls
484// (#255).
485func buildHome(workdir string, j job) (string, func(), error) {
486 if !j.Trusted {
487 dir := filepath.Join(workdir, fmt.Sprintf("build-%d-home", j.ID))
488 if err := os.Mkdir(dir, 0o700); err != nil {
489 return "", nil, err
490 }
491 return dir, func() {
492 if err := removeTree(dir); err != nil {
493 log.Printf("build %d: removing its home: %v", j.ID, err)
494 }
495 }, nil
496 }
497 root := filepath.Join(workdir, "trusted-home")
498 dir := filepath.Join(root, filepath.FromSlash(j.Repo))
499 if rel, err := filepath.Rel(root, dir); err != nil || rel == "." || strings.HasPrefix(rel, "..") {
500 return "", nil, fmt.Errorf("repository path %q escapes the build home root", j.Repo)
501 }
502 if err := os.MkdirAll(dir, 0o700); err != nil {
503 return "", nil, err
504 }
505 return dir, func() {}, nil
506}
507
508// removeTree deletes dir and everything under it. os.RemoveAll alone
509// fails on a directory without write permission, and the Go module cache
510// makes every directory it fills read-only.
511func removeTree(dir string) error {
512 filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
513 if err == nil && d.IsDir() {
514 os.Chmod(p, 0o700)
515 }
516 return nil
517 })
518 return os.RemoveAll(dir)
519}
520```
521
522Replace `stepEnv` (lines 488-511) with the function and the comment
523that belongs to it:
524
525```go
526// stepEnv builds the environment a build step runs with. It is
527// constructed, not inherited: os.Environ() would hand repository content
528// the runner's entire environment, including anything an operator set on
529// the service (#144).
530//
531// HOME is the build's home (buildHome), not the runner's own: tools read
532// credentials out of dotfiles — .netrc, .npmrc, .gitconfig — and a build
533// has no business finding the runner's.
534//
535// PATH is the one thing carried over: without it a step cannot find the
536// tools the host was provisioned with.
537func stepEnv(j job, home, sshDest string) []string {
538 path := os.Getenv("PATH")
539 if path == "" {
540 path = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
541 }
542 env := []string{
543 "PATH=" + path,
544 "HOME=" + home,
545 "LANG=C.UTF-8",
546 "CI=true",
547 "GITBAY_REPO=" + j.Repo,
548 "GITBAY_SHA=" + j.SHA,
549 "GITBAY_REF=" + j.Ref,
550 "GITBAY_JOB=" + j.Job,
551 "GITBAY_SSH=" + sshDest,
552 }
553 // The server sends secrets only for a trusted build. The claim's
554 // trust flag decides here as well, not whether any arrived (#255).
555 if j.Trusted {
556 for name, value := range j.Secrets {
557 env = append(env, name+"="+value)
558 }
559 }
560 return env
561}
562```
563
564- [ ] **Step 4: Run the tests and see them pass**
565
566Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1`
567Expected: PASS. (`TestStepEnvHomeIsNotTheWorkspace` and the other
568`stepEnv` tests pass unchanged.)
569
570- [ ] **Step 5: Commit**
571
572```bash
573git add cmd/gitbay-runner/main.go cmd/gitbay-runner/home_test.go cmd/gitbay-runner/env_test.go
574git commit -S -m "runner: disposable home for untrusted builds
575
576A trusted build keeps its repository's home, now under
577<workdir>/trusted-home; an untrusted build gets a new home removed with
578the build, and no secrets whatever the claim carries.
579
580Ref #255"
581```
582
583### Task 1.3: wiki, and the MR
584
585**Files:**
586- Modify: `.gitbay/wiki/Threat-Model.org:135-147`, `:172-180`
587- Modify: `.gitbay/wiki/Admin.org:640-642`
588- Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org:30-33`, `:67`
589- Modify: `.gitbay/wiki/Architecture/09-Controls.org:87`
590- Modify: `.gitbay/wiki/Architecture/04-Trust-Boundaries.org` (TB7 row)
591- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org:13` (remove the #255 row)
592
593- [ ] **Step 1: Threat-Model**
594
595In "The CI runner", replace the sentences of the "What a build sees"
596bullet from "=HOME= is a build home" to the end of the bullet with:
597
598```org
599 secrets, and nothing the operator set on the service. =HOME= is a build
600 home under the runner's =-workdir=, not the runner's own home, so a
601 build cannot read the =.netrc=, =.npmrc= or =.gitconfig= where tools
602 keep credentials. A trusted build's home belongs to its repository
603 and persists, so caches survive; an untrusted build's home is new,
604 empty and removed when the build ends, so nothing a fork's build
605 writes is read by a later build (krz/gitbay#255). The claim names a
606 build's trust explicitly, and a runner that finds no trust flag treats
607 the build as untrusted.
608```
609
610Replace the paragraph after the bullets ("Under =-isolation none=,
611anything a step can do…") with:
612
613```org
614Under =-isolation none=, anything a step can do as the runner's user a
615pushed =ci.yml= can do. Under podman a step is confined to its
616container, the bind-mounted workspace and its build home: a trusted
617build's cache is read only by later trusted builds of the same
618repository, and an untrusted build's home is discarded with it. Treat
619the runner host as executing untrusted code all the same: keep it off
620the daemon's host where the database lives, or scope it to repositories
621whose writers you trust. gitbay.org does the latter — its runner builds
622only the repositories the operator names.
623```
624
625- [ ] **Step 2: Admin**
626
627Replace `Admin.org:640-642` ("Each repository gets its own build home…")
628with:
629
630```org
631A trusted build's home is its repository's, under
632=<workdir>/trusted-home/<owner>/<name>=, mounted into its containers as
633=HOME=: caches persist between trusted builds of one repository and are
634never read by another's. An untrusted build — a merge request head from
635a fork — gets =<workdir>/build-<id>-home=, new and empty, removed when
636the build ends. Homes under =<workdir>/home= are from runners before
637krz/gitbay#255, which shared them with untrusted builds; nothing reads
638them any more, and they can be deleted.
639```
640
641- [ ] **Step 3: Architecture pages**
642
643`07-CI-and-Supply-Chain.org`, lifecycle step 2, replace "The claim
644returns id, repository, job, commit, ref, steps, image and — for
645trusted builds only — the repository's secrets (=build.go=)." with
646"The claim returns id, repository, job, commit, ref, steps, image, the
647build's trust, and — for trusted builds only — the repository's secrets
648(=build.go=)."
649
650Replace the Build home row (line 67) with:
651
652```org
653| Build home | trusted: =<workdir>/trusted-home/<owner>/<name>=, one per repository, persistent; untrusted: =<workdir>/build-<id>-home=, removed with the build (=main.go=) |
654```
655
656`09-Controls.org` line 87:
657
658```org
659| Untrusted code runs isolated | in place | rootless podman, cgroup limits; untrusted builds get a disposable home (=cmd/gitbay-runner/main.go=) |
660```
661
662`04-Trust-Boundaries.org` TB7 row, last column:
663
664```org
665| TB7 | Z5 → Z4 container | build steps, workspace, build home | rootless podman, operator-provisioned image, cgroup limits; a trusted build's home is its repository's, an untrusted build's is discarded with it; the network is open (#260) |
666```
667
668`10-Known-Gaps.org`: delete the `#255` row.
669
670- [ ] **Step 4: Verify and commit**
671
672Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control -count=1`
673Expected: PASS.
674
675```bash
676git add .gitbay/wiki
677git commit -S -m "wiki: trusted and untrusted build homes
678
679Closes #255"
680```
681
682- [ ] **Step 5: MR**
683
684```bash
685git push -u origin ci-untrusted-home
686gitbay mr create --source ci-untrusted-home --target main --title "runner: disposable home for untrusted builds"
687```
688
689Body (via `--file -` from a file written with the Write tool): what
690changed, the deploy order (gitbayd before the runner), and a pointer to
691runbook sections R1 and R2. After CI is green and the runbook's R2
692validation passed on the scratch repository:
693`gitbay mr merge <n> --strategy ff`, delete the branch both places.
694
695---
696
697# Part 2: `ci/` statuses, trusted reuse, required contexts (branch `ci-status-trust`, #258)
698
699### Task 2.1: `status set` refuses `ci/`
700
701**Files:**
702- Modify: `internal/control/status.go:15-27` (registration), `:68-70` (after the usage check)
703- Create: `internal/control/status_test.go`
704- Modify: `e2e/readonly_test.go:75`, `e2e/mrweb_test.go:275`, `e2e/status_test.go` (after line 49)
705- Modify: `cmd/gitbay/summaries_gen.go` only if the summary changes (it does not here)
706
707**Interfaces:**
708- Produces: `status set … --context ci/…` exits 4 (`protocol.ExitDenied`) with a message containing `reserved`.
709
710- [ ] **Step 1: Write the failing test**
711
712Create `internal/control/status_test.go`:
713
714```go
715package control
716
717import (
718 "strings"
719 "testing"
720
721 "gitbay.org/gitbay/internal/protocol"
722 "gitbay.org/gitbay/internal/store"
723)
724
725// ci/<job> statuses are the build subsystem's. A writer who could post
726// one could mark ci/test green on their own head before, or instead of,
727// the build (#258).
728func TestStatusSetRefusesReservedContext(t *testing.T) {
729 st, repo, uid := newQueueTestRepo(t)
730 for _, ctx := range []string{"ci/test", "CI/test", "ci/"} {
731 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
732 code := Dispatch(c, []string{"status", "set", repo.Path(), "abc1234", "--context", ctx, "--state", "success"})
733 if code != protocol.ExitDenied || !strings.Contains(errOut.String(), "reserved") {
734 t.Errorf("--context %s: exit %d, %s", ctx, code, errOut.String())
735 }
736 }
737 if has, err := st.RepoHasStatuses(repo.ID); err != nil || has {
738 t.Fatalf("a refused status was stored: %v %v", has, err)
739 }
740}
741```
742
743- [ ] **Step 2: Run it and see it fail**
744
745Run: `go test ./internal/control -run TestStatusSetRefusesReservedContext -count=1`
746Expected: FAIL, exit 3 (`no commit abc1234`), since today the context is
747accepted and the missing commit is what stops it.
748
749- [ ] **Step 3: Implement**
750
751In the registration, change the `--context` flag and the example:
752
753```go
754 {"--context", "<c>", "the check this status reports for; ci/ is reserved for the instance's builds", ""},
755```
756
757```go
758 Examples: []string{
759 "status set krz/gitbay a1b2c3d --context ext/lint --state success",
760 },
761```
762
763After the usage check at `status.go:68-70`, before the `--url` check:
764
765```go
766 // ci/<job> statuses are the build subsystem's: queued, reused,
767 // skipped and finished by the server itself. A writer who could post
768 // one could mark ci/test green on their own head before, or instead
769 // of, the build (#258). Case-folded, so CI/test is no way around it.
770 if strings.HasPrefix(strings.ToLower(context), "ci/") {
771 return c.fail(protocol.ExitDenied, "the ci/ prefix is reserved for the instance's builds; report under another name, such as ext/%s",
772 strings.TrimPrefix(strings.ToLower(context), "ci/"))
773 }
774```
775
776- [ ] **Step 4: Run it and see it pass**
777
778Run: `go test ./internal/control -run 'TestStatusSet|TestHelp' -count=1`
779Expected: PASS.
780
781- [ ] **Step 5: e2e callers off `ci/`, and the refusal over SSH**
782
783`e2e/readonly_test.go:75`: `"--context", "ci/x"` → `"--context", "ext/x"`.
784`e2e/mrweb_test.go:275`: `"--context", "ci/test"` → `"--context", "ext/test"`.
785
786In `e2e/status_test.go`, after the reader-denied check (line 49), add:
787
788```go
789 // ci/ is the instance's own: a writer is refused it (#258).
790 if _, errOut, code := inst.ssh(t, bobKey, "", "status", "set", "alice/svc", head, "--context", "ci/build", "--state", "success"); code != 4 || !strings.Contains(errOut, "reserved") {
791 t.Fatalf("writer posted a ci/ status: exit %d, %s", code, errOut)
792 }
793```
794
795Run: `go test ./e2e -run TestCommitStatuses -count=1`
796Expected: PASS.
797
798- [ ] **Step 6: Commit**
799
800```bash
801git add internal/control/status.go internal/control/status_test.go e2e/readonly_test.go e2e/mrweb_test.go e2e/status_test.go
802git commit -S -m "status set: ci/ is reserved for the instance's builds
803
804Ref #258"
805```
806
807### Task 2.2: reuse only trusted results on the same image
808
809**Files:**
810- Modify: `internal/store/builds.go:453-477` (`SuccessBuildForTree`, `SuccessBuildFor`)
811- Modify: `internal/control/build.go:856-864` (`queueJobs`)
812- Modify: `internal/store/builds_test.go:241-249` (`TestSuccessBuildForTree` call sites) and append a test
813- Test: `internal/control/build_test.go` (append)
814
815**Interfaces:**
816- Produces: `func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error)` — trusted builds only, same image. `SuccessBuildFor` keeps its signature and considers trusted builds only.
817
818- [ ] **Step 1: Write the failing tests**
819
820In `internal/store/builds_test.go`, add `""` as the fourth argument to
821the three `SuccessBuildForTree` calls in `TestSuccessBuildForTree`, then
822append:
823
824```go
825// A result stands for another commit only when it came from a trusted
826// build on the same image: a fork's green build, or one on an image the
827// job has since left, proves nothing about the repository's own (#258).
828func TestSuccessReuseNeedsTrustAndImage(t *testing.T) {
829 s := open(t)
830 if err := s.MigrateUp(); err != nil {
831 t.Fatal(err)
832 }
833 uid, _ := s.CreateUser("cmc", true)
834 repoID, _ := s.CreateRepo("user", uid, "app", "public")
835 for _, b := range []struct {
836 sha, image string
837 trusted bool
838 }{
839 {"aaa", "", false},
840 {"bbb", "localhost/old:1", true},
841 } {
842 if _, err := s.CreateBuild(repoID, "unit", b.sha, "main", `["true"]`, b.image, "tree1", b.trusted); err != nil {
843 t.Fatal(err)
844 }
845 claimed, ok, err := s.ClaimBuild([]int64{repoID}, true)
846 if err != nil || !ok {
847 t.Fatalf("claim: ok=%v err=%v", ok, err)
848 }
849 if err := s.FinishBuild(claimed.ID, "success"); err != nil {
850 t.Fatal(err)
851 }
852 }
853 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", ""); ok {
854 t.Fatalf("reused build %d: untrusted, or on another image", prev.Number)
855 }
856 if prev, ok, _ := s.SuccessBuildForTree(repoID, "tree1", "unit", "localhost/old:1"); !ok || prev.SHA != "bbb" {
857 t.Fatalf("trusted build on the same image not found: ok=%v prev=%+v", ok, prev)
858 }
859 if _, ok, _ := s.SuccessBuildFor(repoID, "aaa", "unit"); ok {
860 t.Error("an untrusted success stood for its commit")
861 }
862}
863```
864
865Append to `internal/control/build_test.go`:
866
867```go
868// A fork's green build of a commit does not stand for the repository's
869// own: the same commit landing on a branch, or a commit with the same
870// tree, is built again as trusted (#258).
871func TestQueueBranchBuildsRebuildsWhatOnlyAForkBuilt(t *testing.T) {
872 st, repo, uid := newQueueTestRepo(t)
873 git := gitRunner(t)
874 root := t.TempDir()
875
876 src := filepath.Join(root, "src")
877 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
878 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(
879 "jobs:\n unit:\n steps:\n - echo hi\n"), 0o644)
880 git(root, "init", "-q", "-b", "main", "src")
881 git(src, "add", ".")
882 git(src, "commit", "-q", "-m", "base")
883 first := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
884 git(src, "commit", "-q", "--allow-empty", "-m", "same tree")
885 second := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
886
887 dir := RepoDir(root, repo.OwnerName, repo.Name)
888 os.MkdirAll(filepath.Dir(dir), 0o755)
889 git(root, "clone", "-q", "--bare", src, dir)
890
891 // A fork's merge request head, built untrusted and green.
892 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, first)
893 b, ok, err := st.ClaimBuild([]int64{repo.ID}, true)
894 if err != nil || !ok || b.Trusted {
895 t.Fatalf("claim: ok=%v trusted=%v err=%v", ok, b.Trusted, err)
896 }
897 if err := st.FinishBuild(b.ID, "success"); err != nil {
898 t.Fatal(err)
899 }
900
901 // The same commit lands on main, then a commit with the same tree.
902 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
903 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
904 pending, _ := st.ListBuilds(repo.ID, store.BuildFilter{Status: "pending"}, 10)
905 if len(pending) != 2 {
906 t.Fatalf("queued %d builds, want 2 (one per commit): %+v", len(pending), pending)
907 }
908 for _, p := range pending {
909 if !p.Trusted {
910 t.Errorf("build %d queued untrusted on a branch push", p.Number)
911 }
912 }
913}
914```
915
916- [ ] **Step 2: Run them and see them fail**
917
918Run: `go test ./internal/store -run 'TestSuccessBuildForTree|TestSuccessReuse' -count=1`
919Expected: build failure (`too many arguments in call to s.SuccessBuildForTree`).
920
921Run: `go test ./internal/control -run TestQueueBranchBuildsRebuildsWhatOnlyAForkBuilt -count=1`
922Expected: FAIL, `queued 0 builds, want 2`.
923
924- [ ] **Step 3: Store**
925
926Replace `internal/store/builds.go:453-477` with:
927
928```go
929// SuccessBuildForTree finds a passed build of the job for a tree rather
930// than a commit: a rebase that changes nothing in the tree has already
931// been built (#177). Only a trusted build on the image the job names
932// counts: a fork's result, or one from an image the job has left, does
933// not stand for the repository's own (#258). An empty tree never
934// matches.
935func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) {
936 if tree == "" {
937 return Build{}, false, nil
938 }
939 b, err := scanBuild(s.DB.QueryRow(buildSelect+
940 " WHERE repo_id = ? AND tree = ? AND job = ? AND image = ? AND trusted = 1 AND status = 'success'"+
941 " ORDER BY number DESC LIMIT 1", repoID, tree, job, image))
942 if errors.Is(err, sql.ErrNoRows) {
943 return Build{}, false, nil
944 }
945 return b, err == nil, err
946}
947
948// SuccessBuildFor finds a passed trusted build of the commit for the job,
949// on any ref: what a cancelled duplicate can point back at.
950func (s *Store) SuccessBuildFor(repoID int64, sha, job string) (Build, bool, error) {
951 b, err := scanBuild(s.DB.QueryRow(buildSelect+
952 " WHERE repo_id = ? AND sha = ? AND job = ? AND trusted = 1 AND status = 'success' ORDER BY number DESC LIMIT 1", repoID, sha, job))
953 if errors.Is(err, sql.ErrNoRows) {
954 return Build{}, false, nil
955 }
956 return b, err == nil, err
957}
958```
959
960- [ ] **Step 4: `queueJobs`**
961
962Replace `internal/control/build.go:856-859` with:
963
964```go
965 // A build of this commit that passed, or is queued or running,
966 // stands for it — unless this queue is trusted and that build was
967 // not: a fork's head that lands on a branch is built again as the
968 // repository's own (#258).
969 if b, ok := built[j.Name]; ok && (b.Trusted || !trusted) &&
970 (b.Status == "success" || b.Status == "pending" || b.Status == "running") {
971 continue
972 }
973 if prev, ok, _ := st.SuccessBuildForTree(repo.ID, tree, j.Name, j.Image); ok && prev.SHA != sha {
974```
975
976(the body of the `if prev, ok` block is unchanged.)
977
978- [ ] **Step 5: Run the tests and see them pass**
979
980Run: `go vet ./... && go test ./internal/store ./internal/control ./internal/hookd ./internal/ci -count=1`
981Expected: PASS. `TestPushShapes` (hookd) has no row where a fork's
982build lands on a branch, so its table is unchanged.
983
984- [ ] **Step 6: Commit**
985
986```bash
987git add internal/store/builds.go internal/store/builds_test.go internal/control/build.go internal/control/build_test.go
988git commit -S -m "ci: reuse only trusted results on the job's image
989
990Ref #258"
991```
992
993### Task 2.3: required contexts
994
995**Files:**
996- Modify: `internal/store/repos.go:25-37` (`RepoSettings`)
997- Modify: `internal/control/output.go:59-60` (`GatesOut`)
998- Modify: `internal/control/mr.go:45-49` (registration), after `runRequireChecks` (`:326-341`), `:1579-1603` (`MergeGates` checks block)
999- Modify: `internal/control/repo.go:710-717` (`repo settings show`)
1000- Modify: `internal/control/checksgate_test.go` (helper refactor, two tests)
1001- Test: `internal/control/mr_test.go` (append)
1002- Modify: `cmd/gitbay/main.go:580` (add a `pass`), `cmd/gitbay/summaries_gen.go` (regenerated)
1003- Modify: `internal/httpd/settings.go:106-107`, `:228-229`; `internal/web/templates/settings.html:73-78`; `internal/web/templates/mr.html:124`
1004- Modify: `internal/httpd/mrpage_test.go` (`TestMRGatesRender`), `e2e/settingsweb_test.go`
1005
1006**Interfaces:**
1007- Produces:
1008 - `RepoSettings.RequiredContexts []string` (`json:"required_contexts,omitempty"`)
1009 - `GatesOut.ChecksMissing []string` (`json:"checks_missing,omitempty"`)
1010 - command `repo settings require-contexts <owner/name> [<context>...]`
1011
1012- [ ] **Step 1: Write the failing tests**
1013
1014In `internal/control/checksgate_test.go`, replace `gatesForHeadSeeded`
1015(lines 20-73) with a general helper and a thin wrapper:
1016
1017```go
1018func gatesForHeadSeeded(t *testing.T, ciYML string, seed bool) GatesOut {
1019 return gatesFor(t, ciYML, nil, func(st *store.Store, repoID, uid int64, targetSHA, _ string) {
1020 if !seed {
1021 return
1022 }
1023 if err := st.SetCommitStatus(repoID, targetSHA, "lint", "success", "", "", uid); err != nil {
1024 t.Fatal(err)
1025 }
1026 })
1027}
1028
1029// gatesFor builds a repository with require_checks on and set applied to
1030// its settings, a bare dir holding the given .gitbay/ci.yml (empty
1031// string for none), and one MR; seed records statuses before the gates
1032// are computed.
1033func gatesFor(t *testing.T, ciYML string, set func(*store.RepoSettings),
1034 seed func(st *store.Store, repoID, uid int64, targetSHA, headSHA string)) GatesOut {
1035 t.Helper()
1036 st, repo, uid := newQueueTestRepo(t)
1037 if _, err := st.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1038 s.RequireChecks = true
1039 if set != nil {
1040 set(s)
1041 }
1042 }); err != nil {
1043 t.Fatal(err)
1044 }
1045 repo, err := st.RepoByID(repo.ID)
1046 if err != nil {
1047 t.Fatal(err)
1048 }
1049
1050 git := gitRunner(t)
1051 root := t.TempDir()
1052 src := filepath.Join(root, "src")
1053 os.MkdirAll(src, 0o755)
1054 git(root, "init", "-q", "-b", "main", "src")
1055 os.WriteFile(filepath.Join(src, "README"), []byte("x\n"), 0o644)
1056 git(src, "add", ".")
1057 git(src, "commit", "-q", "-m", "base")
1058 targetSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
1059 git(src, "checkout", "-q", "-b", "feature")
1060 if ciYML != "" {
1061 os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755)
1062 os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte(ciYML), 0o644)
1063 }
1064 os.WriteFile(filepath.Join(src, "README"), []byte("y\n"), 0o644)
1065 git(src, "add", ".")
1066 git(src, "commit", "-q", "-m", "change")
1067 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
1068
1069 dir := RepoDir(root, repo.OwnerName, repo.Name)
1070 os.MkdirAll(filepath.Dir(dir), 0o755)
1071 git(root, "clone", "-q", "--bare", src, dir)
1072
1073 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", headSHA, "md", false); err != nil {
1074 t.Fatal(err)
1075 }
1076 mr, err := st.MRByNumber(repo.ID, 1)
1077 if err != nil {
1078 t.Fatal(err)
1079 }
1080 if seed != nil {
1081 seed(st, repo.ID, uid, targetSHA, headSHA)
1082 }
1083 g, err := MergeGates(st, repo, mr, dir, targetSHA, headSHA)
1084 if err != nil {
1085 t.Fatal(err)
1086 }
1087 return g
1088}
1089```
1090
1091Append to the same file (add `"slices"` to its imports):
1092
1093```go
1094// A required context that has not reported holds the merge as pending,
1095// even when every status that did report is green (#258).
1096func TestRequiredContextMissingIsPending(t *testing.T) {
1097 g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"ext/deploy", "lint"} },
1098 func(st *store.Store, repoID, uid int64, _, headSHA string) {
1099 if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil {
1100 t.Fatal(err)
1101 }
1102 })
1103 if g.Checks != "pending" || !slices.Equal(g.ChecksMissing, []string{"ext/deploy"}) {
1104 t.Fatalf("checks %q, missing %v", g.Checks, g.ChecksMissing)
1105 }
1106 if !checksUnmet(g) || !strings.Contains(strings.Join(g.Unmet, "\n"), "ext/deploy=missing") {
1107 t.Fatalf("unmet: %v", g.Unmet)
1108 }
1109}
1110
1111// Every required context reported green: nothing is held.
1112func TestRequiredContextsReportedPass(t *testing.T) {
1113 g := gatesFor(t, "", func(s *store.RepoSettings) { s.RequiredContexts = []string{"lint"} },
1114 func(st *store.Store, repoID, uid int64, _, headSHA string) {
1115 if err := st.SetCommitStatus(repoID, headSHA, "lint", "success", "", "", uid); err != nil {
1116 t.Fatal(err)
1117 }
1118 })
1119 if checksUnmet(g) || len(g.ChecksMissing) != 0 || g.Checks != "success" {
1120 t.Fatalf("checks %q, missing %v, unmet %v", g.Checks, g.ChecksMissing, g.Unmet)
1121 }
1122}
1123```
1124
1125Append to `internal/control/mr_test.go` (add imports `slices`,
1126`protocol`, `store` if absent):
1127
1128```go
1129// require-contexts stores a deduplicated list, refuses a context with
1130// whitespace, and clears with no contexts (#258).
1131func TestRequireContextsSetsAndClears(t *testing.T) {
1132 st, repo, uid := newQueueTestRepo(t)
1133 alice := store.User{ID: uid, Username: "alice"}
1134 run := func(args ...string) int {
1135 t.Helper()
1136 c, _ := pruneCtx(st, t.TempDir(), alice)
1137 return Dispatch(c, append([]string{"repo", "settings", "require-contexts", repo.Path()}, args...))
1138 }
1139 if code := run("lint", "ext/deploy", "lint"); code != protocol.ExitOK {
1140 t.Fatalf("set: exit %d", code)
1141 }
1142 got, _ := st.RepoByID(repo.ID)
1143 if !slices.Equal(got.Settings.RequiredContexts, []string{"lint", "ext/deploy"}) {
1144 t.Fatalf("stored %v", got.Settings.RequiredContexts)
1145 }
1146 if code := run("bad context"); code != protocol.ExitUsage {
1147 t.Fatalf("a context with a space: exit %d", code)
1148 }
1149 if code := run(); code != protocol.ExitOK {
1150 t.Fatalf("clear: exit %d", code)
1151 }
1152 if got, _ := st.RepoByID(repo.ID); len(got.Settings.RequiredContexts) != 0 {
1153 t.Fatalf("not cleared: %v", got.Settings.RequiredContexts)
1154 }
1155}
1156```
1157
1158- [ ] **Step 2: Run them and see them fail**
1159
1160Run: `go test ./internal/control -run 'TestRequire|TestRequiredContext' -count=1`
1161Expected: build failure (`s.RequiredContexts undefined`).
1162
1163- [ ] **Step 3: Store and output types**
1164
1165`internal/store/repos.go`, in `RepoSettings` after `RequireChecks`:
1166
1167```go
1168 RequireChecks bool `json:"require_checks,omitempty"`
1169 // RequiredContexts are statuses require_checks waits for whether or
1170 // not they have reported; one that has not is pending (#258).
1171 RequiredContexts []string `json:"required_contexts,omitempty"`
1172```
1173
1174`internal/control/output.go`, in `GatesOut` after `Checks`:
1175
1176```go
1177 Checks string `json:"checks,omitempty"` // combined status; "" when none reported
1178 ChecksMissing []string `json:"checks_missing,omitempty"` // required contexts not reported
1179```
1180
1181- [ ] **Step 4: The command**
1182
1183Registration, after `require-checks` at `mr.go:49`:
1184
1185```go
1186 register(Command{Path: []string{"repo", "settings", "require-contexts"},
1187 Summary: "name the statuses require-checks waits for, reported or not",
1188 Usage: "repo settings require-contexts <owner/name> [<context>...] (none clears)",
1189 Examples: []string{"repo settings require-contexts krz/gitbay ci/build ci/test"},
1190 Run: runRequireContexts})
1191```
1192
1193After `runRequireChecks`:
1194
1195```go
1196// maxRequiredContexts bounds the list: a gate naming more checks than
1197// this is a configuration mistake.
1198const maxRequiredContexts = 20
1199
1200func runRequireContexts(c *Ctx, args []string) int {
1201 if len(args) < 1 {
1202 return c.usage()
1203 }
1204 var contexts []string
1205 for _, ctx := range args[1:] {
1206 if ctx == "" || len(ctx) > 100 || strings.ContainsAny(ctx, " \t\r\n") {
1207 return c.fail(protocol.ExitUsage, "a context is 1 to 100 characters with no whitespace: %q", ctx)
1208 }
1209 if !slices.Contains(contexts, ctx) {
1210 contexts = append(contexts, ctx)
1211 }
1212 }
1213 if len(contexts) > maxRequiredContexts {
1214 return c.fail(protocol.ExitUsage, "at most %d required contexts", maxRequiredContexts)
1215 }
1216 repo, code := resolveRepo(c, args[0], policy.CanAdmin)
1217 if code >= 0 {
1218 return code
1219 }
1220 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequiredContexts = contexts })
1221 if err != nil {
1222 return c.fail(protocol.ExitFailure, "%v", err)
1223 }
1224 return c.emit(s, func(w io.Writer) {
1225 if len(contexts) == 0 {
1226 fmt.Fprintf(w, "required contexts cleared on %s\n", repo.Path())
1227 return
1228 }
1229 fmt.Fprintf(w, "required contexts on %s: %s\n", repo.Path(), strings.Join(contexts, ", "))
1230 if !s.RequireChecks {
1231 fmt.Fprintf(c.Stderr, "they apply once require-checks is on: gitbay repo settings require-checks %s on\n", repo.Path())
1232 }
1233 })
1234}
1235```
1236
1237- [ ] **Step 5: `MergeGates`**
1238
1239Replace `mr.go:1579-1603` (the checks block, from the comment through
1240the end of `if set.RequireChecks { … }`) with:
1241
1242```go
1243 // Checks: with require_checks, every status the head carries must be
1244 // green, a head something was going to report on must carry some, and
1245 // every required context must have reported: one that has not is
1246 // pending whatever the others say (#258).
1247 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
1248 if err != nil {
1249 return g, err
1250 }
1251 g.Checks = store.CombinedStatus(statuses)
1252 if set.RequireChecks {
1253 reported := map[string]bool{}
1254 for _, s := range statuses {
1255 reported[s.Context] = true
1256 }
1257 for _, want := range set.RequiredContexts {
1258 if !reported[want] {
1259 g.ChecksMissing = append(g.ChecksMissing, want)
1260 }
1261 }
1262 if len(g.ChecksMissing) > 0 && (g.Checks == "" || g.Checks == "success") {
1263 g.Checks = "pending"
1264 }
1265 switch g.Checks {
1266 case "success":
1267 case "":
1268 if checksExpected(st, repo.ID, dir, headSHA) {
1269 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks and none were reported on %.10s", repo.Path(), headSHA))
1270 }
1271 default:
1272 var bad []string
1273 for _, st := range statuses {
1274 if st.State != "success" {
1275 bad = append(bad, st.Context+"="+st.State)
1276 }
1277 }
1278 for _, m := range g.ChecksMissing {
1279 bad = append(bad, m+"=missing")
1280 }
1281 g.Unmet = append(g.Unmet, fmt.Sprintf("%s requires green checks; %.10s has %s", repo.Path(), headSHA, strings.Join(bad, ", ")))
1282 }
1283 }
1284```
1285
1286`repo.go:710-717`, add a field after `"protected tags"`:
1287
1288```go
1289 "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
1290```
1291
1292- [ ] **Step 6: Run the control tests**
1293
1294Run: `go vet ./... && go test ./internal/control ./internal/store -count=1`
1295Expected: PASS.
1296
1297- [ ] **Step 7: CLI, web, summaries**
1298
1299`cmd/gitbay/main.go`, after the `require-checks` line (580):
1300
1301```go
1302 pass("require-contexts", passOpts{server: []string{"repo", "settings", "require-contexts"}, needsRepo: true}),
1303```
1304
1305Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update -count=1 && go test ./cmd/gitbay -count=1`
1306Expected: PASS; `summaries_gen.go` gains the `repo settings require-contexts` line.
1307
1308`internal/httpd/settings.go`, in `settingsSubmit` after the
1309`require-checks` case:
1310
1311```go
1312 case "require-contexts":
1313 argv = append([]string{"repo", "settings", "require-contexts", repo}, strings.Fields(v("contexts"))...)
1314```
1315
1316and in `fieldLabel` after `require-checks`:
1317
1318```go
1319 case "require-contexts":
1320 return "required contexts"
1321```
1322
1323`internal/web/templates/settings.html`, after the `require-checks`
1324form (line 78):
1325
1326```html
1327<form method="post" action="{{$base}}" class="setform">
1328 <input type="hidden" name="field" value="require-contexts">
1329 <div><label for="contexts">Required contexts</label><p class="hint">Statuses the checks gate waits for until they report, separated by spaces. Applies with required checks on.</p></div>
1330 <div><input type="text" id="contexts" name="contexts" value="{{range $i, $c := .Repo.Settings.RequiredContexts}}{{if $i}} {{end}}{{$c}}{{end}}" autocomplete="off"></div>
1331 <div><button type="submit" class="btn">Save</button></div>
1332</form>
1333```
1334
1335`internal/web/templates/mr.html`, after the `OwnersOutstanding` line
1336(124):
1337
1338```html
1339 {{range .ChecksMissing}}<p class="row none">waiting on <code>{{.}}</code>, not yet reported</p>{{end}}
1340```
1341
1342In `internal/httpd/mrpage_test.go` `TestMRGatesRender`, add after the
1343first `for` loop:
1344
1345```go
1346 if out := render(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on <code>ext/deploy</code>") {
1347 t.Errorf("missing required context not rendered:\n%s", out)
1348 }
1349```
1350
1351In `e2e/settingsweb_test.go`, after
1352`post(url.Values{"field": {"require-checks"}, …})`:
1353
1354```go
1355 post(url.Values{"field": {"require-contexts"}, "contexts": {"ext/deploy lint"}})
1356```
1357
1358and add `` `"required_contexts":["ext/deploy","lint"]` `` to the
1359`settings show --json` want list.
1360
1361Run: `go test ./internal/httpd ./internal/web -count=1 && go test ./e2e -run TestRepoSettingsWeb -count=1`
1362Expected: PASS.
1363
1364- [ ] **Step 8: Commit**
1365
1366```bash
1367git add internal/store/repos.go internal/control cmd/gitbay internal/httpd internal/web e2e/settingsweb_test.go
1368git commit -S -m "repo settings: required contexts, pending until reported
1369
1370Ref #258"
1371```
1372
1373### Task 2.4: wiki, and the MR
1374
1375**Files:**
1376- Modify: `.gitbay/wiki/API.org:100-122`, `.gitbay/wiki/CI.org:5-9`, `.gitbay/wiki/Users.org:542-545`, `.gitbay/wiki/Parity.org` (settings table near line 194)
1377- Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org:57`, `09-Controls.org:39`, `:92`, `10-Known-Gaps.org` (remove the #258 row)
1378
1379- [ ] **Step 1: API.org**
1380
1381Change the two example lines to `--context ext/build`, and after the
1382paragraph ending "…Each report also emits a =status= event to
1383webhooks." add:
1384
1385```org
1386Contexts starting with =ci/= are the instance's own: its builds queue,
1387reuse, skip and finish them, and =status set= refuses them with exit 4,
1388so a writer cannot mark =ci/test= green on a head the build has not
1389passed. Report under another prefix, such as =ext/=.
1390
1391=repo settings require-contexts <repo> ext/deploy ci/test= names
1392statuses the checks gate waits for whether or not they have reported:
1393one that has not is =pending=, and =mr show= lists it as
1394=ext/deploy=missing=. It applies while =require-checks= is on; with no
1395contexts it clears the list.
1396```
1397
1398- [ ] **Step 2: CI.org**
1399
1400In the *Dedupe* bullet, after "…naming the build it came from (#177).",
1401add: "Only a trusted build counts, and for tree reuse only one on the
1402image the job names: a fork's green build does not stand for the
1403repository's own, so its commit is built again when it lands on a
1404branch (#258)."
1405
1406- [ ] **Step 3: Users.org**
1407
1408After "…a =ci/<job>= commit status, which =repo settings
1409require-checks= can gate merges on." add: "=repo settings
1410require-contexts= names statuses the gate waits for until they report."
1411
1412- [ ] **Step 4: Parity.org**
1413
1414After the `require codeowners` row:
1415
1416```org
1417| require contexts | yes | yes | no |
1418```
1419
1420- [ ] **Step 5: Architecture**
1421
1422`07-CI-and-Supply-Chain.org:57`:
1423
1424```org
1425| =status set= | write on the repository; =ci/*= contexts refused (=status.go=) |
1426```
1427
1428and in lifecycle step 1 replace "or =success= copied from an earlier
1429build of the same tree (#177)." with "or =success= copied from an
1430earlier trusted build of the same tree on the same image (#177, #258)."
1431
1432`09-Controls.org:39`:
1433
1434```org
1435| Merge gates | in place | =MergeGates=; =ci/*= statuses written only by the build subsystem; required contexts |
1436```
1437
1438`09-Controls.org:92`:
1439
1440```org
1441| Build results reused only across equal trust | in place | =SuccessBuildForTree=, =SuccessBuildFor= (=internal/store/builds.go=) |
1442```
1443
1444`10-Known-Gaps.org`: delete the `#258` row.
1445
1446- [ ] **Step 6: Commit and MR**
1447
1448```bash
1449git add .gitbay/wiki
1450git commit -S -m "wiki: reserved ci/ statuses, trusted reuse, required contexts
1451
1452Closes #258"
1453git push -u origin ci-status-trust
1454gitbay mr create --source ci-status-trust --target main --title "ci: reserve ci/ statuses; reuse only trusted results; required contexts"
1455```
1456
1457No runner change: this part deploys with `make deploy` alone. Merge
1458with `--strategy ff` once CI is green; delete the branch both places.
1459
1460---
1461
1462# Part 3: separate the runner's source address from its builds (branch `runner-source-address`, #260)
1463
1464### Task 3.1: the claim carries the instance's public ssh destination
1465
1466**Files:**
1467- Modify: `internal/control/build.go` (the payload from Task 1.1; a helper beside `runRunnerNext`)
1468- Test: `internal/control/runnernext_test.go` (append)
1469
1470**Interfaces:**
1471- Produces: `runner next --json` payload `"ssh": "git@<site host>"`, omitted when `site_url` is empty.
1472
1473- [ ] **Step 1: Write the failing test**
1474
1475```go
1476// A build on the daemon's own host is given the public destination, not
1477// the loopback address its runner polls (#260).
1478func TestRunnerNextCarriesPublicSSH(t *testing.T) {
1479 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
1480 if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true); err != nil {
1481 t.Fatal(err)
1482 }
1483 c, out := runnerCtx(st, uid, root) // site_url https://x.test
1484 c.JSON = true
1485 if code := runRunnerNext(c, nil); code != protocol.ExitOK {
1486 t.Fatalf("runner next: exit %d, output:\n%s", code, out.String())
1487 }
1488 if !strings.Contains(out.String(), `"ssh":"git@x.test"`) {
1489 t.Fatalf("claim lacks the public destination:\n%s", out.String())
1490 }
1491}
1492```
1493
1494- [ ] **Step 2: Run it and see it fail**
1495
1496Run: `go test ./internal/control -run TestRunnerNextCarriesPublicSSH -count=1`
1497Expected: FAIL, `claim lacks the public destination`.
1498
1499- [ ] **Step 3: Implement**
1500
1501Add after `maxOrphanSkip`:
1502
1503```go
1504// publicSSH is the instance's ssh destination as anyone outside reaches
1505// it. A runner on the daemon's own host polls over loopback and hands
1506// its builds this instead, so no build connects from the runner's source
1507// address (#260). Empty when site_url is not set.
1508func publicSSH(c *Ctx) string {
1509 if host := c.Cfg.SiteHost(); host != "" {
1510 return "git@" + host
1511 }
1512 return ""
1513}
1514```
1515
1516In the payload struct add, after `Trusted`:
1517
1518```go
1519 // SSH is the instance's public destination for the build's
1520 // GITBAY_SSH when its runner polls over loopback (#260).
1521 SSH string `json:"ssh,omitempty"`
1522```
1523
1524and `SSH: publicSSH(c),` in the literal.
1525
1526- [ ] **Step 4: Run it and see it pass**
1527
1528Run: `go test ./internal/control -run TestRunnerNext -count=1`
1529Expected: PASS.
1530
1531- [ ] **Step 5: Commit**
1532
1533```bash
1534git add internal/control/build.go internal/control/runnernext_test.go
1535git commit -S -m "runner next: send the instance's public ssh destination
1536
1537Ref #260"
1538```
1539
1540### Task 3.2: a loopback runner's builds get no host loopback
1541
1542**Files:**
1543- Modify: `cmd/gitbay-runner/main.go:32-42` (`job`), `:433` (`stepEnv` call), `:465-486` (`buildSSH`)
1544- Modify: `cmd/gitbay-runner/isolate.go:150-154` (podman run args)
1545- Modify: `cmd/gitbay-runner/env_test.go:190-212`
1546
1547**Interfaces:**
1548- Consumes: the `ssh` claim field from Task 3.1.
1549- Produces:
1550 - `job.SSH string` (`json:"ssh"`)
1551 - `func (r *runner) loopbackRemote() bool`
1552 - `func (r *runner) buildSSH(public string) string` (was `buildSSH()`)
1553 - `func (r *runner) buildNetwork() []string`
1554
1555- [ ] **Step 1: Write the failing tests**
1556
1557Replace `TestStepEnvCarriesInstanceAddress` (`env_test.go:190-212`) with:
1558
1559```go
1560// A build that talks back to the instance needs an address that works
1561// from where it runs. A runner polling over loopback keeps its podman
1562// builds off the host's loopback, so they get the instance's public
1563// destination from the claim; any other remote is used as it is (#260).
1564func TestStepEnvCarriesInstanceAddress(t *testing.T) {
1565 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
1566 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
1567 t.Errorf("GITBAY_SSH missing: %q", env)
1568 }
1569 for _, tc := range []struct{ remote, isolation, public, want string }{
1570 {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"},
1571 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
1572 {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
1573 {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"},
1574 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
1575 {"gitbay.org", isolationPodman, "git@gitbay.org", "gitbay.org"},
1576 } {
1577 r := &runner{remote: tc.remote, isolation: tc.isolation}
1578 if got := r.buildSSH(tc.public); got != tc.want {
1579 t.Errorf("remote %s under %s, public %q: got %s want %s", tc.remote, tc.isolation, tc.public, got, tc.want)
1580 }
1581 }
1582}
1583
1584// Only a runner that polls over loopback shares an address a build could
1585// connect from, so only its builds lose the host-loopback mapping (#260).
1586func TestBuildNetworkKeepsLoopbackRunnersBuildsOff(t *testing.T) {
1587 for _, tc := range []struct {
1588 remote string
1589 want []string
1590 }{
1591 {"git@127.0.0.1", []string{"--network", "pasta:--no-map-gw"}},
1592 {"localhost", []string{"--network", "pasta:--no-map-gw"}},
1593 {"git@::1", []string{"--network", "pasta:--no-map-gw"}},
1594 {"git@gitbay.org", nil},
1595 } {
1596 r := &runner{remote: tc.remote, isolation: isolationPodman}
1597 if got := r.buildNetwork(); strings.Join(got, " ") != strings.Join(tc.want, " ") {
1598 t.Errorf("remote %s: %q, want %q", tc.remote, got, tc.want)
1599 }
1600 }
1601}
1602```
1603
1604- [ ] **Step 2: Run them and see them fail**
1605
1606Run: `go test ./cmd/gitbay-runner -count=1`
1607Expected: build failure (`too many arguments in call to r.buildSSH`, `r.buildNetwork undefined`).
1608
1609- [ ] **Step 3: Implement**
1610
1611`job`, after `Trusted`:
1612
1613```go
1614 // SSH is the instance's public ssh destination, for a build whose
1615 // runner polls over loopback (#260).
1616 SSH string `json:"ssh"`
1617```
1618
1619Replace `buildSSH` (`main.go:465-486`) with:
1620
1621```go
1622// loopbackRemote reports whether the runner polls the daemon on its own
1623// host over loopback.
1624func (r *runner) loopbackRemote() bool {
1625 _, host, ok := strings.Cut(r.remote, "@")
1626 if !ok {
1627 host = r.remote
1628 }
1629 return host == "127.0.0.1" || host == "localhost" || host == "::1"
1630}
1631
1632// buildSSH is the instance's ssh destination as a build reaches it. A
1633// runner polling over loopback keeps its podman builds off the host's
1634// loopback (buildNetwork), so they get the instance's public destination
1635// from the claim. Any other remote is a real host elsewhere and works as
1636// it is, and under -isolation none a build runs on the host itself.
1637func (r *runner) buildSSH(public string) string {
1638 if r.isolation == isolationPodman && r.loopbackRemote() && public != "" {
1639 return public
1640 }
1641 return r.remote
1642}
1643
1644// buildNetwork is the podman network option for a build. pasta maps the
1645// container's gateway address to the host's loopback, and a build's
1646// connection through it arrives from 127.0.0.1 — the address a runner on
1647// the daemon's host polls from. The SSH auth limiter counts failures per
1648// source address, so a build sharing the runner's could throttle its
1649// polling (#260). --no-map-gw removes the mapping: the build reaches the
1650// host only at its public address, as any client on the internet does,
1651// and keeps its outbound access.
1652func (r *runner) buildNetwork() []string {
1653 if !r.loopbackRemote() {
1654 return nil
1655 }
1656 return []string{"--network", "pasta:--no-map-gw"}
1657}
1658```
1659
1660In `run`, the `stepEnv` call becomes `env := stepEnv(j, home, r.buildSSH(j.SSH))`.
1661
1662In `isolate.go`, after the `--env-file` append (line 153):
1663
1664```go
1665 args = append(args, r.buildNetwork()...)
1666```
1667
1668- [ ] **Step 4: Run the tests and see them pass**
1669
1670Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1`
1671Expected: PASS.
1672
1673- [ ] **Step 5: Commit**
1674
1675```bash
1676git add cmd/gitbay-runner
1677git commit -S -m "runner: builds off the host's loopback when the runner polls over it
1678
1679Ref #260"
1680```
1681
1682### Task 3.3: egress policy in the wiki, and the MR
1683
1684**Files:**
1685- Modify: `.gitbay/wiki/Threat-Model.org` ("The CI runner", after the *Images* bullet)
1686- Modify: `.gitbay/wiki/CI.org` (new section before "* The table")
1687- Modify: `.gitbay/wiki/Users.org:550-555` (the `GITBAY_SSH` sentence)
1688- Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org` (Network row), `04-Trust-Boundaries.org` (TB7), `09-Controls.org:91`
1689
1690- [ ] **Step 1: Threat-Model**
1691
1692Add a bullet after *Images are provisioned…*:
1693
1694```org
1695- *What a build can reach.* Outbound internet, trusted or not: a fork's
1696 merge request to a Go repository has to fetch its modules. Not the
1697 host's loopback: a runner that polls the daemon over loopback starts
1698 its containers with pasta's gateway mapping off, so a build reaches
1699 the host only at its public address, as anyone on the internet does,
1700 and =GITBAY_SSH= names that address. That keeps the runner's source
1701 address, =127.0.0.1=, one no build can connect from; the SSH auth
1702 limiter counts failures per address, and a build sharing the runner's
1703 could throttle its polling (krz/gitbay#260). The forge's public ports
1704 — 22, 80, 443 and the operator's sshd — are reachable from a build
1705 exactly as from the internet. Under =-isolation none= a build runs on
1706 the host and shares its loopback; that mode is for instances where
1707 every repository is trusted.
1708```
1709
1710- [ ] **Step 2: CI.org**
1711
1712Add before `* The table`:
1713
1714```org
1715* What a build can reach
1716
1717Builds have outbound internet access, trusted and untrusted alike, and
1718no access to the runner host's loopback when the runner polls the
1719daemon over it: the forge is reached at its public address, the one in
1720=GITBAY_SSH=. See the Threat-Model page, "What a build can reach", for
1721why (krz/gitbay#260).
1722```
1723
1724- [ ] **Step 3: Users.org**
1725
1726Replace "(=git@gitbay.org= from a runner elsewhere; inside a container
1727on the server's own runner the host is at a private address the runner
1728fills in)" with "(=git@gitbay.org=, the instance's public address, from
1729a runner elsewhere and from a container on the server's own runner
1730alike)".
1731
1732- [ ] **Step 4: Architecture**
1733
1734`07-CI-and-Supply-Chain.org` Network row:
1735
1736```org
1737| Network | pasta; outbound open; a loopback runner's builds run with =--no-map-gw= and reach the host only at its public address (=main.go=, #260) |
1738```
1739
1740`04-Trust-Boundaries.org` TB7: replace "the network is open (#260)"
1741with "outbound is open and the host's loopback is not reachable
1742(#260)". `09-Controls.org:91`:
1743
1744```org
1745| Build network egress restricted | partial | host loopback closed to builds; outbound open by decision (#260) |
1746```
1747
1748The Known-Gaps row for #260 and its "What can a build reach…" question
1749stay until the runbook's R3 results are recorded.
1750
1751- [ ] **Step 5: Verify, commit, MR**
1752
1753Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control -count=1`
1754Expected: PASS.
1755
1756```bash
1757git add .gitbay/wiki
1758git commit -S -m "wiki: what a build can reach
1759
1760Ref #260"
1761git push -u origin runner-source-address
1762gitbay mr create --source runner-source-address --target main --title "runner: keep builds off the runner's source address"
1763```
1764
1765Before merging, run the runbook's R3 on the scratch repository. Merge
1766with `--strategy ff`; delete the branch both places.
1767
1768---
1769
1770# Part 4: failed step and duration (branch `build-failure-report`, #266)
1771
1772### Task 4.1: store the failed step
1773
1774**Files:**
1775- Create: `internal/store/migrations/0065_build_failure.up.sql`, `0065_build_failure.down.sql`
1776- Modify: `internal/store/builds.go:12-33` (`Build`), `:67-78` (`buildSelect`, `scanBuild`), after `FinishBuild` (`:251-264`)
1777- Test: `internal/store/builds_test.go` (append; add `"errors"` to imports)
1778
1779**Interfaces:**
1780- Produces:
1781 - `Build.FailedStep int`, `Build.FailedReason string`
1782 - `func (s *Store) SetBuildFailure(id int64, step int, reason string) error` — only on a running build; `ErrNotFound` otherwise.
1783
1784- [ ] **Step 1: Write the failing test**
1785
1786```go
1787// Where a failed build stopped is recorded while it runs, before the
1788// outcome, and a finished build is not rewritten (#266).
1789func TestSetBuildFailure(t *testing.T) {
1790 s := open(t)
1791 if err := s.MigrateUp(); err != nil {
1792 t.Fatal(err)
1793 }
1794 uid, _ := s.CreateUser("cmc", true)
1795 repoID, _ := s.CreateRepo("user", uid, "app", "public")
1796 if _, err := s.CreateBuild(repoID, "unit", "abc", "main", `["true","false"]`, "", "", true); err != nil {
1797 t.Fatal(err)
1798 }
1799 b, ok, err := s.ClaimBuild(nil, false)
1800 if err != nil || !ok {
1801 t.Fatalf("claim: ok=%v err=%v", ok, err)
1802 }
1803 if err := s.SetBuildFailure(b.ID, 2, "exit 1"); err != nil {
1804 t.Fatal(err)
1805 }
1806 if err := s.FinishBuild(b.ID, "failure"); err != nil {
1807 t.Fatal(err)
1808 }
1809 got, err := s.BuildByID(b.ID)
1810 if err != nil {
1811 t.Fatal(err)
1812 }
1813 if got.FailedStep != 2 || got.FailedReason != "exit 1" {
1814 t.Fatalf("failed step %d reason %q", got.FailedStep, got.FailedReason)
1815 }
1816 if err := s.SetBuildFailure(b.ID, 1, "late"); !errors.Is(err, ErrNotFound) {
1817 t.Fatalf("rewrote a finished build: %v", err)
1818 }
1819}
1820```
1821
1822- [ ] **Step 2: Run it and see it fail**
1823
1824Run: `go test ./internal/store -run TestSetBuildFailure -count=1`
1825Expected: build failure (`s.SetBuildFailure undefined`).
1826
1827- [ ] **Step 3: Migration**
1828
1829`0065_build_failure.up.sql`:
1830
1831```sql
1832-- Where a failed build stopped: the 1-based step, 0 when it stopped
1833-- before any step or did not fail, and the runner's one-line reason.
1834ALTER TABLE builds ADD COLUMN failed_step INTEGER NOT NULL DEFAULT 0;
1835ALTER TABLE builds ADD COLUMN failed_reason TEXT NOT NULL DEFAULT '';
1836```
1837
1838`0065_build_failure.down.sql`:
1839
1840```sql
1841ALTER TABLE builds DROP COLUMN failed_reason;
1842ALTER TABLE builds DROP COLUMN failed_step;
1843```
1844
1845- [ ] **Step 4: Store**
1846
1847In `Build`, after `Trusted`:
1848
1849```go
1850 // FailedStep is the 1-based step a failed build stopped at, 0 when it
1851 // stopped before its first step or did not fail. FailedReason is the
1852 // runner's one line: "exit 1", "build timed out after 45m0s".
1853 FailedStep int
1854 FailedReason string
1855```
1856
1857`buildSelect` and `scanBuild`:
1858
1859```go
1860const buildSelect = `
1861 SELECT id, repo_id, number, job, sha, ref, steps, image, tree, status, created_at, started_at, finished_at, log_closed_at, trusted,
1862 failed_step, failed_reason
1863 FROM builds`
1864
1865func scanBuild(row interface{ Scan(...any) error }) (Build, error) {
1866 var b Build
1867 var trusted int
1868 err := row.Scan(&b.ID, &b.RepoID, &b.Number, &b.Job, &b.SHA, &b.Ref, &b.Steps, &b.Image, &b.Tree,
1869 &b.Status, &b.CreatedAt, &b.StartedAt, &b.FinishedAt, &b.LogClosedAt, &trusted,
1870 &b.FailedStep, &b.FailedReason)
1871 b.Trusted = trusted != 0
1872 return b, err
1873}
1874```
1875
1876After `FinishBuild`:
1877
1878```go
1879// SetBuildFailure records where a running build failed. The runner
1880// reports it with the outcome; it is written first, so a reader woken
1881// by the finish sees both.
1882func (s *Store) SetBuildFailure(id int64, step int, reason string) error {
1883 res, err := s.DB.Exec(`UPDATE builds SET failed_step = ?, failed_reason = ?
1884 WHERE id = ? AND status = 'running'`, step, reason, id)
1885 if err != nil {
1886 return err
1887 }
1888 if n, _ := res.RowsAffected(); n == 0 {
1889 return ErrNotFound
1890 }
1891 return nil
1892}
1893```
1894
1895- [ ] **Step 5: Run the tests and see them pass**
1896
1897Run: `go test ./internal/store -count=1`
1898Expected: PASS, `TestMigrateUpDown` included.
1899
1900- [ ] **Step 6: Commit**
1901
1902```bash
1903git add internal/store
1904git commit -S -m "store: failed step and reason on a build
1905
1906Ref #266"
1907```
1908
1909### Task 4.2: `runner done --step --reason`
1910
1911**Files:**
1912- Modify: `internal/control/build.go:102-106` (registration), `:648-713` (`runRunnerDone`)
1913- Test: `internal/control/runnernext_test.go` (append)
1914
1915**Interfaces:**
1916- Consumes: `Store.SetBuildFailure`.
1917- Produces: `runner done <build-id> success|failure [--step <n>] [--reason <text>]`; `func failureReason(s string) string`.
1918
1919- [ ] **Step 1: Write the failing tests**
1920
1921```go
1922// runner done records the failed step and a one-line reason (#266).
1923func TestRunnerDoneRecordsFailedStep(t *testing.T) {
1924 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
1925 n, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["go build ./...","go test ./..."]`, "", "", true)
1926 if err != nil {
1927 t.Fatal(err)
1928 }
1929 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
1930 if err != nil || !ok {
1931 t.Fatalf("claim: ok=%v err=%v", ok, err)
1932 }
1933 c, out := runnerCtx(st, uid, root)
1934 if code := runRunnerDone(c, []string{fmt.Sprint(b.ID), "failure", "--step", "2", "--reason", "exit 1\n"}); code != protocol.ExitOK {
1935 t.Fatalf("runner done: exit %d\n%s", code, out.String())
1936 }
1937 got, _ := st.BuildByNumber(repo.ID, n)
1938 if got.Status != "failure" || got.FailedStep != 2 || got.FailedReason != "exit 1" {
1939 t.Fatalf("status %s step %d reason %q", got.Status, got.FailedStep, got.FailedReason)
1940 }
1941}
1942
1943// A report with no flags — an older runner — or with a step past the
1944// job's still finishes the build; the step is then recorded as 0.
1945func TestRunnerDoneToleratesMissingOrBadStep(t *testing.T) {
1946 st, repo, uid, root, baseSHA, _ := setupOrphanRepo(t)
1947 for _, extra := range [][]string{nil, {"--step", "9"}} {
1948 n, _ := st.CreateBuild(repo.ID, "unit", baseSHA, "main", `["true"]`, "", "", true)
1949 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
1950 if err != nil || !ok {
1951 t.Fatalf("claim: ok=%v err=%v", ok, err)
1952 }
1953 c, out := runnerCtx(st, uid, root)
1954 if code := runRunnerDone(c, append([]string{fmt.Sprint(b.ID), "failure"}, extra...)); code != protocol.ExitOK {
1955 t.Fatalf("runner done %v: exit %d\n%s", extra, code, out.String())
1956 }
1957 if got, _ := st.BuildByNumber(repo.ID, n); got.Status != "failure" || got.FailedStep != 0 {
1958 t.Fatalf("%v: status %s step %d", extra, got.Status, got.FailedStep)
1959 }
1960 }
1961}
1962```
1963
1964- [ ] **Step 2: Run them and see them fail**
1965
1966Run: `go test ./internal/control -run TestRunnerDone -count=1`
1967Expected: FAIL, the first with exit 2 (usage: four arguments where two
1968are accepted).
1969
1970- [ ] **Step 3: Implement**
1971
1972Registration:
1973
1974```go
1975 register(Command{Path: []string{"runner", "done"},
1976 Summary: "finish a build",
1977 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]",
1978 Flags: []Flag{
1979 {"--step", "<n>", "the 1-based step a failed build stopped at", ""},
1980 {"--reason", "<text>", "how it failed, one line", ""},
1981 },
1982 Examples: []string{"runner done 431 success", "runner done 431 failure --step 3 --reason 'exit 1'"},
1983 Run: runRunnerDone})
1984```
1985
1986Replace `runRunnerDone` with:
1987
1988```go
1989func runRunnerDone(c *Ctx, args []string) int {
1990 key, code := runnerSession(c)
1991 if code >= 0 {
1992 return code
1993 }
1994 f, err := parseFlags(args, flagSpec{Values: []string{"--step", "--reason"}, MaxPos: 2,
1995 Usage: "runner done <build-id> success|failure [--step <n>] [--reason <text>]"})
1996 if err != nil {
1997 return c.fail(protocol.ExitUsage, "%v", err)
1998 }
1999 if len(f.Pos) != 2 || (f.Pos[1] != "success" && f.Pos[1] != "failure") {
2000 return c.usage()
2001 }
2002 outcome := f.Pos[1]
2003 id, err := strconv.ParseInt(f.Pos[0], 10, 64)
2004 if err != nil {
2005 return c.fail(protocol.ExitUsage, "bad build id %q", f.Pos[0])
2006 }
2007 b, err := c.Store.BuildByID(id)
2008 if err != nil {
2009 return c.fail(protocol.ExitNotFound, "no build %d", id)
2010 }
2011 if ok, err := runnerMayBuild(c, key, b.RepoID); err != nil {
2012 return c.fail(protocol.ExitFailure, "%v", err)
2013 } else if !ok {
2014 return c.fail(protocol.ExitDenied, "this key is not attached to the build's repository; a repository admin attaches it with repo runner add")
2015 }
2016 // Cancelled underneath the runner: its report is late, not wrong.
2017 // The row, the status and the log were settled by the cancel.
2018 if b.Status == "cancelled" {
2019 c.Store.RunnerDone(key.ID)
2020 return c.emit(map[string]any{"build": b.Number, "status": "cancelled"}, func(w io.Writer) {
2021 fmt.Fprintf(w, "build %d was cancelled\n", b.Number)
2022 })
2023 }
2024 if outcome == "failure" {
2025 // A step the job does not have is recorded as none rather than
2026 // refused: refusing would lose the outcome over a detail (#266).
2027 var steps []string
2028 json.Unmarshal([]byte(b.Steps), &steps)
2029 step, _ := strconv.Atoi(f.Value("--step"))
2030 if step < 0 || step > len(steps) {
2031 step = 0
2032 }
2033 if err := c.Store.SetBuildFailure(id, step, failureReason(f.Value("--reason"))); err != nil && !errors.Is(err, store.ErrNotFound) {
2034 return c.fail(protocol.ExitFailure, "recording build %d's failure: %v", id, err)
2035 }
2036 }
2037 if err := c.Store.FinishBuild(id, outcome); err != nil {
2038 return c.fail(protocol.ExitFailure, "finishing build %d: %v", id, err)
2039 }
2040 c.Store.RunnerDone(key.ID)
2041 repo, err := c.Store.RepoByID(b.RepoID)
2042 if err != nil {
2043 return c.fail(protocol.ExitFailure, "%v", err)
2044 }
2045 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
2046 desc := "build " + outcome
2047 if err := c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, outcome, desc, url, c.User.ID); err != nil {
2048 return c.fail(protocol.ExitFailure, "%v", err)
2049 }
2050 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
2051 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
2052 // A red build mails the repo's notify targets with the log tail — a
2053 // failed scheduled job must not wait to be noticed.
2054 if outcome == "failure" {
2055 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
2056 tail := ""
2057 if log, err := c.Store.BuildLog(id); err == nil && len(log) > 0 {
2058 if len(log) > 2000 {
2059 log = log[len(log)-2000:]
2060 }
2061 tail = string(log)
2062 }
2063 notify(c, targets, notice{repo: repo, kind: "build",
2064 subject: fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
2065 action: fmt.Sprintf("build %d failed: %s on %s", b.Number, b.Job, b.Ref),
2066 body: fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url),
2067 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
2068 }
2069 }
2070 return c.emit(map[string]any{"build": b.Number, "status": outcome}, func(w io.Writer) {
2071 fmt.Fprintf(w, "build %d %s\n", b.Number, outcome)
2072 })
2073}
2074
2075// failureReason keeps a runner's reason to one line of at most 200
2076// bytes: it is shown on the build page and by build show.
2077func failureReason(s string) string {
2078 s = strings.Join(strings.Fields(s), " ")
2079 if len(s) > 200 {
2080 s = s[:200]
2081 }
2082 return strings.ToValidUTF8(s, "")
2083}
2084```
2085
2086- [ ] **Step 4: Run the tests and see them pass**
2087
2088Run: `go vet ./... && go test ./internal/control -count=1`
2089Expected: PASS.
2090
2091- [ ] **Step 5: Commit**
2092
2093```bash
2094git add internal/control/build.go internal/control/runnernext_test.go
2095git commit -S -m "runner done: record the failed step and reason
2096
2097Ref #266"
2098```
2099
2100### Task 4.3: the runner names the failed step
2101
2102**Files:**
2103- Modify: `cmd/gitbay-runner/main.go:245-277` (`step`), `:309-435` (`run`), `:363-398` (`runStep`)
2104- Modify: `cmd/gitbay-runner/isolate.go:79-197` (`runSteps`, `runStepsPodman`)
2105- Modify: `cmd/gitbay-runner/report.go:19-23` (`reportDone`), new `doneArgs`
2106- Test: `cmd/gitbay-runner/steps_test.go` (create), `cmd/gitbay-runner/report_test.go` (append)
2107
2108**Interfaces:**
2109- Consumes: `runner done … --step <n> --reason <text>` from Task 4.2.
2110- Produces:
2111 - `type failure struct { Step int; Reason string }`
2112 - `func exitReason(err error) string`
2113 - `run(j job) *failure`, `runSteps(…) *failure`, `runStepsPodman(…) *failure` (nil is success)
2114 - `func (r *runner) reportDone(id int64, status string, f *failure) error`
2115 - `func doneArgs(id int64, status string, f *failure) []string`
2116
2117- [ ] **Step 1: Write the failing tests**
2118
2119Create `cmd/gitbay-runner/steps_test.go`:
2120
2121```go
2122package main
2123
2124import (
2125 "os"
2126 "os/exec"
2127 "strings"
2128 "testing"
2129 "time"
2130)
2131
2132// The failing step is named by number in the log and in the outcome
2133// reported to the server (#266).
2134func TestRunStepsNamesTheFailedStep(t *testing.T) {
2135 r := &runner{isolation: isolationNone}
2136 run := func(cmd *exec.Cmd, _ time.Time) (bool, string) {
2137 if err := cmd.Run(); err != nil {
2138 return false, exitReason(err)
2139 }
2140 return true, ""
2141 }
2142 env := []string{"PATH=" + os.Getenv("PATH")}
2143 var log strings.Builder
2144 f := r.runSteps(job{Steps: []string{"true", "exit 3", "true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run)
2145 if f == nil || f.Step != 2 || f.Reason != "exit 3" {
2146 t.Fatalf("failure %+v, want step 2, exit 3", f)
2147 }
2148 if !strings.Contains(log.String(), "step 2/3 failed: exit 3\n") {
2149 t.Fatalf("log does not name the step:\n%s", log.String())
2150 }
2151 if f := r.runSteps(job{Steps: []string{"true"}}, t.TempDir(), env, &log, time.Now().Add(time.Minute), run); f != nil {
2152 t.Fatalf("a passing job failed: %+v", f)
2153 }
2154}
2155```
2156
2157Append to `cmd/gitbay-runner/report_test.go` (add imports
2158`"strings"` and `"gitbay.org/gitbay/internal/protocol"`):
2159
2160```go
2161// The reason survives the trip: ssh joins arguments with spaces and the
2162// server splits the line again with POSIX rules (#266).
2163func TestDoneArgsNameTheFailedStep(t *testing.T) {
2164 got := doneArgs(7, "failure", &failure{Step: 3, Reason: "can't: exit 1"})
2165 argv, err := protocol.Tokenize(strings.Join(got, " "))
2166 if err != nil {
2167 t.Fatal(err)
2168 }
2169 want := []string{"runner", "done", "7", "failure", "--step", "3", "--reason", "can't: exit 1"}
2170 if strings.Join(argv, "|") != strings.Join(want, "|") {
2171 t.Fatalf("server reads %q, want %q", argv, want)
2172 }
2173 if got := doneArgs(7, "success", nil); strings.Join(got, " ") != "runner done 7 success" {
2174 t.Fatalf("success: %q", got)
2175 }
2176 if got := doneArgs(7, "failure", &failure{Reason: "git clone: exit 128"}); strings.Contains(strings.Join(got, " "), "--step") {
2177 t.Fatalf("a failure before any step sent a step: %q", got)
2178 }
2179}
2180```
2181
2182- [ ] **Step 2: Run them and see them fail**
2183
2184Run: `go test ./cmd/gitbay-runner -count=1`
2185Expected: build failure (`undefined: exitReason`, `undefined: doneArgs`, `undefined: failure`).
2186
2187- [ ] **Step 3: `failure` and `exitReason`**
2188
2189In `main.go`, before `run`:
2190
2191```go
2192// failure says where a build stopped: Step is the 1-based step that
2193// failed, 0 when the build stopped before its first step (the clone, the
2194// container), and Reason is one short line (#266).
2195type failure struct {
2196 Step int
2197 Reason string
2198}
2199
2200// exitReason is how a finished command's failure reads in a build's log
2201// and on the build: "exit 1" for a command that exited, the error
2202// otherwise (a signal, a start failure).
2203func exitReason(err error) string {
2204 var ee *exec.ExitError
2205 if errors.As(err, &ee) && ee.ExitCode() >= 0 {
2206 return fmt.Sprintf("exit %d", ee.ExitCode())
2207 }
2208 return err.Error()
2209}
2210```
2211
2212Add `"errors"` to `main.go`'s imports.
2213
2214- [ ] **Step 4: `run` and `step`**
2215
2216In `run`, the signature becomes `func (r *runner) run(j job) *failure`
2217with its comment "…Returns nil when every step succeeded, else where the
2218build stopped." Each early `return false` returns a failure instead:
2219
2220```go
2221 home, doneHome, err := buildHome(r.workdir, j)
2222 if err != nil {
2223 log.Printf("build %d: build home: %v", j.ID, err)
2224 return &failure{Reason: "preparing the build home failed"}
2225 }
2226 defer doneHome()
2227```
2228
2229```go
2230 pipe, err := logCmd.StdinPipe()
2231 if err != nil {
2232 log.Printf("build %d: log pipe: %v", j.ID, err)
2233 return &failure{Reason: "opening the log stream failed"}
2234 }
2235```
2236
2237```go
2238 if err := logCmd.Start(); err != nil {
2239 log.Printf("build %d: log stream: %v", j.ID, err)
2240 return &failure{Reason: "opening the log stream failed"}
2241 }
2242```
2243
2244In `runStep`, `return false, fmt.Sprintf("step failed: %v", err)`
2245becomes `return false, exitReason(err)`.
2246
2247The clone loop's failure:
2248
2249```go
2250 if ok, why := runStep(cmd, deadline); !ok {
2251 fmt.Fprintf(sink, "git %s: %s\n", args[0], why)
2252 return &failure{Reason: "git " + args[0] + ": " + why}
2253 }
2254```
2255
2256`step()`, from `status := "failure"` to the report:
2257
2258```go
2259 f := r.run(j)
2260 status := "success"
2261 if f != nil {
2262 status = "failure"
2263 }
2264 if err := r.reportDone(j.ID, status, f); err != nil {
2265 return true, err
2266 }
2267```
2268
2269- [ ] **Step 5: `runSteps`, `runStepsPodman`**
2270
2271In `isolate.go`, the `runSteps` comment ends "…Returns nil when every
2272step succeeded." and the function becomes:
2273
2274```go
2275func (r *runner) runSteps(j job, dir string, env []string, sink io.Writer, deadline time.Time, runStep stepRunner) *failure {
2276 if r.isolation == isolationNone {
2277 for i, step := range j.Steps {
2278 fmt.Fprintf(sink, "$ %s\n", step)
2279 cmd := exec.Command(toolpath.Look("sh"), "-c", step)
2280 cmd.Dir, cmd.Env = dir, env
2281 cmd.Stdout, cmd.Stderr = sink, sink
2282 if ok, why := runStep(cmd, deadline); !ok {
2283 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
2284 return &failure{Step: i + 1, Reason: why}
2285 }
2286 }
2287 return nil
2288 }
2289 return r.runStepsPodman(j, dir, env, sink, deadline, runStep)
2290}
2291```
2292
2293`runStepsPodman` returns `*failure`. Its setup failures (env file,
2294cgroup, container start) keep their log lines and return
2295`&failure{Reason: "preparing the build environment failed"}`,
2296`&failure{Reason: "preparing the build cgroup failed"}` and
2297`&failure{Reason: "starting the build container failed"}`
2298respectively. The step loop:
2299
2300```go
2301 for i, step := range j.Steps {
2302 fmt.Fprintf(sink, "$ %s\n", step)
2303 cmd := exec.Command(podman, append(r.podmanGlobal(), "exec", "--workdir", "/workspace", name, "sh", "-c", step)...)
2304 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + r.podmanHome()}
2305 intoCgroup(cmd, cgroupFD)
2306 cmd.Stdout, cmd.Stderr = sink, sink
2307 if ok, why := runStep(cmd, deadline); !ok {
2308 fmt.Fprintf(sink, "step %d/%d failed: %s\n", i+1, len(j.Steps), why)
2309 return &failure{Step: i + 1, Reason: why}
2310 }
2311 }
2312 return nil
2313```
2314
2315`podman exec` exits with the step's own status, so `exit 1` is the
2316step's.
2317
2318- [ ] **Step 6: `reportDone`, `doneArgs`**
2319
2320In `report.go` (add `"strconv"` and `"strings"` to its imports):
2321
2322```go
2323func (r *runner) reportDone(id int64, status string, f *failure) error {
2324 args := doneArgs(id, status, f)
2325 return reportWithRetry(func() (string, error) {
2326 return r.ssh(nil, args...)
2327 }, id, retryDelays)
2328}
2329
2330// doneArgs is the runner done command for a build's outcome. The reason
2331// is single-quoted: ssh joins arguments with spaces, and the server
2332// splits the line again with POSIX rules.
2333func doneArgs(id int64, status string, f *failure) []string {
2334 args := []string{"runner", "done", fmt.Sprint(id), status}
2335 if f == nil {
2336 return args
2337 }
2338 if f.Step > 0 {
2339 args = append(args, "--step", strconv.Itoa(f.Step))
2340 }
2341 if f.Reason != "" {
2342 args = append(args, "--reason", "'"+strings.ReplaceAll(f.Reason, "'", `'\''`)+"'")
2343 }
2344 return args
2345}
2346```
2347
2348(The comment above `reportDone` is unchanged.)
2349
2350- [ ] **Step 7: Run the tests and see them pass**
2351
2352Run: `go vet ./cmd/gitbay-runner && go test ./cmd/gitbay-runner -count=1`
2353Expected: PASS.
2354
2355- [ ] **Step 8: Commit**
2356
2357```bash
2358git add cmd/gitbay-runner
2359git commit -S -m "runner: name the failed step and report it
2360
2361Ref #266"
2362```
2363
2364### Task 4.4: `build show`, `build log --step/--tail`
2365
2366**Files:**
2367- Create: `internal/control/buildlog.go`, `internal/control/buildlog_test.go`
2368- Modify: `internal/control/build.go:40-47` (registration), `:109-126` (`BuildOut`, `buildToOut`), `:221-238` (`runBuildShow`), `:240-258` (`runBuildLog`)
2369
2370**Interfaces:**
2371- Consumes: `Build.FailedStep`, `Build.FailedReason`, `Build.Elapsed()`.
2372- Produces (used by Task 4.5):
2373 - `type LogSection struct { N int; Step string; Text string }`
2374 - `func SplitBuildLog(log string, steps []string) []LogSection`
2375 - `func FailedSection(sections []LogSection, status string, failedStep int) int`
2376 - `BuildOut.FailedStep int` (`failed_step`), `BuildOut.FailedReason string` (`failed_reason`), `BuildOut.DurationS int64` (`duration_s`), `BuildOut.Steps []string` (`steps`, on `build show` only)
2377
2378- [ ] **Step 1: Write the failing tests**
2379
2380Create `internal/control/buildlog_test.go`:
2381
2382```go
2383package control
2384
2385import (
2386 "bytes"
2387 "fmt"
2388 "reflect"
2389 "regexp"
2390 "strings"
2391 "testing"
2392
2393 "gitbay.org/gitbay/internal/protocol"
2394 "gitbay.org/gitbay/internal/store"
2395)
2396
2397func TestSplitBuildLog(t *testing.T) {
2398 log := "$ git clone ssh://x/a.git (abc)\n" +
2399 "$ go build ./...\n" +
2400 "built\n" +
2401 "$ go test ./...\n" +
2402 "--- FAIL: TestX\n" +
2403 "step 2/2 failed: exit 1\n"
2404 got := SplitBuildLog(log, []string{"go build ./...", "go test ./..."})
2405 want := []LogSection{
2406 {N: 0, Text: "$ git clone ssh://x/a.git (abc)\n"},
2407 {N: 1, Step: "go build ./...", Text: "built\n"},
2408 {N: 2, Step: "go test ./...", Text: "--- FAIL: TestX\nstep 2/2 failed: exit 1\n"},
2409 }
2410 if !reflect.DeepEqual(got, want) {
2411 t.Fatalf("got %+v\nwant %+v", got, want)
2412 }
2413}
2414
2415// A step's line inside other output, not at a line start, does not cut;
2416// a build that stopped before a step has no section for it; an empty
2417// setup is left out.
2418func TestSplitBuildLogStopsAtMissingStep(t *testing.T) {
2419 got := SplitBuildLog("$ make\nrunning: $ make test\nerror\n", []string{"make", "make test"})
2420 want := []LogSection{{N: 1, Step: "make", Text: "running: $ make test\nerror\n"}}
2421 if !reflect.DeepEqual(got, want) {
2422 t.Fatalf("got %+v\nwant %+v", got, want)
2423 }
2424}
2425
2426func TestTailLines(t *testing.T) {
2427 for _, tc := range []struct {
2428 in string
2429 n int
2430 want string
2431 }{
2432 {"a\nb\nc\n", 2, "b\nc\n"},
2433 {"a\nb\nc\n", 5, "a\nb\nc\n"},
2434 {"a\nb", 1, "b"},
2435 } {
2436 if got := string(tailLines([]byte(tc.in), tc.n)); got != tc.want {
2437 t.Errorf("tailLines(%q, %d) = %q, want %q", tc.in, tc.n, got, tc.want)
2438 }
2439 }
2440}
2441
2442// failedBuild is a finished failure whose second of two steps failed,
2443// having run 10m56s.
2444func failedBuild(t *testing.T) (*store.Store, store.Repo, int64, int64) {
2445 t.Helper()
2446 st, repo, uid := newQueueTestRepo(t)
2447 n, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["go build ./...","go test ./..."]`, "", "", true)
2448 if err != nil {
2449 t.Fatal(err)
2450 }
2451 b, ok, err := st.ClaimBuild([]int64{repo.ID}, false)
2452 if err != nil || !ok {
2453 t.Fatalf("claim: ok=%v err=%v", ok, err)
2454 }
2455 st.AppendBuildLog(b.ID, []byte("$ git clone x (abc)\n$ go build ./...\nok\n$ go test ./...\none\n--- FAIL: TestX\nstep 2/2 failed: exit 1\n"))
2456 if err := st.SetBuildFailure(b.ID, 2, "exit 1"); err != nil {
2457 t.Fatal(err)
2458 }
2459 if err := st.FinishBuild(b.ID, "failure"); err != nil {
2460 t.Fatal(err)
2461 }
2462 if _, err := st.DB.Exec(`UPDATE builds SET started_at = '2026-09-27T10:00:00Z', finished_at = '2026-09-27T10:10:56Z' WHERE id = ?`, b.ID); err != nil {
2463 t.Fatal(err)
2464 }
2465 return st, repo, uid, n
2466}
2467
2468func TestBuildLogStepAndTail(t *testing.T) {
2469 st, repo, uid, n := failedBuild(t)
2470 run := func(args ...string) (string, int) {
2471 t.Helper()
2472 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
2473 code := Dispatch(c, append([]string{"build", "log", repo.Path(), fmt.Sprint(n)}, args...))
2474 return c.Stdout.(*bytes.Buffer).String() + errOut.String(), code
2475 }
2476 if out, _ := run("--step", "1"); out != "ok\n" {
2477 t.Errorf("--step 1: %q", out)
2478 }
2479 if out, _ := run("--step", "failed", "--tail", "2"); out != "--- FAIL: TestX\nstep 2/2 failed: exit 1\n" {
2480 t.Errorf("--step failed --tail 2: %q", out)
2481 }
2482 if out, _ := run("--tail", "1"); out != "step 2/2 failed: exit 1\n" {
2483 t.Errorf("--tail 1: %q", out)
2484 }
2485 if _, code := run("--step", "3"); code != protocol.ExitUsage {
2486 t.Errorf("--step past the job: exit %d", code)
2487 }
2488 if _, code := run("--follow", "--tail", "1"); code != protocol.ExitUsage {
2489 t.Errorf("--follow with --tail: exit %d", code)
2490 }
2491}
2492
2493func TestBuildShowNamesFailedStepAndDuration(t *testing.T) {
2494 st, repo, uid, n := failedBuild(t)
2495 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
2496 if code := Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)}); code != protocol.ExitOK {
2497 t.Fatalf("exit %d: %s", code, errOut)
2498 }
2499 out := c.Stdout.(*bytes.Buffer).String()
2500 for _, re := range []string{`failed step\s+2/2 go test \./\.\.\. \(exit 1\)`, `duration\s+10m56s`} {
2501 if !regexp.MustCompile(re).MatchString(out) {
2502 t.Errorf("build show missing %s:\n%s", re, out)
2503 }
2504 }
2505 c, _ = pruneCtx(st, t.TempDir(), store.User{ID: uid})
2506 c.JSON = true
2507 Dispatch(c, []string{"build", "show", repo.Path(), fmt.Sprint(n)})
2508 for _, want := range []string{`"failed_step":2`, `"failed_reason":"exit 1"`, `"duration_s":656`, `"steps":["go build ./...","go test ./..."]`} {
2509 if !strings.Contains(c.Stdout.(*bytes.Buffer).String(), want) {
2510 t.Errorf("build show --json missing %s", want)
2511 }
2512 }
2513}
2514```
2515
2516- [ ] **Step 2: Run them and see them fail**
2517
2518Run: `go test ./internal/control -run 'TestSplitBuildLog|TestTailLines|TestBuildLogStep|TestBuildShowNames' -count=1`
2519Expected: build failure (`undefined: SplitBuildLog`).
2520
2521- [ ] **Step 3: `buildlog.go`**
2522
2523```go
2524package control
2525
2526import "strings"
2527
2528// LogSection is one part of a build log: the setup before the first
2529// step (N 0), or one step and its output.
2530type LogSection struct {
2531 N int // 0 for the setup, else the 1-based step
2532 Step string // the step's command; "" for the setup
2533 Text string
2534}
2535
2536// SplitBuildLog cuts a log at the "$ <step>" line the runner writes
2537// before each step, matching the build's steps in order and only at a
2538// line start. Output before the first step is the setup section, left
2539// out when empty. A step with no line in the log — the build stopped
2540// before it — has no section, and neither has any step after it.
2541func SplitBuildLog(log string, steps []string) []LogSection {
2542 var out []LogSection
2543 cur := LogSection{}
2544 start := 0
2545 for i, step := range steps {
2546 marker := "$ " + step + "\n"
2547 at := findLine(log, marker, start)
2548 if at < 0 {
2549 break
2550 }
2551 cur.Text = log[start:at]
2552 if cur.N > 0 || cur.Text != "" {
2553 out = append(out, cur)
2554 }
2555 cur = LogSection{N: i + 1, Step: step}
2556 start = at + len(marker)
2557 }
2558 cur.Text = log[start:]
2559 if cur.N > 0 || cur.Text != "" {
2560 out = append(out, cur)
2561 }
2562 return out
2563}
2564
2565// findLine is the index of line in log at or after from where it starts
2566// a line, or -1.
2567func findLine(log, line string, from int) int {
2568 for i := from; i <= len(log)-len(line); {
2569 j := strings.Index(log[i:], line)
2570 if j < 0 {
2571 return -1
2572 }
2573 at := i + j
2574 if at == 0 || log[at-1] == '\n' {
2575 return at
2576 }
2577 i = at + 1
2578 }
2579 return -1
2580}
2581
2582// FailedSection is the index of the section a failed build stopped in:
2583// the step the runner named, or the last section when it named none (an
2584// older runner, or a failure the runner could not tie to a step). -1
2585// when the build did not fail or its log is empty.
2586func FailedSection(sections []LogSection, status string, failedStep int) int {
2587 if status != "failure" || len(sections) == 0 {
2588 return -1
2589 }
2590 for i, s := range sections {
2591 if failedStep > 0 && s.N == failedStep {
2592 return i
2593 }
2594 }
2595 return len(sections) - 1
2596}
2597
2598// tailLines is the last n lines of b; a final newline ends the last line
2599// rather than starting another.
2600func tailLines(b []byte, n int) []byte {
2601 end := len(b)
2602 if end > 0 && b[end-1] == '\n' {
2603 end--
2604 }
2605 for i := end - 1; i >= 0; i-- {
2606 if b[i] == '\n' {
2607 n--
2608 if n == 0 {
2609 return b[i+1:]
2610 }
2611 }
2612 }
2613 return b
2614}
2615```
2616
2617- [ ] **Step 4: `BuildOut`, `build show`**
2618
2619`BuildOut`, after `Subject`:
2620
2621```go
2622 // FailedStep is the 1-based step a failed build stopped at, 0 when
2623 // none; FailedReason says how ("exit 1") (#266).
2624 FailedStep int `json:"failed_step,omitempty"`
2625 FailedReason string `json:"failed_reason,omitempty"`
2626 // DurationS is how long the build ran, once it has a start and a
2627 // finish.
2628 DurationS int64 `json:"duration_s,omitempty"`
2629 // Steps are the job's commands; build show only.
2630 Steps []string `json:"steps,omitempty"`
2631```
2632
2633`buildToOut`:
2634
2635```go
2636func buildToOut(b store.Build) BuildOut {
2637 return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA,
2638 Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt,
2639 FailedStep: b.FailedStep, FailedReason: b.FailedReason,
2640 DurationS: int64(b.Elapsed() / time.Second)}
2641}
2642```
2643
2644`runBuildShow`:
2645
2646```go
2647func runBuildShow(c *Ctx, args []string) int {
2648 repo, b, code := buildRef(c, args)
2649 if code >= 0 {
2650 return code
2651 }
2652 d := buildToOut(b)
2653 json.Unmarshal([]byte(b.Steps), &d.Steps)
2654 return c.emit(d, func(w io.Writer) {
2655 failedStep, failed := "", ""
2656 if d.FailedStep > 0 && d.FailedStep <= len(d.Steps) {
2657 step, _, _ := strings.Cut(d.Steps[d.FailedStep-1], "\n")
2658 failedStep = fmt.Sprintf("%d/%d %s", d.FailedStep, len(d.Steps), step)
2659 if d.FailedReason != "" {
2660 failedStep += " (" + d.FailedReason + ")"
2661 }
2662 } else {
2663 failed = d.FailedReason
2664 }
2665 duration := ""
2666 if d.DurationS > 0 {
2667 duration = (time.Duration(d.DurationS) * time.Second).String()
2668 }
2669 v := c.view(w)
2670 v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status)
2671 v.fields(
2672 "sha", fmt.Sprintf("%.10s", d.SHA),
2673 "ref", d.Ref,
2674 "queued", c.when(d.CreatedAt),
2675 "finished", c.when(d.FinishedAt),
2676 "duration", duration,
2677 "failed step", failedStep,
2678 "failed", failed,
2679 "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)),
2680 )
2681 })
2682}
2683```
2684
2685- [ ] **Step 5: `build log`**
2686
2687Registration:
2688
2689```go
2690 register(Command{Path: []string{"build", "log"},
2691 Summary: "print a build's log, or follow it until the build ends",
2692 Usage: "build log <owner/name> <n> [--follow] [--step <step>|failed] [--tail <lines>]",
2693 Flags: []Flag{
2694 {"--follow", "", "stream the log until the build ends", ""},
2695 {"--step", "<step>|failed", "only one step's output: 0 for the setup, a step number, or the one that failed", ""},
2696 {"--tail", "<lines>", "only the last lines", ""},
2697 },
2698 Examples: []string{"build log krz/gitbay 431 --follow", "build log krz/gitbay 431 --step failed --tail 40"},
2699 ReadOnly: true, Run: runBuildLog})
2700```
2701
2702`runBuildLog`:
2703
2704```go
2705func runBuildLog(c *Ctx, args []string) int {
2706 f, err := parseFlags(args, flagSpec{Bools: []string{"--follow"}, Values: []string{"--step", "--tail"}, MaxPos: 2, Usage: c.Cmd.Usage})
2707 if err != nil {
2708 return c.fail(protocol.ExitUsage, "%v", err)
2709 }
2710 repo, b, code := buildRef(c, f.Pos)
2711 if code >= 0 {
2712 return code
2713 }
2714 if f.Has("--follow") {
2715 if f.Has("--step") || f.Has("--tail") {
2716 return c.fail(protocol.ExitUsage, "--step and --tail read the stored log; drop --follow")
2717 }
2718 return followBuildLog(c, repo, b)
2719 }
2720 tail := 0
2721 if f.Has("--tail") {
2722 if tail, err = strconv.Atoi(f.Value("--tail")); err != nil || tail < 1 {
2723 return c.fail(protocol.ExitUsage, "--tail takes a number of lines, 1 or more")
2724 }
2725 }
2726 log, err := c.Store.BuildLog(b.ID)
2727 if err != nil {
2728 return c.fail(protocol.ExitFailure, "%v", err)
2729 }
2730 if f.Has("--step") {
2731 var steps []string
2732 json.Unmarshal([]byte(b.Steps), &steps)
2733 sections := SplitBuildLog(string(log), steps)
2734 at := -1
2735 if want := f.Value("--step"); want == "failed" {
2736 if at = FailedSection(sections, b.Status, b.FailedStep); at < 0 {
2737 return c.fail(protocol.ExitNotFound, "build %d did not fail", b.Number)
2738 }
2739 } else {
2740 n, err := strconv.Atoi(want)
2741 if err != nil || n < 0 || n > len(steps) {
2742 return c.fail(protocol.ExitUsage, "--step takes 0 (the setup) to %d, or failed", len(steps))
2743 }
2744 for i, s := range sections {
2745 if s.N == n {
2746 at = i
2747 }
2748 }
2749 if at < 0 {
2750 return c.fail(protocol.ExitNotFound, "build %d has no output for step %d", b.Number, n)
2751 }
2752 }
2753 log = []byte(sections[at].Text)
2754 }
2755 if tail > 0 {
2756 log = tailLines(log, tail)
2757 }
2758 c.Stdout.Write(log)
2759 return protocol.ExitOK
2760}
2761```
2762
2763- [ ] **Step 6: Run the tests and see them pass**
2764
2765Run: `go vet ./... && go test ./internal/control -count=1 && go test ./cmd/gitbay -run TestSummariesAreCurrent -count=1`
2766Expected: PASS (the `build log` summary is unchanged; no regeneration
2767needed).
2768
2769- [ ] **Step 7: Commit**
2770
2771```bash
2772git add internal/control
2773git commit -S -m "build show: failed step and duration; build log --step, --tail
2774
2775Ref #266"
2776```
2777
2778### Task 4.5: the build page folds by step
2779
2780**Files:**
2781- Modify: `internal/httpd/builds.go:1-18` (imports: add `"time"`), `:287-322` (`build`, `buildView`)
2782- Modify: `internal/web/templates/build.html:14-17`
2783- Modify: `internal/web/static/style.css:1116`
2784- Test: `internal/httpd/buildpages_test.go` (append)
2785
2786**Interfaces:**
2787- Consumes: `control.SplitBuildLog`, `control.FailedSection`, `control.LogSection`, `BuildOut` fields from Task 4.4.
2788- Produces: `buildView.Steps []logStep`, `buildView.Failed bool`, `buildView.Duration string`; `func logSteps(log string, b control.BuildOut) ([]logStep, bool)`.
2789
2790- [ ] **Step 1: Write the failing test**
2791
2792Append to `internal/httpd/buildpages_test.go`:
2793
2794```go
2795// A failed build's page folds its log by step, opens the step that
2796// failed and links to it; no JavaScript (#266).
2797func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) {
2798 b := control.BuildOut{Number: 61, Job: "test", Status: "failure",
2799 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
2800 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656,
2801 Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"}
2802 log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n"
2803 v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"}
2804 v.Steps, v.Failed = logSteps(log, b)
2805 var sb strings.Builder
2806 if err := web.Render(&sb, "build.html", v); err != nil {
2807 t.Fatalf("render: %v", err)
2808 }
2809 out := sb.String()
2810 for _, want := range []string{
2811 `<details class="difffold buildstep" id="failed" open>`,
2812 "step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure",
2813 "ran 10m56s", "--- FAIL: TestCLI",
2814 } {
2815 if !strings.Contains(out, want) {
2816 t.Errorf("build.html missing %q", want)
2817 }
2818 }
2819 if n := strings.Count(out, `class="difffold buildstep"`); n != 3 {
2820 t.Errorf("%d step folds, want 3 (setup and two steps)", n)
2821 }
2822 if n := strings.Count(out, `id="failed"`); n != 1 {
2823 t.Errorf("%d failed anchors, want 1", n)
2824 }
2825}
2826```
2827
2828- [ ] **Step 2: Run it and see it fail**
2829
2830Run: `go test ./internal/httpd -run TestBuildPageFoldsStepsAndOpensFailure -count=1`
2831Expected: build failure (`undefined: logSteps`).
2832
2833- [ ] **Step 3: Handler**
2834
2835Replace `buildView` and add `logStep` and `logSteps`:
2836
2837```go
2838type buildView struct {
2839 repoPage
2840 Build control.BuildOut
2841 Log string
2842 // Steps is the finished log cut at its steps, nil when there is no
2843 // step to cut at; Failed says whether one of them is marked failed.
2844 Steps []logStep
2845 Failed bool
2846 Duration string
2847 Live bool
2848 CanWrite bool
2849 Notice string
2850}
2851
2852type logStep struct {
2853 control.LogSection
2854 Failed bool
2855}
2856
2857// logSteps cuts a finished build's log at its steps and marks the one it
2858// failed at. Nil when no step's line is in the log — a build that
2859// stopped in the clone — which renders as one block.
2860func logSteps(log string, b control.BuildOut) ([]logStep, bool) {
2861 sections := control.SplitBuildLog(log, b.Steps)
2862 stepped := false
2863 for _, s := range sections {
2864 if s.N > 0 {
2865 stepped = true
2866 }
2867 }
2868 if !stepped {
2869 return nil, false
2870 }
2871 failed := control.FailedSection(sections, b.Status, b.FailedStep)
2872 out := make([]logStep, len(sections))
2873 for i, s := range sections {
2874 out[i] = logStep{LogSection: s, Failed: i == failed}
2875 }
2876 return out, failed >= 0
2877}
2878```
2879
2880In `build`, replace the last two lines (`v.Log, _, _ = …` and
2881`s.render(…)`) with:
2882
2883```go
2884 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
2885 v.Steps, v.Failed = logSteps(v.Log, b)
2886 if b.DurationS > 0 {
2887 v.Duration = (time.Duration(b.DurationS) * time.Second).String()
2888 }
2889 s.render(w, "build.html", v)
2890```
2891
2892Add `"time"` to the imports.
2893
2894- [ ] **Step 4: Template**
2895
2896Replace `build.html:14-17` with:
2897
2898```html
2899<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{with .Duration}} · ran {{.}}{{end}}{{end}}{{if .Failed}} · <a href="#failed">Jump to failure</a>{{end}}</p>
2900{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2901<pre class="code buildlog" tabindex="0">{{.Log}}</pre>
2902{{else if .Steps}}{{$total := len .Build.Steps}}{{range .Steps}}
2903<details class="difffold buildstep"{{if .Failed}} id="failed" open{{end}}>
2904 <summary>{{if .N}}<span>step {{.N}}/{{$total}}</span> <code>{{.Step}}</code>{{else}}<span>setup</span>{{end}}{{if .Failed}} <span class="chip check-failure">failed</span>{{end}}</summary>
2905 <pre class="code buildlog" tabindex="0">{{.Text}}</pre>
2906</details>{{end}}
2907{{else if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
2908```
2909
2910The live branch keeps its single `<pre>` directly after the marker, which
2911`streamBuild` requires (`builds.go:340`).
2912
2913- [ ] **Step 5: CSS**
2914
2915Replace `style.css:1116` with:
2916
2917```css
2918pre.buildlog { max-height: 40rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
2919details.buildstep pre.buildlog { margin: 0; border: 0; border-radius: 0; }
2920details.buildstep summary code { overflow-wrap: anywhere; }
2921```
2922
2923- [ ] **Step 6: Run the tests**
2924
2925Run: `go test ./internal/httpd ./internal/web -count=1`
2926Expected: PASS (`TestPreBlocksAreFocusable` sees the new `<pre>` with
2927`tabindex="0"`; no new template, so `TestMainWidthClass` is unchanged).
2928
2929- [ ] **Step 7: Commit**
2930
2931```bash
2932git add internal/httpd internal/web
2933git commit -S -m "web: build log folded by step, failed step open
2934
2935Ref #266"
2936```
2937
2938### Task 4.6: e2e, wiki, and the MR
2939
2940**Files:**
2941- Modify: `e2e/ci_test.go:145-149` (`TestCI`)
2942- Modify: `.gitbay/wiki/CI.org` (after the `build log --follow` paragraph), `.gitbay/wiki/Users.org:542-547`, `.gitbay/wiki/Parity.org:211-213`, `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org` (lifecycle step 5)
2943
2944- [ ] **Step 1: e2e**
2945
2946Replace `e2e/ci_test.go:145-149` with:
2947
2948```go
2949 out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN)
2950 if !strings.Contains(out, "step 1/1 failed: exit 1") {
2951 t.Fatalf("broken log:\n%s", out)
2952 }
2953 out, _, _ = inst.ssh(t, aliceKey, "", "build", "show", "alice/app", brokenN)
2954 if !strings.Contains(out, "1/1 false (exit 1)") {
2955 t.Fatalf("build show does not name the failed step:\n%s", out)
2956 }
2957 if out, _, _ = inst.ssh(t, aliceKey, "", "build", "log", "alice/app", brokenN, "--step", "failed"); strings.Contains(out, "git clone") || !strings.Contains(out, "exit 1") {
2958 t.Fatalf("build log --step failed:\n%s", out)
2959 }
2960 if _, body := inst.get(t, "/alice/app/builds/"+brokenN); !strings.Contains(body, `id="failed" open`) {
2961 t.Fatalf("build page does not open the failed step:\n%s", body)
2962 }
2963```
2964
2965Before editing, check the `broken` job's step in `TestCI`'s `ci.yml`
2966(line ~100) is `"false"`; the expected `1/1 false` follows from it.
2967
2968Run: `go test ./e2e -run 'TestCI$' -count=1`
2969Expected: PASS.
2970
2971- [ ] **Step 2: Wiki**
2972
2973CI.org, after the `build log --follow` paragraph:
2974
2975```org
2976A failed build names the step it stopped at: the log's last line reads
2977=step 3/3 failed: exit 1=, and =build show= prints =failed step= (=3/3
2978go test ./... (exit 1)=) and =duration=. =build log <owner/name> <n>
2979--step failed= prints only that step's output, =--step 2= another one
2980(=0= is the clone before the first step), and =--tail 40= the last forty
2981lines of whichever was chosen; neither combines with =--follow=. The
2982build page folds the finished log into one section per step, opens the
2983failed one and links to it from the top as "Jump to failure". Builds
2984from before this reported no step; their last section is taken as the
2985failed one.
2986```
2987
2988Users.org, after "…=build list= takes =--ref=, =--status= and =--job=
2989to narrow the listing, combinable;" sentence group, add: "=build show=
2990names a failed build's step and how long it ran, and =build log= takes
2991=--step <n>|failed= and =--tail <lines>=."
2992
2993Parity.org, after `build log follow (until it ends)`:
2994
2995```org
2996| build failed step, duration | yes | yes | no |
2997| build log one step | yes | yes | no |
2998| build log tail | yes | no | no |
2999```
3000
3001`07-CI-and-Supply-Chain.org`, lifecycle step 5, replace
3002"=runner done <id> success|failure= sets the status," with "=runner
3003done <id> success|failure [--step <n>] [--reason <text>]= records where
3004a failed build stopped, sets the status,".
3005
3006- [ ] **Step 3: Verify, commit, MR**
3007
3008Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./cmd/gitbay ./internal/control ./internal/store ./internal/httpd ./internal/web -count=1`
3009Expected: PASS.
3010
3011```bash
3012git add e2e/ci_test.go .gitbay/wiki
3013git commit -S -m "wiki: failed step, build log --step and --tail
3014
3015Closes #266"
3016git push -u origin build-failure-report
3017gitbay mr create --source build-failure-report --target main --title "builds: name the failed step and duration; jump to failure"
3018```
3019
3020Deploy `gitbayd` (schema 64→65 on the first start, or whatever the
3021number is after renumbering), then validate per runbook R4, then merge
3022with `--strategy ff` and delete the branch both places.
3023
3024---
3025
3026# Open questions
3027
30281. **pasta on bay1.** The plan relies on `--network pasta:--no-map-gw`
3029 removing the host-loopback path and on a pasta container reaching the
3030 host's public address. The code comment at `main.go:465-470` says
3031 pasta exposes the host at `169.254.1.2` as its `--map-host-loopback`
3032 default; newer podman instead passes `--map-guest-addr 169.254.1.2`,
3033 which maps to the host's public address. Which one bay1's podman
3034 does is not in the repository. Runbook R0 measures it before Part 3
3035 deploys; if `--no-map-gw` is refused or leaves `127.0.0.1` reachable,
3036 stop and revisit Part 3 before merging.
30372. **Default image and tree reuse.** Tree reuse now keys on the job's
3038 declared `image:`. A job that names none runs on the runner's
3039 `-image`, which the server does not know; bumping `-image`
3040 (`gitbay-ci:2` → `:3`) does not invalidate reuse for such jobs.
3041 Covering it would need the runner to report the image it resolved
3042 (and its digest) on `runner done`, and reuse to compare that. Not
3043 planned; confirm that the declared image is enough.
30443. **Non-22 ssh ports.** `GITBAY_SSH` is `user@host` — hutch and orgo
3045 build URLs as `ssh://$GITBAY_SSH/...` — so the claim's `ssh` carries
3046 no port. An instance with `[ssh] port` other than 22 and a loopback
3047 runner gives its builds a destination without the port. gitbay.org
3048 is on 22. Should the field carry the port (and the builds' scripts
3049 change), or is this left to such an instance's own ssh config?
30504. **Required contexts without require-checks.** Decided here as "the
3051 list applies only while require-checks is on, and the command says
3052 so". The alternative is that setting contexts turns the gate on.
3053 Confirm.
30545. **Throttling test on gitbay.org.** Under open registration the
3055 limiter never counts an unknown key's attempt (see Decisions), so
3056 the issue's throttling test on bay1 is expected to show nothing. R3
3057 runs it as the issue asks and records that; a closed-registration
3058 scratch daemon on bay1 would be needed to show the limiter itself
3059 separating the two addresses. Is the source-address measurement
3060 enough to close #260?
3061
3062---
3063
3064# Operator runbook (cmc)
3065
3066Everything here runs from the laptop against bay1. One forge write per
3067Bash call; after `make deploy` the CLI's control master is gone, so do
3068not poll with several ssh calls a tick. Operator ssh is
3069`ssh -p 2222 root@gitbay.org`.
3070
3071### R1. Scratch repository and scoped runner (once, before Part 1's runner deploy)
3072
30731. Create the scratch repository and its fork, and attach the bay1
3074 runner key to the scratch repository only:
3075
3076 ```sh
3077 gitbay repo create cmc/ci-scratch --private
3078 gitbay repo fork cmc/ci-scratch --name ci-scratch-fork
3079 ssh -p 2222 root@gitbay.org cat /var/lib/gitbay-runner/.ssh/id_ed25519.pub > /tmp/ci-runner.pub
3080 gitbay repo runner add cmc/ci-scratch < /tmp/ci-runner.pub
3081 ```
3082
30832. Scope the service to it with a second drop-in that sorts after
3084 `override.conf`. Copy the current `ExecStart` from
3085 `deploy/gitbay-runner.override.conf` and add
3086 `-repos cmc/ci-scratch`:
3087
3088 ```sh
3089 ssh -p 2222 root@gitbay.org 'cat > /etc/systemd/system/gitbay-runner.service.d/zz-scratch.conf' <<'EOF'
3090 [Service]
3091 ExecStart=
3092 ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -isolation podman -image localhost/gitbay-ci:2 -cpus 3 -memory 6g -untrusted -repos cmc/ci-scratch
3093 EOF
3094 ```
3095
3096 `make deploy-runner` reloads and restarts the unit, so the scoped
3097 `ExecStart` is live for each validation below. While it is in place
3098 builds of real repositories queue and wait.
3099
31003. After each validation: remove the drop-in and restart.
3101
3102 ```sh
3103 ssh -p 2222 root@gitbay.org 'rm /etc/systemd/system/gitbay-runner.service.d/zz-scratch.conf && systemctl daemon-reload && systemctl restart gitbay-runner'
3104 ```
3105
3106### R2. Part 1 (#255): validate, then discard the old homes
3107
31081. `make deploy` from the Part 1 branch; `curl -s https://gitbay.org/healthz`
3109 names its commit. Install the R1 drop-in, then `make deploy-runner`.
31102. On `cmc/ci-scratch` `main`, push a `.gitbay/ci.yml`:
3111
3112 ```yaml
3113 jobs:
3114 home:
3115 steps:
3116 - echo "HOME=$HOME"
3117 - test ! -e "$HOME/poison" || { echo "POISONED"; exit 1; }
3118 - touch "$HOME/trusted-marker"
3119 ```
3120
3121 The build passes; its log shows `HOME=/var/lib/gitbay-runner/work/trusted-home/cmc/ci-scratch`.
31223. In `cmc/ci-scratch-fork`, change the step list to
3123 `echo "HOME=$HOME"`, `ls -a "$HOME"`, `touch "$HOME/poison"`, push a
3124 branch and open an MR into `cmc/ci-scratch`. The untrusted build's
3125 log shows `HOME=/var/lib/gitbay-runner/work/build-<id>-home` and an
3126 empty listing (no `trusted-marker`).
31274. On bay1: `ls /var/lib/gitbay-runner/work` shows no `build-<id>-home`
3128 left behind.
31295. `gitbay build trigger cmc/ci-scratch home`: passes (no `POISONED`).
31306. Remove the R1 drop-in (R1 step 3). Merge Part 1.
31317. Discard the homes that trusted and untrusted builds shared:
3132
3133 ```sh
3134 ssh -p 2222 root@gitbay.org 'chmod -R u+w /var/lib/gitbay-runner/work/home && rm -rf /var/lib/gitbay-runner/work/home'
3135 ```
3136
3137 The laptop runner (`~/Library/Caches/gitbay-runner/home` or its
3138 configured workdir) gets the same once its brew bottle carries Part 1.
31398. Record: the release's CHANGELOG upgrade note says to deploy gitbayd
3140 before runners, and that `<workdir>/home` can be deleted after the
3141 runner upgrade. Nothing further in the wiki; Part 1's MR updated it.
3142
3143### R3. Part 3 (#260): pasta check, source addresses, throttling
3144
31450. Before merging Part 3, on bay1 as the runner user:
3146
3147 ```sh
3148 ssh -p 2222 root@gitbay.org "podman --version; pasta --version | head -1"
3149 ssh -p 2222 root@gitbay.org "su - ci-runner -s /bin/sh -c 'podman --cgroup-manager=cgroupfs run --rm --pull=never --network pasta:--no-map-gw --entrypoint sh localhost/gitbay-ci:2 -c \"getent hosts proxy.golang.org; timeout 5 bash -c \\\"exec 3<>/dev/tcp/gitbay.org/22\\\" && echo public-ok; cat /proc/net/route\"'"
3150 ```
3151
3152 Expected: `proxy.golang.org` resolves, `public-ok` prints. If podman
3153 rejects the option, stop (open question 1).
31541. `make deploy` from the Part 3 branch, the R1 drop-in, then
3155 `make deploy-runner`.
31562. On `cmc/ci-scratch` `main`, a probe job (keep the step under 4096
3157 bytes):
3158
3159 ```yaml
3160 jobs:
3161 probe:
3162 steps:
3163 - echo "GITBAY_SSH=$GITBAY_SSH"
3164 - |
3165 gw=$(awk '$2=="00000000"{print $3}' /proc/net/route | head -1)
3166 echo "gateway (hex, little-endian): $gw"
3167 for a in 127.0.0.1 169.254.1.2; do timeout 5 bash -c "exec 3<>/dev/tcp/$a/22" && echo "reach $a:22" || echo "no $a:22"; done
3168 - |
3169 ssh-keygen -q -t ed25519 -N '' -f /tmp/k
3170 for i in $(seq 1 30); do ssh -F /dev/null -i /tmp/k -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 "$GITBAY_SSH" whoami; done
3171 - bash -c 'exec 3<>/dev/tcp/${GITBAY_SSH#*@}/22; sleep 90'
3172 ```
3173
31743. While the last step holds its connection open, on bay1:
3175
3176 ```sh
3177 ssh -p 2222 root@gitbay.org "ss -tn state established '( sport = :22 )'"
3178 ```
3179
3180 Record the peer address of the build's connection and of the
3181 runner's `runner log` session (`127.0.0.1`). Expected: the build's
3182 peer is the host's public address, never `127.0.0.1`.
31834. `gitbay audit --json` (admin): look for `auth.throttled` rows since
3184 the build started. Expected: none, since registration is open (see
3185 Decisions); the runner kept claiming (`gitbay admin runners` shows a
3186 recent poll).
31875. Expected build log: `GITBAY_SSH=git@gitbay.org`, `no 127.0.0.1:22`,
3188 the `169.254.1.2` result as measured, the `whoami` loop answering
3189 from an anonymous session.
31906. Remove the R1 drop-in. Trigger one real trusted job that talks back
3191 (`gitbay build trigger krz/orgo <its release or pages job>` only if
3192 one is due; otherwise wait for the next hutch/orgo scheduled job) and
3193 check it reached `git@gitbay.org`.
31947. Record in the wiki, one commit on a branch `wiki-260-results`
3195 (`Closes #260`): the measured source addresses and the pasta/podman
3196 versions under Threat-Model "What a build can reach"; the Known-Gaps
3197 question "What can a build reach on the host's network?" answered
3198 with the date and result, and the `#260` row removed; `09-Controls`
3199 row status left `partial` (outbound is open by decision).
3200
3201### R4. Part 4 (#266): validate the failure report
3202
32031. `make deploy` from the Part 4 branch (migration 0065 runs on start),
3204 the R1 drop-in, `make deploy-runner`.
32052. On `cmc/ci-scratch` `main`:
3206
3207 ```yaml
3208 jobs:
3209 fail:
3210 steps:
3211 - echo one
3212 - echo two
3213 - echo about to fail; exit 7
3214 ```
3215
32163. Expected: the log ends `step 3/3 failed: exit 7`; `gitbay build show
3217 cmc/ci-scratch <n>` prints `failed step 3/3 echo about to fail; exit 7
3218 (exit 7)` and a `duration`; `gitbay build log cmc/ci-scratch <n> --step
3219 failed` prints `about to fail` and the failure line; the build page
3220 opens step 3 and "Jump to failure" scrolls to it; at phone width the
3221 log wraps with no sideways scroll.
32224. Remove the R1 drop-in; merge Part 4. Once all four parts are in,
3223 delete `cmc/ci-scratch` and its fork, or keep them as the standing
3224 scratch pair for the next runner change.
3225
3226---
3227
3228# Self-review
3229
3230- **Coverage.** #255: explicit trust (1.1), disposable untrusted home
3231 and trusted-only caches (1.2), the discard (R2.7), wiki (1.3). #258:
3232 `ci/` refused (2.1), reuse by trust and image (2.2), required contexts
3233 with missing as pending in `MergeGates` (2.3), wiki (2.4). #260: code
3234 (3.1, 3.2), egress policy in Threat-Model and CI (3.3), throttling
3235 test and source-address measurement in the runbook (R3), with the
3236 scratch-repository rule (R1). #266: runner names the step (4.3), stored
3237 step and duration (4.1; duration derived), `build show` (4.4),
3238 `build log --step`/`--tail` (4.4), web `<details>` per step with the
3239 failed one open, `id="failed"`, "Jump to failure", duration beside
3240 finished (4.5), `pre.buildlog` wraps (4.5).
3241- **Placeholders.** Every code step carries the code. The one reference
3242 to "the number after renumbering" is the migration rule, not a gap.
3243- **Names across tasks.** `buildHome` (1.2) is used by `run` in 1.2 and
3244 4.3. `job.Trusted` (1.2), `job.SSH` (3.2). `buildSSH(public string)`
3245 replaces `buildSSH()` in 3.2 and the call site changes there.
3246 `failure`/`exitReason`/`doneArgs` (4.3). `SetBuildFailure` (4.1) is
3247 used in 4.2 and 4.4's test fixture. `SplitBuildLog`, `FailedSection`,
3248 `LogSection` (4.4) are used by `logSteps` (4.5). `SuccessBuildForTree`
3249 gains `image` in 2.2 and its one caller changes there.
3250 `GatesOut.ChecksMissing` (2.3) is rendered in `mr.html` (2.3).
docs/plans/2026-09-27-cli-ux.md added +1849
@@ -0,0 +1,1849 @@
1# CLI UX small fixes 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 #265, #267 and #268: the dashboard's activity feed reads
6as sentences instead of raw event payloads and stops repeating assigned
7issues; CLI help and usage print the form the caller actually typed
8(`gitbay ...` or `ssh git@host ...`), with the `auth` grouping, the
9`--help` flag and command summaries fixed to match; and five smaller UX
10findings (the unregistered-key message, `issue create` flags, `mr show`
11plurals, a `repo readme` command, and a truncated mirror timestamp).
12
13**Architecture:** No schema changes and no new migrations. The dashboard
14and web feed currently keep two copies of "turn a stored event into a
15sentence" (`internal/httpd/feed.go`) and "the worst of a set of build
16statuses" (`internal/httpd/builds.go`); both move into
17`internal/control` so the CLI can call them directly (same package) and
18`internal/httpd` calls the exported forms. Help and usage already carry
19a `Ctx.Term` set from the CLI's `--term=<cols>[,color]`; `c.program()`
20already picks `"gitbay"` or `"ssh git@<host>"` from it for the `--help`
21path, but `c.usage()`/`c.usageWith()` (the wrong-argument path) do not
22yet call it, and the CLI's `auth` grouping is not a registry path at
23all, so its `--help` falls back to cobra's own listing.
24
25**Tech stack:** Go, `golang.org/x/crypto/ssh`, cobra.
26
27**Spec:** none — these are small, independently-scoped fixes; this plan
28is its own spec.
29
30## Global constraints
31
32- Three MRs, each on its own branch off `main`: `cli-ux-activity`
33 (#265), `cli-ux-help` (#267), `cli-ux-fixes` (#268). No MR depends on
34 either of the others; land in any order.
35- Commits are signed (the repository refuses unsigned ones); messages
36 reference the issue they touch (`Ref #N`), and the last commit that
37 finishes an issue says `Closes #N`. No attribution to any assistant,
38 model or AI anywhere: commits, MR bodies, comments.
39- MR: `gitbay mr create --source <branch> --target main --title "..."`;
40 merge with `gitbay mr merge <n> --strategy ff` once CI is green
41 (this repository requires signed commits, so `squash`/`merge` are
42 refused), then delete the branch locally and on the remote. If the
43 merge reports the branch is behind, rebase onto `main`, force-push,
44 merge again.
45- Locally: `go build ./...`, `go vet ./...`, and the unit tests of every
46 touched package. Run at most the one e2e test being written per task
47 (`go test ./e2e -run TestName -count=1`); CI on bay1 runs the full
48 suite.
49- No new migrations; none of #265/#267/#268 touch the schema. The plan
50 numbers 0078–0079 pre-assigned to "plan 6" go unused.
51- Registries that fail CI when a new thing lacks its row: a `ReadOnly`
52 command needs an entry in `readArgs` in `e2e/readonly_test.go`; a new
53 control command needs a `pass()` entry in `cmd/gitbay/main.go`
54 (`cmd/gitbay/summaries_test.go`'s coverage and `summaries_gen.go`
55 currency checks); a command reading stdin needs `ReadsStdin: true`.
56- `--json` output: field shapes are unchanged throughout this plan.
57 Where a task changes plain-text wording it says so; JSON error
58 strings for usage refusals do change in Part 2 (Task 2.1), which is
59 called out there specifically since no other task touches JSON text.
60- The `.gitbay/wiki/Parity.org` page is updated in the same commit that
61 changes the row it describes (Task 3.4).
62- Writing style: plain, direct, no hype; code comments match the
63 surrounding density; no before/after narration in comments or docs.
64
65## Order and dependencies
66
671. **`cli-ux-activity`** — closes #265. Independent.
682. **`cli-ux-help`** — closes #267. Independent.
693. **`cli-ux-fixes`** — closes #268. Independent.
70
71None of these three depend on any of the other five plans running in
72parallel (credentials-and-sessions, ci-trust-and-build-reporting,
73server-hardening, data-at-rest-and-backup, web-ux); nothing here touches
74authentication, secrets, CI, backups or the pages those plans change.
75
76---
77
78# Part 1: dashboard activity, no duplicates, one empty-state wording (branch `cli-ux-activity`, closes #265)
79
80### Task 1.1: move the feed-line sentence renderer into `internal/control`
81
82The web renders "recent activity" as a sentence (`cmc opened issue #12`)
83via `internal/httpd/feed.go`'s unexported `feedLine`/`feedLines`, which
84the CLI cannot reach — `internal/httpd` imports `internal/control`, not
85the other way around. Move the renderer into `internal/control` so both
86sides call the same code; `internal/httpd` becomes a thin caller of the
87exported form.
88
89**Files:**
90- Create: `internal/control/feedline.go` (from `internal/httpd/feed.go`)
91- Create: `internal/control/feedline_test.go` (from `internal/httpd/feed_test.go`)
92- Modify: `internal/httpd/builds.go:150-183` (`worstStatus`, `runStatusPriority` move out; `combinedStatus` calls the moved form)
93- Modify: `internal/httpd/web.go:219`, `:470`, `:493`, `:511` (`feedLine`/`feedLines` → `control.FeedLine`/`control.FeedLines`)
94- Modify: `internal/httpd/ownerpage_test.go:58` (`feedLine{...}` → `control.FeedLine{...}`)
95- Delete: `internal/httpd/feed.go`, `internal/httpd/feed_test.go`
96
97**Interfaces:**
98- Produces: `type FeedLine struct{ Actor, Verb, Ref, Repo, URL string; When string; WhenT time.Time; State string; Jobs []string; sha string }` (exported type, one unexported field kept for the fold logic — same package as its only user); `func FeedLines(events []store.FeedEvent) []FeedLine`; `func WorstStatus(statuses []string) string`.
99- Consumes (Task 1.2, 1.3): the same `FeedLines`/`FeedLine`.
100
101- [ ] **Step 1: Run the existing web feed tests to see the baseline pass**
102
103Run: `go test ./internal/httpd -run TestFeedLines -count=1`
104Expected: PASS (nothing changed yet).
105
106- [ ] **Step 2: Move the renderer**
107
108`git mv internal/httpd/feed.go internal/control/feedline.go` and
109`git mv internal/httpd/feed_test.go internal/control/feedline_test.go`.
110In `internal/control/feedline.go`, change `package httpd` to
111`package control`, capitalize the moved identifiers, and drop the now-
112unused `"gitbay.org/gitbay/internal/store"` import path prefix
113adjustments are unnecessary (the import path is the same from either
114package). Concretely:
115
116```go
117package control
118
119import (
120 "encoding/json"
121 "fmt"
122 "slices"
123 "strings"
124 "time"
125
126 "gitbay.org/gitbay/internal/store"
127)
128
129// FeedLine is one activity entry, already phrased and linked.
130type FeedLine struct {
131 Actor string
132 Verb string // "opened issue", "merged", "ran 2 jobs on"
133 Ref string // "#12", "!35", "v0.4.0", a short sha
134 Repo string
135 URL string
136 When string // the stored timestamp, for anything still reading it raw
137 WhenT time.Time // parsed from When, for ago/whenT rendering
138 State string // a build run's combined status; empty for anything else
139 Jobs []string // job names folded into a build run
140 sha string // the commit a build event fired on, for fold-matching
141}
142```
143
144Keep the rest of the function bodies (`FeedLines`, `issueVerb`, `mrVerb`,
145`parseEventTime`) unchanged apart from `feedLines` → `FeedLines` and
146`feedLine{` → `FeedLine{`; `issueVerb`/`mrVerb`/`parseEventTime` stay
147unexported (nothing outside the package calls them directly). In
148`internal/control/feedline_test.go`, change `package httpd` to
149`package control` and `feedLines(` → `FeedLines(` throughout (ten call
150sites, all named `feedLines(events)`).
151
152- [ ] **Step 3: Move `worstStatus`**
153
154In `internal/httpd/builds.go`, cut `runStatusPriority` and `worstStatus`
155(the two declarations at lines 150–183) and paste them into
156`internal/control/feedline.go`, renaming `worstStatus` to `WorstStatus`
157and updating its one internal call site in `FeedLines`
158(`out[i].State = worstStatus(statuses[i])` → `WorstStatus(...)`). In
159`internal/httpd/builds.go`, `combinedStatus` becomes:
160
161```go
162func combinedStatus(builds []control.BuildOut) string {
163 statuses := make([]string, len(builds))
164 for i, b := range builds {
165 statuses[i] = b.Status
166 }
167 return control.WorstStatus(statuses)
168}
169```
170
171- [ ] **Step 4: Update `internal/httpd/web.go`'s call sites**
172
173Line 219 (`dashboard`'s anonymous struct): `Feed []control.FeedLine`.
174Line 470: `func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine`,
175its final `return feedLines(events)` becomes `return control.FeedLines(events)`.
176Line 493 inside `dashboard`: `feedLines(events)` → `control.FeedLines(events)`.
177Line 511 (`ownerPage.Log`): `Log []control.FeedLine`.
178
179- [ ] **Step 5: Update `internal/httpd/ownerpage_test.go:58`**
180
181```go
182d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
183```
184
185- [ ] **Step 6: Build and test both packages**
186
187Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd -count=1`
188Expected: PASS. A compile error naming `feedLine`/`feedLines`/`worstStatus`
189means a call site in `internal/httpd` was missed — `grep -rn
190"feedLine\|worstStatus" internal/httpd/*.go` should come back empty
191except inside comments.
192
193- [ ] **Step 7: Commit**
194
195```bash
196git add internal/control/feedline.go internal/control/feedline_test.go internal/httpd/feed.go internal/httpd/feed_test.go internal/httpd/builds.go internal/httpd/web.go internal/httpd/ownerpage_test.go
197git commit -m "control: move the feed-line sentence renderer from httpd, so the CLI can share it" -m "Ref #265"
198```
199
200### Task 1.2: a labelled event's sentence names the labels
201
202`issue.labeled`/`mr.labeled` events currently fall through `issueVerb`/
203`mrVerb`'s default case (`"issue " + s"`, i.e. "issue labeled"), naming
204neither what changed nor which labels — on the web today, not only in
205the CLI this plan is fixing. Give both label events their own verb and
206carry the label list alongside the ref.
207
208**Files:**
209- Modify: `internal/control/feedline.go` (`FeedLine`, `FeedLines`, `issueVerb`, `mrVerb`)
210- Modify: `internal/control/feedline_test.go`
211- Modify: `internal/web/templates/dashboard.html:49`, `internal/web/templates/owner.html:39`
212
213**Interfaces:**
214- Produces: `FeedLine.Extra string` — trailing detail rendered after the ref; empty for every event kind but a labelled one.
215
216- [ ] **Step 1: Write the failing test**
217
218```go
219func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) {
220 events := []store.FeedEvent{
221 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled",
222 Data: `{"number":262,"labels":["ops","security"]}`},
223 }
224 lines := FeedLines(events)
225 if len(lines) != 1 {
226 t.Fatalf("FeedLines returned %d lines, want 1", len(lines))
227 }
228 l := lines[0]
229 if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" {
230 t.Errorf("got %+v", l)
231 }
232}
233
234func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) {
235 events := []store.FeedEvent{
236 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled",
237 Data: `{"number":471,"labels":["review"]}`},
238 }
239 lines := FeedLines(events)
240 if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" {
241 t.Errorf("got %+v", lines)
242 }
243}
244```
245
246- [ ] **Step 2: Run and see them fail**
247
248Run: `go test ./internal/control -run TestFeedLinesNamesTheLabels -count=1`
249Expected: FAIL (`Verb = "issue labeled"`/`"merge request labeled"`, `Extra` unset).
250
251- [ ] **Step 3: Implement**
252
253Add a field to the local decode struct and the `FeedLine` type, then set
254`Extra` for the two label kinds. In `FeedLines`, the local `d` struct
255gains `Labels []string`:
256
257```go
258var d struct {
259 Number int64 `json:"number"`
260 Job string `json:"job"`
261 Tag string `json:"tag"`
262 SHA string `json:"sha"`
263 Labels []string `json:"labels"`
264}
265```
266
267`FeedLine` gains, after `Jobs`:
268
269```go
270 // Extra is trailing detail shown after the ref: the label list on a
271 // labelled event, empty for everything else.
272 Extra string
273```
274
275In the `case "issue":`/`case "mr":` arms, after setting `l.Verb, l.Ref`:
276
277```go
278 case "issue":
279 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
280 l.URL = fmt.Sprintf("/%s/issues/%d", e.RepoPath, d.Number)
281 if rest == "labeled" {
282 l.Extra = strings.Join(d.Labels, ", ")
283 }
284 case "mr":
285 l.Verb, l.Ref = mrVerb(rest), fmt.Sprintf("!%d", d.Number)
286 l.URL = fmt.Sprintf("/%s/mrs/%d", e.RepoPath, d.Number)
287 if rest == "labeled" {
288 l.Extra = strings.Join(d.Labels, ", ")
289 }
290```
291
292`issueVerb` and `mrVerb` each gain a case:
293
294```go
295 case "labeled":
296 return "labelled"
297```
298
299- [ ] **Step 4: Run**
300
301Run: `go test ./internal/control -count=1`
302Expected: PASS.
303
304- [ ] **Step 5: Templates carry `Extra`**
305
306`internal/web/templates/dashboard.html:49` and
307`internal/web/templates/owner.html:39` both gain `{{if .Extra}} {{.Extra}}{{end}}`
308right after the closing `</a>` of the ref link, before the `<br>`:
309
310```
311{{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a>{{if .Extra}} {{.Extra}}{{end}}<br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
312```
313
314- [ ] **Step 6: Build**
315
316Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1`
317Expected: PASS.
318
319- [ ] **Step 7: Commit**
320
321```bash
322git add internal/control/feedline.go internal/control/feedline_test.go internal/web/templates/dashboard.html internal/web/templates/owner.html
323git commit -m "feed: a labelled event names the labels" -m "Ref #265"
324```
325
326### Task 1.3: dashboard and feed render activity as sentences, not raw payloads
327
328`gitbay dashboard`'s "recent activity" table and `gitbay feed` both
329print the event's kind and its raw JSON payload
330(`2026-09-28T02:36:51Z cmc issue.labeled krz/gitbay
331{"number":262,"labels":["ops","security"]}`). Render the same sentence
332the web shows instead; the payload stays available under `--json`
333(`DashboardOut.Activity`/`FeedOut.Data` are untouched).
334
335**Files:**
336- Modify: `internal/control/dashboard.go` (`runDashboard`'s `activityRows`, `runFeed`'s plain formatter)
337- Test: `internal/control/dashboard_test.go`
338
339**Interfaces:**
340- Consumes: `FeedLines`, `FeedLine` (Task 1.1/1.2).
341
342- [ ] **Step 1: Write the failing test**
343
344```go
345func TestDashboardActivityIsASentence(t *testing.T) {
346 c := notifTestCtx(t, "cmc")
347 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
348 if err != nil {
349 t.Fatal(err)
350 }
351 repo, err := c.Store.RepoByID(repoID)
352 if err != nil {
353 t.Fatal(err)
354 }
355 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.labeled", `{"number":262,"labels":["ops","security"]}`)
356
357 var out bytes.Buffer
358 c.Stdout, c.Stderr = &out, &out
359 if code := runDashboard(c, nil); code != 0 {
360 t.Fatalf("exit %d: %s", code, out.String())
361 }
362 if strings.Contains(out.String(), `{"number"`) {
363 t.Errorf("raw payload leaked into plain output:\n%s", out.String())
364 }
365 if !strings.Contains(out.String(), "cmc labelled krz/gitbay#262 ops, security") {
366 t.Errorf("no sentence in output:\n%s", out.String())
367 }
368}
369```
370
371- [ ] **Step 2: Run and see it fail**
372
373Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
374Expected: FAIL (output has `KIND`/`DATA` columns and the raw JSON).
375
376- [ ] **Step 3: Implement in `runDashboard`**
377
378Replace the `activityRows`/`section("recent activity:", ...)` block:
379
380```go
381 lines := FeedLines(events)
382 activityRows := make([][]cell, len(lines))
383 for i, l := range lines {
384 sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
385 if l.Extra != "" {
386 sentence += " " + l.Extra
387 }
388 activityRows[i] = []cell{cAge(l.When), cFlex(sentence)}
389 }
390 section("recent activity:", []string{"WHEN", "EVENT"}, activityRows)
391```
392
393`events` is already in scope (it is what `d.Activity = feedOutputs(events)`
394was built from, a few lines above); nothing else in `runDashboard` reads
395it again, so no variable needs renaming.
396
397- [ ] **Step 4: Run**
398
399Run: `go test ./internal/control -run TestDashboardActivityIsASentence -count=1`
400Expected: PASS.
401
402- [ ] **Step 5: Same fix in `runFeed`, its own failing test first**
403
404```go
405func TestFeedIsASentence(t *testing.T) {
406 c := notifTestCtx(t, "cmc")
407 repoID, err := c.Store.CreateRepo("user", c.User.ID, "gitbay", "public")
408 if err != nil {
409 t.Fatal(err)
410 }
411 repo, err := c.Store.RepoByID(repoID)
412 if err != nil {
413 t.Fatal(err)
414 }
415 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", `{"number":1}`)
416
417 var out bytes.Buffer
418 c.Stdout, c.Stderr = &out, &out
419 if code := runFeed(c, nil); code != 0 {
420 t.Fatalf("exit %d: %s", code, out.String())
421 }
422 if !strings.Contains(out.String(), "cmc opened issue krz/gitbay#1") {
423 t.Errorf("no sentence in output:\n%s", out.String())
424 }
425}
426```
427
428Run: `go test ./internal/control -run TestFeedIsASentence -count=1`
429Expected: FAIL.
430
431Implement: `runFeed`'s plain closure changes from the five-column
432`WHEN`/`ACTOR`/`KIND`/`REPO`/`DATA` table to the same two-column shape,
433built from `FeedLines(events)` (the same `events` slice `runFeed`
434already queried, before `feedOutputs(events)` is called for `ds`):
435
436```go
437 lines := FeedLines(events)
438 return c.emitPage(p, ds, next, func(w io.Writer) {
439 tb := c.table(w, "WHEN", "EVENT")
440 for _, l := range lines {
441 sentence := fmt.Sprintf("%s %s %s%s", l.Actor, l.Verb, l.Repo, l.Ref)
442 if l.Extra != "" {
443 sentence += " " + l.Extra
444 }
445 tb.row(cAge(l.When), cFlex(sentence))
446 }
447 tb.flush()
448 })
449```
450
451`ds` (the `FeedOut` slice) stays exactly as it was: it is what `--json`
452still emits, and `emitPage`'s cursor logic pages `ds`, not `lines` — the
453two slices are always the same length and order since both come from
454the same `events`.
455
456- [ ] **Step 6: Run**
457
458Run: `go test ./internal/control -count=1`
459Expected: PASS. A failure elsewhere in the package on a "recent
460activity"/"WHEN\tACTOR\tKIND" assertion means an existing test asserted
461the old five-column shape; update its expectation to the new sentence
462(the test name will say `TestDashboard...` or `TestFeed...`).
463
464- [ ] **Step 7: Commit**
465
466```bash
467git add internal/control/dashboard.go internal/control/dashboard_test.go
468git commit -m "dashboard, feed: render activity as the web's sentence, not the raw payload" -m "Ref #265"
469```
470
471### Task 1.4: an issue assigned to you no longer repeats in "open issues"
472
473`dashboardIssuesQuery` (open issues you are involved in) and
474`assignedIssuesQuery` (open issues assigned to you) overlap whenever an
475assigned issue also sits in a repository you can otherwise reach — the
476common case — so the same issue prints under both "assigned to you:"
477and "open issues:" on the CLI, and under both lists on the web
478dashboard, which calls the same two store methods
479(`internal/httpd/web.go:206-209`). Exclude assigned issues from the
480"open issues" query; the fix is in the store, so both surfaces get it
481at once.
482
483**Files:**
484- Modify: `internal/store/dashboard.go` (`dashboardIssuesQuery`)
485- Test: `internal/store/dashboard_test.go` (create)
486
487**Interfaces:**
488- Consumes: nothing new.
489- Produces: nothing new (`DashboardIssues` keeps its signature).
490
491- [ ] **Step 1: Write the failing test**
492
493```go
494package store
495
496import "testing"
497
498// An issue assigned to the user is not repeated under DashboardIssues:
499// AssignedIssues already covers it, and a repository the user can
500// otherwise reach (here, one they own) is the common case where the two
501// queries used to overlap (#265).
502func TestDashboardIssuesExcludesAssignedIssues(t *testing.T) {
503 s := open(t)
504 if err := s.MigrateUp(); err != nil {
505 t.Fatal(err)
506 }
507 uid, err := s.CreateUser("cmc", false)
508 if err != nil {
509 t.Fatal(err)
510 }
511 repoID, err := s.CreateRepo("user", uid, "gitbay", "public")
512 if err != nil {
513 t.Fatal(err)
514 }
515 repo, err := s.RepoByID(repoID)
516 if err != nil {
517 t.Fatal(err)
518 }
519 assignedNum, err := s.CreateIssue(repo.ID, uid, "assigned to me", "", "markdown")
520 if err != nil {
521 t.Fatal(err)
522 }
523 if _, err := s.CreateIssue(repo.ID, uid, "not assigned", "", "markdown"); err != nil {
524 t.Fatal(err)
525 }
526 assigned, err := s.IssueByNumber(repo.ID, assignedNum)
527 if err != nil {
528 t.Fatal(err)
529 }
530 if err := s.SetIssueAssignee(assigned.ID, uid, true); err != nil {
531 t.Fatal(err)
532 }
533
534 issues, err := s.DashboardIssues(uid)
535 if err != nil {
536 t.Fatal(err)
537 }
538 if len(issues) != 1 || issues[0].Title != "not assigned" {
539 t.Fatalf("DashboardIssues = %+v, want only the unassigned issue", issues)
540 }
541 assignedList, err := s.AssignedIssues(uid)
542 if err != nil {
543 t.Fatal(err)
544 }
545 if len(assignedList) != 1 || assignedList[0].Title != "assigned to me" {
546 t.Fatalf("AssignedIssues = %+v, want the assigned issue", assignedList)
547 }
548}
549```
550
551- [ ] **Step 2: Run and see it fail**
552
553Run: `go test ./internal/store -run TestDashboardIssuesExcludesAssignedIssues -count=1`
554Expected: FAIL (`DashboardIssues` returns both issues).
555
556- [ ] **Step 3: Implement**
557
558`dashboardIssuesQuery` in `internal/store/dashboard.go` gains one
559`NOT EXISTS` clause:
560
561```go
562const dashboardIssuesQuery = `
563 SELECT COALESCE(u.username, o.name) || '/' || r.name,
564 x.number, x.title, au.username, x.state, x.updated_at
565 FROM issues x
566 JOIN repos r ON r.id = x.repo_id
567 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
568 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
569 JOIN users au ON au.id = x.author_id
570 WHERE x.state = 'open' AND ` + involvedCond + `
571 AND NOT EXISTS (SELECT 1 FROM issue_assignees ia
572 WHERE ia.issue_id = x.id AND ia.user_id = ?1)
573 ORDER BY x.updated_at DESC LIMIT 50`
574```
575
576- [ ] **Step 4: Run the new test, then the package and the query-plan guard**
577
578Run: `go test ./internal/store -count=1`
579Expected: PASS, `TestDashboardQueriesUseIndexes`'s `DashboardIssues` case
580included — a correlated `NOT EXISTS` does not change which index drives
581the `ORDER BY`, so the plan should still show `issues_recent` with no
582`USE TEMP B-TREE FOR ORDER BY`. If it does regress, the `NOT EXISTS`
583subquery needs `issue_assignees`'s existing `(issue_id, user_id)` index
584(check `migrations/` for its name) rather than a new one — this task
585does not add a migration.
586
587- [ ] **Step 5: Run the CLI package too**
588
589Run: `go test ./internal/control -count=1`
590Expected: PASS. `TestDashboardEmptySectionsSayNone` and any other
591dashboard test that seeded an assigned issue and expected it under
592"open issues" needs its expectation updated to match the new,
593non-overlapping behavior.
594
595- [ ] **Step 6: Commit**
596
597```bash
598git add internal/store/dashboard.go internal/store/dashboard_test.go
599git commit -m "dashboard: an assigned issue no longer repeats under open issues" -m "Ref #265"
600```
601
602### Task 1.5: `notifications list`'s empty state names `--all`
603
604An inbox with only read notifications prints the generic `nothing to
605list` on stderr when `notifications list` is run without `--all`,
606without saying unread items are what it shows by default.
607
608**Files:**
609- Modify: `internal/control/notifications.go` (`runNotificationsList`)
610- Test: `internal/control/notifications_test.go`
611
612- [ ] **Step 1: Write the failing test**
613
614```go
615func TestNotificationsListEmptyUnreadSaysHowToSeeRead(t *testing.T) {
616 c, repo, bob := testRepoWithWatcher(t)
617 // Give bob one notice, then mark it read, so his inbox has rows but
618 // no unread ones.
619 c.User = store.User{ID: bob, Username: "bob"}
620 notify(c, []int64{bob}, notice{repo: repo, kind: "issue", subject: "s", action: "a", path: "x"})
621 if code := runNotificationsRead(c, []string{"--all"}); code != protocol.ExitOK {
622 t.Fatalf("mark read: exit %d", code)
623 }
624 var out, errOut bytes.Buffer
625 c.Stdout, c.Stderr = &out, &errOut
626 if code := runNotificationsList(c, nil); code != protocol.ExitOK {
627 t.Fatalf("exit %d: %s", code, errOut.String())
628 }
629 if got := errOut.String(); got != "no unread notifications (--all for read ones)\n" {
630 t.Errorf("stderr = %q", got)
631 }
632 // --all sees it and stays the generic message when that too is empty.
633 out.Reset()
634 errOut.Reset()
635 if code := runNotificationsList(c, []string{"--all"}); code != protocol.ExitOK {
636 t.Fatalf("exit %d: %s", code, errOut.String())
637 }
638 if !strings.Contains(out.String(), "s") {
639 t.Errorf("--all did not show the read notice: %q", out.String())
640 }
641}
642```
643
644(This test needs `notify` and `notice` — the same helpers
645`testRepoWithWatcher`'s package already exercises in
646`notifications_test.go`'s other tests; if their exact names differ,
647`grep -n "^func notify\b\|^type notice\b" internal/control/*.go` and use
648what is actually there.)
649
650- [ ] **Step 2: Run and see it fail**
651
652Run: `go test ./internal/control -run TestNotificationsListEmptyUnreadSaysHowToSeeRead -count=1`
653Expected: FAIL (stderr is `nothing to list`).
654
655- [ ] **Step 3: Implement**
656
657In `runNotificationsList`, after `ds` is built and before the `return
658c.emitPage(...)`:
659
660```go
661 if !c.JSON && !p.active && len(ds) == 0 {
662 msg := "nothing to list"
663 if !all {
664 msg = "no unread notifications (--all for read ones)"
665 }
666 fmt.Fprintln(c.Stderr, msg)
667 return protocol.ExitOK
668 }
669 return c.emitPage(p, ds, next, func(w io.Writer) {
670```
671
672This runs before pagination wraps the result (`p.active`, from
673`--limit`/`--cursor`) and before JSON, both of which already have their
674own well-defined empty shape (`{"items":[],...}` or a bare `[]`) that
675this task leaves alone.
676
677- [ ] **Step 4: Run**
678
679Run: `go test ./internal/control -run TestNotifications -count=1`
680Expected: PASS.
681
682- [ ] **Step 5: Run the package, commit, open the MR**
683
684Run: `go test ./internal/control ./internal/store ./internal/httpd -count=1`
685Expected: PASS.
686
687```bash
688git add internal/control/notifications.go internal/control/notifications_test.go
689git commit -m "notifications list: name --all when the empty inbox is just read items" -m "Closes #265"
690git push -u origin cli-ux-activity
691gitbay mr create --source cli-ux-activity --target main --title "dashboard activity as sentences, no duplicate issues"
692```
693
694Wait for CI, merge with `--strategy ff`, delete the branch both places.
695
696---
697
698# Part 2: help and usage print the form the caller typed (branch `cli-ux-help`, closes #267)
699
700### Task 2.1: `c.usage()`/`c.usageWith()` print the program form
701
702`c.usage()` prints the bare registered usage (`usage: keys remove
703<fingerprint>`), with neither the `gitbay` nor the `ssh git@host`
704prefix `c.program()`/`helpVerb` already use for `--help`. Give both the
705same prefix, and mark a leading `<owner/name>` optional when the caller
706is the CLI at a terminal — the CLI fills it in from the clone's origin
707remote (`cmd/gitbay/ssh.go`'s `withRepo`); stock ssh never does.
708
709**Files:**
710- Modify: `internal/control/help.go` (`cliUsage`, new)
711- Modify: `internal/control/control.go` (`usage`, `usageWith`)
712- Modify: `internal/control/control_test.go` (`TestArgumentRefusalsNameTheUsage`)
713- Test: `internal/control/help_test.go`
714
715**Interfaces:**
716- Produces: `func cliUsage(usage string) string` — marks the first
717 `<owner/name>` optional; `func (c *Ctx) cmdUsage() string` — the
718 program-prefixed, owner/name-optional-at-a-terminal usage line.
719
720- [ ] **Step 1: Write the failing test**
721
722```go
723func TestCmdUsagePrefixesTheProgram(t *testing.T) {
724 c := &Ctx{Cmd: Command{Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
725 if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
726 t.Errorf("ssh form: %q", got)
727 }
728 c.Term = Term{Cols: 100}
729 if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
730 t.Errorf("cli form: %q", got)
731 }
732
733 c2 := &Ctx{Cmd: Command{Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
734 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
735 t.Errorf("optional owner/name: %q", got)
736 }
737}
738```
739
740- [ ] **Step 2: Run and see it fail**
741
742Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
743Expected: FAIL to compile (`c.cmdUsage undefined`).
744
745- [ ] **Step 3: Implement `cliUsage` and `cmdUsage` in `internal/control/help.go`**
746
747```go
748// cliUsage marks a leading <owner/name> optional in a CLI-rendered usage
749// line: the CLI infers it inside a clone (cmd/gitbay/ssh.go's withRepo),
750// stock ssh never does. Only the first occurrence is marked — a usage
751// line never repeats the placeholder.
752func cliUsage(usage string) string {
753 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
754}
755
756// cmdUsage is the registered usage as this call should see it: the
757// gitbay form with <owner/name> optional at a terminal, the ssh form
758// otherwise. Every usage message — the --help path and a wrong-argument
759// refusal alike — goes through this, so a caller never sees the bare
760// registered path with no program in front of it.
761func (c *Ctx) cmdUsage() string {
762 shape := c.Cmd.Usage
763 if c.Term.Cols > 0 {
764 shape = cliUsage(shape)
765 }
766 return c.program() + " " + shape
767}
768```
769
770- [ ] **Step 4: Run**
771
772Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
773Expected: PASS.
774
775- [ ] **Step 5: Route `usage`/`usageWith` through it**
776
777In `internal/control/control.go`:
778
779```go
780// usage reports a bad invocation with the command's registered usage,
781// the one source of it.
782func (c *Ctx) usage() int {
783 return c.fail(protocol.ExitUsage, "usage: %s", c.cmdUsage())
784}
785
786// usageWith reports a specific problem with the arguments, then the
787// registered usage, so a person always sees the shape that was expected.
788func (c *Ctx) usageWith(msg string) int {
789 return c.fail(protocol.ExitUsage, "%s\nusage: %s", msg, c.cmdUsage())
790}
791```
792
793Also use it in `helpVerb` (`internal/control/help.go`), which today
794recomputes the same "cut at ` [--`" shape independently:
795
796```go
797 shape := cmd.Usage
798 if i := strings.Index(shape, " [--"); i >= 0 {
799 shape = shape[:i] + " [flags]"
800 }
801 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
802```
803
804stays as its own thing (it additionally collapses everything from the
805first optional flag into `[flags]`, which `cmdUsage` does not do), but
806its `c.program()` + owner/name handling should not fork from
807`cmdUsage`'s: replace the `c.program()` call with `cliUsage` applied the
808same way:
809
810```go
811 shape := cmd.Usage
812 if c.Term.Cols > 0 {
813 shape = cliUsage(shape)
814 }
815 if i := strings.Index(shape, " [--"); i >= 0 {
816 shape = shape[:i] + " [flags]"
817 }
818 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
819```
820
821- [ ] **Step 6: Update the test this changes**
822
823`TestArgumentRefusalsNameTheUsage` in `internal/control/control_test.go`
824asserts `errOut.String()` contains the bare `"usage: " +
825strings.Join(argv, " ")`; with no `Cfg.Server.SiteURL` and no `Term` set
826on its `Ctx`, the message now reads `usage: ssh git@ build show` (an
827empty host — `hostOf("")` returns `""`). Set a `SiteURL` on the test's
828`Ctx` and assert the ssh-prefixed form:
829
830```go
831func TestArgumentRefusalsNameTheUsage(t *testing.T) {
832 for _, argv := range [][]string{{"build", "show"}, {"release", "show"}, {"mr", "resolve"}, {"issue", "show"}} {
833 var out, errOut bytes.Buffer
834 c := &Ctx{Scope: "full", Stdout: &out, Stderr: &errOut,
835 Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
836 if code := Dispatch(c, argv); code != protocol.ExitUsage {
837 t.Errorf("%v: exit %d, want %d (%s)", argv, code, protocol.ExitUsage, errOut.String())
838 continue
839 }
840 want := "usage: ssh git@forge.test " + strings.Join(argv, " ")
841 if !strings.Contains(errOut.String(), want) {
842 t.Errorf("%v: no usage line: got %q, want to contain %q", argv, errOut.String(), want)
843 }
844 }
845}
846```
847
848(Add `"gitbay.org/gitbay/internal/config"` to the file's imports if it
849is not already there.)
850
851- [ ] **Step 7: Run the package**
852
853Run: `go test ./internal/control -count=1`
854Expected: PASS. Any other test asserting a bare `"usage: <path>..."` with
855no program prefix needs the same treatment — `grep -rn '"usage: '
856internal/control/*_test.go` finds them all.
857
858- [ ] **Step 8: Commit**
859
860```bash
861git add internal/control/help.go internal/control/control.go internal/control/control_test.go internal/control/help_test.go
862git commit -m "usage: print the program form (gitbay or ssh git@host), owner/name optional at a terminal" -m "Ref #267"
863```
864
865### Task 2.2: `--help` check in `keys add` and `pgp add`
866
867Every other passthrough command checks for `--help`/`-h` in `pass()`
868before reading stdin; `keysAdd.RunE` and `pgpAdd.RunE` in
869`cmd/gitbay/main.go`'s `authCmd()` were given their own `RunE` (to wire
870stdin directly) and lost that check, so `gitbay auth keys add --help`
871tries to read a public key from stdin instead of showing help, and
872blocks or fails depending on what stdin happens to be.
873
874**Files:**
875- Modify: `cmd/gitbay/main.go` (`authCmd`'s `keysAdd.RunE`, `pgpAdd.RunE`)
876- Test: `cmd/gitbay/main_test.go`
877
878- [ ] **Step 1: Write the failing test**
879
880```go
881func TestKeysAddAndPGPAddCheckHelpBeforeStdin(t *testing.T) {
882 for _, args := range [][]string{{"auth", "keys", "add", "--help"}, {"auth", "pgp", "add", "--help"}} {
883 root := newRoot()
884 root.SetArgs(args)
885 root.SetIn(strings.NewReader("")) // would block/fail if read as the key body
886 if err := root.Execute(); err != nil {
887 t.Errorf("%v: %v", args, err)
888 }
889 }
890}
891```
892
893(`cmd/gitbay` runs its `RunE` through `os.Exit`, so this test only
894proves the command does not attempt to read stdin as a key before
895exiting — check with `go test ./cmd/gitbay -run
896TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -v` that it does not
897hang; if the harness needs the process not to call `os.Exit` at all,
898grep `main_test.go` for how existing `--help` tests in this package
899already handle that and follow the same pattern rather than inventing a
900new one.)
901
902- [ ] **Step 2: Run and see it fail (or hang)**
903
904Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
905Expected: FAIL or timeout (stdin read attempted).
906
907- [ ] **Step 3: Implement**
908
909`keysAdd.RunE` and `pgpAdd.RunE` in `cmd/gitbay/main.go` each gain the
910same loop `pass()` already has, before resolving the target:
911
912```go
913 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
914 for _, a := range args {
915 if a == "--help" || a == "-h" {
916 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}))
917 }
918 }
919 t, err := resolveTarget()
920 ...
921```
922
923and, for `pgpAdd`:
924
925```go
926 RunE: func(cmd *cobra.Command, args []string) error {
927 for _, a := range args {
928 if a == "--help" || a == "-h" {
929 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}))
930 }
931 }
932 t, err := resolveTarget()
933 ...
934```
935
936- [ ] **Step 4: Run**
937
938Run: `go test ./cmd/gitbay -run TestKeysAddAndPGPAddCheckHelpBeforeStdin -count=1 -timeout 5s`
939Expected: PASS.
940
941- [ ] **Step 5: Build and run the package**
942
943Run: `go build ./... && go test ./cmd/gitbay -count=1`
944Expected: PASS.
945
946- [ ] **Step 6: Commit**
947
948```bash
949git add cmd/gitbay/main.go cmd/gitbay/main_test.go
950git commit -m "auth keys add, pgp add: check --help before reading stdin" -m "Ref #267"
951```
952
953### Task 2.3: `auth --help` renders with the registry layout
954
955`auth` is a CLI-only grouping — no registry command's path starts with
956`auth`, so `gitbay auth --help` asks the server for help on prefix
957`"auth"`, gets `ExitNotFound`, and `cmd/gitbay/main.go`'s `group()`
958falls back to cobra's own subcommand listing, which carries no flags or
959examples (the reason `group()` exists at all, per its own comment).
960Give the registry an alias table for CLI-only groupings so `auth`
961renders the same READ/WRITE, aligned-summary layout every real noun
962gets.
963
964**Files:**
965- Modify: `internal/control/help.go` (`runHelp`, `nounSummaries`)
966- Modify: `cmd/gitbay/main.go` (`authCmd`'s `group("auth", ...)` description)
967- Test: `internal/control/help_test.go`
968
969**Interfaces:**
970- Produces: `var nounAliases map[string][]string` — a CLI-only noun name to the real registry prefixes it gathers.
971
972- [ ] **Step 1: Write the failing test**
973
974```go
975func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
976 var out bytes.Buffer
977 c := &Ctx{Stdout: &out, Term: Term{Cols: 100}}
978 if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
979 t.Fatalf("exit %d", code)
980 }
981 got := out.String()
982 for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
983 if !strings.Contains(got, want) {
984 t.Errorf("missing %q in:\n%s", want, got)
985 }
986 }
987 if strings.Contains(got, "no command matches") {
988 t.Errorf("auth did not resolve: %s", got)
989 }
990}
991```
992
993- [ ] **Step 2: Run and see it fail**
994
995Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1`
996Expected: FAIL (`no command matches "auth"`).
997
998- [ ] **Step 3: Implement the alias table and the lookup change**
999
1000In `internal/control/help.go`, near `nounSummaries`:
1001
1002```go
1003// nounAliases groups a CLI-only noun (one with no registry path of its
1004// own, such as auth, which the cmd/gitbay CLI assembles from several
1005// unrelated registry prefixes) into the real prefixes it gathers, so
1006// `help auth` renders with the same layout a real noun gets instead of
1007// falling back to whatever a caller does when help fails.
1008var nounAliases = map[string][]string{
1009 "auth": {"account export", "whoami", "keys", "email", "pgp", "token"},
1010}
1011```
1012
1013and add, to `nounSummaries`:
1014
1015```go
1016 "auth": "whoami, SSH and PGP keys, email, API tokens",
1017```
1018
1019In `runHelp`, widen the match to every aliased prefix:
1020
1021```go
1022func runHelp(c *Ctx, args []string) int {
1023 prefix := joinPath(args)
1024 prefixes := []string{prefix}
1025 if aliased, ok := nounAliases[prefix]; ok {
1026 prefixes = aliased
1027 }
1028 var matched []Command
1029 for _, cmd := range registry {
1030 p := joinPath(cmd.Path)
1031 for _, pfx := range prefixes {
1032 if pfx == "" || p == pfx || strings.HasPrefix(p, pfx+" ") {
1033 matched = append(matched, cmd)
1034 break
1035 }
1036 }
1037 }
1038```
1039
1040The rest of `runHelp` is unchanged: `matched[0].Path` never equals
1041`"auth"` literally (nothing in the registry is named that), so the
1042alias always takes the `helpNoun` branch, which already handles a
1043command list whose paths do not share `prefix` as an actual prefix —
1044`strings.TrimPrefix` is a no-op on a path it does not match, so `keys
1045add`, `pgp add`, `token create` and `whoami` print by their real, full
1046paths under the `auth` heading.
1047
1048- [ ] **Step 4: Sync the CLI's own description**
1049
1050`cmd/gitbay/main.go`'s `authCmd()`:
1051
1052```go
1053 return group("auth", "whoami, SSH and PGP keys, email, API tokens",
1054```
1055
1056(`TestGroupsSayWhatTheServerSays` checks this against
1057`nounSummaries["auth"]`, added above.)
1058
1059- [ ] **Step 5: Run**
1060
1061Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1`
1062Expected: PASS.
1063
1064- [ ] **Step 6: Run both packages**
1065
1066Run: `go test ./internal/control ./cmd/gitbay -count=1`
1067Expected: PASS.
1068
1069- [ ] **Step 7: Commit**
1070
1071```bash
1072git add internal/control/help.go internal/control/help_test.go cmd/gitbay/main.go
1073git commit -m "help: auth (and any future CLI-only grouping) renders with the registry layout" -m "Ref #267"
1074```
1075
1076**Open question** (cannot be resolved from the code, flagging rather
1077than guessing): #267's own text quotes the desired usage as `gitbay auth
1078keys remove <fingerprint>` — with `auth` in the printed command — but
1079the server has no notion of the CLI's `auth` grouping; it only knows the
1080registered path `keys remove`. This task and Task 2.1 make the server
1081print `gitbay keys remove <fingerprint>` (correct, runnable, but missing
1082the `auth` cobra sits it under). Inserting `auth` would mean either
1083teaching the registry about a purely cobra-side grouping, or having the
1084CLI rewrite the server's usage string client-side by pattern-matching
1085its own command tree — decide which, if the exact wording matters, before
1086merging this MR.
1087
1088### Task 2.4: verb-phrase summaries
1089
1090Six commands' one-line summaries are bare nouns rather than a phrase
1091saying what the command does: `issue comment`/`mr comment` ("comment"),
1092`issue label`/`mr label` ("labels"), `issue assign` ("assignees"), `mr
1093review` ("review").
1094
1095**Files:**
1096- Modify: `internal/control/issue.go:70`, `:93`, `:102`
1097- Modify: `internal/control/mr.go:146`, `:156`, `:176`
1098- Modify: `cmd/gitbay/summaries_gen.go` (regenerated, not hand-edited)
1099- Test: `cmd/gitbay/summaries_test.go` (existing `TestSummariesAreCurrent` enforces this)
1100
1101- [ ] **Step 1: Change the six `Summary` strings**
1102
1103`internal/control/issue.go:70`: `Summary: "add a comment",`
1104`internal/control/issue.go:93`: `Summary: "add or remove labels",`
1105`internal/control/issue.go:102`: `Summary: "add or remove assignees",`
1106`internal/control/mr.go:146`: `Summary: "add a comment",`
1107`internal/control/mr.go:156`: `Summary: "record a review verdict",`
1108`internal/control/mr.go:176`: `Summary: "add or remove labels",`
1109
1110- [ ] **Step 2: Regenerate `summaries_gen.go`**
1111
1112Run: `go test ./cmd/gitbay -run TestSummariesAreCurrent -update`
1113This rewrites `cmd/gitbay/summaries_gen.go`'s six affected map entries
1114(`"issue comment"`, `"issue label"`, `"issue assign"`, `"mr comment"`,
1115`"mr review"`, `"mr label"`) to the new strings; nothing else in the
1116generated file changes.
1117
1118- [ ] **Step 3: Run**
1119
1120Run: `go test ./internal/control ./cmd/gitbay -count=1`
1121Expected: PASS.
1122
1123- [ ] **Step 4: Commit and open the MR**
1124
1125```bash
1126git add internal/control/issue.go internal/control/mr.go cmd/gitbay/summaries_gen.go
1127git commit -m "summaries: verb phrases instead of bare nouns" -m "Closes #267"
1128git push -u origin cli-ux-help
1129gitbay mr create --source cli-ux-help --target main --title "CLI help and usage print the form the caller typed"
1130```
1131
1132Wait for CI, merge with `--strategy ff`, delete the branch both places.
1133
1134---
1135
1136# Part 3: unregistered key, issue create flags, mr show plurals, repo readme, mirror time (branch `cli-ux-fixes`, closes #268)
1137
1138### Task 3.1: the unregistered-key message names the fingerprint and the real host
1139
1140`runAnonymous` in `internal/sshd/sshd.go:332` tells a connecting
1141stranger to register with a literal `<host>` placeholder and no
1142fingerprint, whether they are truly unknown or someone on a new laptop
1143whose existing account has a different key. Print the fingerprint and
1144the real host, and offer both the web and the ssh path.
1145
1146**Files:**
1147- Modify: `internal/sshd/sshd.go` (`runAnonymous`)
1148- Test: `internal/sshd/sshd_test.go`
1149
1150- [ ] **Step 1: Write the failing test**
1151
1152```go
1153func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
1154 st, cleanup := newTestStore(t) // reuse whatever helper sshd_test.go's other tests use to open a migrated store
1155 defer cleanup()
1156 srv := &Server{st: st, cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
1157 pub, _, err := ed25519.GenerateKey(rand.Reader)
1158 if err != nil {
1159 t.Fatal(err)
1160 }
1161 sshPub, err := ssh.NewPublicKey(pub)
1162 if err != nil {
1163 t.Fatal(err)
1164 }
1165 var out bytes.Buffer
1166 ch := &fakeChannel{stderr: &out} // sshd_test.go's existing fake ssh.Channel, if it has one
1167 code := srv.runAnonymous(ch, base64.StdEncoding.EncodeToString(sshPub.Marshal()), "whoami")
1168 if code != protocol.ExitDenied {
1169 t.Fatalf("exit %d", code)
1170 }
1171 fp := ssh.FingerprintSHA256(sshPub)
1172 for _, want := range []string{fp, "forge.test", "https://forge.test/settings#keys", "ssh git@forge.test register"} {
1173 if !strings.Contains(out.String(), want) {
1174 t.Errorf("message missing %q:\n%s", want, out.String())
1175 }
1176 }
1177}
1178```
1179
1180`newTestStore`/`fakeChannel` are placeholders for whatever
1181`internal/sshd/sshd_test.go` already provides for its other
1182`runAnonymous`-adjacent tests — read the top of that file (`grep -n
1183"^func " internal/sshd/sshd_test.go`) and use its actual helpers rather
1184than the names guessed here.
1185
1186- [ ] **Step 2: Run and see it fail**
1187
1188Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1189Expected: FAIL (message contains the literal string `<host>`, no fingerprint).
1190
1191- [ ] **Step 3: Implement**
1192
1193```go
1194 if len(argv) == 0 || argv[0] != "register" {
1195 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://"), "/")
1196 fp := ssh.FingerprintSHA256(pub)
1197 flag := map[string]string{"open": "--email <address>", "invite": "--invite <code>"}[s.cfg.Registration.Mode]
1198 fmt.Fprintf(ch.Stderr(),
1199 "this key (%s) is not registered on %s.\n"+
1200 "already have an account? add it at https://%s/settings#keys\n"+
1201 "new here? ssh git@%s register --username <name> %s\n",
1202 fp, host, host, host, flag)
1203 return protocol.ExitDenied
1204 }
1205```
1206
1207- [ ] **Step 4: Run**
1208
1209Run: `go test ./internal/sshd -run TestUnregisteredKeyMessageNamesFingerprintAndHost -count=1`
1210Expected: PASS.
1211
1212- [ ] **Step 5: Run the package**
1213
1214Run: `go test ./internal/sshd -count=1`
1215Expected: PASS. A failing e2e-adjacent unit test asserting the old `this
1216key is not registered here` text needs its expectation updated the same
1217way.
1218
1219- [ ] **Step 6: Commit**
1220
1221```bash
1222git add internal/sshd/sshd.go internal/sshd/sshd_test.go
1223git commit -m "sshd: unregistered-key message names the fingerprint and the real host" -m "Ref #268"
1224```
1225
1226### Task 3.2: `issue create` takes `--label`, `--milestone`, `--assignee`
1227
1228`issue create` only sets title, body and format; labels, milestone and
1229assignees each need a separate call afterward, unlike the web form. Add
1230the three flags (label repeatable) and document that `$EDITOR` already
1231opens when neither `--body` nor `--file` is given (`cmd/gitbay/ssh.go`'s
1232`withRepo`/`maybeEditor` machinery already does this via `issueCmd()`'s
1233`editor: "issue"` — this task only adds the missing flags and says so
1234in the registered help).
1235
1236**Files:**
1237- Modify: `internal/control/issue.go` (`init`'s `issue create` registration, `runIssueCreate`)
1238- Test: `internal/control/issue_test.go`
1239
1240**Interfaces:**
1241- Consumes: `parseFlags`/`flagSpec.Multi` (existing), `c.Store.SetIssueLabel`, `c.Store.MilestoneByTitle`, `c.Store.SetIssueMilestone`, `c.Store.UserByUsername`, `c.Store.SetIssueAssignee` (all existing store methods).
1242
1243- [ ] **Step 1: Write the failing test**
1244
1245```go
1246func TestIssueCreateSetsLabelsMilestoneAndAssignee(t *testing.T) {
1247 c := notifTestCtx(t, "alice")
1248 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
1249 if err != nil {
1250 t.Fatal(err)
1251 }
1252 repo, err := c.Store.RepoByID(repoID)
1253 if err != nil {
1254 t.Fatal(err)
1255 }
1256 if err := c.Store.SetLabel(repo, "bug", "ff0000"); err != nil {
1257 t.Fatal(err)
1258 }
1259 if _, err := c.Store.CreateMilestone(repo.ID, "m1", ""); err != nil {
1260 t.Fatal(err)
1261 }
1262 if _, err := c.Store.CreateUser("bob", false); err != nil {
1263 t.Fatal(err)
1264 }
1265
1266 if code := runIssueCreate(c, []string{repo.Path(), "--title", "t",
1267 "--label", "bug", "--milestone", "m1", "--assignee", "bob"}); code != 0 {
1268 t.Fatalf("exit %d: %s", code, c.Stderr.(*bytes.Buffer).String())
1269 }
1270 issue, err := c.Store.IssueByNumber(repo.ID, 1)
1271 if err != nil {
1272 t.Fatal(err)
1273 }
1274 if len(issue.Labels) != 1 || issue.Labels[0] != "bug" {
1275 t.Errorf("labels = %v", issue.Labels)
1276 }
1277 if issue.Milestone != "m1" {
1278 t.Errorf("milestone = %q", issue.Milestone)
1279 }
1280 if len(issue.Assignees) != 1 || issue.Assignees[0] != "bob" {
1281 t.Errorf("assignees = %v", issue.Assignees)
1282 }
1283}
1284```
1285
1286(`c.Store.SetLabel`/`CreateMilestone` are placeholders for the real
1287label/milestone creation helpers — `grep -n "func (s \*Store)
1288SetLabel\|func (s \*Store) CreateMilestone" internal/store/*.go` for
1289their actual names and signatures and use those; `issue.Milestone`
1290similarly needs to match whatever field `store.Issue` actually carries
1291for its milestone title, e.g. via `grep -n "Milestone" internal/store/issues.go`.)
1292
1293- [ ] **Step 2: Run and see it fail**
1294
1295Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
1296Expected: FAIL, exit 2 (`--label` not accepted).
1297
1298- [ ] **Step 3: Register the new flags**
1299
1300```go
1301 register(Command{Path: []string{"issue", "create"},
1302 Summary: "open an issue",
1303 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
1304 Flags: []Flag{
1305 {"--title", "<t>", "the issue's title", ""},
1306 {"--body", "<b>", "the issue's body", ""},
1307 {"--file", "-", "read the body from stdin", ""},
1308 {"--format", "md|org", "the body's markup", "md"},
1309 {"--label", "<l>", "label to add, may repeat", ""},
1310 {"--milestone", "<title>", "milestone to set", ""},
1311 {"--assignee", "<user>", "user to assign, may repeat", ""},
1312 },
1313 Examples: []string{
1314 `issue create krz/gitbay --title "crash on empty repo" --body "steps to reproduce..."`,
1315 "issue create krz/gitbay --title notes --file - < notes.md",
1316 "issue create krz/gitbay --title bug --label bug --label priority --milestone v1 --assignee cmc",
1317 },
1318 ReadsStdin: true, Run: runIssueCreate})
1319```
1320
1321Note in a doc comment above `runIssueCreate`, since the flags list
1322above has no room for prose: `$EDITOR` opens for the body when the CLI
1323is asked for neither `--body` nor `--file` — that behavior is entirely
1324client-side (`cmd/gitbay/main.go`'s `issueCmd()` already sets
1325`editor: "issue"`), this registration only documents it:
1326
1327```go
1328// runIssueCreate opens an issue. The CLI opens $EDITOR for the body
1329// when neither --body nor --file is given (cmd/gitbay's issueCmd,
1330// editor: "issue"); over stock ssh the body must be one of the two.
1331func runIssueCreate(c *Ctx, args []string) int {
1332 f, err := parseFlags(args, flagSpec{
1333 Values: []string{"--format", "--title", "--body", "--file", "--milestone"},
1334 Multi: []string{"--label", "--assignee"},
1335 MaxPos: 1,
1336 Usage: "issue create <owner/name> --title <t> [--body <b> | --file -] [--format md|org] [--label <l>]... [--milestone <title>] [--assignee <user>]...",
1337 })
1338 if err != nil {
1339 return c.fail(protocol.ExitUsage, "%v", err)
1340 }
1341```
1342
1343- [ ] **Step 4: Set labels, milestone and assignees after creation**
1344
1345After the existing `n, err := c.Store.CreateIssue(...)` block and its
1346`RecordEvent`/notify calls, before the final `return c.emit(...)`:
1347
1348```go
1349 for _, l := range f.List("--label") {
1350 if err := c.Store.SetIssueLabel(repo, n, l, true); err != nil {
1351 return c.failErr(err)
1352 }
1353 }
1354 if m := f.Value("--milestone"); m != "" {
1355 ms, err := c.Store.MilestoneByTitle(repo, m)
1356 if err != nil {
1357 return milestoneErr(c, repo, m, err)
1358 }
1359 if err := c.Store.SetIssueMilestone(n, ms.ID); err != nil {
1360 return c.fail(protocol.ExitFailure, "%v", err)
1361 }
1362 }
1363 for _, name := range f.List("--assignee") {
1364 u, err := c.Store.UserByUsername(name)
1365 if errors.Is(err, store.ErrNotFound) {
1366 return c.fail(protocol.ExitNotFound, "no such user %q", name)
1367 }
1368 if err != nil {
1369 return c.fail(protocol.ExitFailure, "%v", err)
1370 }
1371 if err := c.Store.SetIssueAssignee(n, u.ID, true); err != nil {
1372 return c.fail(protocol.ExitFailure, "%v", err)
1373 }
1374 }
1375```
1376
1377`SetIssueLabel`'s second parameter in `runIssueLabel` is `issue.ID`, not
1378the issue number — `CreateIssue` returns the number `n`, so fetch the
1379row first if `SetIssueLabel`/`SetIssueMilestone`/`SetIssueAssignee` all
1380key on the database id rather than the number (check each store
1381method's actual first parameter — `grep -n "func (s \*Store)
1382SetIssueLabel\|SetIssueMilestone\|SetIssueAssignee" internal/store/*.go`
1383and adjust to fetch `issue, err := c.Store.IssueByNumber(repo.ID, n)`
1384first if any of them needs `issue.ID` rather than `n`). Add
1385`"errors"` to the file's imports if not already present.
1386
1387- [ ] **Step 5: Run**
1388
1389Run: `go test ./internal/control -run TestIssueCreateSetsLabelsMilestoneAndAssignee -count=1`
1390Expected: PASS.
1391
1392- [ ] **Step 6: Run the package, regenerate the CLI summary if `Usage` changed its flag list**
1393
1394Run: `go test ./internal/control -count=1`
1395Expected: PASS (the `Summary` string is unchanged, so
1396`summaries_gen.go` does not need regenerating — only `Usage`/`Flags`
1397changed, which is not part of that generated file).
1398
1399- [ ] **Step 7: Commit**
1400
1401```bash
1402git add internal/control/issue.go internal/control/issue_test.go
1403git commit -m "issue create: --label, --milestone, --assignee" -m "Ref #268"
1404```
1405
1406### Task 3.3: `mr show` pluralizes its multi-row section headings
1407
1408`mr show`'s commit/check/review sub-tables print a singular label
1409(`commit:`, `check:`) even when they hold several rows.
1410
1411**Files:**
1412- Modify: `internal/control/mr.go` (the three `v.section(...)` calls around lines 793, 802, 811)
1413- Test: `internal/control/mr_test.go`
1414
1415- [ ] **Step 1: Write the failing test**
1416
1417Find `mr show`'s existing plain-output test (`grep -n "func Test.*MRShow"
1418internal/control/mr_test.go`) and add a case with more than one commit,
1419check and review, asserting the plural, counted heading:
1420
1421```go
1422func TestMRShowPluralizesMultiRowSections(t *testing.T) {
1423 // build on whatever fixture the existing MR-show tests in this file
1424 // use to get a repo with an open MR; push two commits onto its
1425 // source branch, set two statuses, and record two reviews before
1426 // calling runMRShow, following that fixture's own setup exactly.
1427 ...
1428 out := ... // runMRShow's plain stdout
1429 for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} {
1430 if !strings.Contains(out, want) {
1431 t.Errorf("missing %q in:\n%s", want, out)
1432 }
1433 }
1434}
1435```
1436
1437- [ ] **Step 2: Run and see it fail**
1438
1439Run: `go test ./internal/control -run TestMRShowPluralizesMultiRowSections -count=1`
1440Expected: FAIL (headings read `commit:`, `check:`, `review:`).
1441
1442- [ ] **Step 3: Implement**
1443
1444```go
1445 if len(commits) > 1 {
1446 v.section(fmt.Sprintf("commits (%d)", len(commits)))
1447 tb := c.table(w, "SHA", "SUBJECT")
1448 ...
1449 }
1450
1451 if len(checks) > 1 {
1452 v.section(fmt.Sprintf("checks (%d)", len(checks)))
1453 tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED")
1454 ...
1455 }
1456
1457 if len(rs) > 1 {
1458 v.section(fmt.Sprintf("reviews (%d)", len(rs)))
1459 tb := c.table(w, "REVIEWER", "VERDICT", "WHEN")
1460 ...
1461 }
1462```
1463
1464(`v.section` prints `label + ":"` in plain mode already — do not add a
1465trailing colon inside the `fmt.Sprintf` string.)
1466
1467- [ ] **Step 4: Run**
1468
1469Run: `go test ./internal/control -run TestMRShow -count=1`
1470Expected: PASS.
1471
1472- [ ] **Step 5: Run the package**
1473
1474Run: `go test ./internal/control -count=1`
1475Expected: PASS.
1476
1477- [ ] **Step 6: Commit**
1478
1479```bash
1480git add internal/control/mr.go internal/control/mr_test.go
1481git commit -m "mr show: pluralize commits/checks/reviews section headings" -m "Ref #268"
1482```
1483
1484### Task 3.4: `repo readme` prints a repository's README
1485
1486No command prints a repository's README; the web page's own
1487README-picking logic (`pickReadme` in `internal/httpd/web.go`) is not
1488reachable from `internal/control`. Move it into `internal/control`,
1489exported, and add `repo readme <owner/name> [--ref <ref>]` following
1490`repo cat`'s shape.
1491
1492**Files:**
1493- Modify: `internal/control/read.go` (new `repo readme` registration and `runRepoReadme`, model on `runRepoCat`/`runRepoTree`)
1494- Modify: `internal/httpd/web.go` (move `readmeRank`/`pickReadme` out, call site at line 660 updated)
1495- Modify: `cmd/gitbay/main.go` (`repoCmd`, new `pass("readme", ...)`)
1496- Modify: `e2e/readonly_test.go` (`readArgs["repo readme"]`)
1497- Modify: `.gitbay/wiki/Parity.org` (Repositories table)
1498- Test: `internal/control/read_test.go`
1499
1500**Interfaces:**
1501- Produces: `func PickReadme(entries []gitutil.TreeEntry) string` (moved from `internal/httpd`, exported).
1502
1503- [ ] **Step 1: Move `readmeRank`/`pickReadme`**
1504
1505Cut both from `internal/httpd/web.go` (around lines 1185–1210) and paste
1506into `internal/control/read.go`, renaming `pickReadme` to `PickReadme`:
1507
1508```go
1509// readmeRank orders competing README files: richer renderers win.
1510var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
1511
1512// PickReadme returns the best README-ish blob in a tree listing: any
1513// file named "readme" or "readme.<ext>" (case-insensitive), preferring
1514// formats we can render richly.
1515func PickReadme(entries []gitutil.TreeEntry) string {
1516 best, bestRank := "", 1<<30
1517 for _, e := range entries {
1518 if e.Type != "blob" {
1519 continue
1520 }
1521 lower := strings.ToLower(e.Name)
1522 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1523 continue
1524 }
1525 rank, ok := readmeRank[path.Ext(lower)]
1526 if !ok {
1527 rank = 10 // plaintext fallback
1528 }
1529 if rank < bestRank {
1530 best, bestRank = e.Name, rank
1531 }
1532 }
1533 return best
1534}
1535```
1536
1537In `internal/httpd/web.go`, the call site at line 660 becomes
1538`readmeName := control.PickReadme(entries)`. Remove the unused `"path"`
1539import from `web.go` only if nothing else in the file still uses it
1540(`grep -n '"path"' internal/httpd/web.go` and `grep -n "path\."
1541internal/httpd/web.go` — this file is large and almost certainly uses
1542`path` elsewhere, so this removal is likely a no-op check, not an edit).
1543
1544- [ ] **Step 2: Build to confirm the move alone is clean**
1545
1546Run: `go build ./... && go vet ./...`
1547Expected: no errors.
1548
1549- [ ] **Step 3: Write the failing test for the new command**
1550
1551```go
1552func TestRepoReadmePicksTheRichestFormat(t *testing.T) {
1553 st, repo, uid := newQueueTestRepo(t)
1554 dir := RepoDir(config.Config{}.Server.Root, repo.OwnerName, repo.Name) // adjust to however read_test.go's existing repo-cat tests get a working tree with committed files — reuse that helper rather than re-deriving RepoDir's root
1555 git := gitRunner(t)
1556 git(dir, "init", "--bare") // only if newQueueTestRepo does not already leave a real git repo on disk; check runRepoCat's own test setup and mirror it exactly
1557 ...
1558 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
1559 if code := Dispatch(c, []string{"repo", "readme", repo.Path()}); code != protocol.ExitOK {
1560 t.Fatalf("exit %d: %s", code, errOut)
1561 }
1562 if got := c.Stdout.(*bytes.Buffer).String(); got != "# app\n\nhello\n" {
1563 t.Errorf("readme = %q", got)
1564 }
1565}
1566```
1567
1568`runRepoCat`'s own test in `internal/control/read_test.go` already sets
1569up a real on-disk repository with a committed file — copy that setup
1570exactly (bare repo, a work tree pushed into it, matching
1571`gitTestEnv()`/`gitRunner(t)` from `build_test.go`) rather than
1572reinventing it; commit a `README.md` instead of whatever file that test
1573uses.
1574
1575- [ ] **Step 4: Run and see it fail**
1576
1577Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
1578Expected: FAIL (`unknown command "readme"`).
1579
1580- [ ] **Step 5: Register the command and implement it**
1581
1582In `internal/control/read.go`'s `init()`, after the `repo cat`
1583registration:
1584
1585```go
1586 register(Command{
1587 Path: []string{"repo", "readme"},
1588 Summary: "print a repository's README",
1589 Usage: "repo readme <owner/name> [--ref <ref>]",
1590 Flags: []Flag{
1591 {"--ref", "<ref>", "branch, tag or commit to read", "the default branch"},
1592 },
1593 Examples: []string{"repo readme krz/gitbay"},
1594 ReadOnly: true,
1595 Run: runRepoReadme,
1596 })
1597```
1598
1599```go
1600func runRepoReadme(c *Ctx, args []string) int {
1601 pos, ref, code := readArgs(c, args, c.Cmd.Usage, 1)
1602 if code >= 0 {
1603 return code
1604 }
1605 if len(pos) != 1 {
1606 return c.usage()
1607 }
1608 repo, code := resolveRepo(c, pos[0], policy.CanRead)
1609 if code >= 0 {
1610 return code
1611 }
1612 if ref == "" {
1613 ref = repo.DefaultBranch
1614 }
1615 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
1616 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
1617 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
1618 }
1619 entries, err := gitutil.ListTree(dir, ref, "")
1620 if err != nil {
1621 return c.fail(protocol.ExitNotFound, "no such path in %s at %s", repo.Path(), ref)
1622 }
1623 name := PickReadme(entries)
1624 if name == "" {
1625 return c.fail(protocol.ExitNotFound, "%s has no README at %s", repo.Path(), ref)
1626 }
1627 limit := c.Cfg.Limits.MaxBlobBytes
1628 data, err := gitutil.ReadBlob(dir, ref, name, limit+1)
1629 if err != nil {
1630 return c.fail(protocol.ExitFailure, "%v", err)
1631 }
1632 truncated := int64(len(data)) > limit
1633 if truncated {
1634 data = data[:limit]
1635 }
1636 binary := gitutil.IsBinary(data)
1637 type out struct {
1638 Path string `json:"path"`
1639 Ref string `json:"ref"`
1640 File string `json:"file"`
1641 Size int `json:"size"`
1642 Truncated bool `json:"truncated,omitempty"`
1643 Binary bool `json:"binary,omitempty"`
1644 Content string `json:"content,omitempty"`
1645 Base64 string `json:"base64,omitempty"`
1646 }
1647 d := out{Path: repo.Path(), Ref: ref, File: name, Size: len(data), Truncated: truncated, Binary: binary}
1648 if binary {
1649 d.Base64 = base64.StdEncoding.EncodeToString(data)
1650 } else {
1651 d.Content = string(data)
1652 }
1653 return c.emit(d, func(w io.Writer) {
1654 if binary {
1655 fmt.Fprintf(w, "%s is binary (%d bytes)\n", d.File, d.Size)
1656 return
1657 }
1658 io.WriteString(w, d.Content)
1659 if truncated {
1660 fmt.Fprintln(w, "... truncated")
1661 }
1662 })
1663}
1664```
1665
1666(Match `runRepoCat`'s actual truncation/binary field names and JSON tags
1667exactly — read the rest of its `out` struct at
1668`internal/control/read.go:355` onward and copy its shape rather than
1669inventing a divergent one, so a client handles both commands the same
1670way.)
1671
1672- [ ] **Step 6: Run**
1673
1674Run: `go test ./internal/control -run TestRepoReadmePicksTheRichestFormat -count=1`
1675Expected: PASS.
1676
1677- [ ] **Step 7: Wire the CLI passthrough**
1678
1679`cmd/gitbay/main.go`'s `repoCmd()`, next to `pass("cat", ...)`:
1680
1681```go
1682 pass("readme", passOpts{server: []string{"repo", "readme"}, needsRepo: true}),
1683```
1684
1685- [ ] **Step 8: Add it to the ReadOnly coverage list**
1686
1687`e2e/readonly_test.go`'s `readArgs` map gains, next to `"repo refs"`:
1688
1689```go
1690 "repo readme": {"alice/app"},
1691```
1692
1693(the fixture's `alice/app` already has a committed `README.md`, so this
1694does not need a `notFoundOK` entry.)
1695
1696- [ ] **Step 9: Update Parity**
1697
1698`.gitbay/wiki/Parity.org`'s Repositories table gains a row, next to
1699`| read a file | yes | yes | yes |`:
1700
1701```
1702| render a README | yes | yes | yes |
1703```
1704
1705- [ ] **Step 10: Run the full local suite for touched packages**
1706
1707Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/httpd ./cmd/gitbay -count=1`
1708Expected: PASS.
1709
1710- [ ] **Step 11: Commit**
1711
1712```bash
1713git add internal/control/read.go internal/control/read_test.go internal/httpd/web.go cmd/gitbay/main.go e2e/readonly_test.go .gitbay/wiki/Parity.org
1714git commit -m "repo readme: print a repository's README, the web page's file order" -m "Ref #268"
1715```
1716
1717### Task 3.5: `repo show`'s mirror time drops the milliseconds
1718
1719`repo show`'s mirror sub-table prints `LAST SYNC` with milliseconds
1720(`2026-09-24T15:31:50.839Z`) instead of the second-truncated form every
1721other timestamp in a `view` uses.
1722
1723**Files:**
1724- Modify: `internal/control/repo.go` (`runRepoShow`'s mirror table row, around line 474)
1725- Test: `internal/control/repo_test.go`
1726
1727- [ ] **Step 1: Write the failing test**
1728
1729Find `repo show`'s existing mirror-table test (`grep -n
1730"func Test.*Mirror" internal/control/repo_test.go`), or add one if none
1731exists:
1732
1733```go
1734func TestRepoShowMirrorTimeIsTruncatedToTheSecond(t *testing.T) {
1735 c, repo, _ := newQueueTestRepo(t) // adjust to whatever gives an admin Ctx over a repo with a mirror row in repo_test.go's existing fixtures
1736 if err := c.Store.CreateMirror(repo.ID, "push", "ssh://example.test/x.git", ""); err != nil {
1737 t.Fatal(err)
1738 }
1739 if err := c.Store.MarkMirrorSynced(repo.ID, "ssh://example.test/x.git", "2026-09-24T15:31:50.839Z"); err != nil {
1740 t.Fatal(err)
1741 }
1742 var out bytes.Buffer
1743 c.Stdout, c.User.IsAdmin = &out, true // repo show's mirror section is admin-only in this Ctx
1744 if code := runRepoShow(c, []string{repo.Path()}); code != 0 {
1745 t.Fatalf("exit %d", code)
1746 }
1747 if strings.Contains(out.String(), ".839Z") {
1748 t.Errorf("milliseconds leaked: %s", out.String())
1749 }
1750 if !strings.Contains(out.String(), "2026-09-24T15:31:50Z") {
1751 t.Errorf("no truncated timestamp: %s", out.String())
1752 }
1753}
1754```
1755
1756(`CreateMirror`/`MarkMirrorSynced` are placeholders — `grep -n "func (s
1757\*Store) .*Mirror" internal/store/*.go` for the real names/signatures
1758that get a `ListMirrors` row with a non-empty `LastSync`, and use those;
1759`runRepoShow`'s mirror section additionally requires
1760`policy.CanAdmin(c.User, repo, grant)` to hold for the caller, so the
1761test's `Ctx` needs to be the repo's owner or otherwise admin over it —
1762`newQueueTestRepo`'s `uid` already owns the repo it returns, which
1763satisfies that.)
1764
1765- [ ] **Step 2: Run and see it fail**
1766
1767Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
1768Expected: FAIL (`.839Z` present).
1769
1770- [ ] **Step 3: Implement**
1771
1772```go
1773 tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(c.when(m.LastSync))), cState(status))
1774```
1775
1776(`c.when` is already what every other timestamp in a `view` goes
1777through: RFC3339-to-the-second in plain output, `2006-01-02 15:04 UTC`
1778at a terminal; `orDash` keeps an empty `LastSync` — a mirror that has
1779never synced — printing `-` rather than an empty cell, since `c.when("")`
1780returns `""` unchanged.)
1781
1782- [ ] **Step 4: Run**
1783
1784Run: `go test ./internal/control -run TestRepoShowMirrorTimeIsTruncatedToTheSecond -count=1`
1785Expected: PASS.
1786
1787- [ ] **Step 5: Run the package**
1788
1789Run: `go test ./internal/control -count=1`
1790Expected: PASS.
1791
1792- [ ] **Step 6: Commit, open the MR**
1793
1794```bash
1795git add internal/control/repo.go internal/control/repo_test.go
1796git commit -m "repo show: truncate the mirror's last-sync time to the second" -m "Closes #268"
1797git push -u origin cli-ux-fixes
1798gitbay mr create --source cli-ux-fixes --target main --title "CLI UX review small fixes"
1799```
1800
1801Wait for CI, merge with `--strategy ff`, delete the branch both places.
1802
1803---
1804
1805## Self-review
1806
1807**Spec coverage** (against #265/#267/#268's text, this plan's spec):
1808
1809- #265: sentences for activity (Task 1.1–1.3), no duplicate assigned
1810 issues (Task 1.4), one empty-state wording for `notifications list`
1811 (Task 1.5). The issue's other empty-state line ("Empty sections print
1812 none; empty lists elsewhere print nothing to list on stderr") already
1813 matches current behavior (`internal/control/dashboard.go`'s `section`
1814 helper, `internal/control/control.go`'s `emit`) — no task needed.
1815- #267: CLI sends `--term`/`Ctx.Term` (already present; verified, not
1816 re-implemented) and the server now uses it in `usage()`/`usageWith()`
1817 too (Task 2.1); `[<owner/name>]` optional from the CLI (Task 2.1);
1818 `--help` check in `keys add`/`pgp add` (Task 2.2); `auth` rendered
1819 with the registry layout (Task 2.3); verb-phrase summaries (Task 2.4).
1820- #268: unregistered-key message (Task 3.1); `issue create` flags
1821 (Task 3.2); `mr show` plurals (Task 3.3); `repo readme` (Task 3.4);
1822 `repo show` mirror time (Task 3.5).
1823
1824**Placeholder scan:** Tasks 3.3 and 3.4's tests name real assertions but
1825lean on "copy this file's existing fixture setup" rather than spelling
1826out git plumbing calls verbatim, and Task 3.1's test invents
1827`newTestStore`/`fakeChannel` names to be replaced by whatever
1828`internal/sshd/sshd_test.go` actually has. That is intentional, not a
1829placeholder in the sense the skill warns against: the actual assertions
1830(what strings must appear, what exit code, what store rows) are
1831concrete; only the test-scaffolding names are marked as needing a look
1832at each file's neighbors before typing them in, because this plan was
1833written from reading the production code, not the test helper's exact
1834current shape in every file it touches. Anyone executing this plan
1835reads the named test file's other tests first, per each step's own
1836instruction, before writing the step.
1837
1838**Type consistency:** `FeedLine`/`FeedLines`/`WorstStatus` (Task 1.1)
1839are used with the same names in Tasks 1.2 and 1.3. `cmdUsage`/`cliUsage`
1840(Task 2.1) are used with the same names in Task 2.3's commentary and
1841nowhere else redefines them. `PickReadme` (Task 3.4) is the only name
1842introduced for that logic and is used consistently in its own task.
1843
1844**Open question**, restated from Task 2.3: whether `gitbay auth keys
1845remove <fingerprint>`'s usage line should literally say `auth` (the
1846CLI's own grouping, invisible to the server) or `gitbay keys remove
1847<fingerprint>` (what the server can actually know and this plan
1848implements) needs a decision from whoever merges Part 2, since #267's
1849own text quotes the former.
docs/plans/2026-09-27-credentials-and-sessions.md added +3583
@@ -0,0 +1,3583 @@
1# Credentials and sessions 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:** Revocation of an SSH key takes effect on open connections
6(#256); an expiring credential cannot mint one that outlives it, and
7credentials record the token that made them (#257); SSH and deploy
8keys take an optional expiry and show their last use (#277); browser
9sessions end after 12 hours idle (#276); `web login` over SSH spends
10the login-link budget (#278).
11
12**Architecture:** The store announces revocations it commits
13(`Store.OnRevoke`); the SSH listener tracks which key opened each
14connection and cuts the ones a revocation names, killing a git
15transport's process group. A 15-second sweep catches revocations made
16by another process and keys that expire while connected. Every exec
17re-reads its key. `Command.MintsCredential` marks the commands that
18create credentials; `Dispatch` refuses them when `Ctx.Expires` is set.
19`api_tokens` and `ssh_keys` gain `created_by_token`; `ssh_keys` gains
20`expires_at`; `web_sessions` gains a sliding expiry under an absolute
21cap.
22
23**Tech stack:** Go, `golang.org/x/crypto/ssh`, SQLite (modernc), OpenSSH
24client for e2e.
25
26**Spec:** issues #256, #257, #276, #277, #278 on krz/gitbay (the
27decisions on #256 and #257 are recorded there), and the brief
28`/private/tmp/claude-501/-Users-cmc-git-krz-gitbay/7b2f1ea4-aab6-44e2-b2ad-d4ec6852ce42/scratchpad/brief.md`.
29
30## Global constraints
31
32- Each MR on its own branch off `main`. Commits are signed (the repo
33 refuses unsigned), messages reference issues (`Ref #N`, and
34 `Closes #N` on the commit that finishes one). No attribution to any
35 assistant, model or AI anywhere: commits, MR bodies, comments.
36- MR: `gitbay mr create --source <branch> --target main --title "..."`;
37 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
38 delete the branch locally and on the remote. Behind main → rebase,
39 force-push, merge again.
40- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
41 packages, and at most the one e2e test being written
42 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
43- Registries that fail CI when a new thing lacks its row: top-level route
44 word in `internal/policy/names.go`; new page template in the width map
45 of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
46 command in `readArgs` in `e2e/readonly_test.go`; new control command
47 needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
48 a command reading stdin needs `ReadsStdin: true`. This plan adds no
49 route, template, command or read command; it changes flags and a
50 `Summary` of none, so `cmd/gitbay/summaries_gen.go` stays current.
51- Migrations: this plan owns 0060–0064 and uses 0060, 0061, 0062. Six
52 plans are written in parallel with pre-assigned ranges; whoever lands
53 second renumbers to the next free number at execution time.
54 Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written SQL, no
55 ORM. `TestMigrateUpDown` (`internal/store/store_test.go`) exercises
56 every down script.
57- Secrets travel on stdin, never argv; never logged or echoed.
58- Wiki pages live in `.gitbay/wiki/` (Parity, API, Admin, Threat-Model,
59 CI, Users, Performance, and the `Architecture/` folder with its
60 Known-Gaps table and controls matrix). Update the page in the same MR
61 that changes the behaviour it describes, and remove the matching
62 row from `Architecture/10-Known-Gaps.org`.
63- Release notes go in `CHANGELOG.org` under the topmost heading that
64 has no tag yet. If the top heading is a released version, add
65 `* Unreleased` above it; whoever tags renames it.
66- Writing style: plain, direct, no hype; code comments match the
67 surrounding density. Comments and docs state facts, never
68 before/after narration.
69- Work in a worktree of `krz/gitbay`; the main checkout may hold
70 another session's edits.
71
72## Order and dependencies
73
74| # | Branch | Closes | Migration | Depends on |
75|---|---|---|---|---|
76| 1 | `revoke-closes-connections` | #256 | none | — |
77| 2 | `token-delegation` | #257 | 0060 | MR 1 (`Store.announce`, `fingerprint` e2e helper, `Exec` taking a key) |
78| 3 | `key-expiry` | #277 | 0061 | MR 1 (sweep, per-exec check), MR 2 (`KeyOrigin`, `Ctx.Expires`) |
79| 4 | `session-idle` | #276 | 0062 | — |
80| 5 | `weblogin-limit` | #278 | none | — |
81
82\#256 goes first. #257 and #277 both add columns to `ssh_keys`; both
83are nullable `ADD COLUMN`s with no table rebuild, so they compose in
84either order, and `KeyOrigin` (MR 2) is the one insert path both use.
85
86Other plans:
87
88- Plan 5 (web-ux) #264 depends on MR 2's `--scope read` default.
89- Plan 3 (server-hardening) touches the same code: #262 limits
90 concurrent git processes in `gitutil.Transport` / `sshd.runGit`
91 (MR 1 changes both signatures), #275 audits refused commands in
92 `control.Dispatch` (MR 2 adds a refusal there, which #275 should
93 audit like the others), #282 changes `hookd`. Whoever lands second
94 rebases; the conflicts are mechanical.
95
96## File map
97
98| File | MR | Responsibility |
99|---|---|---|
100| `internal/store/revoke.go` (create) | 1 | `Revoked`, `OnRevoke`, `announce`, `LiveSSHKeys` |
101| `internal/store/store.go` | 1 | subscriber fields on `Store` |
102| `internal/store/users.go` | 1, 2, 3 | removals announce; `KeyOrigin`, `AddSSHKeyFrom`; `expires_at` |
103| `internal/sshd/sshd.go` | 1, 3 | connection tracking, `cut`, sweep, per-exec check, `Exec(key)`; expired keys refused |
104| `internal/gitutil/gitutil.go`, `proc_unix.go`, `proc_other.go` | 1 | `Transport` takes a cancel channel, kills the process group |
105| `cmd/gitbayd/system.go` | 1, 3 | `Exec` call; expired keys in system mode |
106| `internal/control/control.go` | 2 | `MintsCredential`, `Ctx.TokenID`, `Ctx.Expires`, the refusal |
107| `internal/store/tokens.go` | 2 | token id, creator, chained revoke |
108| `internal/control/token.go` | 2, 3 | default read, creator, `revoke --created`; `ttlFlag` |
109| `internal/control/identity.go`, `deploykey.go`, `runnerrepo.go`, `adminhost.go`, `register.go`, `web.go` | 2, 3, 4, 5 | flags, creator, `--ttl`, list columns, login limit |
110| `internal/httpd/api.go` | 2 | token into `Ctx` |
111| `internal/store/sessions.go` | 4 | sliding session expiry |
112| `internal/control/loginlink.go` | 5 | comment |
113| `e2e/revoke_test.go` (create) | 1 | multiplexed connection cut |
114| `e2e/tokenorigin_test.go` (create) | 2 | expiring token refused, `revoke --created` |
115| `.gitbay/wiki/*`, `CHANGELOG.org` | all | docs in the MR that changes behaviour |
116
117---
118
119# MR 1: removing a key closes its connections (branch `revoke-closes-connections`, #256)
120
121Decision on the issue: revocation is immediate, running commands
122included. What that means here, stated in the Threat-Model page:
123
124- Every exec and every git transport session re-reads its key: gone,
125 moved to another account, or re-scoped takes effect on the next
126 command.
127- `keys remove`, `repo deploy-key remove`, `admin user disable`,
128 `admin user delete` and (MR 2) `token revoke --created` close every
129 connection opened by an affected key. A git transport on it is
130 killed with its process group. A control command in flight loses its
131 channel; one that watches `Done` (`build log --follow`) stops, one
132 already inside its store write finishes that write and its output is
133 lost.
134- A push cut before its pre-receive hook answers updates no refs. The
135 ref transaction that follows the hook is not interrupted mid-write in
136 practice; it is milliseconds long.
137- Revocations committed by another process (`gitbayd admin` on the
138 host) are found by a 15-second sweep.
139- System mode (`ssh.mode = "system"`) runs one process per exec, so
140 the per-exec check applies; a forced-command process already running
141 is not cut (open question 4).
142
143### Task 1.1: the store announces revocations and answers which keys are live
144
145**Files:**
146- Create: `internal/store/revoke.go`
147- Modify: `internal/store/store.go:23-30` (`Store` struct)
148- Modify: `internal/store/users.go` — `DeleteUser` (58-101), `SetUserDisabled` (171-194), `RemoveSSHKey` (291-309), `RemoveDeployKey` (534-554)
149- Test: `internal/store/revoke_test.go` (create)
150
151**Interfaces:**
152- Produces:
153 - `type Revoked struct { KeyIDs []int64; UserID int64 }`
154 - `func (s *Store) OnRevoke(f func(Revoked))`
155 - `func (s *Store) announce(r Revoked)` (package-private; MR 2 calls it)
156 - `func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error)`
157
158- [ ] **Step 1: Write the failing test**
159
160`internal/store/revoke_test.go`:
161
162```go
163package store
164
165import (
166 "slices"
167 "testing"
168)
169
170func revokeFixture(t *testing.T) (*Store, int64, *[]Revoked) {
171 t.Helper()
172 s := open(t)
173 if err := s.MigrateUp(); err != nil {
174 t.Fatal(err)
175 }
176 uid, err := s.CreateUser("alice", false)
177 if err != nil {
178 t.Fatal(err)
179 }
180 var got []Revoked
181 s.OnRevoke(func(r Revoked) { got = append(got, r) })
182 return s, uid, &got
183}
184
185func keyID(t *testing.T, s *Store, fp string) int64 {
186 t.Helper()
187 k, err := s.SSHKeyByFingerprint(fp)
188 if err != nil {
189 t.Fatal(err)
190 }
191 return k.ID
192}
193
194func TestRemovalsAnnounceTheirKeys(t *testing.T) {
195 s, uid, got := revokeFixture(t)
196 if err := s.AddSSHKey(uid, "SHA256:a", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
197 t.Fatal(err)
198 }
199 if err := s.AddSSHKey(uid, "SHA256:d", "ssh-ed25519", []byte("d"), "deploy:7:ro", ""); err != nil {
200 t.Fatal(err)
201 }
202 a, d := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:d")
203
204 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != nil {
205 t.Fatal(err)
206 }
207 if err := s.RemoveDeployKey(7, "SHA256:d"); err != nil {
208 t.Fatal(err)
209 }
210 if err := s.SetUserDisabled(uid, true); err != nil {
211 t.Fatal(err)
212 }
213 if err := s.SetUserDisabled(uid, false); err != nil {
214 t.Fatal(err)
215 }
216 want := []Revoked{{KeyIDs: []int64{a}}, {KeyIDs: []int64{d}}, {UserID: uid}}
217 if !slices.EqualFunc(*got, want, func(x, y Revoked) bool {
218 return slices.Equal(x.KeyIDs, y.KeyIDs) && x.UserID == y.UserID
219 }) {
220 t.Fatalf("announced %+v, want %+v (enabling announces nothing)", *got, want)
221 }
222 // A removal that found nothing announces nothing.
223 if err := s.RemoveSSHKey(uid, "SHA256:a"); err != ErrNotFound {
224 t.Fatalf("second remove: %v", err)
225 }
226 if len(*got) != 3 {
227 t.Fatalf("a miss was announced: %+v", *got)
228 }
229}
230
231func TestDeleteUserAnnounces(t *testing.T) {
232 s, uid, got := revokeFixture(t)
233 if err := s.DeleteUser(uid); err != nil {
234 t.Fatal(err)
235 }
236 if len(*got) != 1 || (*got)[0].UserID != uid {
237 t.Fatalf("announced %+v", *got)
238 }
239}
240
241func TestLiveSSHKeys(t *testing.T) {
242 s, uid, _ := revokeFixture(t)
243 bob, err := s.CreateUser("bob", false)
244 if err != nil {
245 t.Fatal(err)
246 }
247 for _, k := range []struct {
248 uid int64
249 fp string
250 }{{uid, "SHA256:a"}, {bob, "SHA256:b"}} {
251 if err := s.AddSSHKey(k.uid, k.fp, "ssh-ed25519", []byte(k.fp), "full", ""); err != nil {
252 t.Fatal(err)
253 }
254 }
255 a, b := keyID(t, s, "SHA256:a"), keyID(t, s, "SHA256:b")
256 if _, err := s.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", bob); err != nil {
257 t.Fatal(err)
258 }
259 live, err := s.LiveSSHKeys([]int64{a, b, 999})
260 if err != nil {
261 t.Fatal(err)
262 }
263 if !live[a] || live[b] || live[999] {
264 t.Fatalf("live = %v; want only %d", live, a)
265 }
266 if live, err := s.LiveSSHKeys(nil); err != nil || len(live) != 0 {
267 t.Fatalf("no ids: %v %v", live, err)
268 }
269}
270```
271
272- [ ] **Step 2: Run it and see it fail**
273
274Run: `go test ./internal/store -run 'TestRemovalsAnnounceTheirKeys|TestDeleteUserAnnounces|TestLiveSSHKeys' -count=1`
275Expected: FAIL to compile, `s.OnRevoke undefined`.
276
277- [ ] **Step 3: Add the subscriber fields**
278
279In `internal/store/store.go`, the `Store` struct becomes:
280
281```go
282type Store struct {
283 DB *sql.DB
284
285 // logWait holds one channel per build someone is following, closed
286 // by the next change to that build's row (BuildLogWait).
287 logMu sync.Mutex
288 logWait map[int64]chan struct{}
289
290 // onRevoke runs after each key revocation this process commits.
291 revokeMu sync.Mutex
292 onRevoke []func(Revoked)
293}
294```
295
296- [ ] **Step 4: Create `internal/store/revoke.go`**
297
298```go
299package store
300
301import (
302 "slices"
303 "strings"
304)
305
306// Revoked names SSH keys that stopped being valid: by id, or every key
307// of an account. The SSH listener closes the connections they opened.
308type Revoked struct {
309 KeyIDs []int64
310 UserID int64 // every key of this account; 0 for none
311}
312
313// OnRevoke registers f to run after each revocation this process
314// commits. Revocations committed by another process (gitbayd admin on
315// the host) are not announced; the listener's sweep finds those.
316func (s *Store) OnRevoke(f func(Revoked)) {
317 s.revokeMu.Lock()
318 defer s.revokeMu.Unlock()
319 s.onRevoke = append(s.onRevoke, f)
320}
321
322// announce runs the subscribers. Call it after the commit, outside any
323// transaction.
324func (s *Store) announce(r Revoked) {
325 s.revokeMu.Lock()
326 fs := slices.Clone(s.onRevoke)
327 s.revokeMu.Unlock()
328 for _, f := range fs {
329 f(r)
330 }
331}
332
333// LiveSSHKeys reports which of ids still name a registered key on an
334// account that is not disabled.
335func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
336 live := map[int64]bool{}
337 if len(ids) == 0 {
338 return live, nil
339 }
340 args := make([]any, len(ids))
341 for i, id := range ids {
342 args[i] = id
343 }
344 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
345 WHERE u.disabled = 0 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
346 if err != nil {
347 return nil, err
348 }
349 defer rows.Close()
350 for rows.Next() {
351 var id int64
352 if err := rows.Scan(&id); err != nil {
353 return nil, err
354 }
355 live[id] = true
356 }
357 return live, rows.Err()
358}
359```
360
361- [ ] **Step 5: Announce from the four removals**
362
363`RemoveSSHKey` in `internal/store/users.go`:
364
365```go
366// RemoveSSHKey removes a key owned by userID, bumps the key epoch, and
367// announces the revocation.
368func (s *Store) RemoveSSHKey(userID int64, fingerprint string) error {
369 tx, err := s.DB.Begin()
370 if err != nil {
371 return err
372 }
373 defer tx.Rollback()
374 var id int64
375 err = tx.QueryRow("DELETE FROM ssh_keys WHERE user_id = ? AND fingerprint = ? RETURNING id", userID, fingerprint).Scan(&id)
376 if errors.Is(err, sql.ErrNoRows) {
377 return ErrNotFound
378 }
379 if err != nil {
380 return err
381 }
382 if err := bumpKeyEpoch(tx); err != nil {
383 return err
384 }
385 if err := tx.Commit(); err != nil {
386 return err
387 }
388 s.announce(Revoked{KeyIDs: []int64{id}})
389 return nil
390}
391```
392
393`RemoveDeployKey`:
394
395```go
396// RemoveDeployKey removes a deploy key from a repository by fingerprint;
397// any repo admin may remove it regardless of who added it.
398func (s *Store) RemoveDeployKey(repoID int64, fingerprint string) error {
399 tx, err := s.DB.Begin()
400 if err != nil {
401 return err
402 }
403 defer tx.Rollback()
404 var id int64
405 err = tx.QueryRow(
406 "DELETE FROM ssh_keys WHERE fingerprint = ? AND scope LIKE 'deploy:' || ? || ':%' RETURNING id",
407 fingerprint, repoID).Scan(&id)
408 if errors.Is(err, sql.ErrNoRows) {
409 return ErrNotFound
410 }
411 if err != nil {
412 return err
413 }
414 if err := bumpKeyEpoch(tx); err != nil {
415 return err
416 }
417 if err := tx.Commit(); err != nil {
418 return err
419 }
420 s.announce(Revoked{KeyIDs: []int64{id}})
421 return nil
422}
423```
424
425`SetUserDisabled`, the tail from `if disabled {`:
426
427```go
428 if disabled {
429 // A pending login link is a session in waiting, so it goes with
430 // the sessions and API tokens. Re-enabling means minting again.
431 for _, table := range []string{"web_sessions", "api_tokens", "login_tokens"} {
432 if _, err := s.DB.Exec("DELETE FROM "+table+" WHERE user_id = ?", userID); err != nil {
433 return err
434 }
435 }
436 s.announce(Revoked{UserID: userID})
437 }
438 return nil
439}
440```
441
442Update its doc comment's last clause to: "and leaves the SSH keys registered but refused at every entry point until re-enabled; connections they opened are closed."
443
444`DeleteUser`, the tail:
445
446```go
447 res, err := s.DB.Exec("DELETE FROM users WHERE id = ?", id)
448 if err != nil {
449 return err
450 }
451 if n, _ := res.RowsAffected(); n == 0 {
452 return ErrNotFound
453 }
454 s.announce(Revoked{UserID: id})
455 return nil
456}
457```
458
459- [ ] **Step 6: Run the tests**
460
461Run: `go test ./internal/store -count=1`
462Expected: PASS.
463
464- [ ] **Step 7: Commit**
465
466```bash
467git add internal/store/revoke.go internal/store/revoke_test.go internal/store/store.go internal/store/users.go
468git commit -S -m "store: announce key revocations; LiveSSHKeys
469
470Ref #256"
471```
472
473### Task 1.2: `gitutil.Transport` can be cancelled
474
475**Files:**
476- Modify: `internal/gitutil/gitutil.go:39-56`
477- Create: `internal/gitutil/proc_unix.go`, `internal/gitutil/proc_other.go`
478- Test: `internal/gitutil/transport_test.go` (create)
479
480**Interfaces:**
481- Produces: `func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error` — closing `cancel` kills the git process and its children; a nil `cancel` never fires.
482
483- [ ] **Step 1: Write the failing test**
484
485`internal/gitutil/transport_test.go`:
486
487```go
488package gitutil
489
490import (
491 "io"
492 "os/exec"
493 "strings"
494 "testing"
495 "time"
496)
497
498// Closing cancel kills the transport; it does not wait for the client
499// to hang up. Stdin ends only after the kill, as a cut connection's
500// does, so a clean exit here would mean the kill never happened.
501func TestTransportCancelKillsGit(t *testing.T) {
502 dir := t.TempDir()
503 if out, err := exec.Command("git", "init", "-q", "--bare", dir).CombinedOutput(); err != nil {
504 t.Fatalf("git init: %v\n%s", err, out)
505 }
506 in, w := io.Pipe()
507 cancel := make(chan struct{})
508 errc := make(chan error, 1)
509 go func() { errc <- Transport("git-upload-pack", dir, in, io.Discard, io.Discard, nil, 0, cancel) }()
510 close(cancel)
511 time.AfterFunc(500*time.Millisecond, func() { w.Close() })
512 select {
513 case err := <-errc:
514 if err == nil || !strings.Contains(err.Error(), "killed") {
515 t.Fatalf("Transport returned %v, want the process killed", err)
516 }
517 case <-time.After(5 * time.Second):
518 t.Fatal("Transport did not return after cancel")
519 }
520}
521```
522
523- [ ] **Step 2: Run it and see it fail**
524
525Run: `go test ./internal/gitutil -run TestTransportCancelKillsGit -count=1`
526Expected: FAIL to compile, too many arguments to `Transport`.
527
528- [ ] **Step 3: Process-group helpers**
529
530`internal/gitutil/proc_unix.go`:
531
532```go
533//go:build unix
534
535package gitutil
536
537import (
538 "os/exec"
539 "syscall"
540)
541
542// ownProcessGroup puts cmd in a process group of its own, so killTree
543// ends what it started too: receive-pack runs index-pack and the hooks.
544func ownProcessGroup(cmd *exec.Cmd) {
545 if cmd.SysProcAttr == nil {
546 cmd.SysProcAttr = &syscall.SysProcAttr{}
547 }
548 cmd.SysProcAttr.Setpgid = true
549}
550
551func killTree(cmd *exec.Cmd) {
552 if cmd.Process == nil {
553 return
554 }
555 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
556 cmd.Process.Kill()
557 }
558}
559```
560
561`internal/gitutil/proc_other.go`:
562
563```go
564//go:build !unix
565
566package gitutil
567
568import "os/exec"
569
570func ownProcessGroup(cmd *exec.Cmd) {}
571
572func killTree(cmd *exec.Cmd) {
573 if cmd.Process != nil {
574 cmd.Process.Kill()
575 }
576}
577```
578
579- [ ] **Step 4: Transport**
580
581Replace `Transport` in `internal/gitutil/gitutil.go`:
582
583```go
584// Transport runs a git transport service against repoPath with the
585// client's streams. Closing cancel kills the service and everything it
586// started; a push killed before its pre-receive hook answers updates no
587// refs.
588func Transport(service, repoPath string, stdin io.Reader, stdout, errW io.Writer, extraEnv []string, maxPack int64, cancel <-chan struct{}) error {
589 var args []string
590 switch service {
591 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
592 if service == "git-receive-pack" && maxPack > 0 {
593 args = []string{"-c", fmt.Sprintf("receive.maxInputSize=%d", maxPack)}
594 }
595 args = append(args, strings.TrimPrefix(service, "git-"), repoPath)
596 default:
597 return fmt.Errorf("unknown service %q", service)
598 }
599 cmd := exec.Command(toolpath.Look("git"), args...)
600 cmd.Env = append(os.Environ(), extraEnv...)
601 cmd.Stdin = stdin
602 cmd.Stdout = stdout
603 cmd.Stderr = errW
604 ownProcessGroup(cmd)
605 if err := cmd.Start(); err != nil {
606 return err
607 }
608 finished := make(chan struct{})
609 go func() {
610 select {
611 case <-cancel:
612 killTree(cmd)
613 case <-finished:
614 }
615 }()
616 err := cmd.Wait()
617 close(finished)
618 return err
619}
620```
621
622If the current doc comment above `Transport` (line 38 and up) says something different, keep its first sentence and replace the rest with the above.
623
624- [ ] **Step 5: Fix the caller so the tree builds**
625
626In `internal/sshd/sshd.go:464`, pass `nil` for now (Task 1.3 wires the channel):
627
628```go
629 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, nil); err != nil {
630```
631
632- [ ] **Step 6: Run the tests**
633
634Run: `go build ./... && go test ./internal/gitutil -count=1`
635Expected: PASS.
636
637- [ ] **Step 7: Commit**
638
639```bash
640git add internal/gitutil internal/sshd/sshd.go
641git commit -S -m "gitutil: Transport takes a cancel channel and kills its process group
642
643Ref #256"
644```
645
646### Task 1.3: sshd re-reads the key per exec and cuts revoked connections
647
648**Files:**
649- Modify: `internal/sshd/sshd.go` — `conn` (46-53), `New` (55-71), `Serve` (158-180), `handleConn` (214-238), `handleSession` (240-292), `runExec` (299-313), `Exec` (339-385), `runGit` (387-468)
650- Modify: `cmd/gitbayd/system.go:97`
651- Modify: `internal/sshd/sshd_test.go:20-87` (`followServer` split)
652- Test: `internal/sshd/revoke_test.go` (create)
653
654**Interfaces:**
655- Consumes: `store.Revoked`, `Store.OnRevoke`, `Store.LiveSSHKeys` (Task 1.1); `gitutil.Transport(..., cancel)` (Task 1.2).
656- Produces:
657 - `func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string, stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int` — scope and audit source come from `key`.
658 - `func (s *Server) sweepOnce()` (tests call it).
659 - Test helpers in package `sshd`: `type testServer struct{ srv *Server; st *store.Store; client *ssh.Client; uid, keyID int64; fp string }`, `newTestServer(t) testServer`, `withBuild(t, ts)`, `execStatus(client, cmd) (int, string)`, `waitClosed(t, client)`.
660
661- [ ] **Step 1: Split the test fixture**
662
663In `internal/sshd/sshd_test.go`, replace `followServer` (lines 20-87) with:
664
665```go
666// testServer is an embedded server over a fresh store holding alice
667// with one full-scope key, and a client connected with that key.
668type testServer struct {
669 srv *Server
670 st *store.Store
671 client *ssh.Client
672 uid int64
673 keyID int64
674 fp string
675}
676
677func newTestServer(t *testing.T) testServer {
678 t.Helper()
679 root := t.TempDir()
680 st, err := store.Open(filepath.Join(root, "gitbay.db"))
681 if err != nil {
682 t.Fatal(err)
683 }
684 t.Cleanup(func() { st.Close() })
685 if err := st.MigrateUp(); err != nil {
686 t.Fatal(err)
687 }
688 uid, err := st.CreateUser("alice", false)
689 if err != nil {
690 t.Fatal(err)
691 }
692 _, priv, err := ed25519.GenerateKey(rand.Reader)
693 if err != nil {
694 t.Fatal(err)
695 }
696 signer, err := ssh.NewSignerFromKey(priv)
697 if err != nil {
698 t.Fatal(err)
699 }
700 pub := signer.PublicKey()
701 fp := ssh.FingerprintSHA256(pub)
702 if err := st.AddSSHKey(uid, fp, pub.Type(), pub.Marshal(), "full", "test"); err != nil {
703 t.Fatal(err)
704 }
705 key, err := st.SSHKeyByFingerprint(fp)
706 if err != nil {
707 t.Fatal(err)
708 }
709
710 cfg := config.Default()
711 cfg.Server.Root = root
712 srv, err := New(cfg, st)
713 if err != nil {
714 t.Fatal(err)
715 }
716 ln, err := net.Listen("tcp", "127.0.0.1:0")
717 if err != nil {
718 t.Fatal(err)
719 }
720 go srv.Serve(ln)
721 t.Cleanup(func() { ln.Close() })
722
723 client, err := ssh.Dial("tcp", ln.Addr().String(), &ssh.ClientConfig{
724 User: "git",
725 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
726 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
727 Timeout: 5 * time.Second,
728 })
729 if err != nil {
730 t.Fatal(err)
731 }
732 t.Cleanup(func() { client.Close() })
733 return testServer{srv: srv, st: st, client: client, uid: uid, keyID: key.ID, fp: fp}
734}
735
736// withBuild gives alice the public repo alice/app and a queued build 1
737// whose log has one line.
738func withBuild(t *testing.T, ts testServer) {
739 t.Helper()
740 repoID, err := ts.st.CreateRepo("user", ts.uid, "app", "public")
741 if err != nil {
742 t.Fatal(err)
743 }
744 id, err := ts.st.CreateBuild(repoID, "unit", "abc", "main", `["true"]`, "", "", true)
745 if err != nil {
746 t.Fatal(err)
747 }
748 if err := ts.st.AppendBuildLog(id, []byte("queued\n")); err != nil {
749 t.Fatal(err)
750 }
751}
752
753// followServer starts an embedded server holding alice, her public repo
754// alice/app and a queued build 1 whose log has one line, and returns it
755// with a client connected as alice.
756func followServer(t *testing.T) (*Server, *ssh.Client) {
757 t.Helper()
758 ts := newTestServer(t)
759 withBuild(t, ts)
760 return ts.srv, ts.client
761}
762```
763
764Run: `go test ./internal/sshd -count=1`
765Expected: PASS (behaviour unchanged).
766
767- [ ] **Step 2: Write the failing tests**
768
769`internal/sshd/revoke_test.go`:
770
771```go
772package sshd
773
774import (
775 "bytes"
776 "errors"
777 "strings"
778 "testing"
779 "time"
780
781 "golang.org/x/crypto/ssh"
782)
783
784// execStatus runs cmd on a new session and returns its exit status and
785// stderr; -1 when the session could not run.
786func execStatus(client *ssh.Client, cmd string) (int, string) {
787 sess, err := client.NewSession()
788 if err != nil {
789 return -1, err.Error()
790 }
791 defer sess.Close()
792 var stderr bytes.Buffer
793 sess.Stderr = &stderr
794 err = sess.Run(cmd)
795 var exit *ssh.ExitError
796 switch {
797 case err == nil:
798 return 0, stderr.String()
799 case errors.As(err, &exit):
800 return exit.ExitStatus(), stderr.String()
801 }
802 return -1, err.Error()
803}
804
805// waitClosed fails unless the server closes the client's connection
806// within five seconds.
807func waitClosed(t *testing.T, client *ssh.Client) {
808 t.Helper()
809 done := make(chan struct{})
810 go func() { client.Wait(); close(done) }()
811 select {
812 case <-done:
813 case <-time.After(5 * time.Second):
814 t.Fatal("the connection stayed open")
815 }
816}
817
818// Each exec reads the key again. The rows change behind the store's
819// back here, so no revocation is announced and the connection stays up:
820// what refuses the command is the per-exec check alone.
821func TestExecRevalidatesKey(t *testing.T) {
822 ts := newTestServer(t)
823 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
824 t.Fatalf("whoami: %d %s", code, errOut)
825 }
826 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET scope = 'git' WHERE id = ?", ts.keyID); err != nil {
827 t.Fatal(err)
828 }
829 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "does not allow control commands") {
830 t.Fatalf("whoami after re-scope: %d %q", code, errOut)
831 }
832 if _, err := ts.st.DB.Exec("DELETE FROM ssh_keys WHERE id = ?", ts.keyID); err != nil {
833 t.Fatal(err)
834 }
835 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "no longer registered") {
836 t.Fatalf("whoami after delete: %d %q", code, errOut)
837 }
838}
839
840// Removing the key cuts the connection, ending a command running on it.
841func TestRemoveKeyCutsConnection(t *testing.T) {
842 ts := newTestServer(t)
843 withBuild(t, ts)
844 var stderr bytes.Buffer
845 sess := startFollow(t, ts.client, &stderr)
846 if err := ts.st.RemoveSSHKey(ts.uid, ts.fp); err != nil {
847 t.Fatal(err)
848 }
849 waited := make(chan error, 1)
850 go func() { waited <- sess.Wait() }()
851 select {
852 case err := <-waited:
853 if err == nil {
854 t.Fatal("the follow exited cleanly after its key was removed")
855 }
856 case <-time.After(5 * time.Second):
857 t.Fatal("the follow outlived its key")
858 }
859 waitClosed(t, ts.client)
860}
861
862func TestDisableCutsConnection(t *testing.T) {
863 ts := newTestServer(t)
864 if err := ts.st.SetUserDisabled(ts.uid, true); err != nil {
865 t.Fatal(err)
866 }
867 waitClosed(t, ts.client)
868}
869
870// A revocation made by another process is not announced here; the
871// sweep finds it. A live key survives the sweep.
872func TestSweepCutsOutOfProcessRevocation(t *testing.T) {
873 ts := newTestServer(t)
874 ts.srv.sweepOnce()
875 if code, errOut := execStatus(ts.client, "whoami"); code != 0 {
876 t.Fatalf("the sweep cut a live key: %d %s", code, errOut)
877 }
878 if _, err := ts.st.DB.Exec("UPDATE users SET disabled = 1 WHERE id = ?", ts.uid); err != nil {
879 t.Fatal(err)
880 }
881 ts.srv.sweepOnce()
882 waitClosed(t, ts.client)
883}
884```
885
886- [ ] **Step 3: Run them and see them fail**
887
888Run: `go test ./internal/sshd -run 'TestExecRevalidatesKey|TestRemoveKeyCutsConnection|TestDisableCutsConnection|TestSweepCutsOutOfProcessRevocation' -count=1`
889Expected: FAIL to compile, `ts.srv.sweepOnce undefined`.
890
891- [ ] **Step 4: Track the key on each connection**
892
893In `internal/sshd/sshd.go` add `"slices"` to the imports. Replace `conn`:
894
895```go
896// conn is one accepted connection and how many sessions it is running.
897// A CLI's shared connection sits idle between commands; on shutdown an
898// idle connection is closed at once and only a session mid-command is
899// waited for (#141).
900type conn struct {
901 net net.Conn
902 active atomic.Int32
903 // keyID and userID are the key that authenticated the connection and
904 // its account: 0 before the handshake and for an unregistered key.
905 // Guarded by Server.mu.
906 keyID, userID int64
907 revoked chan struct{} // closed by cut
908 cutOnce sync.Once
909}
910
911// cut ends the connection because its key was revoked: a git transport
912// on it is killed, and every other command loses its channel.
913func (c *conn) cut() {
914 c.cutOnce.Do(func() { close(c.revoked) })
915 c.net.Close()
916}
917```
918
919In `New`, after `s.sshCfg = sc`:
920
921```go
922 st.OnRevoke(s.revoke)
923```
924
925`Serve` becomes:
926
927```go
928// Serve accepts connections on ln until it is closed.
929func (s *Server) Serve(ln net.Listener) error {
930 served := make(chan struct{})
931 defer close(served)
932 go s.sweep(served)
933 for {
934 nc, err := ln.Accept()
935 if err != nil {
936 return err
937 }
938 c := &conn{net: nc, revoked: make(chan struct{})}
939 s.mu.Lock()
940 s.conns[c] = struct{}{}
941 s.mu.Unlock()
942 s.sessions.Add(1)
943 go func() {
944 defer s.sessions.Done()
945 defer func() {
946 s.mu.Lock()
947 delete(s.conns, c)
948 s.mu.Unlock()
949 }()
950 s.handleConn(c)
951 }()
952 }
953}
954```
955
956Add after `Serve`:
957
958```go
959// revoke closes the connections opened by the keys r names.
960func (s *Server) revoke(r store.Revoked) {
961 s.mu.Lock()
962 defer s.mu.Unlock()
963 for c := range s.conns {
964 if c.keyID == 0 {
965 continue
966 }
967 if (r.UserID != 0 && c.userID == r.UserID) || slices.Contains(r.KeyIDs, c.keyID) {
968 c.cut()
969 }
970 }
971}
972
973// sweepInterval bounds how long a revocation this process was not told
974// about (gitbayd admin on the host) leaves a connection open.
975const sweepInterval = 15 * time.Second
976
977func (s *Server) sweep(served <-chan struct{}) {
978 t := time.NewTicker(sweepInterval)
979 defer t.Stop()
980 for {
981 select {
982 case <-t.C:
983 s.sweepOnce()
984 case <-served:
985 return
986 case <-s.stopping:
987 return
988 }
989 }
990}
991
992// sweepOnce cuts every connection whose key is no longer live. Only
993// connections whose key was asked about are judged: one that
994// authenticated while the query ran waits for the next sweep.
995func (s *Server) sweepOnce() {
996 asked := map[int64]bool{}
997 s.mu.Lock()
998 for c := range s.conns {
999 if c.keyID != 0 {
1000 asked[c.keyID] = true
1001 }
1002 }
1003 s.mu.Unlock()
1004 if len(asked) == 0 {
1005 return
1006 }
1007 live, err := s.st.LiveSSHKeys(slices.Collect(maps.Keys(asked)))
1008 if err != nil {
1009 slog.Error("ssh sweep: key lookup", "err", err)
1010 return
1011 }
1012 s.mu.Lock()
1013 defer s.mu.Unlock()
1014 for c := range s.conns {
1015 if asked[c.keyID] && !live[c.keyID] {
1016 c.cut()
1017 }
1018 }
1019}
1020```
1021
1022Add `"maps"` to the imports.
1023
1024In `handleConn`, after `defer sconn.Close()`:
1025
1026```go
1027 ext := sconn.Permissions.Extensions
1028 s.mu.Lock()
1029 c.keyID, _ = strconv.ParseInt(ext["key-id"], 10, 64)
1030 c.userID, _ = strconv.ParseInt(ext["user-id"], 10, 64)
1031 s.mu.Unlock()
1032```
1033
1034and pass `c` to the session: `s.handleSession(c, sconn, ch, chReqs)`.
1035
1036`handleSession` takes the connection:
1037
1038```go
1039func (s *Server) handleSession(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, reqs <-chan *ssh.Request) {
1040```
1041
1042and its exec case calls `code := s.runExec(c, sconn, ch, term, payload.Command, done)`.
1043
1044- [ ] **Step 5: Re-read the key per exec**
1045
1046Replace `runExec`:
1047
1048```go
1049func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term control.Term, cmdline string, done <-chan struct{}) int {
1050 ext := sconn.Permissions.Extensions
1051 if blob := ext["anon-key"]; blob != "" {
1052 return s.runAnonymous(ch, blob, cmdline)
1053 }
1054 userID, _ := strconv.ParseInt(ext["user-id"], 10, 64)
1055 keyID, _ := strconv.ParseInt(ext["key-id"], 10, 64)
1056 // A connection outlives its commands, so the key is read again for
1057 // each one: what it may do is what it may do now (#256).
1058 key, err := s.st.SSHKeyByID(keyID)
1059 if errors.Is(err, store.ErrNotFound) || (err == nil && key.UserID != userID) {
1060 fmt.Fprintln(ch.Stderr(), "this key is no longer registered")
1061 return protocol.ExitDenied
1062 }
1063 if err != nil {
1064 slog.Error("ssh exec: key lookup", "err", err)
1065 fmt.Fprintln(ch.Stderr(), "authentication temporarily unavailable")
1066 return protocol.ExitFailure
1067 }
1068 user, err := s.st.UserByID(userID)
1069 if err != nil {
1070 fmt.Fprintln(ch.Stderr(), "account no longer exists")
1071 return protocol.ExitDenied
1072 }
1073 _ = s.st.TouchSSHKey(keyID)
1074 return Exec(s.cfg, s.st, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
1075}
1076```
1077
1078- [ ] **Step 6: `Exec` takes the key; `runGit` takes the cancel channel**
1079
1080```go
1081// Exec runs one SSH exec command line for an authenticated key. It is the
1082// single dispatch path shared by the embedded listener and the system-sshd
1083// forced command (gitbayd shell). Closing revoked kills a git transport.
1084func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey, term control.Term, cmdline string,
1085 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
1086```
1087
1088Inside `Exec`: `runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)`, `runLFSAuthenticate(cfg, st, user, key.Scope, argv, stdout, stderr)`, and in the `Ctx` literal `Scope: key.Scope, Source: key.Fingerprint`.
1089
1090`runGit` gains a last parameter `revoked <-chan struct{}` and passes it on:
1091
1092```go
1093func runGit(cfg config.Config, st *store.Store, user store.User, scope string, argv []string,
1094 stdin io.Reader, stdout, stderr io.Writer, revoked <-chan struct{}) int {
1095```
1096
1097```go
1098 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, env, maxPack, revoked); err != nil {
1099```
1100
1101In `cmd/gitbayd/system.go:97`:
1102
1103```go
1104 code := sshd.Exec(cfg, st, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
1105```
1106
1107- [ ] **Step 7: Run the tests**
1108
1109Run: `go build ./... && go vet ./... && go test ./internal/sshd ./internal/gitutil ./internal/store -count=1`
1110Expected: PASS.
1111
1112- [ ] **Step 8: Commit**
1113
1114```bash
1115git add internal/sshd cmd/gitbayd/system.go
1116git commit -S -m "sshd: re-read the key per exec; revocation cuts its connections
1117
1118Ref #256"
1119```
1120
1121### Task 1.4: e2e — a multiplexed connection is cut, a push in flight moves no ref
1122
1123**Files:**
1124- Create: `e2e/revoke_test.go`
1125
1126**Interfaces:**
1127- Produces (package `e2e`): `pkt(s string) string`, `readPkt(r *bufio.Reader) (string, error)`, `fingerprint(t *testing.T, pubPath string) string` — MR 2 uses `fingerprint`.
1128
1129- [ ] **Step 1: Write the test**
1130
1131```go
1132package e2e
1133
1134import (
1135 "bufio"
1136 "fmt"
1137 "io"
1138 "os"
1139 "os/exec"
1140 "path/filepath"
1141 "strconv"
1142 "strings"
1143 "testing"
1144 "time"
1145)
1146
1147// pkt frames one pkt-line.
1148func pkt(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
1149
1150// readPkt reads one pkt-line; a flush reads as "".
1151func readPkt(r *bufio.Reader) (string, error) {
1152 var n [4]byte
1153 if _, err := io.ReadFull(r, n[:]); err != nil {
1154 return "", err
1155 }
1156 size, err := strconv.ParseUint(string(n[:]), 16, 16)
1157 if err != nil {
1158 return "", err
1159 }
1160 if size == 0 {
1161 return "", nil
1162 }
1163 buf := make([]byte, size-4)
1164 _, err = io.ReadFull(r, buf)
1165 return string(buf), err
1166}
1167
1168// fingerprint is the SHA256 fingerprint of a public key file.
1169func fingerprint(t *testing.T, pubPath string) string {
1170 t.Helper()
1171 out, err := exec.Command("ssh-keygen", "-lf", pubPath).Output()
1172 if err != nil {
1173 t.Fatalf("ssh-keygen -lf: %v", err)
1174 }
1175 return strings.Fields(string(out))[1]
1176}
1177
1178// Removing a key cuts the connections it opened: every session
1179// multiplexed on a ControlMaster, and a push in flight, which moves no
1180// ref (#256).
1181func TestRemovedKeyCutsMultiplexedConnection(t *testing.T) {
1182 t.Parallel()
1183 inst := startInstance(t)
1184 aliceKey := setupPublicRepo(t, inst, "alice/app")
1185 spare := inst.newKey(t, "spare")
1186 pub, err := os.ReadFile(spare + ".pub")
1187 if err != nil {
1188 t.Fatal(err)
1189 }
1190 if _, errOut, code := inst.ssh(t, aliceKey, string(pub), "keys", "add"); code != 0 {
1191 t.Fatalf("keys add: %s", errOut)
1192 }
1193
1194 // The control socket sits under the system temp dir: t.TempDir() on
1195 // macOS is long enough to pass the 104-byte socket path limit.
1196 cmDir, err := os.MkdirTemp("", "cm")
1197 if err != nil {
1198 t.Fatal(err)
1199 }
1200 t.Cleanup(func() { os.RemoveAll(cmDir) })
1201 muxArgs := []string{
1202 "-p", fmt.Sprint(inst.port),
1203 "-i", aliceKey,
1204 "-o", "IdentitiesOnly=yes",
1205 "-o", "StrictHostKeyChecking=no",
1206 "-o", "UserKnownHostsFile=" + filepath.Join(inst.sshDir, "known_hosts"),
1207 "-o", "BatchMode=yes",
1208 "-o", "ControlMaster=auto",
1209 "-o", "ControlPath=" + filepath.Join(cmDir, "%C"),
1210 "-o", "ControlPersist=60",
1211 }
1212 mux := func(args ...string) *exec.Cmd {
1213 return exec.Command("ssh", append(append([]string{}, muxArgs...), args...)...)
1214 }
1215 t.Cleanup(func() { mux("-O", "exit", "git@127.0.0.1").Run() })
1216
1217 if out, err := mux("git@127.0.0.1", "whoami").Output(); err != nil || strings.TrimSpace(string(out)) != "alice" {
1218 t.Fatalf("whoami over the master: %v %q", err, out)
1219 }
1220
1221 // A push held open mid-pack: the ref update is sent, the pack is not.
1222 push := mux("git@127.0.0.1", "git-receive-pack", "alice/app")
1223 stdin, err := push.StdinPipe()
1224 if err != nil {
1225 t.Fatal(err)
1226 }
1227 stdout, err := push.StdoutPipe()
1228 if err != nil {
1229 t.Fatal(err)
1230 }
1231 if err := push.Start(); err != nil {
1232 t.Fatal(err)
1233 }
1234 adv := bufio.NewReader(stdout)
1235 first, err := readPkt(adv)
1236 if err != nil || len(first) < 40 {
1237 t.Fatalf("advertisement: %q %v", first, err)
1238 }
1239 oldSHA := first[:40]
1240 for {
1241 line, err := readPkt(adv)
1242 if err != nil {
1243 t.Fatalf("advertisement: %v", err)
1244 }
1245 if line == "" {
1246 break
1247 }
1248 }
1249 newSHA := strings.Repeat("1", 40)
1250 io.WriteString(stdin, pkt(oldSHA+" "+newSHA+" refs/heads/main\x00report-status\n")+"0000")
1251 // A pack header announcing one object, and no object.
1252 stdin.Write([]byte("PACK\x00\x00\x00\x02\x00\x00\x00\x01"))
1253 exited := make(chan error, 1)
1254 go func() {
1255 io.Copy(io.Discard, adv)
1256 exited <- push.Wait()
1257 }()
1258
1259 if _, errOut, code := inst.ssh(t, spare, "", "keys", "remove", fingerprint(t, aliceKey+".pub")); code != 0 {
1260 t.Fatalf("keys remove: %s", errOut)
1261 }
1262 select {
1263 case err := <-exited:
1264 if err == nil {
1265 t.Fatal("the push exited cleanly after its key was removed")
1266 }
1267 case <-time.After(10 * time.Second):
1268 t.Fatal("the push outlived its key")
1269 }
1270
1271 // The master went with the connection; a new one authenticates
1272 // again, and the key is unknown.
1273 if out, err := mux("git@127.0.0.1", "whoami").CombinedOutput(); err == nil {
1274 t.Fatalf("whoami after removal succeeded: %s", out)
1275 }
1276 refs := mustGit(t, t.TempDir(), inst.gitEnv(spare), "ls-remote", inst.sshURL("alice/app"), "refs/heads/main")
1277 if !strings.HasPrefix(refs, oldSHA) {
1278 t.Fatalf("main moved: %s, want %s", refs, oldSHA)
1279 }
1280}
1281```
1282
1283- [ ] **Step 2: Run it**
1284
1285Run: `go test ./e2e -run TestRemovedKeyCutsMultiplexedConnection -count=1`
1286Expected: PASS. To see it fail, stash Task 1.3's `st.OnRevoke(s.revoke)` line: the push then hangs until the 10-second timeout.
1287
1288- [ ] **Step 3: Commit**
1289
1290```bash
1291git add e2e/revoke_test.go
1292git commit -S -m "e2e: removing a key cuts its multiplexed connection and a push in flight
1293
1294Ref #256"
1295```
1296
1297### Task 1.5: docs
1298
1299**Files:**
1300- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `09-Controls.org:28`, `05-Identity-and-Access.org:17-18`, `08-Operations.org:88-89`, `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Users.org` (after the keys block, ~line 70)
1301
1302- [ ] **Step 1: Edit the pages**
1303
1304`10-Known-Gaps.org`: delete the `#256` row. In the paragraph under the table, drop "#256 closes a removed key's connections, running commands included;" so it opens "Decisions already taken on these: #257 refuses ...".
1305
1306`09-Controls.org`, the revocation row becomes:
1307
1308```
1309| Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=) |
1310```
1311
1312`05-Identity-and-Access.org`, the SSH user key and deploy key rows' Revocation cells become `=keys remove= (own keys); closes its connections` and `=repo deploy-key remove= (repo admin); closes its connections`.
1313
1314`08-Operations.org`: delete the two lines "Open connections of a removed key keep working until they close; see #256."
1315
1316`Threat-Model.org`, add to "Trust boundaries" after the "SSH public key = identity" bullet:
1317
1318```
1319- *Revocation is immediate.* Every exec and every git transport session
1320 re-reads its key. Removing a key, removing a deploy key, disabling or
1321 deleting an account closes the connections the affected keys opened:
1322 a git transport is killed with its children, and a push killed before
1323 its pre-receive hook answers moves no ref. A control command already
1324 inside its database write finishes it; its output is lost. A
1325 revocation made by =gitbayd admin= on the host, another process, is
1326 found within 15 seconds. In =ssh.mode = "system"= each exec is its
1327 own process: the next exec is refused, one already running is not cut.
1328```
1329
1330`Users.org`, after the paragraph that ends "Labels are one line of up to 64 bytes.":
1331
1332```
1333Removing a key closes every connection it opened, including the CLI's
1334shared one; removing the key the current command runs on ends that
1335command's connection too.
1336```
1337
1338- [ ] **Step 2: Commit, open the MR**
1339
1340```bash
1341git add .gitbay/wiki
1342git commit -S -m "wiki: revocation closes open connections
1343
1344Closes #256"
1345git push -u origin revoke-closes-connections
1346gitbay mr create --source revoke-closes-connections --target main --title "sshd: removing a key closes its connections"
1347```
1348
1349Merge with `--strategy ff` once CI is green; delete the branch locally and on the remote.
1350
1351---
1352
1353# MR 2: expiring credentials cannot mint; credentials record their token (branch `token-delegation`, #257)
1354
1355Decisions on the issue: a token with an expiry is refused on every
1356credential-minting command, marked on `Command` and checked in
1357`Dispatch`; tokens and keys record the token that created them;
1358`token revoke` lists what the token created and can revoke it too;
1359`token create` defaults to `--scope read`.
1360
1361Commands marked `MintsCredential`: `token create`, `keys add`,
1362`repo deploy-key add`, `repo runner add` (attaches or creates a key
1363that can claim builds), `web login` (a login link opens a seven-day
1364session), `admin invite`, `admin user create` (with `--key` or a
1365verified address it is a way in), `email verify` and
1366`admin email verify` (a verified address receives login links). See
1367open question 2.
1368
1369`created_by_token` references `api_tokens(id) ON DELETE SET NULL`: a
1370revoked token's id is never reused for a live row, because SQLite
1371reuses the highest rowid after it is deleted and a dangling integer
1372would then name the wrong token. Revoking without `--created` lists
1373what the token made and then drops the link.
1374
1375### Task 2.1: migration 0060 and the store
1376
1377**Files:**
1378- Create: `internal/store/migrations/0060_credential_origin.up.sql`, `.down.sql`
1379- Modify: `internal/store/tokens.go` (whole file)
1380- Modify: `internal/store/users.go` — `SSHKey` (18-28), `AddSSHKey` (270-289), `ListSSHKeys` (335-353)
1381- Test: `internal/store/tokens_test.go` (create)
1382
1383**Interfaces:**
1384- Consumes: `Store.announce`, `Revoked` (MR 1).
1385- Produces:
1386 - `type APIToken struct { ID int64; Name, Scope, CreatedAt string; ExpiresAt, LastUsedAt *time.Time; CreatedBy string }` — `CreatedBy` is the creating token's name, "" for none.
1387 - `func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error`
1388 - `func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error)`
1389 - `type Created struct { Tokens []string; Keys []string }` — token names and key fingerprints.
1390 - `func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error)`
1391 - `type KeyOrigin struct { CreatedByToken int64 }` (MR 3 adds `ExpiresAt`)
1392 - `func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error`; `AddSSHKey` keeps its signature and calls it with `KeyOrigin{}`.
1393 - `SSHKey.CreatedBy string` — filled by `ListSSHKeys` only.
1394
1395- [ ] **Step 1: Write the failing test**
1396
1397`internal/store/tokens_test.go`:
1398
1399```go
1400package store
1401
1402import (
1403 "slices"
1404 "testing"
1405 "time"
1406)
1407
1408func tokenID(t *testing.T, s *Store, hash string) int64 {
1409 t.Helper()
1410 _, tok, err := s.APITokenUser(hash)
1411 if err != nil {
1412 t.Fatal(err)
1413 }
1414 return tok.ID
1415}
1416
1417// parent made child, child made grandchild and a key; the key belongs
1418// to another account, as admin user create --key makes one.
1419func tokenChain(t *testing.T) (*Store, int64, *[]Revoked) {
1420 t.Helper()
1421 s, uid, got := revokeFixture(t)
1422 bob, err := s.CreateUser("bob", false)
1423 if err != nil {
1424 t.Fatal(err)
1425 }
1426 if err := s.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1427 t.Fatal(err)
1428 }
1429 if err := s.CreateAPIToken(uid, "child", "h-child", "full", nil, tokenID(t, s, "h-parent")); err != nil {
1430 t.Fatal(err)
1431 }
1432 child := tokenID(t, s, "h-child")
1433 if err := s.CreateAPIToken(uid, "grandchild", "h-grand", "read", nil, child); err != nil {
1434 t.Fatal(err)
1435 }
1436 if err := s.AddSSHKeyFrom(bob, "SHA256:k", "ssh-ed25519", []byte("k"), "full", "", KeyOrigin{CreatedByToken: child}); err != nil {
1437 t.Fatal(err)
1438 }
1439 return s, uid, got
1440}
1441
1442func TestTokenRecordsItsCreator(t *testing.T) {
1443 s, uid, _ := tokenChain(t)
1444 toks, err := s.ListAPITokens(uid)
1445 if err != nil {
1446 t.Fatal(err)
1447 }
1448 by := map[string]string{}
1449 for _, tk := range toks {
1450 by[tk.Name] = tk.CreatedBy
1451 }
1452 if by["parent"] != "" || by["child"] != "parent" || by["grandchild"] != "child" {
1453 t.Fatalf("created by: %v", by)
1454 }
1455 bob, _ := s.UserByUsername("bob")
1456 keys, err := s.ListSSHKeys(bob.ID)
1457 if err != nil || len(keys) != 1 || keys[0].CreatedBy != "child" {
1458 t.Fatalf("key created by: %+v %v", keys, err)
1459 }
1460}
1461
1462func TestRevokeAPITokenListsWhatItCreated(t *testing.T) {
1463 s, uid, got := tokenChain(t)
1464 c, err := s.RevokeAPIToken(uid, "parent", false)
1465 if err != nil {
1466 t.Fatal(err)
1467 }
1468 if !slices.Equal(c.Tokens, []string{"child", "grandchild"}) || !slices.Equal(c.Keys, []string{"SHA256:k"}) {
1469 t.Fatalf("created = %+v", c)
1470 }
1471 // Listed, not removed; the link to the revoked parent is gone.
1472 toks, _ := s.ListAPITokens(uid)
1473 if len(toks) != 2 || toks[0].Name != "child" || toks[0].CreatedBy != "" {
1474 t.Fatalf("tokens after revoke: %+v", toks)
1475 }
1476 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != nil {
1477 t.Fatalf("the key went: %v", err)
1478 }
1479 if len(*got) != 0 {
1480 t.Fatalf("announced %+v with nothing revoked but the token", *got)
1481 }
1482}
1483
1484func TestRevokeAPITokenWithCreated(t *testing.T) {
1485 s, uid, got := tokenChain(t)
1486 k, _ := s.SSHKeyByFingerprint("SHA256:k")
1487 if _, err := s.RevokeAPIToken(uid, "parent", true); err != nil {
1488 t.Fatal(err)
1489 }
1490 if toks, _ := s.ListAPITokens(uid); len(toks) != 0 {
1491 t.Fatalf("tokens left: %+v", toks)
1492 }
1493 if _, err := s.SSHKeyByFingerprint("SHA256:k"); err != ErrNotFound {
1494 t.Fatalf("key left: %v", err)
1495 }
1496 if len(*got) != 1 || !slices.Equal((*got)[0].KeyIDs, []int64{k.ID}) {
1497 t.Fatalf("announced %+v", *got)
1498 }
1499 if _, err := s.RevokeAPIToken(uid, "parent", true); err != ErrNotFound {
1500 t.Fatalf("second revoke: %v", err)
1501 }
1502}
1503
1504func TestAPITokenUserCarriesExpiry(t *testing.T) {
1505 s, uid, _ := revokeFixture(t)
1506 exp := time.Now().Add(time.Hour)
1507 if err := s.CreateAPIToken(uid, "brief", "h-brief", "full", &exp, 0); err != nil {
1508 t.Fatal(err)
1509 }
1510 _, tok, err := s.APITokenUser("h-brief")
1511 if err != nil || tok.ExpiresAt == nil || tok.Name != "brief" || tok.ID == 0 {
1512 t.Fatalf("token %+v %v", tok, err)
1513 }
1514}
1515```
1516
1517- [ ] **Step 2: Run it and see it fail**
1518
1519Run: `go test ./internal/store -run 'TestTokenRecordsItsCreator|TestRevokeAPIToken|TestAPITokenUserCarriesExpiry' -count=1`
1520Expected: FAIL to compile.
1521
1522- [ ] **Step 3: Migration**
1523
1524`internal/store/migrations/0060_credential_origin.up.sql`:
1525
1526```sql
1527-- The API token a credential was created through. NULL when it was not,
1528-- and once that token is revoked.
1529ALTER TABLE api_tokens ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1530ALTER TABLE ssh_keys ADD COLUMN created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL;
1531```
1532
1533`internal/store/migrations/0060_credential_origin.down.sql`:
1534
1535```sql
1536ALTER TABLE ssh_keys DROP COLUMN created_by_token;
1537ALTER TABLE api_tokens DROP COLUMN created_by_token;
1538```
1539
1540- [ ] **Step 4: Tokens in the store**
1541
1542Replace `internal/store/tokens.go`:
1543
1544```go
1545package store
1546
1547import (
1548 "database/sql"
1549 "errors"
1550 "fmt"
1551 "strings"
1552 "time"
1553)
1554
1555type APIToken struct {
1556 ID int64
1557 Name string
1558 Scope string
1559 CreatedAt string
1560 ExpiresAt *time.Time
1561 LastUsedAt *time.Time
1562 CreatedBy string // name of the token that created this one; "" for none
1563}
1564
1565// nullID stores 0 as NULL.
1566func nullID(id int64) any {
1567 if id == 0 {
1568 return nil
1569 }
1570 return id
1571}
1572
1573// CreateAPIToken stores a token hash; expires nil means no expiry,
1574// createdByToken 0 means it was not created through a token.
1575func (s *Store) CreateAPIToken(userID int64, name, tokenHash, scope string, expires *time.Time, createdByToken int64) error {
1576 var exp any
1577 if expires != nil {
1578 exp = fmtTime(*expires)
1579 }
1580 _, err := s.DB.Exec(
1581 "INSERT INTO api_tokens (user_id, name, token_hash, scope, expires_at, created_by_token) VALUES (?, ?, ?, ?, ?, ?)",
1582 userID, name, tokenHash, scope, exp, nullID(createdByToken))
1583 if isUniqueErr(err) {
1584 return fmt.Errorf("you already have a token named %q", name)
1585 }
1586 return err
1587}
1588
1589// APITokenUser resolves a presented token to its user and the token;
1590// expired and unknown tokens fail identically.
1591func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
1592 var userID int64
1593 var t APIToken
1594 var exp sql.NullString
1595 err := s.DB.QueryRow(`
1596 SELECT user_id, id, name, scope, expires_at FROM api_tokens
1597 WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > ?)`,
1598 tokenHash, fmtTime(time.Now())).Scan(&userID, &t.ID, &t.Name, &t.Scope, &exp)
1599 if errors.Is(err, sql.ErrNoRows) {
1600 return User{}, APIToken{}, ErrNotFound
1601 }
1602 if err != nil {
1603 return User{}, APIToken{}, err
1604 }
1605 t.ExpiresAt = parseTime(exp)
1606 s.DB.Exec("UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE token_hash = ?", tokenHash)
1607 u, err := s.UserByID(userID)
1608 return u, t, err
1609}
1610
1611func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
1612 rows, err := s.DB.Query(`
1613 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')
1614 FROM api_tokens t LEFT JOIN api_tokens p ON p.id = t.created_by_token
1615 WHERE t.user_id = ? ORDER BY t.name`, userID)
1616 if err != nil {
1617 return nil, err
1618 }
1619 defer rows.Close()
1620 var out []APIToken
1621 for rows.Next() {
1622 var t APIToken
1623 var exp, used sql.NullString
1624 if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp, &used, &t.CreatedBy); err != nil {
1625 return nil, err
1626 }
1627 t.ExpiresAt = parseTime(exp)
1628 t.LastUsedAt = parseTime(used)
1629 out = append(out, t)
1630 }
1631 return out, rows.Err()
1632}
1633
1634// Created is what a token made, directly or through tokens it made:
1635// token names and SSH key fingerprints.
1636type Created struct {
1637 Tokens []string `json:"tokens"`
1638 Keys []string `json:"keys"`
1639}
1640
1641// chainCTE selects the token named by the first argument and every
1642// token created from it, at any depth.
1643const chainCTE = `WITH RECURSIVE chain(id) AS (
1644 SELECT ? UNION SELECT t.id FROM api_tokens t JOIN chain ON t.created_by_token = chain.id)`
1645
1646// RevokeAPIToken deletes the user's token by name and returns what it
1647// created. withCreated deletes those too; otherwise they stay and lose
1648// the link to the revoked token.
1649func (s *Store) RevokeAPIToken(userID int64, name string, withCreated bool) (Created, error) {
1650 tx, err := s.DB.Begin()
1651 if err != nil {
1652 return Created{}, err
1653 }
1654 defer tx.Rollback()
1655 var id int64
1656 err = tx.QueryRow("SELECT id FROM api_tokens WHERE user_id = ? AND name = ?", userID, name).Scan(&id)
1657 if errors.Is(err, sql.ErrNoRows) {
1658 return Created{}, ErrNotFound
1659 }
1660 if err != nil {
1661 return Created{}, err
1662 }
1663 var c Created
1664 rows, err := tx.Query(chainCTE+` SELECT name FROM api_tokens WHERE id IN (SELECT id FROM chain) AND id != ? ORDER BY name`, id, id)
1665 if err != nil {
1666 return Created{}, err
1667 }
1668 for rows.Next() {
1669 var n string
1670 if err := rows.Scan(&n); err != nil {
1671 rows.Close()
1672 return Created{}, err
1673 }
1674 c.Tokens = append(c.Tokens, n)
1675 }
1676 rows.Close()
1677 var keyIDs []int64
1678 rows, err = tx.Query(chainCTE+` SELECT id, fingerprint FROM ssh_keys WHERE created_by_token IN (SELECT id FROM chain) ORDER BY id`, id)
1679 if err != nil {
1680 return Created{}, err
1681 }
1682 for rows.Next() {
1683 var kid int64
1684 var fp string
1685 if err := rows.Scan(&kid, &fp); err != nil {
1686 rows.Close()
1687 return Created{}, err
1688 }
1689 keyIDs = append(keyIDs, kid)
1690 c.Keys = append(c.Keys, fp)
1691 }
1692 rows.Close()
1693
1694 if !withCreated {
1695 if _, err := tx.Exec("DELETE FROM api_tokens WHERE id = ?", id); err != nil {
1696 return Created{}, err
1697 }
1698 return c, tx.Commit()
1699 }
1700 if len(keyIDs) > 0 {
1701 args := make([]any, len(keyIDs))
1702 for i, k := range keyIDs {
1703 args[i] = k
1704 }
1705 if _, err := tx.Exec("DELETE FROM ssh_keys WHERE id IN (?"+strings.Repeat(", ?", len(keyIDs)-1)+")", args...); err != nil {
1706 return Created{}, err
1707 }
1708 if err := bumpKeyEpoch(tx); err != nil {
1709 return Created{}, err
1710 }
1711 }
1712 if _, err := tx.Exec(chainCTE+` DELETE FROM api_tokens WHERE id IN (SELECT id FROM chain)`, id); err != nil {
1713 return Created{}, err
1714 }
1715 if err := tx.Commit(); err != nil {
1716 return Created{}, err
1717 }
1718 if len(keyIDs) > 0 {
1719 s.announce(Revoked{KeyIDs: keyIDs})
1720 }
1721 return c, nil
1722}
1723```
1724
1725Before writing `nullID`, run `grep -rn "func nullID" internal/store`; if one exists, use it and drop this copy.
1726
1727- [ ] **Step 5: Keys in the store**
1728
1729In `internal/store/users.go`, add to `SSHKey` after `LastUsedAt`:
1730
1731```go
1732 CreatedBy string // name of the API token that added the key; "" for none. ListSSHKeys only.
1733```
1734
1735Replace `AddSSHKey`:
1736
1737```go
1738// KeyOrigin is how a key came to be.
1739type KeyOrigin struct {
1740 CreatedByToken int64 // the API token that added it; 0 for none
1741}
1742
1743// AddSSHKey registers a key and bumps the key epoch in one transaction.
1744func (s *Store) AddSSHKey(userID int64, fingerprint, algo string, blob []byte, scope, label string) error {
1745 return s.AddSSHKeyFrom(userID, fingerprint, algo, blob, scope, label, KeyOrigin{})
1746}
1747
1748// AddSSHKeyFrom is AddSSHKey recording where the key came from.
1749func (s *Store) AddSSHKeyFrom(userID int64, fingerprint, algo string, blob []byte, scope, label string, o KeyOrigin) error {
1750 tx, err := s.DB.Begin()
1751 if err != nil {
1752 return err
1753 }
1754 defer tx.Rollback()
1755 if _, err := tx.Exec(
1756 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token) VALUES (?, ?, ?, ?, ?, ?, ?)",
1757 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken)); err != nil {
1758 if isUniqueErr(err) {
1759 return ErrDuplicateKey
1760 }
1761 return err
1762 }
1763 if err := bumpKeyEpoch(tx); err != nil {
1764 return err
1765 }
1766 return tx.Commit()
1767}
1768```
1769
1770`ListSSHKeys`:
1771
1772```go
1773func (s *Store) ListSSHKeys(userID int64) ([]SSHKey, error) {
1774 rows, err := s.DB.Query(
1775 `SELECT k.id, k.user_id, k.fingerprint, k.algo, k.blob, k.scope, k.label, k.created_at,
1776 COALESCE(k.last_used_at, ''), COALESCE(t.name, '')
1777 FROM ssh_keys k LEFT JOIN api_tokens t ON t.id = k.created_by_token
1778 WHERE k.user_id = ? ORDER BY k.id`,
1779 userID)
1780 if err != nil {
1781 return nil, err
1782 }
1783 defer rows.Close()
1784 var keys []SSHKey
1785 for rows.Next() {
1786 var k SSHKey
1787 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.CreatedAt, &k.LastUsedAt, &k.CreatedBy); err != nil {
1788 return nil, err
1789 }
1790 keys = append(keys, k)
1791 }
1792 return keys, rows.Err()
1793}
1794```
1795
1796- [ ] **Step 6: Run the store tests**
1797
1798Run: `go test ./internal/store -count=1`
1799Expected: PASS. (`go build ./...` fails until Task 2.2 updates the callers.)
1800
1801- [ ] **Step 7: Commit**
1802
1803```bash
1804git add internal/store
1805git commit -S -m "store: tokens and keys record the token that created them; chained revoke
1806
1807Ref #257"
1808```
1809
1810### Task 2.2: `MintsCredential`, `Ctx.Expires`, and the API wiring
1811
1812**Files:**
1813- Modify: `internal/control/control.go` — `Ctx` (21-57), `Command` (79-91), `Dispatch` (after line 161)
1814- Modify: `internal/httpd/api.go:30-78`, `:126-144`; `internal/httpd/apiread.go:26`
1815- Modify: registrations in `internal/control/token.go:16`, `identity.go:33`, `deploykey.go:16`, `runnerrepo.go:21`, `web.go:16`, `adminhost.go:24`, `:53`, `:58`, `register.go:38`
1816- Test: `internal/control/token_test.go` (create)
1817
1818**Interfaces:**
1819- Consumes: `store.APITokenUser` returning `APIToken` (Task 2.1).
1820- Produces:
1821 - `Command.MintsCredential bool`
1822 - `Ctx.TokenID int64` — the API token behind the request, 0 for none.
1823 - `Ctx.Expires *time.Time` — when the credential behind the request lapses; nil when it does not. MR 3 sets it for keys.
1824
1825- [ ] **Step 1: Write the failing tests**
1826
1827`internal/control/token_test.go`:
1828
1829```go
1830package control
1831
1832import (
1833 "bytes"
1834 "slices"
1835 "strings"
1836 "testing"
1837 "time"
1838
1839 "gitbay.org/gitbay/internal/protocol"
1840 "gitbay.org/gitbay/internal/store"
1841)
1842
1843// The minting commands, pinned: adding one to the list, or dropping
1844// one, is a decision this test makes someone take.
1845func TestMintingCommandsMarked(t *testing.T) {
1846 want := []string{
1847 "admin email verify", "admin invite", "admin user create", "email verify",
1848 "keys add", "repo deploy-key add", "repo runner add", "token create", "web login",
1849 }
1850 var got []string
1851 for _, cmd := range Commands() {
1852 if cmd.MintsCredential {
1853 got = append(got, joinPath(cmd.Path))
1854 }
1855 }
1856 slices.Sort(got)
1857 if !slices.Equal(got, want) {
1858 t.Fatalf("MintsCredential on %q, want %q", got, want)
1859 }
1860}
1861
1862// Dispatch refuses before the command runs, so no arguments are needed.
1863func TestExpiringCredentialCannotMint(t *testing.T) {
1864 exp := time.Now().Add(time.Hour)
1865 for _, cmd := range Commands() {
1866 if !cmd.MintsCredential {
1867 continue
1868 }
1869 var out, errOut bytes.Buffer
1870 c := &Ctx{User: store.User{ID: 1, Username: "root", IsAdmin: true}, Scope: "full", Expires: &exp, Stdout: &out, Stderr: &errOut}
1871 if code := Dispatch(c, cmd.Path); code != protocol.ExitDenied || !strings.Contains(errOut.String(), "expires") {
1872 t.Errorf("%s: exit %d %q, want %d and the reason", joinPath(cmd.Path), code, errOut.String(), protocol.ExitDenied)
1873 }
1874 }
1875}
1876
1877func TestTokenCreateDefaultsToReadAndRecordsCreator(t *testing.T) {
1878 st, _, uid := newQueueTestRepo(t)
1879 if err := st.CreateAPIToken(uid, "parent", "h-parent", "full", nil, 0); err != nil {
1880 t.Fatal(err)
1881 }
1882 _, parent, err := st.APITokenUser("h-parent")
1883 if err != nil {
1884 t.Fatal(err)
1885 }
1886 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
1887 c.Cfg.Limits.WriteRate = -1
1888 c.TokenID = parent.ID
1889 if code := Dispatch(c, []string{"token", "create", "--name", "child"}); code != protocol.ExitOK {
1890 t.Fatalf("exit %d: %s", code, errOut)
1891 }
1892 toks, err := st.ListAPITokens(uid)
1893 if err != nil {
1894 t.Fatal(err)
1895 }
1896 for _, tk := range toks {
1897 if tk.Name == "child" && (tk.Scope != "read" || tk.CreatedBy != "parent") {
1898 t.Fatalf("child: %+v", tk)
1899 }
1900 }
1901}
1902```
1903
1904- [ ] **Step 2: Run them and see them fail**
1905
1906Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint|TestTokenCreateDefaultsToRead' -count=1`
1907Expected: FAIL to compile, `unknown field MintsCredential`.
1908
1909- [ ] **Step 3: `Ctx`, `Command`, `Dispatch`**
1910
1911In `Ctx`, after `Source string`:
1912
1913```go
1914 // TokenID is the API token behind this request, 0 for none. A
1915 // credential the request creates records it.
1916 TokenID int64
1917 // Expires is when the credential behind this request lapses; nil
1918 // when it does not. Dispatch refuses MintsCredential commands when
1919 // it is set.
1920 Expires *time.Time
1921```
1922
1923In `Command`, after `ReadOnly`:
1924
1925```go
1926 // MintsCredential marks a command that creates a credential or a way
1927 // to obtain one: tokens, keys, login links, invites, accounts,
1928 // verified addresses. An expiring credential may not run it.
1929 MintsCredential bool
1930```
1931
1932In `Dispatch`, after the `c.ReadOnly && !cmd.ReadOnly` check:
1933
1934```go
1935 // What an expiring credential creates would outlive it (#257).
1936 if cmd.MintsCredential && c.Expires != nil {
1937 return c.fail(protocol.ExitDenied,
1938 "%s creates a credential, and the one this request came with expires; use a token or key without an expiry", joinPath(cmd.Path))
1939 }
1940```
1941
1942- [ ] **Step 4: Mark the nine commands**
1943
1944Add `MintsCredential: true,` to each registration: `token create` (`token.go:16`), `keys add` (`identity.go:33`), `repo deploy-key add` (`deploykey.go:16`), `repo runner add` (`runnerrepo.go:21`), `web login` (`web.go:16`), `admin user create` (`adminhost.go:24`), `admin email verify` (`adminhost.go:53`), `admin invite` (`adminhost.go:58`), `email verify` (`register.go:38`). For example:
1945
1946```go
1947 register(Command{Path: []string{"web", "login"},
1948 Summary: "mint a one-time browser login URL",
1949 Usage: "web login",
1950 MintsCredential: true,
1951 Examples: []string{"web login"}, Run: runWebLogin})
1952```
1953
1954- [ ] **Step 5: The API passes the token**
1955
1956In `internal/httpd/api.go`, `apiAuth` returns the token:
1957
1958```go
1959// apiAuth resolves the bearer token; failures are uniform 401s.
1960func (s *Server) apiAuth(w http.ResponseWriter, r *http.Request) (store.User, store.APIToken, bool) {
1961 token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
1962 if !ok || token == "" {
1963 w.Header().Set("WWW-Authenticate", `Bearer realm="gitbay api"`)
1964 apiError(w, http.StatusUnauthorized, "missing bearer token; mint one over SSH: token create --name <n>")
1965 return store.User{}, store.APIToken{}, false
1966 }
1967 user, tok, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(token)))
1968 if err != nil {
1969 if errors.Is(err, store.ErrNotFound) {
1970 apiError(w, http.StatusUnauthorized, "invalid or expired token")
1971 return store.User{}, store.APIToken{}, false
1972 }
1973 apiError(w, http.StatusInternalServerError, "internal error")
1974 return store.User{}, store.APIToken{}, false
1975 }
1976 return user, tok, true
1977}
1978```
1979
1980In `apiCmd`: `user, tok, ok := s.apiAuth(w, r)`, and in the `Ctx` literal replace `ReadOnly: scope == "read",` with:
1981
1982```go
1983 ReadOnly: tok.Scope == "read",
1984 TokenID: tok.ID,
1985 Expires: tok.ExpiresAt,
1986```
1987
1988`apiRead` keeps `user, _, ok := s.apiAuth(w, r)`; it compiles unchanged.
1989
1990- [ ] **Step 6: Run the tests**
1991
1992Run: `go test ./internal/control -run 'TestMintingCommandsMarked|TestExpiringCredentialCannotMint' -count=1`
1993Expected: PASS. `TestTokenCreateDefaultsToRead...` still fails until Task 2.3.
1994
1995- [ ] **Step 7: Commit**
1996
1997```bash
1998git add internal/control/control.go internal/control/token_test.go internal/control/*.go internal/httpd/api.go
1999git commit -S -m "control: expiring credentials cannot run credential-minting commands
2000
2001Ref #257"
2002```
2003
2004### Task 2.3: commands record their token; `token create` defaults to read; `token revoke --created`
2005
2006**Files:**
2007- Modify: `internal/control/token.go` (registrations 16-34, `runTokenCreate` 49-88, `runTokenList` 90-122, `runTokenRevoke` 124-137)
2008- Modify: `internal/control/identity.go` — `runKeysList` (76-100), `runKeysAdd` (152)
2009- Modify: `internal/control/deploykey.go:71`, `internal/control/runnerrepo.go:60`, `internal/control/adminhost.go:125`
2010- Modify: `e2e/api_test.go:50`, `:266-282` (`mintToken`)
2011
2012**Interfaces:**
2013- Consumes: `Ctx.TokenID`, `store.KeyOrigin`, `Store.AddSSHKeyFrom`, `Store.RevokeAPIToken` (Tasks 2.1–2.2).
2014
2015- [ ] **Step 1: `token create`**
2016
2017Registration:
2018
2019```go
2020 register(Command{Path: []string{"token", "create"},
2021 Summary: "mint an API token (shown once)",
2022 Usage: "token create --name <n> [--scope read|full] [--ttl 30d|720h]",
2023 Flags: []Flag{
2024 {"--name", "<n>", "the token's name", ""},
2025 {"--scope", "read|full", "what the token may do; full is needed to change anything", "read"},
2026 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
2027 },
2028 Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"},
2029 MintsCredential: true,
2030 Run: runTokenCreate})
2031```
2032
2033In `runTokenCreate`: the `parseFlags` usage becomes `Usage: c.Cmd.Usage`; `name, scope, ttl := f.Value("--name"), "read", f.Value("--ttl")`; the store call:
2034
2035```go
2036 if err := c.Store.CreateAPIToken(c.User.ID, name, store.HashToken(token), scope, expires, c.TokenID); err != nil {
2037```
2038
2039- [ ] **Step 2: `token list` shows the creator in JSON**
2040
2041In `runTokenList`, the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` after `LastUsedAt`, and the append becomes `out{t.Name, t.Scope, t.CreatedAt, t.ExpiresAt, t.LastUsedAt, t.CreatedBy}`. Plain output is unchanged.
2042
2043- [ ] **Step 3: `token revoke [--created]`**
2044
2045Registration:
2046
2047```go
2048 register(Command{Path: []string{"token", "revoke"},
2049 Summary: "revoke an API token by name",
2050 Usage: "token revoke <name> [--created]",
2051 Flags: []Flag{
2052 {"--created", "", "also revoke the tokens and keys it created, at any depth", ""},
2053 },
2054 Examples: []string{"token revoke laptop", "token revoke laptop --created"},
2055 Run: runTokenRevoke})
2056```
2057
2058```go
2059func runTokenRevoke(c *Ctx, args []string) int {
2060 f, err := parseFlags(args, flagSpec{Bools: []string{"--created"}, MaxPos: 1, Usage: c.Cmd.Usage})
2061 if err != nil {
2062 return c.fail(protocol.ExitUsage, "%v", err)
2063 }
2064 name := f.pos(0)
2065 if name == "" {
2066 return c.usage()
2067 }
2068 withCreated := f.Has("--created")
2069 created, err := c.Store.RevokeAPIToken(c.User.ID, name, withCreated)
2070 if err != nil {
2071 if errors.Is(err, store.ErrNotFound) {
2072 return c.fail(protocol.ExitNotFound, "no token named %q", name)
2073 }
2074 return c.fail(protocol.ExitFailure, "%v", err)
2075 }
2076 type out struct {
2077 Revoked string `json:"revoked"`
2078 Created store.Created `json:"created"`
2079 CreatedRevoked bool `json:"created_revoked"`
2080 }
2081 d := out{name, created, withCreated}
2082 return c.emit(d, func(w io.Writer) {
2083 fmt.Fprintf(w, "revoked %s\n", name)
2084 if len(created.Tokens)+len(created.Keys) == 0 {
2085 return
2086 }
2087 if withCreated {
2088 fmt.Fprintln(w, "and what it created:")
2089 } else {
2090 fmt.Fprintln(w, "it created these, still in place:")
2091 }
2092 for _, n := range created.Tokens {
2093 fmt.Fprintf(w, " token %s\n", n)
2094 }
2095 for _, fp := range created.Keys {
2096 fmt.Fprintf(w, " key %s\n", fp)
2097 }
2098 })
2099}
2100```
2101
2102- [ ] **Step 4: Key-adding commands record the token**
2103
2104`identity.go`, `runKeysAdd`:
2105
2106```go
2107 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2108```
2109
2110`deploykey.go:71`:
2111
2112```go
2113 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2114```
2115
2116`runnerrepo.go:60`:
2117
2118```go
2119 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), "runner", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2120```
2121
2122`adminhost.go:125`:
2123
2124```go
2125 if err := c.Store.AddSSHKeyFrom(uid, fp, pub.Type(), pub.Marshal(), "full", label, store.KeyOrigin{CreatedByToken: c.TokenID}); err != nil {
2126```
2127
2128`keys list` JSON: in `runKeysList` the `out` struct gains `CreatedBy string `json:"created_by,omitempty"`` and the append passes `k.CreatedBy`. Plain output is unchanged in this MR.
2129
2130- [ ] **Step 5: e2e callers that write with a default-scope token**
2131
2132`e2e/api_test.go:50`: `"token", "create", "--name", "ci", "--scope", "full", "--json"`.
2133`mintToken` (`e2e/api_test.go:268`): `"token", "create", "--name", name, "--scope", "full", "--json"`.
2134Then `grep -rn '"token", "create"' e2e` and check each remaining call: a token only used for reads, or for a refusal, needs nothing.
2135
2136- [ ] **Step 6: Run the tests**
2137
2138Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/httpd -count=1`
2139Expected: PASS, including `TestHelpIsComplete` (every flag in the usage is described) and `TestTokenCreateDefaultsToReadAndRecordsCreator`.
2140
2141- [ ] **Step 7: Commit**
2142
2143```bash
2144git add internal/control e2e/api_test.go
2145git commit -S -m "token: default --scope read; record the creating token; revoke --created
2146
2147Ref #257"
2148```
2149
2150### Task 2.4: e2e — delegation over the API
2151
2152**Files:**
2153- Create: `e2e/tokenorigin_test.go`
2154
2155**Interfaces:**
2156- Consumes: `fingerprint` (MR 1, `e2e/revoke_test.go`), `inst.apiCall`.
2157
2158- [ ] **Step 1: Write the test**
2159
2160```go
2161package e2e
2162
2163import (
2164 "encoding/json"
2165 "fmt"
2166 "os"
2167 "strings"
2168 "testing"
2169)
2170
2171// An expiring token cannot mint a credential that outlives it, and
2172// revoking a token can take what it created with it (#257).
2173func TestTokenDelegation(t *testing.T) {
2174 t.Parallel()
2175 inst := startInstanceWith(t, "[api]\nenabled = true\n")
2176 aliceKey := inst.newKey(t, "alice")
2177 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
2178
2179 mint := func(args ...string) (token, scope string) {
2180 t.Helper()
2181 out, errOut, code := inst.ssh(t, aliceKey, "", append([]string{"token", "create", "--json"}, args...)...)
2182 if code != 0 {
2183 t.Fatalf("token create %v: %s", args, errOut)
2184 }
2185 var env struct {
2186 Data struct {
2187 Token string `json:"token"`
2188 Scope string `json:"scope"`
2189 } `json:"data"`
2190 }
2191 if err := json.Unmarshal([]byte(out), &env); err != nil {
2192 t.Fatalf("token create output: %v %s", err, out)
2193 }
2194 return env.Data.Token, env.Data.Scope
2195 }
2196 if _, scope := mint("--name", "plain"); scope != "read" {
2197 t.Fatalf("default scope %q, want read", scope)
2198 }
2199 brief, _ := mint("--name", "brief", "--scope", "full", "--ttl", "1h")
2200 lasting, _ := mint("--name", "lasting", "--scope", "full")
2201
2202 spare := inst.newKey(t, "spare")
2203 pub, err := os.ReadFile(spare + ".pub")
2204 if err != nil {
2205 t.Fatal(err)
2206 }
2207 status, body := inst.apiCall(t, brief, []string{"keys", "add"}, string(pub))
2208 if status != 403 || !strings.Contains(fmt.Sprint(body["error"]), "expires") {
2209 t.Fatalf("expiring token added a key: %d %v", status, body)
2210 }
2211 if status, _ := inst.apiCall(t, brief, []string{"whoami"}, ""); status != 200 {
2212 t.Fatalf("expiring token refused a read: %d", status)
2213 }
2214 if status, body := inst.apiCall(t, lasting, []string{"keys", "add"}, string(pub)); status != 200 {
2215 t.Fatalf("keys add: %d %v", status, body)
2216 }
2217 if status, body := inst.apiCall(t, lasting, []string{"token", "create", "--name", "child"}, ""); status != 200 {
2218 t.Fatalf("token create: %d %v", status, body)
2219 }
2220 if _, errOut, code := inst.ssh(t, spare, "", "whoami"); code != 0 {
2221 t.Fatalf("the added key does not work: %s", errOut)
2222 }
2223
2224 out, errOut, code := inst.ssh(t, aliceKey, "", "token", "revoke", "lasting", "--created")
2225 if code != 0 || !strings.Contains(out, "token child") || !strings.Contains(out, fingerprint(t, spare+".pub")) {
2226 t.Fatalf("revoke --created: exit %d\n%s%s", code, out, errOut)
2227 }
2228 if _, _, code := inst.ssh(t, spare, "", "whoami"); code == 0 {
2229 t.Fatal("a key the revoked token created still works")
2230 }
2231 if out, _, _ := inst.ssh(t, aliceKey, "", "token", "list"); strings.Contains(out, "child") {
2232 t.Fatalf("the child token survived:\n%s", out)
2233 }
2234}
2235```
2236
2237- [ ] **Step 2: Run it**
2238
2239Run: `go test ./e2e -run TestTokenDelegation -count=1`
2240Expected: PASS.
2241
2242- [ ] **Step 3: Commit**
2243
2244```bash
2245git add e2e/tokenorigin_test.go
2246git commit -S -m "e2e: expiring tokens refused on minting; revoke --created
2247
2248Ref #257"
2249```
2250
2251### Task 2.5: docs and release note
2252
2253**Files:**
2254- Modify: `.gitbay/wiki/API.org:14-33` (Tokens), `.gitbay/wiki/Threat-Model.org` (Trust boundaries), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:20`, `09-Controls.org:29`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org:358`, `CHANGELOG.org`, `internal/web/templates/account.html:194`
2255
2256- [ ] **Step 1: Edit the pages**
2257
2258`API.org`, the Tokens section's first paragraph and code block become:
2259
2260```
2261Tokens are minted wherever the registry is reached: over SSH, on the
2262API, anywhere. =token create= makes a =read= token unless =--scope full=
2263is given; a read token runs only commands marked read-only. A full-scope
2264token can mint another, but a token with a =--ttl= cannot run any
2265command that creates a credential — =token create=, =keys add=,
2266=repo deploy-key add=, =repo runner add=, =web login=, =admin invite=,
2267=admin user create=, =email verify=, =admin email verify= — since what
2268it made would outlive it. Give a token the narrowest scope and shortest
2269TTL that does its job, and revoke it when the job is over.
2270
2271#+begin_src sh
2272gitbay auth token create --name ci [--scope read|full] [--ttl 30d]
2273gitbay auth token list
2274gitbay auth token revoke ci [--created]
2275#+end_src
2276
2277Tokens and keys record the token they were created through. =token
2278revoke= prints what the token created, at any depth; with =--created=
2279it revokes those too, and their SSH connections close. Without it they
2280stay and the link is dropped.
2281```
2282
2283`Threat-Model.org`, in Trust boundaries, replace "so a bearer token is worth exactly its scope and no more." with "so a bearer token is worth exactly its scope and no more, and a credential with an expiry cannot create one that outlives it."
2284
2285`05-Identity-and-Access.org`: the API token row's Scope cell becomes `=read= (default) or =full=; with an expiry, no credential-minting command`, and its Revocation cell `=token revoke [--created]=`.
2286
2287`09-Controls.org`:
2288
2289```
2290| Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=) |
2291```
2292
2293`10-Known-Gaps.org`: delete the `#257` row and the "#257 refuses credential creation ..." sentence, leaving the paragraph out if nothing remains in it.
2294
2295`Parity.org`, after the `API token mint` row:
2296
2297```
2298| API token revoke with what it created | yes | no | no |
2299```
2300
2301`account.html:194`: `gitbay auth token create --name laptop # API tokens, read-only unless --scope full`.
2302
2303`CHANGELOG.org`, under the unreleased heading (see Global constraints):
2304
2305```
2306*Upgrade note.* =token create= makes a =read= token unless given
2307=--scope full=. A script that mints a token and then writes with it
2308must add =--scope full=. Existing tokens keep their scope.
2309
2310- A token with a =--ttl= is refused on every command that creates a
2311 credential: tokens, keys, deploy keys, runner keys, login links,
2312 invites, accounts and verified addresses (#257).
2313- Tokens and SSH keys record the token they were created through.
2314 =token revoke <name>= lists what it created; =--created= revokes
2315 those too.
2316- Removing an SSH key, a deploy key, or disabling an account closes the
2317 connections the key opened, a push in flight included (#256).
2318```
2319
2320(The #256 line belongs to MR 1's changes; add it here if MR 1 did not touch the changelog.)
2321
2322- [ ] **Step 2: Commit, open the MR**
2323
2324```bash
2325git add .gitbay/wiki CHANGELOG.org internal/web/templates/account.html
2326git commit -S -m "wiki: token delegation, read default; release note
2327
2328Closes #257"
2329git push -u origin token-delegation
2330gitbay mr create --source token-delegation --target main --title "token: expiring tokens cannot mint credentials; record creator; default read scope"
2331```
2332
2333---
2334
2335# MR 3: optional expiry for SSH and deploy keys (branch `key-expiry`, #277)
2336
2337The issue says to consider this with #257. An expiring key is treated
2338like an expiring token: `Exec` sets `Ctx.Expires`, so `Dispatch` refuses
2339the minting commands to it (open question 1).
2340
2341### Task 3.1: migration 0061 and the store
2342
2343**Files:**
2344- Create: `internal/store/migrations/0061_ssh_key_expiry.up.sql`, `.down.sql`
2345- Modify: `internal/store/users.go` — `SSHKey`, `KeyOrigin`, `AddSSHKeyFrom`, `SSHKeyByFingerprint` (324-333), `SSHKeyByID` (502-511), `ListSSHKeys`, `ListDeployKeys` (514-532)
2346- Modify: `internal/store/revoke.go` — `LiveSSHKeys`
2347- Test: `internal/store/keyexpiry_test.go` (create)
2348
2349**Interfaces:**
2350- Produces:
2351 - `SSHKey.ExpiresAt *time.Time` — nil when the key never expires; filled by every key query.
2352 - `func (k SSHKey) Expired(now time.Time) bool`
2353 - `KeyOrigin.ExpiresAt *time.Time`
2354 - `LiveSSHKeys` also excludes expired keys.
2355
2356- [ ] **Step 1: Write the failing test**
2357
2358```go
2359package store
2360
2361import (
2362 "testing"
2363 "time"
2364)
2365
2366func TestKeyExpiry(t *testing.T) {
2367 s, uid, _ := revokeFixture(t)
2368 past, future := time.Now().Add(-time.Minute), time.Now().Add(time.Hour)
2369 for fp, exp := range map[string]*time.Time{"SHA256:old": &past, "SHA256:new": &future, "SHA256:ever": nil} {
2370 if err := s.AddSSHKeyFrom(uid, fp, "ssh-ed25519", []byte(fp), "full", "", KeyOrigin{ExpiresAt: exp}); err != nil {
2371 t.Fatal(err)
2372 }
2373 }
2374 now := time.Now()
2375 ids := map[string]int64{}
2376 for _, fp := range []string{"SHA256:old", "SHA256:new", "SHA256:ever"} {
2377 k, err := s.SSHKeyByFingerprint(fp)
2378 if err != nil {
2379 t.Fatal(err)
2380 }
2381 ids[fp] = k.ID
2382 byID, err := s.SSHKeyByID(k.ID)
2383 if err != nil || (byID.ExpiresAt == nil) != (k.ExpiresAt == nil) {
2384 t.Fatalf("%s by id: %+v %v", fp, byID, err)
2385 }
2386 if got, want := k.Expired(now), fp == "SHA256:old"; got != want {
2387 t.Errorf("%s Expired = %v, want %v", fp, got, want)
2388 }
2389 }
2390 live, err := s.LiveSSHKeys([]int64{ids["SHA256:old"], ids["SHA256:new"], ids["SHA256:ever"]})
2391 if err != nil {
2392 t.Fatal(err)
2393 }
2394 if live[ids["SHA256:old"]] || !live[ids["SHA256:new"]] || !live[ids["SHA256:ever"]] {
2395 t.Fatalf("live = %v", live)
2396 }
2397 keys, err := s.ListSSHKeys(uid)
2398 if err != nil || len(keys) != 3 || keys[2].ExpiresAt != nil {
2399 t.Fatalf("list: %+v %v", keys, err)
2400 }
2401}
2402```
2403
2404- [ ] **Step 2: Run it and see it fail**
2405
2406Run: `go test ./internal/store -run TestKeyExpiry -count=1`
2407Expected: FAIL to compile, `unknown field ExpiresAt in struct literal of type KeyOrigin`.
2408
2409- [ ] **Step 3: Migration**
2410
2411`0061_ssh_key_expiry.up.sql`:
2412
2413```sql
2414-- When the key stops authenticating; NULL for never.
2415ALTER TABLE ssh_keys ADD COLUMN expires_at TEXT;
2416```
2417
2418`0061_ssh_key_expiry.down.sql`:
2419
2420```sql
2421ALTER TABLE ssh_keys DROP COLUMN expires_at;
2422```
2423
2424- [ ] **Step 4: Store**
2425
2426`SSHKey` gains, after `CreatedBy`:
2427
2428```go
2429 ExpiresAt *time.Time // nil when the key never expires
2430```
2431
2432and the method:
2433
2434```go
2435// Expired reports whether the key has lapsed at now.
2436func (k SSHKey) Expired(now time.Time) bool {
2437 return k.ExpiresAt != nil && !k.ExpiresAt.After(now)
2438}
2439```
2440
2441`KeyOrigin`:
2442
2443```go
2444// KeyOrigin is how a key came to be.
2445type KeyOrigin struct {
2446 CreatedByToken int64 // the API token that added it; 0 for none
2447 ExpiresAt *time.Time // when it stops authenticating; nil for never
2448}
2449```
2450
2451`AddSSHKeyFrom`'s insert:
2452
2453```go
2454 var exp any
2455 if o.ExpiresAt != nil {
2456 exp = fmtTime(*o.ExpiresAt)
2457 }
2458 if _, err := tx.Exec(
2459 "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope, label, created_by_token, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
2460 userID, fingerprint, algo, blob, scope, label, nullID(o.CreatedByToken), exp); err != nil {
2461```
2462
2463`SSHKeyByFingerprint` and `SSHKeyByID` select `expires_at` last and scan it through a `sql.NullString`:
2464
2465```go
2466func (s *Store) SSHKeyByFingerprint(fingerprint string) (SSHKey, error) {
2467 var k SSHKey
2468 var exp sql.NullString
2469 err := s.DB.QueryRow(
2470 "SELECT id, user_id, fingerprint, algo, blob, scope, label, expires_at FROM ssh_keys WHERE fingerprint = ?",
2471 fingerprint).Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &exp)
2472 if errors.Is(err, sql.ErrNoRows) {
2473 return k, ErrNotFound
2474 }
2475 k.ExpiresAt = parseTime(exp)
2476 return k, err
2477}
2478```
2479
2480`SSHKeyByID` is the same with `WHERE id = ?` and `id`.
2481
2482`ListSSHKeys`: add `k.expires_at` after `COALESCE(t.name, '')`, scan into `var exp sql.NullString` declared per row, then `k.ExpiresAt = parseTime(exp)`.
2483
2484`ListDeployKeys`:
2485
2486```go
2487func (s *Store) ListDeployKeys(repoID int64) ([]SSHKey, error) {
2488 rows, err := s.DB.Query(
2489 `SELECT id, user_id, fingerprint, algo, blob, scope, label, COALESCE(last_used_at, ''), expires_at
2490 FROM ssh_keys WHERE scope LIKE 'deploy:' || ? || ':%' ORDER BY id`,
2491 repoID)
2492 if err != nil {
2493 return nil, err
2494 }
2495 defer rows.Close()
2496 var keys []SSHKey
2497 for rows.Next() {
2498 var k SSHKey
2499 var exp sql.NullString
2500 if err := rows.Scan(&k.ID, &k.UserID, &k.Fingerprint, &k.Algo, &k.Blob, &k.Scope, &k.Label, &k.LastUsedAt, &exp); err != nil {
2501 return nil, err
2502 }
2503 k.ExpiresAt = parseTime(exp)
2504 keys = append(keys, k)
2505 }
2506 return keys, rows.Err()
2507}
2508```
2509
2510`LiveSSHKeys` in `revoke.go`:
2511
2512```go
2513// LiveSSHKeys reports which of ids still name a registered, unexpired
2514// key on an account that is not disabled.
2515func (s *Store) LiveSSHKeys(ids []int64) (map[int64]bool, error) {
2516 live := map[int64]bool{}
2517 if len(ids) == 0 {
2518 return live, nil
2519 }
2520 args := []any{fmtTime(time.Now())}
2521 for _, id := range ids {
2522 args = append(args, id)
2523 }
2524 rows, err := s.DB.Query(`SELECT k.id FROM ssh_keys k JOIN users u ON u.id = k.user_id
2525 WHERE u.disabled = 0 AND (k.expires_at IS NULL OR k.expires_at > ?)
2526 AND k.id IN (?`+strings.Repeat(", ?", len(ids)-1)+`)`, args...)
2527 if err != nil {
2528 return nil, err
2529 }
2530 defer rows.Close()
2531 for rows.Next() {
2532 var id int64
2533 if err := rows.Scan(&id); err != nil {
2534 return nil, err
2535 }
2536 live[id] = true
2537 }
2538 return live, rows.Err()
2539}
2540```
2541
2542Add `"time"` to `revoke.go`'s imports.
2543
2544- [ ] **Step 5: Run the tests**
2545
2546Run: `go test ./internal/store -count=1`
2547Expected: PASS.
2548
2549- [ ] **Step 6: Commit**
2550
2551```bash
2552git add internal/store
2553git commit -S -m "store: ssh_keys.expires_at; expired keys are not live
2554
2555Ref #277"
2556```
2557
2558### Task 3.2: expired keys refused at authentication, per exec, and in system mode
2559
2560**Files:**
2561- Modify: `internal/sshd/sshd.go` — `authenticate` (after line 148), `runExec`, `Exec` (the `Ctx` literal)
2562- Modify: `cmd/gitbayd/system.go:45-48`, `:80-84`
2563- Test: `internal/sshd/revoke_test.go` (append)
2564
2565**Interfaces:**
2566- Consumes: `SSHKey.Expired`, `SSHKey.ExpiresAt` (Task 3.1); `Ctx.Expires` (MR 2); `newTestServer`, `execStatus`, `waitClosed` (MR 1).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Append to `internal/sshd/revoke_test.go`:
2571
2572```go
2573// A key that expires while connected: the next exec is refused, and
2574// the sweep closes the connection.
2575func TestExpiredKeyRefusedAndCut(t *testing.T) {
2576 ts := newTestServer(t)
2577 past := time.Now().Add(-time.Second).UTC().Format("2006-01-02T15:04:05.000Z")
2578 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", past, ts.keyID); err != nil {
2579 t.Fatal(err)
2580 }
2581 if code, errOut := execStatus(ts.client, "whoami"); code != 4 || !strings.Contains(errOut, "expired") {
2582 t.Fatalf("whoami with an expired key: %d %q", code, errOut)
2583 }
2584 ts.srv.sweepOnce()
2585 waitClosed(t, ts.client)
2586}
2587
2588// An expiring key may not mint.
2589func TestExpiringKeyCannotMint(t *testing.T) {
2590 ts := newTestServer(t)
2591 future := time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
2592 if _, err := ts.st.DB.Exec("UPDATE ssh_keys SET expires_at = ? WHERE id = ?", future, ts.keyID); err != nil {
2593 t.Fatal(err)
2594 }
2595 if code, errOut := execStatus(ts.client, "token create --name x"); code != 4 || !strings.Contains(errOut, "expires") {
2596 t.Fatalf("token create with an expiring key: %d %q", code, errOut)
2597 }
2598}
2599```
2600
2601And a handshake test, which needs its own key: add to `revoke_test.go`
2602
2603```go
2604func TestExpiredKeyRefusedAtAuth(t *testing.T) {
2605 ts := newTestServer(t)
2606 _, priv, err := ed25519.GenerateKey(rand.Reader)
2607 if err != nil {
2608 t.Fatal(err)
2609 }
2610 signer, err := ssh.NewSignerFromKey(priv)
2611 if err != nil {
2612 t.Fatal(err)
2613 }
2614 pub := signer.PublicKey()
2615 past := time.Now().Add(-time.Minute)
2616 if err := ts.st.AddSSHKeyFrom(ts.uid, ssh.FingerprintSHA256(pub), pub.Type(), pub.Marshal(), "full", "", store.KeyOrigin{ExpiresAt: &past}); err != nil {
2617 t.Fatal(err)
2618 }
2619 _, err = ssh.Dial("tcp", ts.client.RemoteAddr().String(), &ssh.ClientConfig{
2620 User: "git",
2621 Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
2622 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
2623 Timeout: 5 * time.Second,
2624 })
2625 if err == nil {
2626 t.Fatal("an expired key authenticated")
2627 }
2628}
2629```
2630
2631with `"crypto/ed25519"`, `"crypto/rand"` and `"gitbay.org/gitbay/internal/store"` in the imports.
2632
2633- [ ] **Step 2: Run them and see them fail**
2634
2635Run: `go test ./internal/sshd -run 'TestExpiredKey|TestExpiringKeyCannotMint' -count=1`
2636Expected: FAIL: the expired key authenticates and whoami exits 0.
2637
2638- [ ] **Step 3: sshd**
2639
2640In `authenticate`, after the `if err != nil { ... }` block that handles unknown keys and before `s.authLimiter.success(ip)`:
2641
2642```go
2643 if key.Expired(time.Now()) {
2644 s.st.Audit(key.UserID, "auth.expired", map[string]any{"ip": ip, "fingerprint": fp})
2645 return nil, fmt.Errorf("key %s has expired", fp)
2646 }
2647```
2648
2649In `runExec`, after the lookup's error handling and before `UserByID`:
2650
2651```go
2652 if key.Expired(time.Now()) {
2653 fmt.Fprintln(ch.Stderr(), "this key has expired; remove it and add a new one")
2654 return protocol.ExitDenied
2655 }
2656```
2657
2658In `Exec`, the `Ctx` literal gains `Expires: key.ExpiresAt,`.
2659
2660- [ ] **Step 4: System mode**
2661
2662`cmd/gitbayd/system.go`, `authorized-keys`:
2663
2664```go
2665 key, err := st.SSHKeyByFingerprint(ssh.FingerprintSHA256(pub))
2666 if err != nil || key.Expired(time.Now()) {
2667 return nil // unknown or expired key: no output, auth fails
2668 }
2669```
2670
2671`shell`, after the `SSHKeyByID` error check:
2672
2673```go
2674 if key.Expired(time.Now()) {
2675 fmt.Fprintln(os.Stderr, "this key has expired; remove it and add a new one")
2676 os.Exit(protocol.ExitDenied)
2677 }
2678```
2679
2680Add `"time"` to the imports.
2681
2682- [ ] **Step 5: Run the tests**
2683
2684Run: `go build ./... && go test ./internal/sshd -count=1`
2685Expected: PASS.
2686
2687- [ ] **Step 6: Commit**
2688
2689```bash
2690git add internal/sshd cmd/gitbayd/system.go
2691git commit -S -m "sshd: refuse expired keys at auth and per exec; expiring keys cannot mint
2692
2693Ref #277"
2694```
2695
2696### Task 3.3: `--ttl` on `keys add` and `repo deploy-key add`; lists show last use and expiry
2697
2698**Files:**
2699- Modify: `internal/control/token.go` (add `ttlFlag` after `parseTTL`)
2700- Modify: `internal/control/identity.go` — `keys add` registration (33-44), `runKeysList`, `runKeysAdd`
2701- Modify: `internal/control/deploykey.go` — registration (16-23), `runDeployKeyAdd` (36-80), `runDeployKeyList` (82-115)
2702- Modify: `e2e/ssh_test.go:267`, `:276`
2703- Test: `internal/control/keyexpiry_test.go` (create)
2704
2705**Interfaces:**
2706- Produces:
2707 - `func (c *Ctx) ttlFlag(f flags) (*time.Time, int)` — nil when `--ttl` is absent; code -1 when the caller may go on.
2708 - `func (c *Ctx) usedText(ts string) string`, `func expiresText(t *time.Time, now time.Time) string`
2709
2710- [ ] **Step 1: Write the failing test**
2711
2712`internal/control/keyexpiry_test.go`:
2713
2714```go
2715package control
2716
2717import (
2718 "bytes"
2719 "crypto/ed25519"
2720 "crypto/rand"
2721 "strings"
2722 "testing"
2723 "time"
2724
2725 "golang.org/x/crypto/ssh"
2726
2727 "gitbay.org/gitbay/internal/protocol"
2728 "gitbay.org/gitbay/internal/store"
2729)
2730
2731// authorizedKey is a fresh public key as an authorized_keys line.
2732func authorizedKey(t *testing.T, comment string) string {
2733 t.Helper()
2734 pub, _, err := ed25519.GenerateKey(rand.Reader)
2735 if err != nil {
2736 t.Fatal(err)
2737 }
2738 sp, err := ssh.NewPublicKey(pub)
2739 if err != nil {
2740 t.Fatal(err)
2741 }
2742 return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sp))) + " " + comment + "\n"
2743}
2744
2745func TestKeysAddTTLAndList(t *testing.T) {
2746 st, repo, uid := newQueueTestRepo(t)
2747 user := store.User{ID: uid, Username: "alice"}
2748 run := func(stdin string, argv ...string) (string, string, int) {
2749 c, errOut := pruneCtx(st, t.TempDir(), user)
2750 c.Cfg.Limits.WriteRate = -1
2751 c.Stdin = strings.NewReader(stdin)
2752 code := Dispatch(c, argv)
2753 return c.Stdout.(*bytes.Buffer).String(), errOut.String(), code
2754 }
2755 if _, errOut, code := run(authorizedKey(t, "laptop"), "keys", "add", "--ttl", "1h"); code != protocol.ExitOK {
2756 t.Fatalf("keys add --ttl: %d %s", code, errOut)
2757 }
2758 if _, errOut, code := run(authorizedKey(t, "ci"), "repo", "deploy-key", "add", repo.Path(), "--ttl", "2d"); code != protocol.ExitOK {
2759 t.Fatalf("deploy-key add --ttl: %d %s", code, errOut)
2760 }
2761 if _, _, code := run(authorizedKey(t, "x"), "keys", "add", "--ttl", "soon"); code != protocol.ExitUsage {
2762 t.Fatalf("bad ttl: exit %d", code)
2763 }
2764
2765 keys, err := st.ListSSHKeys(uid)
2766 if err != nil || len(keys) != 2 {
2767 t.Fatalf("keys: %+v %v", keys, err)
2768 }
2769 for _, k := range keys {
2770 if k.ExpiresAt == nil || k.ExpiresAt.Before(time.Now()) || k.ExpiresAt.After(time.Now().Add(49*time.Hour)) {
2771 t.Errorf("%s expires %v", k.Label, k.ExpiresAt)
2772 }
2773 }
2774 out, _, _ := run("", "keys", "list")
2775 if !strings.Contains(out, "\tlaptop\tnever used\texpires ") {
2776 t.Fatalf("keys list:\n%s", out)
2777 }
2778 out, _, _ = run("", "repo", "deploy-key", "list", repo.Path())
2779 if !strings.Contains(out, "\tci\tnever used\texpires ") {
2780 t.Fatalf("deploy-key list:\n%s", out)
2781 }
2782}
2783```
2784
2785- [ ] **Step 2: Run it and see it fail**
2786
2787Run: `go test ./internal/control -run TestKeysAddTTLAndList -count=1`
2788Expected: FAIL, `keys add --ttl` exits 2 (unknown flag).
2789
2790- [ ] **Step 3: Helpers**
2791
2792In `internal/control/token.go` after `parseTTL`:
2793
2794```go
2795// ttlFlag reads --ttl as an expiry; nil when the flag is absent. The
2796// code is -1 when the caller may go on.
2797func (c *Ctx) ttlFlag(f flags) (*time.Time, int) {
2798 if !f.Has("--ttl") {
2799 return nil, -1
2800 }
2801 d, err := parseTTL(f.Value("--ttl"))
2802 if err != nil || d <= 0 {
2803 return nil, c.fail(protocol.ExitUsage, "bad ttl %q: give a duration such as 30d or 720h", f.Value("--ttl"))
2804 }
2805 t := time.Now().Add(d)
2806 return &t, -1
2807}
2808```
2809
2810In `internal/control/identity.go` after `keyLabel`:
2811
2812```go
2813// usedText is a key's last use as a list shows it.
2814func (c *Ctx) usedText(ts string) string {
2815 switch {
2816 case ts == "":
2817 return "never used"
2818 case c.Term.Cols == 0:
2819 return "used " + stamp(ts)
2820 }
2821 return "used " + relAge(ts, termNow())
2822}
2823
2824// expiresText is a credential's expiry as a list shows it. It is
2825// absolute at a terminal too: relAge reads only the past.
2826func expiresText(t *time.Time, now time.Time) string {
2827 if t == nil {
2828 return "never expires"
2829 }
2830 s := stamp(t.UTC().Format(time.RFC3339Nano))
2831 if !t.After(now) {
2832 return "expired " + s
2833 }
2834 return "expires " + s
2835}
2836```
2837
2838Add `"time"` to `identity.go`'s imports.
2839
2840- [ ] **Step 4: `keys add --ttl`**
2841
2842Registration:
2843
2844```go
2845 register(Command{
2846 Path: []string{"keys", "add"},
2847 Summary: "register an SSH public key (authorized_keys format)",
2848 Usage: "keys add [--scope full|git|runner] [--label <text>] [--ttl 30d|720h] < key.pub",
2849 Flags: []Flag{
2850 {"--scope", "full|git|runner", "what the key may do", "full"},
2851 {"--label", "<text>", "a name for the key", ""},
2852 {"--ttl", "30d|720h", "how long the key authenticates; an expiring key cannot mint credentials", "never expires"},
2853 },
2854 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
2855 ReadsStdin: true,
2856 MintsCredential: true,
2857 Run: runKeysAdd,
2858 })
2859```
2860
2861In `runKeysAdd`: `parseFlags(args, flagSpec{Values: []string{"--scope", "--label", "--ttl"}, MaxPos: 0, Usage: c.Cmd.Usage})`; after the scope check:
2862
2863```go
2864 expires, code := c.ttlFlag(f)
2865 if code >= 0 {
2866 return code
2867 }
2868```
2869
2870the store call:
2871
2872```go
2873 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2874```
2875
2876and the output:
2877
2878```go
2879 type out struct {
2880 Fingerprint string `json:"fingerprint"`
2881 Scope string `json:"scope"`
2882 Label string `json:"label"`
2883 ExpiresAt *time.Time `json:"expires_at,omitempty"`
2884 }
2885 d := out{fp, scope, label, expires}
2886 return c.emit(d, func(w io.Writer) {
2887 line := fmt.Sprintf("added %s (%s)", d.Fingerprint, d.Scope)
2888 if d.Label != "" {
2889 line += " " + d.Label
2890 }
2891 if d.ExpiresAt != nil {
2892 line += ", " + expiresText(d.ExpiresAt, time.Now())
2893 }
2894 fmt.Fprintln(w, line)
2895 })
2896```
2897
2898- [ ] **Step 5: `keys list` columns**
2899
2900```go
2901 type out struct {
2902 Fingerprint string `json:"fingerprint"`
2903 Algo string `json:"algo"`
2904 Scope string `json:"scope"`
2905 Label string `json:"label"`
2906 CreatedBy string `json:"created_by,omitempty"`
2907 LastUsedAt string `json:"last_used_at,omitempty"`
2908 ExpiresAt *time.Time `json:"expires_at,omitempty"`
2909 }
2910 var ds []out
2911 for _, k := range keys {
2912 ds = append(ds, out{k.Fingerprint, k.Algo, k.Scope, k.Label, k.CreatedBy, k.LastUsedAt, k.ExpiresAt})
2913 }
2914 now := time.Now()
2915 return c.emit(ds, func(w io.Writer) {
2916 tb := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LABEL", "USED", "EXPIRES")
2917 for _, d := range ds {
2918 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Scope), cText(d.Label),
2919 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
2920 }
2921 tb.flush()
2922 })
2923```
2924
2925- [ ] **Step 6: `repo deploy-key add --ttl`, list columns**
2926
2927Registration:
2928
2929```go
2930 register(Command{Path: []string{"repo", "deploy-key", "add"},
2931 Summary: "bind a read-only (or --rw) key to one repository",
2932 Usage: "repo deploy-key add <owner/name> [--rw] [--ttl 30d|720h] < key.pub",
2933 Flags: []Flag{
2934 {"--rw", "", "the key may push, not just fetch", ""},
2935 {"--ttl", "30d|720h", "how long the key authenticates", "never expires"},
2936 },
2937 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
2938 ReadsStdin: true,
2939 MintsCredential: true,
2940 Run: runDeployKeyAdd})
2941```
2942
2943`runDeployKeyAdd`, replacing its argument loop (lines 37-52):
2944
2945```go
2946func runDeployKeyAdd(c *Ctx, args []string) int {
2947 f, err := parseFlags(args, flagSpec{Values: []string{"--ttl"}, Bools: []string{"--rw"}, MaxPos: 1, Usage: c.Cmd.Usage})
2948 if err != nil {
2949 return c.fail(protocol.ExitUsage, "%v", err)
2950 }
2951 path := f.pos(0)
2952 if path == "" {
2953 return c.usage()
2954 }
2955 mode := "ro"
2956 if f.Has("--rw") {
2957 mode = "rw"
2958 }
2959 expires, code := c.ttlFlag(f)
2960 if code >= 0 {
2961 return code
2962 }
2963 repo, code := resolveRepo(c, path, policy.CanAdmin)
2964 if code >= 0 {
2965 return code
2966 }
2967```
2968
2969The rest is as before except the store call and output:
2970
2971```go
2972 if err := c.Store.AddSSHKeyFrom(c.User.ID, fp, pub.Type(), pub.Marshal(), scope, label, store.KeyOrigin{CreatedByToken: c.TokenID, ExpiresAt: expires}); err != nil {
2973 if errors.Is(err, store.ErrDuplicateKey) {
2974 return c.failErr(err)
2975 }
2976 return c.fail(protocol.ExitFailure, "%v", err)
2977 }
2978 d := map[string]any{"fingerprint": fp, "mode": mode}
2979 if expires != nil {
2980 d["expires_at"] = expires
2981 }
2982 return c.emit(d, func(w io.Writer) {
2983 line := fmt.Sprintf("deploy key %s (%s) bound to %s", fp, mode, repo.Path())
2984 if expires != nil {
2985 line += ", " + expiresText(expires, time.Now())
2986 }
2987 fmt.Fprintln(w, line)
2988 })
2989```
2990
2991`runDeployKeyList`:
2992
2993```go
2994 type out struct {
2995 Fingerprint string `json:"fingerprint"`
2996 Algo string `json:"algo"`
2997 Mode string `json:"mode"`
2998 Label string `json:"label"`
2999 LastUsedAt string `json:"last_used_at,omitempty"`
3000 ExpiresAt *time.Time `json:"expires_at,omitempty"`
3001 }
3002 var ds []out
3003 for _, k := range keys {
3004 mode := "ro"
3005 if policy.DeployScopeAllows(k.Scope, repo.ID, true) {
3006 mode = "rw"
3007 }
3008 ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt})
3009 }
3010 now := time.Now()
3011 return c.emit(ds, func(w io.Writer) {
3012 tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES")
3013 for _, d := range ds {
3014 tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label),
3015 cText(c.usedText(d.LastUsedAt)), cText(expiresText(d.ExpiresAt, now)))
3016 }
3017 tb.flush()
3018 })
3019```
3020
3021Add `"time"` to `deploykey.go`'s imports.
3022
3023- [ ] **Step 7: The e2e rows that end at the label**
3024
3025`e2e/ssh_test.go:267`: `if !strings.Contains(out, "\tgit\talice2\t") {`
3026`e2e/ssh_test.go:276`: `if !strings.Contains(out, "\tgit\tbuild box\t") {`
3027
3028- [ ] **Step 8: Run the tests**
3029
3030Run: `go build ./... && go vet ./... && go test ./internal/control ./internal/store ./internal/sshd -count=1 && go test ./e2e -run TestSSHControlPlane -count=1`
3031
3032(Check the name of the test holding `e2e/ssh_test.go:255-276` with `grep -n "^func Test" e2e/ssh_test.go` and run that one.)
3033Expected: PASS.
3034
3035- [ ] **Step 9: Commit**
3036
3037```bash
3038git add internal/control e2e/ssh_test.go
3039git commit -S -m "keys: --ttl on keys add and repo deploy-key add; lists show last use and expiry
3040
3041Ref #277"
3042```
3043
3044### Task 3.4: docs
3045
3046**Files:**
3047- Modify: `.gitbay/wiki/Users.org` (keys block ~61-66 and the scopes paragraph), `.gitbay/wiki/Architecture/05-Identity-and-Access.org:17-18`, `09-Controls.org:27`, `10-Known-Gaps.org`, `.gitbay/wiki/Parity.org` (Accounts), `.gitbay/wiki/API.org` (the paragraph added in MR 2), `CHANGELOG.org`
3048
3049- [ ] **Step 1: Edit the pages**
3050
3051`Users.org`, add to the keys code block:
3052
3053```
3054gitbay auth keys add --scope git --ttl 90d < ~/.ssh/ci_key.pub
3055```
3056
3057and after the labels paragraph:
3058
3059```
3060=--ttl 90d= (or any Go duration, =720h=) makes a key stop
3061authenticating after that long; =repo deploy-key add= takes the same
3062flag. An expiring key cannot create credentials: tokens, keys, login
3063links. =keys list= shows when each key was last used and when it
3064expires, so a key nobody uses is easy to spot.
3065```
3066
3067`05-Identity-and-Access.org`: SSH user key and deploy key Expiry cells become `optional =--ttl=, refused at auth`.
3068
3069`09-Controls.org`:
3070
3071```
3072| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
3073```
3074
3075`10-Known-Gaps.org`: delete the `#277` row.
3076
3077`Parity.org`, after `SSH key label`:
3078
3079```
3080| SSH key expiry and last use | yes | no | no |
3081```
3082
3083`API.org`: in the paragraph MR 2 added, "a token with a =--ttl= cannot run" becomes "a token or SSH key with a =--ttl= cannot run".
3084
3085`CHANGELOG.org`:
3086
3087```
3088- =keys add= and =repo deploy-key add= take =--ttl=; an expired key is
3089 refused at authentication, and an open connection on it closes within
3090 15 seconds. An expiring key cannot create credentials, like an
3091 expiring token. =keys list= and =repo deploy-key list= gain =USED= and
3092 =EXPIRES= columns, after the label (#277).
3093```
3094
3095- [ ] **Step 2: Commit, open the MR**
3096
3097```bash
3098git add .gitbay/wiki CHANGELOG.org
3099git commit -S -m "wiki: key expiry
3100
3101Closes #277"
3102git push -u origin key-expiry
3103gitbay mr create --source key-expiry --target main --title "keys: optional expiry for SSH and deploy keys"
3104```
3105
3106---
3107
3108# MR 4: idle timeout for browser sessions (branch `session-idle`, #276)
3109
3110A session lapses after 12 hours without a request and after seven
3111days regardless. `expires_at` holds the sliding expiry, so the auth
3112query and the retention sweep (`expires_at <= ?`,
3113`internal/store/retention.go:51`) need no change;
3114`absolute_expires_at` holds the cap. Renewal writes at most once a
3115minute per session. The cookie's `MaxAge` stays seven days.
3116
3117### Task 4.1: migration 0062 and the store
3118
3119**Files:**
3120- Create: `internal/store/migrations/0062_web_session_idle.up.sql`, `.down.sql`
3121- Modify: `internal/store/sessions.go:67-121`
3122- Test: `internal/store/sessions_test.go` (append)
3123
3124**Interfaces:**
3125- Produces:
3126 - `const WebSessionIdle = 12 * time.Hour`
3127 - `CreateWebSession(hash string, userID int64, ttl time.Duration) error` — unchanged signature; `ttl` is now the absolute cap.
3128 - `WebSessionUser(hash string) (User, error)` — unchanged signature; renews.
3129 - `WebSession.LastUsedAt string `json:"last_used_at"``
3130
3131- [ ] **Step 1: Write the failing tests**
3132
3133Append to `internal/store/sessions_test.go`:
3134
3135```go
3136func sessionFixture(t *testing.T) (*Store, int64) {
3137 t.Helper()
3138 s := open(t)
3139 if err := s.MigrateUp(); err != nil {
3140 t.Fatal(err)
3141 }
3142 uid, err := s.CreateUser("cmc", false)
3143 if err != nil {
3144 t.Fatal(err)
3145 }
3146 return s, uid
3147}
3148
3149func sessionTimes(t *testing.T, s *Store, hash string) (expires, absolute time.Time) {
3150 t.Helper()
3151 var e, a string
3152 if err := s.DB.QueryRow("SELECT expires_at, absolute_expires_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&e, &a); err != nil {
3153 t.Fatal(err)
3154 }
3155 return *parseTime(sql.NullString{String: e, Valid: true}), *parseTime(sql.NullString{String: a, Valid: true})
3156}
3157
3158func TestWebSessionIdleExpiry(t *testing.T) {
3159 s, uid := sessionFixture(t)
3160 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3161 t.Fatal(err)
3162 }
3163 exp, abs := sessionTimes(t, s, "h")
3164 if d := time.Until(exp); d < WebSessionIdle-time.Minute || d > WebSessionIdle {
3165 t.Fatalf("a new session expires in %s, want %s", d, WebSessionIdle)
3166 }
3167 if d := time.Until(abs); d < 7*24*time.Hour-time.Minute {
3168 t.Fatalf("absolute cap in %s", d)
3169 }
3170 // Idle past the window: gone.
3171 old := fmtTime(time.Now().Add(-time.Second))
3172 s.DB.Exec("UPDATE web_sessions SET expires_at = ? WHERE token_hash = 'h'", old)
3173 if _, err := s.WebSessionUser("h"); err != ErrNotFound {
3174 t.Fatalf("idle session: %v", err)
3175 }
3176}
3177
3178func TestWebSessionRenewsUpToTheCap(t *testing.T) {
3179 s, uid := sessionFixture(t)
3180 if err := s.CreateWebSession("h", uid, 7*24*time.Hour); err != nil {
3181 t.Fatal(err)
3182 }
3183 // Last used two minutes ago, one minute left: a request renews it.
3184 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = 'h'",
3185 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(time.Now().Add(time.Minute)))
3186 if _, err := s.WebSessionUser("h"); err != nil {
3187 t.Fatal(err)
3188 }
3189 if exp, _ := sessionTimes(t, s, "h"); time.Until(exp) < WebSessionIdle-time.Minute {
3190 t.Fatalf("not renewed: expires in %s", time.Until(exp))
3191 }
3192 // Near the cap, renewal stops at it.
3193 capAt := time.Now().Add(time.Hour)
3194 s.DB.Exec("UPDATE web_sessions SET last_used_at = ?, absolute_expires_at = ? WHERE token_hash = 'h'",
3195 fmtTime(time.Now().Add(-2*time.Minute)), fmtTime(capAt))
3196 if _, err := s.WebSessionUser("h"); err != nil {
3197 t.Fatal(err)
3198 }
3199 if exp, _ := sessionTimes(t, s, "h"); exp.After(capAt) {
3200 t.Fatalf("renewed past the cap: %s > %s", exp, capAt)
3201 }
3202 list, err := s.ListWebSessions(uid)
3203 if err != nil || len(list) != 1 || list[0].LastUsedAt == "" {
3204 t.Fatalf("list: %+v %v", list, err)
3205 }
3206}
3207```
3208
3209Add `"database/sql"` to the file's imports.
3210
3211- [ ] **Step 2: Run them and see them fail**
3212
3213Run: `go test ./internal/store -run 'TestWebSession' -count=1`
3214Expected: FAIL to compile, `undefined: WebSessionIdle`.
3215
3216- [ ] **Step 3: Migration**
3217
3218`0062_web_session_idle.up.sql`:
3219
3220```sql
3221-- expires_at slides forward on use, never past absolute_expires_at.
3222-- Sessions open now keep their cap and get a full idle window from here.
3223ALTER TABLE web_sessions ADD COLUMN absolute_expires_at TEXT;
3224ALTER TABLE web_sessions ADD COLUMN last_used_at TEXT;
3225UPDATE web_sessions SET
3226 absolute_expires_at = expires_at,
3227 last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
3228 expires_at = min(expires_at, strftime('%Y-%m-%dT%H:%M:%fZ','now','+12 hours'));
3229```
3230
3231`0062_web_session_idle.down.sql`:
3232
3233```sql
3234UPDATE web_sessions SET expires_at = absolute_expires_at;
3235ALTER TABLE web_sessions DROP COLUMN last_used_at;
3236ALTER TABLE web_sessions DROP COLUMN absolute_expires_at;
3237```
3238
3239- [ ] **Step 4: Store**
3240
3241Replace `CreateWebSession` and `WebSessionUser`:
3242
3243```go
3244// WebSessionIdle is how long a browser session lasts without a request.
3245// Each use moves its expiry this far ahead, never past the cap it was
3246// created with. Migration 0062 repeats the value for sessions it
3247// converts.
3248const WebSessionIdle = 12 * time.Hour
3249
3250// CreateWebSession stores a session that lapses after WebSessionIdle
3251// without use, and after ttl regardless.
3252func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) error {
3253 now := time.Now()
3254 _, err := s.DB.Exec(
3255 "INSERT INTO web_sessions (token_hash, user_id, expires_at, absolute_expires_at, last_used_at) VALUES (?, ?, ?, ?, ?)",
3256 hash, userID, fmtTime(now.Add(min(ttl, WebSessionIdle))), fmtTime(now.Add(ttl)), fmtTime(now))
3257 return err
3258}
3259
3260// WebSessionUser resolves a session cookie hash to its user and renews
3261// the session's idle expiry. A session is written at most once a
3262// minute, so a burst of requests costs one UPDATE.
3263func (s *Store) WebSessionUser(hash string) (User, error) {
3264 now := time.Now()
3265 var userID int64
3266 err := s.DB.QueryRow(
3267 "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?",
3268 hash, fmtTime(now)).Scan(&userID)
3269 if errors.Is(err, sql.ErrNoRows) {
3270 return User{}, ErrNotFound
3271 }
3272 if err != nil {
3273 return User{}, err
3274 }
3275 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
3276 WHERE token_hash = ? AND last_used_at < ?`,
3277 fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute)))
3278 return s.UserByID(userID)
3279}
3280```
3281
3282`WebSession` and `ListWebSessions`:
3283
3284```go
3285type WebSession struct {
3286 ID string `json:"id"`
3287 CreatedAt string `json:"created_at"`
3288 ExpiresAt string `json:"expires_at"`
3289 LastUsedAt string `json:"last_used_at"`
3290}
3291
3292// ListWebSessions lists the user's unexpired browser sessions, newest first.
3293func (s *Store) ListWebSessions(userID int64) ([]WebSession, error) {
3294 rows, err := s.DB.Query(`SELECT substr(token_hash, 1, 12), created_at, expires_at, COALESCE(last_used_at, created_at)
3295 FROM web_sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC`,
3296 userID, fmtTime(time.Now()))
3297 if err != nil {
3298 return nil, err
3299 }
3300 defer rows.Close()
3301 var out []WebSession
3302 for rows.Next() {
3303 var ws WebSession
3304 if err := rows.Scan(&ws.ID, &ws.CreatedAt, &ws.ExpiresAt, &ws.LastUsedAt); err != nil {
3305 return nil, err
3306 }
3307 out = append(out, ws)
3308 }
3309 return out, rows.Err()
3310}
3311```
3312
3313- [ ] **Step 5: Run the tests**
3314
3315Run: `go test ./internal/store -count=1`
3316Expected: PASS, including `TestSweepRemovesExpiredSessionsAndTokens` (a `-time.Hour` ttl still makes a dead session).
3317
3318- [ ] **Step 6: Commit**
3319
3320```bash
3321git add internal/store
3322git commit -S -m "store: web sessions lapse after 12 hours idle, under the absolute cap
3323
3324Ref #276"
3325```
3326
3327### Task 4.2: `web sessions list` shows last use; docs
3328
3329**Files:**
3330- Modify: `internal/control/web.go:33-54`
3331- Modify: `internal/httpd/accounts.go:147` (comment only)
3332- Modify: `.gitbay/wiki/Users.org:672-678`, `.gitbay/wiki/Architecture/05-Identity-and-Access.org:21`, `:36-38`, `09-Controls.org:26`, `10-Known-Gaps.org`, `CHANGELOG.org`
3333
3334- [ ] **Step 1: The list**
3335
3336```go
3337 return c.emit(sessions, func(w io.Writer) {
3338 tb := c.table(w, "ID", "SINCE", "UNTIL", "USED")
3339 for _, s := range sessions {
3340 since, until, used := s.CreatedAt, s.ExpiresAt, s.LastUsedAt
3341 if c.Term.Cols == 0 {
3342 since, until, used = stamp(since), stamp(until), stamp(used)
3343 } else {
3344 since, until, used = relAge(since, termNow()), relAge(until, termNow()), relAge(used, termNow())
3345 }
3346 tb.row(cRef(s.ID), cText("since "+since), cText("until "+until), cText("used "+used))
3347 }
3348 tb.flush()
3349 })
3350```
3351
3352- [ ] **Step 2: The call site says what the ttl is**
3353
3354`internal/httpd/accounts.go`, above line 147:
3355
3356```go
3357 // Seven days is the cap; the store ends it sooner after
3358 // store.WebSessionIdle without a request.
3359```
3360
3361- [ ] **Step 3: Run the tests**
3362
3363Run: `go build ./... && go test ./internal/control ./internal/httpd -count=1 && go test ./e2e -run TestWebSessionsListRevoke -count=1`
3364Expected: PASS.
3365
3366- [ ] **Step 4: Docs**
3367
3368`Users.org`, the Browser sessions paragraph:
3369
3370```
3371=gitbay web login= mints a one-time URL; the session it opens ends
3372after twelve hours without a request, and after seven days in any case.
3373=gitbay web sessions list= shows each of yours by a short id with its
3374creation, expiry and last use, and =gitbay web sessions revoke <id>=
3375or =--all= ends them from the terminal, which is where a lost laptop is
3376handled.
3377```
3378
3379`05-Identity-and-Access.org`: the Web session Expiry cell becomes `12 h idle, 7 days absolute`; in the paragraph under the table, "=MaxAge= 7 days" becomes "=MaxAge= 7 days (the session itself also ends after 12 hours idle)".
3380
3381`09-Controls.org`:
3382
3383```
3384| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
3385```
3386
3387`10-Known-Gaps.org`: delete the `#276` row.
3388
3389`CHANGELOG.org`:
3390
3391```
3392- Browser sessions end after twelve hours without a request, and after
3393 seven days as before. Sessions open at upgrade get a fresh twelve
3394 hours. =web sessions list= shows when each was last used (#276).
3395```
3396
3397- [ ] **Step 5: Commit, open the MR**
3398
3399```bash
3400git add internal/control/web.go internal/httpd/accounts.go .gitbay/wiki CHANGELOG.org
3401git commit -S -m "web: sessions list shows last use; docs for the idle timeout
3402
3403Closes #276"
3404git push -u origin session-idle
3405gitbay mr create --source session-idle --target main --title "web: idle timeout for browser sessions"
3406```
3407
3408---
3409
3410# MR 5: `web login` over SSH spends the login-link budget (branch `weblogin-limit`, #278)
3411
3412### Task 5.1: the limit in `runWebLogin`
3413
3414**Files:**
3415- Modify: `internal/control/web.go:80-99`
3416- Modify: `internal/control/loginlink.go:12-19` (comment)
3417- Test: `internal/control/weblogin_test.go` (create)
3418- Modify: `.gitbay/wiki/Architecture/10-Known-Gaps.org`, `CHANGELOG.org`
3419
3420**Interfaces:**
3421- Consumes: `maxLoginLinksPerHour` (`loginlink.go:19`), `Store.CountLoginTokensSince`.
3422
3423- [ ] **Step 1: Write the failing test**
3424
3425```go
3426package control
3427
3428import (
3429 "strings"
3430 "testing"
3431
3432 "gitbay.org/gitbay/internal/protocol"
3433 "gitbay.org/gitbay/internal/store"
3434)
3435
3436// web login over SSH counts against the same hourly bound as the
3437// mailed links, since both insert into login_tokens (#278).
3438func TestWebLoginSharesTheLoginLinkLimit(t *testing.T) {
3439 st, _, uid := newQueueTestRepo(t)
3440 for i := 0; i <= maxLoginLinksPerHour; i++ {
3441 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"})
3442 c.Cfg.Web.Mode = "accounts"
3443 c.Cfg.Server.SiteURL = "https://gitbay.test"
3444 c.Cfg.Limits.WriteRate = -1
3445 code := Dispatch(c, []string{"web", "login"})
3446 switch {
3447 case i < maxLoginLinksPerHour && code != protocol.ExitOK:
3448 t.Fatalf("link %d: exit %d %s", i+1, code, errOut)
3449 case i == maxLoginLinksPerHour && (code != protocol.ExitDenied || !strings.Contains(errOut.String(), "login links")):
3450 t.Fatalf("link %d: exit %d %q, want refused", i+1, code, errOut)
3451 }
3452 }
3453}
3454```
3455
3456- [ ] **Step 2: Run it and see it fail**
3457
3458Run: `go test ./internal/control -run TestWebLoginSharesTheLoginLinkLimit -count=1`
3459Expected: FAIL, the sixth link exits 0.
3460
3461- [ ] **Step 3: Implement**
3462
3463In `runWebLogin`, after the web-mode check:
3464
3465```go
3466 n, err := c.Store.CountLoginTokensSince(c.User.ID, time.Now().Add(-time.Hour))
3467 if err != nil {
3468 return c.fail(protocol.ExitFailure, "%v", err)
3469 }
3470 if n >= maxLoginLinksPerHour {
3471 return c.fail(protocol.ExitDenied,
3472 "%d login links in the last hour is the most an account gets; use one of those, or wait", maxLoginLinksPerHour)
3473 }
3474```
3475
3476`loginlink.go`, the comment above `maxLoginLinksPerHour`:
3477
3478```go
3479// maxLoginLinksPerHour bounds what one account's address can be made to
3480// receive. It matches maxEmailAddsPerHour: enough for a person who mistypes
3481// and retries, nothing for a script. CountLoginTokensSince counts every row
3482// in login_tokens, so links minted with "web login" over SSH and links
3483// mailed from the login page share the budget, and both refuse past it.
3484```
3485
3486- [ ] **Step 4: Run the tests**
3487
3488Run: `go test ./internal/control -count=1`
3489Expected: PASS. Then `grep -c '\.login(t\|loginBrowser(t\|"web", "login"' e2e/*.go` and confirm no single e2e test logs one account in more than five times; `TestMRWebReviewLoop` logs alice in once and carol once, and each e2e test runs its own instance.
3490
3491- [ ] **Step 5: Docs**
3492
3493`10-Known-Gaps.org`: delete the `#278` row.
3494
3495`CHANGELOG.org`:
3496
3497```
3498- =web login= over SSH refuses a sixth link in an hour, the same bound
3499 the login page's mailed links have (#278).
3500```
3501
3502- [ ] **Step 6: Commit, open the MR**
3503
3504```bash
3505git add internal/control/web.go internal/control/loginlink.go internal/control/weblogin_test.go .gitbay/wiki CHANGELOG.org
3506git commit -S -m "web: login over SSH applies the login-link limit
3507
3508Closes #278"
3509git push -u origin weblogin-limit
3510gitbay mr create --source weblogin-limit --target main --title "web login over SSH applies the login-link rate limit"
3511```
3512
3513---
3514
3515## Open questions
3516
35171. **Expiring SSH keys and minting.** #277 says to consider it with
3518 #257; this plan treats an expiring key like an expiring token and
3519 refuses it the nine minting commands (MR 3, Task 3.2). One effect:
3520 a person whose only key has a TTL cannot run `web login`, and must
3521 use the mailed link. Confirm, or drop `Expires: key.ExpiresAt` from
3522 `Exec` and `TestExpiringKeyCannotMint`.
35232. **Which commands mint.** The issue names token create, keys add,
3524 repo deploy-key add, admin invite and web login. The plan adds
3525 `repo runner add` (creates or attaches a key that claims builds),
3526 `admin user create` (with `--key` or a verified address it is a way
3527 in), `email verify` and `admin email verify` (a verified address
3528 receives login links, so a token could plant a lasting way in).
3529 `TestMintingCommandsMarked` pins the list.
35303. **LFS transfer tokens.** `git-lfs-authenticate` hands out an HMAC
3531 token valid for an hour (`internal/lfs`), not stored, revocable only
3532 by expiry. A key removed after minting one leaves LFS access for up
3533 to an hour. Not in any of these issues; file separately?
35344. **System mode.** With `ssh.mode = "system"`, each exec is its own
3535 forced-command process: the per-exec check applies, a running one is
3536 not cut. bay1 runs embedded. Closing it would take a poll in
3537 `gitbayd shell`; the plan documents the limit instead.
35385. **Commands already past dispatch.** "Running commands included" is
3539 implemented as: git transports are killed, commands watching `Done`
3540 stop, and a control command already inside its store write finishes
3541 it with its output lost. Cancelling arbitrary control commands would
3542 need a context threaded through every handler.
35436. **Removing the key you are on.** `keys remove <the key this session
3544 uses>` cuts its own connection, so the CLI reports a connection
3545 error instead of "removed". The removal has committed. Acceptable,
3546 or should `revoke` skip the connection running the removal?
35477. **Idle window as a constant.** 12 hours is `store.WebSessionIdle`,
3548 repeated in migration 0062. Should it be configurable?
3549
3550Noticed, not in scope: `token list` at a terminal shows a future
3551expiry through `relAge`, which clamps to zero and prints "just now"
3552(`internal/control/token.go:112-116`). `expiresText` (MR 3) would fix
3553it if applied there.
3554
3555## Self-review
3556
3557- **Coverage.** #256: per-exec re-read (Task 1.3 `runExec`), git
3558 transport session re-read (same path; `Exec` dispatches git),
3559 connection tracking and closing on keys remove / deploy-key remove /
3560 disable / delete (1.1, 1.3), cancelling running commands and pushes
3561 (1.2, 1.3), interrupted receive-pack moves no ref (1.4), e2e with a
3562 multiplexed connection (1.4). #257: `MintsCredential` checked in
3563 `Dispatch` (2.2), migration recording the creator for tokens and keys
3564 (2.1), `token revoke` listing and revoking with `--created` (2.1,
3565 2.3), default `--scope read` and release note (2.3, 2.5), API and
3566 Threat-Model pages (2.5). #277: `--ttl` on both add commands (3.3),
3567 enforced at authentication (3.2), last use in `keys list` (3.3),
3568 considered with #257 (3.2, open question 1), migration designed with
3569 0060 (both nullable `ADD COLUMN`, one insert path via `KeyOrigin`).
3570 #276: idle timeout with renewal, absolute cap kept, last use listed
3571 (4.1, 4.2). #278: limit applied, comment corrected (5.1).
3572- **Placeholders.** None; every code step carries the code. Two steps
3573 ask the executor to confirm a name with grep (`nullID`, the
3574 `ssh_test.go` test name) because they were not unique facts to pin.
3575- **Types.** `Revoked{KeyIDs []int64; UserID int64}`, `OnRevoke`,
3576 `announce`, `LiveSSHKeys([]int64) (map[int64]bool, error)` are used
3577 with those shapes in 1.1, 1.3, 2.1, 3.1. `Exec(..., key store.SSHKey,
3578 ..., done, stopping, revoked)` matches in 1.3, 3.2 and `system.go`.
3579 `APITokenUser` returns `(User, APIToken, error)` in 2.1, 2.2 and the
3580 tests. `KeyOrigin{CreatedByToken, ExpiresAt}` is introduced in 2.1
3581 and extended in 3.1. `Ctx.TokenID int64`, `Ctx.Expires *time.Time`
3582 match across 2.2, 2.3, 3.2, 3.3. `SSHKey.CreatedBy` (name) and
3583 `KeyOrigin.CreatedByToken` (id) are distinct on purpose.
docs/plans/2026-09-27-data-at-rest-and-backup.md added +3302
@@ -0,0 +1,3302 @@
1# Data at rest and backup 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:** Seal the four secret columns under a key file outside the
6database (#273), encrypt backup archives to an age recipient (#274),
7and make `--verify` check git connectivity while repository moves and
8deletions wait for a running backup (#259), ending with a restore drill
9the operator runs and records.
10
11**Architecture:** A new `internal/seal` package holds AES-256-GCM keys
12read from `server.secret_key_file` (default `/etc/gitbay/secret.key`,
13mode 0600, outside `server.root`). The store seals on write and opens
14on read, so no caller above `internal/store` changes. Every value
15carries `gbs1:<key id>:`; `serve` seals leftover clear values and
16values under retired keys at startup, and `gitbayd admin secrets
17rotate` adds a key, reseals, and retires the old one. Backups wrap the
18tar.gz in `filippo.io/age` when `[backup] age_recipients` is set.
19`--verify` extracts repositories and runs `git fsck
20--connectivity-only` on each. A `flock(2)` on `<root>/backup.lock`
21keeps deletes, renames and transfers (daemon process) out of a full
22backup (separate `gitbayd admin backup` process).
23
24**Tech Stack:** Go 1.27, `crypto/aes` + `crypto/cipher` (GCM),
25`filippo.io/age` (new dependency), `syscall.Flock`, SQLite via
26`modernc.org/sqlite`, cobra.
27
28**Spec:** the issue texts of #273, #274 and #259 on krz/gitbay, and the
29decisions recorded in the brief: AES-GCM, key file under `/etc/gitbay`
30mode 0600 excluded from backups, key id prefix on each value, rotation
31command, re-encryption of existing rows; age recipients, `--verify`
32takes an identity file; the clean-host drill is an operator runbook
33recorded on the Admin wiki page.
34
35## Global Constraints
36
37- Each MR on its own branch off `main`. Commits are signed (the repo
38 refuses unsigned), messages reference issues (`Ref #N`, and
39 `Closes #N` on the commit that finishes one). No attribution to any
40 assistant, model or AI anywhere: commits, MR bodies, comments.
41- MR: `gitbay mr create --source <branch> --target main --title "..."`;
42 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
43 delete the branch locally and on the remote. Behind main → rebase,
44 force-push, merge again.
45- Locally: `go build ./...`, `go vet ./...`, unit tests of touched
46 packages, and at most the one e2e test being written
47 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full suite.
48- Registries that fail CI when a new thing lacks its row: top-level route
49 word in `internal/policy/names.go`; new page template in the width map
50 of `TestMainWidthClass` (`internal/web/web_test.go`); new `ReadOnly`
51 command in `readArgs` in `e2e/readonly_test.go`; new control command
52 needs a `pass()` entry in `cmd/gitbay/main.go` (coverage test);
53 a command reading stdin needs `ReadsStdin: true`. This plan adds no
54 control command, route or template: `gitbayd admin secrets` is a
55 host-local cobra command like `admin backup` and `admin gc`, so none
56 of the registries gains a row.
57- Migrations: the highest today is 0059. This plan owns 0072–0074 and
58 uses one, `0072_push_token_hash`. Plans 1–3 own 0060–0071; whoever
59 lands second renumbers to the next free number at execution time
60 (`loadMigrations` in `internal/store/store.go` refuses a gap, so 0072
61 cannot land before 0060–0071 exist: rename it to the next free
62 number). Migrations come in `.up.sql`/`.down.sql` pairs. Hand-written
63 SQL, no ORM.
64- Secrets travel on stdin, never argv; never logged or echoed. The key
65 file's contents are never printed; commands print key ids only.
66- Wiki pages live in `.gitbay/wiki/`. Update the page in the same MR
67 that changes the behaviour it describes, and close the matching
68 Known-Gaps row (`.gitbay/wiki/Architecture/10-Known-Gaps.org`) and
69 controls-matrix row (`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- Plan-specific:
74 - Sealed value format: `gbs1:<8 lowercase hex key id>:<base64 raw
75 std (nonce ‖ ciphertext ‖ tag)>`. Additional data is
76 `<table>.<column>`. An empty value is never sealed (empty means
77 "none" for `webhooks.secret` and `mirrors.token`).
78 - Key file format: one `<id> <base64 32 bytes>` per line, `#`
79 comments; the last key seals, all keys open. Mode must be 0600 or
80 stricter; anything group- or world-readable is refused.
81 - `server.secret_key_file` must not be inside `server.root`
82 (validation error), so neither the archive nor the restic snapshot
83 of `/var/lib/gitbay` can carry it.
84 - A missing key file is fatal for every process that opens the
85 database through `openStore` (serve, shell, authorized-keys, host
86 admin commands), with a message naming the path and
87 `gitbayd admin secrets init`. `gitbayd migrate` does not need it.
88 - Encrypted archives end in `.age`; `--out` without the suffix gets
89 it appended when `[backup] age_recipients` is set.
90
91## Order and dependencies
92
93| MR | Branch | Issue | Contents |
94|----|--------|-------|----------|
95| 1 | `secrets-at-rest` | Closes #273 | `internal/seal`, `server.secret_key_file`, store sealing, migration 0072, reseal at startup, `admin secrets init/rotate/check`, install.sh, e2e harness key, wiki |
96| 2 | `backup-age` | Closes #274 | `[backup] age_recipients`, age-wrapped archives, `--verify --identity`, backup script globs, wiki |
97| 3 | `backup-verify-lock` | Ref #259 | `gitutil.FsckConnectivity`, `--verify` extracts and checks each repository, `internal/backuplock`, delete/rename/transfer/org rename refused during a full backup, drill procedure and record table on the Admin page |
98| — | `restore-drill-record` (operator) | Closes #259 | the first drill's numbers in the Admin page, Known-Gaps and Controls rows closed |
99
100MR 2 and MR 3 both edit `cmd/gitbayd/backup.go`; land them in order.
101MR 2's `testConfig` helper comes from MR 1.
102
103Other plans: no hard dependency. Soft overlaps, resolved by rebase:
104plan 3 (#279) changes `internal/mirror/mirror.go`, which reads
105`store.Mirror.Token` — the field stays a plain string after this plan,
106so its code is unaffected. Plan 5 (#261) touches the migration runner
107in `internal/store/store.go`; migration 0072 does not use the
108`-- foreign_keys: off` directive.
109
110## File map
111
112| File | MR | Responsibility |
113|---|---|---|
114| `internal/seal/seal.go` (create) | 1 | key file read/write, `Keyring`, `Seal`/`Open`, `KeyID` |
115| `internal/seal/seal_test.go` (create) | 1 | round trip, AAD binding, reload on change, mode refusal |
116| `internal/config/config.go` | 1, 2 | `Server.SecretKeyFile`, `Backup.AgeRecipients`, validation |
117| `internal/store/secrets.go` (create) | 1 | `SetKeyring`, `sealValue`/`openValue`, `ResealSecrets`, `SecretKeyUse`, `tokenHash` |
118| `internal/store/store.go` | 1 | `secrets` field on `Store` |
119| `internal/store/cisecrets.go`, `webhooks.go`, `mirrors.go`, `push.go` | 1 | seal in the write transaction, open on read, token hash lookups |
120| `internal/store/migrations/0072_push_token_hash.{up,down}.sql` (create) | 1 | `push_devices.token_hash` + unique index |
121| `cmd/gitbayd/main.go` | 1 | `openStore` loads the keyring; `serve` reseals; `admin secrets` wired |
122| `cmd/gitbayd/secrets.go` (create) | 1 | `admin secrets init|rotate|check` |
123| `cmd/gitbayd/testconfig_test.go` (create) | 1 | `testConfig` helper |
124| `cmd/gitbayd/backup.go` | 2, 3 | age wrap, `--identity`, lock, connectivity |
125| `internal/gitutil/merge.go` | 3 | `FsckConnectivity` |
126| `internal/backuplock/backuplock.go` (create) | 3 | `Hold`, `TryShared`, `ErrBusy` |
127| `internal/control/repo.go`, `org.go` | 3 | `holdOffBackup` in delete/rename/transfer/org rename |
128| `deploy/install.sh` | 1 | create the key file once |
129| `deploy/cloud-init.yaml` | 2 | backup script and monitor globs for `.age` |
130| `e2e/ssh_test.go`, `acme_test.go`, `system_test.go`, `backup_test.go` | 1, 3 | key file in every config; sealed-at-rest and connectivity checks |
131| `.gitbay/wiki/Admin.org`, `Threat-Model.org`, `Architecture/03,06,08,09,10` | 1–3 | docs |
132| `CHANGELOG.org` | 1, 2 | upgrade notes |
133
134---
135
136# MR 1: secrets at rest (branch `secrets-at-rest`, closes #273)
137
138### Task 1.1: `internal/seal`
139
140**Files:**
141- Create: `internal/seal/seal.go`
142- Test: `internal/seal/seal_test.go`
143
144**Interfaces:**
145- Produces:
146 - `const Prefix = "gbs1:"`
147 - `type Key struct { ID string; Secret []byte }`
148 - `func NewKey() (Key, error)`
149 - `func ReadKeys(path string) ([]Key, error)` — refuses a mode with any group/other bit
150 - `func WriteKeys(path string, keys []Key) error` — atomic, 0600, keeps an existing file's owner
151 - `type Keyring`; `func Load(path string) (*Keyring, error)`
152 - `func (k *Keyring) Seal(aad, plain string) (string, error)`
153 - `func (k *Keyring) Open(aad, sealed string) (string, error)`
154 - `func (k *Keyring) CurrentID() (string, error)`
155 - `func IsSealed(v string) bool`, `func KeyID(v string) (string, bool)`
156
157- [ ] **Step 1: Write the failing tests**
158
159```go
160package seal
161
162import (
163 "os"
164 "path/filepath"
165 "strings"
166 "testing"
167)
168
169func keyFile(t *testing.T, keys ...Key) string {
170 t.Helper()
171 path := filepath.Join(t.TempDir(), "secret.key")
172 if err := WriteKeys(path, keys); err != nil {
173 t.Fatal(err)
174 }
175 return path
176}
177
178func newKey(t *testing.T) Key {
179 t.Helper()
180 k, err := NewKey()
181 if err != nil {
182 t.Fatal(err)
183 }
184 return k
185}
186
187func TestSealOpenRoundTrip(t *testing.T) {
188 k := newKey(t)
189 ring, err := Load(keyFile(t, k))
190 if err != nil {
191 t.Fatal(err)
192 }
193 v, err := ring.Seal("build_secrets.value", "hunter2")
194 if err != nil {
195 t.Fatal(err)
196 }
197 if !strings.HasPrefix(v, Prefix+k.ID+":") || strings.Contains(v, "hunter2") {
198 t.Fatalf("sealed value %q", v)
199 }
200 if id, ok := KeyID(v); !ok || id != k.ID {
201 t.Fatalf("KeyID = %q, %v", id, ok)
202 }
203 got, err := ring.Open("build_secrets.value", v)
204 if err != nil || got != "hunter2" {
205 t.Fatalf("Open = %q, %v", got, err)
206 }
207 // Two seals of one value differ: the nonce is random.
208 if w, _ := ring.Seal("build_secrets.value", "hunter2"); w == v {
209 t.Fatal("two seals produced the same value")
210 }
211}
212
213// A value moved to another column does not open there.
214func TestOpenChecksAdditionalData(t *testing.T) {
215 ring, err := Load(keyFile(t, newKey(t)))
216 if err != nil {
217 t.Fatal(err)
218 }
219 v, _ := ring.Seal("mirrors.token", "tok")
220 if _, err := ring.Open("webhooks.secret", v); err == nil {
221 t.Fatal("opened under the wrong column")
222 }
223}
224
225// A running daemon sees a rotation without a restart: the ring re-reads
226// the file when it changes.
227func TestKeyringFollowsTheFile(t *testing.T) {
228 old, next := newKey(t), newKey(t)
229 path := keyFile(t, old)
230 ring, err := Load(path)
231 if err != nil {
232 t.Fatal(err)
233 }
234 before, _ := ring.Seal("webhooks.secret", "s")
235 if err := WriteKeys(path, []Key{old, next}); err != nil {
236 t.Fatal(err)
237 }
238 after, err := ring.Seal("webhooks.secret", "s")
239 if err != nil {
240 t.Fatal(err)
241 }
242 if id, _ := KeyID(after); id != next.ID {
243 t.Fatalf("sealed under %s after rotation, want %s", id, next.ID)
244 }
245 if got, err := ring.Open("webhooks.secret", before); err != nil || got != "s" {
246 t.Fatalf("old value after rotation: %q, %v", got, err)
247 }
248 if err := WriteKeys(path, []Key{next}); err != nil {
249 t.Fatal(err)
250 }
251 if _, err := ring.Open("webhooks.secret", before); err == nil || !strings.Contains(err.Error(), old.ID) {
252 t.Fatalf("a retired key's value opened, or the error does not name the key: %v", err)
253 }
254}
255
256func TestReadKeysRefusesAReadableFile(t *testing.T) {
257 path := keyFile(t, newKey(t))
258 if err := os.Chmod(path, 0o640); err != nil {
259 t.Fatal(err)
260 }
261 if _, err := ReadKeys(path); err == nil || !strings.Contains(err.Error(), "0600") {
262 t.Fatalf("group-readable key file: %v", err)
263 }
264}
265
266func TestWriteKeysMode(t *testing.T) {
267 path := keyFile(t, newKey(t))
268 fi, err := os.Stat(path)
269 if err != nil {
270 t.Fatal(err)
271 }
272 if fi.Mode().Perm() != 0o600 {
273 t.Fatalf("mode %04o", fi.Mode().Perm())
274 }
275}
276
277func TestReadKeysRejectsMalformedLines(t *testing.T) {
278 for _, body := range []string{
279 "",
280 "# only a comment\n",
281 "XYZ12345 AAAA\n",
282 "0123abcd bm90IDMyIGJ5dGVz\n",
283 } {
284 path := filepath.Join(t.TempDir(), "k")
285 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
286 t.Fatal(err)
287 }
288 if _, err := ReadKeys(path); err == nil {
289 t.Errorf("accepted %q", body)
290 }
291 }
292}
293```
294
295- [ ] **Step 2: Run the tests to verify they fail**
296
297Run: `go test ./internal/seal/ -count=1`
298Expected: FAIL, package does not compile (`undefined: WriteKeys`).
299
300- [ ] **Step 3: Write the implementation**
301
302```go
303// Package seal encrypts the secret columns of the database with
304// AES-256-GCM under keys held in a file outside the database and outside
305// server.root, so neither a copy of the database nor a backup opens them
306// (#273).
307package seal
308
309import (
310 "bufio"
311 "bytes"
312 "crypto/aes"
313 "crypto/cipher"
314 "crypto/rand"
315 "encoding/base64"
316 "encoding/hex"
317 "errors"
318 "fmt"
319 "os"
320 "path/filepath"
321 "strings"
322 "sync"
323 "syscall"
324)
325
326// Prefix marks a sealed value: "gbs1:<key id>:<base64 nonce||ciphertext>".
327const Prefix = "gbs1:"
328
329// Key is one line of the key file.
330type Key struct {
331 ID string // 8 lowercase hex characters
332 Secret []byte // 32 bytes
333}
334
335// NewKey returns a key with a random id and secret.
336func NewKey() (Key, error) {
337 id := make([]byte, 4)
338 secret := make([]byte, 32)
339 if _, err := rand.Read(id); err != nil {
340 return Key{}, err
341 }
342 if _, err := rand.Read(secret); err != nil {
343 return Key{}, err
344 }
345 return Key{ID: hex.EncodeToString(id), Secret: secret}, nil
346}
347
348// ReadKeys reads the key file. The last key seals; every key opens.
349func ReadKeys(path string) ([]Key, error) {
350 fi, err := os.Stat(path)
351 if err != nil {
352 return nil, err
353 }
354 if perm := fi.Mode().Perm(); perm&0o077 != 0 {
355 return nil, fmt.Errorf("%s is mode %04o; it must be readable by its owner alone (0600)", path, perm)
356 }
357 data, err := os.ReadFile(path)
358 if err != nil {
359 return nil, err
360 }
361 var keys []Key
362 seen := map[string]bool{}
363 sc := bufio.NewScanner(bytes.NewReader(data))
364 for n := 1; sc.Scan(); n++ {
365 line := strings.TrimSpace(sc.Text())
366 if line == "" || strings.HasPrefix(line, "#") {
367 continue
368 }
369 f := strings.Fields(line)
370 if len(f) != 2 || !validID(f[0]) {
371 return nil, fmt.Errorf("%s:%d: want \"<8 hex id> <base64 32-byte key>\"", path, n)
372 }
373 secret, err := base64.StdEncoding.DecodeString(f[1])
374 if err != nil || len(secret) != 32 {
375 return nil, fmt.Errorf("%s:%d: key is not 32 bytes of base64", path, n)
376 }
377 if seen[f[0]] {
378 return nil, fmt.Errorf("%s:%d: key id %s appears twice", path, n, f[0])
379 }
380 seen[f[0]] = true
381 keys = append(keys, Key{ID: f[0], Secret: secret})
382 }
383 if err := sc.Err(); err != nil {
384 return nil, err
385 }
386 if len(keys) == 0 {
387 return nil, fmt.Errorf("%s holds no keys", path)
388 }
389 return keys, nil
390}
391
392// WriteKeys replaces the key file: a temporary file in the same
393// directory, mode 0600, given the existing file's owner when there is
394// one (rotation runs as root; the daemon reads the file as its own
395// user), then renamed over it.
396func WriteKeys(path string, keys []Key) error {
397 var b strings.Builder
398 b.WriteString("# gitbay secret keys, \"<id> <base64 key>\" per line. The last line seals\n")
399 b.WriteString("# new values; the others open values sealed before a rotation.\n")
400 b.WriteString("# Keep a copy off this host: backups do not carry this file.\n")
401 for _, k := range keys {
402 fmt.Fprintf(&b, "%s %s\n", k.ID, base64.StdEncoding.EncodeToString(k.Secret))
403 }
404 tmp, err := os.CreateTemp(filepath.Dir(path), ".secret-key-*")
405 if err != nil {
406 return err
407 }
408 defer os.Remove(tmp.Name())
409 fail := func(err error) error {
410 tmp.Close()
411 return err
412 }
413 if err := tmp.Chmod(0o600); err != nil {
414 return fail(err)
415 }
416 if fi, err := os.Stat(path); err == nil {
417 if st, ok := fi.Sys().(*syscall.Stat_t); ok {
418 if err := tmp.Chown(int(st.Uid), int(st.Gid)); err != nil {
419 return fail(err)
420 }
421 }
422 }
423 if _, err := tmp.WriteString(b.String()); err != nil {
424 return fail(err)
425 }
426 if err := tmp.Sync(); err != nil {
427 return fail(err)
428 }
429 if err := tmp.Close(); err != nil {
430 return err
431 }
432 return os.Rename(tmp.Name(), path)
433}
434
435// Keyring is the loaded key file. It re-reads the file whenever the file
436// changes, so a running daemon follows a rotation without a restart.
437type Keyring struct {
438 path string
439
440 mu sync.Mutex
441 fi os.FileInfo
442 cur string
443 aead map[string]cipher.AEAD
444}
445
446func Load(path string) (*Keyring, error) {
447 k := &Keyring{path: path}
448 if err := k.refresh(); err != nil {
449 return nil, err
450 }
451 return k, nil
452}
453
454// refresh reloads the file unless it is the one last read. Callers hold k.mu.
455func (k *Keyring) refresh() error {
456 fi, err := os.Stat(k.path)
457 if err != nil {
458 return err
459 }
460 if k.fi != nil && os.SameFile(k.fi, fi) && fi.ModTime().Equal(k.fi.ModTime()) && fi.Size() == k.fi.Size() {
461 return nil
462 }
463 keys, err := ReadKeys(k.path)
464 if err != nil {
465 return err
466 }
467 aead := make(map[string]cipher.AEAD, len(keys))
468 for _, key := range keys {
469 block, err := aes.NewCipher(key.Secret)
470 if err != nil {
471 return err
472 }
473 g, err := cipher.NewGCM(block)
474 if err != nil {
475 return err
476 }
477 aead[key.ID] = g
478 }
479 k.fi, k.cur, k.aead = fi, keys[len(keys)-1].ID, aead
480 return nil
481}
482
483// CurrentID is the id of the key that seals new values.
484func (k *Keyring) CurrentID() (string, error) {
485 k.mu.Lock()
486 defer k.mu.Unlock()
487 if err := k.refresh(); err != nil {
488 return "", err
489 }
490 return k.cur, nil
491}
492
493// Seal encrypts plain under the current key. aad names the column, so a
494// value copied into another column does not open there.
495func (k *Keyring) Seal(aad, plain string) (string, error) {
496 k.mu.Lock()
497 defer k.mu.Unlock()
498 if err := k.refresh(); err != nil {
499 return "", err
500 }
501 g := k.aead[k.cur]
502 nonce := make([]byte, g.NonceSize())
503 if _, err := rand.Read(nonce); err != nil {
504 return "", err
505 }
506 ct := g.Seal(nonce, nonce, []byte(plain), []byte(aad))
507 return Prefix + k.cur + ":" + base64.RawStdEncoding.EncodeToString(ct), nil
508}
509
510// Open decrypts a value Seal produced under any key the file holds.
511func (k *Keyring) Open(aad, sealed string) (string, error) {
512 id, body, ok := split(sealed)
513 if !ok {
514 return "", errors.New("not a sealed value")
515 }
516 k.mu.Lock()
517 defer k.mu.Unlock()
518 if err := k.refresh(); err != nil {
519 return "", err
520 }
521 g, ok := k.aead[id]
522 if !ok {
523 return "", fmt.Errorf("sealed with key %s, which %s does not hold", id, k.path)
524 }
525 ct, err := base64.RawStdEncoding.DecodeString(body)
526 if err != nil || len(ct) < g.NonceSize() {
527 return "", fmt.Errorf("value sealed with key %s is malformed", id)
528 }
529 plain, err := g.Open(nil, ct[:g.NonceSize()], ct[g.NonceSize():], []byte(aad))
530 if err != nil {
531 return "", fmt.Errorf("value sealed with key %s does not open: wrong key or altered value", id)
532 }
533 return string(plain), nil
534}
535
536// IsSealed reports whether v carries the sealed prefix.
537func IsSealed(v string) bool { return strings.HasPrefix(v, Prefix) }
538
539// KeyID is the id of the key that sealed v.
540func KeyID(v string) (string, bool) {
541 id, _, ok := split(v)
542 return id, ok
543}
544
545func split(v string) (id, body string, ok bool) {
546 rest, ok := strings.CutPrefix(v, Prefix)
547 if !ok {
548 return "", "", false
549 }
550 id, body, ok = strings.Cut(rest, ":")
551 return id, body, ok && validID(id)
552}
553
554func validID(s string) bool {
555 if len(s) != 8 || strings.ToLower(s) != s {
556 return false
557 }
558 _, err := hex.DecodeString(s)
559 return err == nil
560}
561```
562
563- [ ] **Step 4: Run the tests to verify they pass**
564
565Run: `go test ./internal/seal/ -count=1 && go vet ./internal/seal/`
566Expected: PASS.
567
568- [ ] **Step 5: Commit**
569
570```bash
571git add internal/seal
572git commit -S -m "seal: AES-256-GCM keyring for secret columns
573
574Ref #273"
575```
576
577### Task 1.2: `server.secret_key_file`
578
579**Files:**
580- Modify: `internal/config/config.go:4-16` (imports), `:48-57` (`Server`), `:271-273` (`Default`), `:319-335` (`Validate`)
581- Test: `internal/config/config_test.go`
582
583**Interfaces:**
584- Produces: `Config.Server.SecretKeyFile string` (`toml:"secret_key_file"`), default `/etc/gitbay/secret.key`.
585
586- [ ] **Step 1: Write the failing test** (append to `config_test.go`)
587
588```go
589func TestSecretKeyFile(t *testing.T) {
590 cfg, err := Load(writeConfig(t, minimal))
591 if err != nil {
592 t.Fatal(err)
593 }
594 if cfg.Server.SecretKeyFile != "/etc/gitbay/secret.key" {
595 t.Errorf("default secret_key_file = %q", cfg.Server.SecretKeyFile)
596 }
597 for body, want := range map[string]string{
598 minimal + "secret_key_file = \"/var/lib/gitbay/secret.key\"\n": "inside server.root",
599 minimal + "secret_key_file = \"/var/lib/gitbay\"\n": "inside server.root",
600 minimal + "secret_key_file = \"\"\n": "server.secret_key_file is required",
601 } {
602 if _, err := Load(writeConfig(t, body)); err == nil || !strings.Contains(err.Error(), want) {
603 t.Errorf("%q: got %v, want an error containing %q", body, err, want)
604 }
605 }
606 if _, err := Load(writeConfig(t, minimal+"secret_key_file = \"/var/lib/gitbay-keys/secret.key\"\n")); err != nil {
607 t.Errorf("a sibling directory of the root is outside it: %v", err)
608 }
609}
610```
611
612- [ ] **Step 2: Run it to verify it fails**
613
614Run: `go test ./internal/config/ -run TestSecretKeyFile -count=1`
615Expected: FAIL, `unknown config key "server.secret_key_file"`.
616
617- [ ] **Step 3: Implement**
618
619Add `"path/filepath"` to the imports. In `Server`, after `SiteURL`:
620
621```go
622 // SecretKeyFile holds the keys that seal the secret columns of the
623 // database (internal/seal). It lives outside Root, so neither a
624 // backup archive nor a snapshot of Root carries it.
625 SecretKeyFile string `toml:"secret_key_file"`
626```
627
628In `Default()`:
629
630```go
631 Server: Server{Root: "/var/lib/gitbay", SecretKeyFile: "/etc/gitbay/secret.key"},
632```
633
634In `Validate()`, after the `server.site_url is required` check:
635
636```go
637 switch {
638 case c.Server.SecretKeyFile == "":
639 errs = append(errs, errors.New("server.secret_key_file is required"))
640 case within(c.Server.Root, c.Server.SecretKeyFile):
641 errs = append(errs, fmt.Errorf("server.secret_key_file %q is inside server.root: backups of the root would carry the key beside the values it seals", c.Server.SecretKeyFile))
642 }
643```
644
645And below `oneOf`:
646
647```go
648// within reports whether path is dir or below it.
649func within(dir, path string) bool {
650 rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(path))
651 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
652}
653```
654
655- [ ] **Step 4: Run the package tests**
656
657Run: `go test ./internal/config/ -count=1`
658Expected: PASS (existing tests use `minimal`, which gets the default).
659
660- [ ] **Step 5: Commit**
661
662```bash
663git add internal/config
664git commit -S -m "config: server.secret_key_file, outside server.root
665
666Ref #273"
667```
668
669### Task 1.3: the store seals and opens
670
671**Files:**
672- Create: `internal/store/secrets.go`, `internal/store/migrations/0072_push_token_hash.up.sql`, `internal/store/migrations/0072_push_token_hash.down.sql`
673- Modify: `internal/store/store.go:23-30` (`Store`), `internal/store/cisecrets.go:3-58`, `internal/store/webhooks.go:41-113`, `internal/store/mirrors.go:23-66`, `internal/store/push.go:32-78,153-202`
674- Test: `internal/store/secrets_test.go` (create)
675
676**Interfaces:**
677- Consumes: `seal.Keyring`, `seal.IsSealed`, `seal.KeyID`, `seal.NewKey`, `seal.WriteKeys`, `seal.Load` (Task 1.1).
678- Produces:
679 - `func (s *Store) SetKeyring(k *seal.Keyring)`
680 - `func (s *Store) ResealSecrets() (int, error)` — seals clear values, reseals values not under the current key, fills missing `push_devices.token_hash`; one transaction; returns values rewritten.
681 - `func (s *Store) SecretKeyUse() (map[string]int, error)` — count per key id (`""` = clear), opening each value.
682 - Unchanged signatures for every existing store function; `Mirror.Token`, `Webhook.Secret`, `Delivery.Secret`, `PushDevice.Token`, `QueuedPush.Token` hold plaintext as before.
683
684- [ ] **Step 1: Write the failing tests**
685
686```go
687package store
688
689import (
690 "path/filepath"
691 "strings"
692 "testing"
693
694 "gitbay.org/gitbay/internal/seal"
695)
696
697// keyedStore is a migrated store with a key file of one key.
698func keyedStore(t *testing.T) (*Store, string, int64, int64) {
699 t.Helper()
700 s := open(t)
701 if err := s.MigrateUp(); err != nil {
702 t.Fatal(err)
703 }
704 path := filepath.Join(t.TempDir(), "secret.key")
705 k, err := seal.NewKey()
706 if err != nil {
707 t.Fatal(err)
708 }
709 if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
710 t.Fatal(err)
711 }
712 ring, err := seal.Load(path)
713 if err != nil {
714 t.Fatal(err)
715 }
716 s.SetKeyring(ring)
717 uid, err := s.CreateUser("alice", false)
718 if err != nil {
719 t.Fatal(err)
720 }
721 repoID, err := s.CreateRepo("user", uid, "app", "public")
722 if err != nil {
723 t.Fatal(err)
724 }
725 return s, path, uid, repoID
726}
727
728// raw reads every stored value of the secret columns.
729func raw(t *testing.T, s *Store) []string {
730 t.Helper()
731 var out []string
732 for _, sc := range secretColumns {
733 rows, err := s.DB.Query("SELECT " + sc.column + " FROM " + sc.table + " WHERE " + sc.column + " != ''")
734 if err != nil {
735 t.Fatal(err)
736 }
737 for rows.Next() {
738 var v string
739 if err := rows.Scan(&v); err != nil {
740 t.Fatal(err)
741 }
742 out = append(out, v)
743 }
744 rows.Close()
745 }
746 return out
747}
748
749func TestSecretColumnsAreSealed(t *testing.T) {
750 s, _, uid, repoID := keyedStore(t)
751 if err := s.SetBuildSecret(repoID, "DEPLOY", "ci-secret"); err != nil {
752 t.Fatal(err)
753 }
754 if _, err := s.AddWebhook(repoID, "https://hook.example/x", "hook-secret", "*"); err != nil {
755 t.Fatal(err)
756 }
757 if _, err := s.AddMirror(repoID, "push", "https://mirror.example/r.git", "u", "mirror-token"); err != nil {
758 t.Fatal(err)
759 }
760 if _, err := s.AddPushDevice(uid, "apns-token", "phone"); err != nil {
761 t.Fatal(err)
762 }
763
764 vals := raw(t, s)
765 if len(vals) != 4 {
766 t.Fatalf("stored %d values, want 4: %v", len(vals), vals)
767 }
768 for _, v := range vals {
769 if !seal.IsSealed(v) {
770 t.Errorf("stored in clear: %q", v)
771 }
772 for _, plain := range []string{"ci-secret", "hook-secret", "mirror-token", "apns-token"} {
773 if strings.Contains(v, plain) {
774 t.Errorf("%q carries %q", v, plain)
775 }
776 }
777 }
778
779 secrets, err := s.BuildSecrets(repoID)
780 if err != nil || secrets["DEPLOY"] != "ci-secret" {
781 t.Fatalf("BuildSecrets = %v, %v", secrets, err)
782 }
783 hooks, err := s.ListWebhooks(repoID)
784 if err != nil || len(hooks) != 1 || hooks[0].Secret != "hook-secret" {
785 t.Fatalf("ListWebhooks = %+v, %v", hooks, err)
786 }
787 ms, err := s.ListMirrors(repoID)
788 if err != nil || len(ms) != 1 || ms[0].Token != "mirror-token" {
789 t.Fatalf("ListMirrors = %+v, %v", ms, err)
790 }
791 ds, err := s.PushDevices(uid)
792 if err != nil || len(ds) != 1 || ds[0].Token != "apns-token" {
793 t.Fatalf("PushDevices = %+v, %v", ds, err)
794 }
795
796 // An empty webhook secret or mirror token stays empty: it means none.
797 if _, err := s.AddWebhook(repoID, "https://hook.example/y", "", "*"); err != nil {
798 t.Fatal(err)
799 }
800 var empty int
801 s.DB.QueryRow("SELECT COUNT(*) FROM webhooks WHERE secret = ''").Scan(&empty)
802 if empty != 1 {
803 t.Errorf("empty secret stored as %d rows of ''", empty)
804 }
805}
806
807// A token re-registered under another account changes hands by its
808// hash, since two seals of one token differ.
809func TestPushDeviceUpsertBySealedToken(t *testing.T) {
810 s, _, uid, _ := keyedStore(t)
811 bob, err := s.CreateUser("bob", false)
812 if err != nil {
813 t.Fatal(err)
814 }
815 first, err := s.AddPushDevice(uid, "tok", "phone")
816 if err != nil {
817 t.Fatal(err)
818 }
819 second, err := s.AddPushDevice(bob, "tok", "ipad")
820 if err != nil {
821 t.Fatal(err)
822 }
823 if first != second {
824 t.Fatalf("re-registration made row %d beside %d", second, first)
825 }
826 if err := s.DeletePushDeviceByToken("tok"); err != nil {
827 t.Fatal(err)
828 }
829 if d, _ := s.PushDevices(bob); len(d) != 0 {
830 t.Fatalf("device left after delete by token: %+v", d)
831 }
832}
833
834// Rows written before sealing existed, and rows under a retired key,
835// end up under the current key.
836func TestResealSecrets(t *testing.T) {
837 s, path, uid, repoID := keyedStore(t)
838 if _, err := s.DB.Exec("INSERT INTO build_secrets (repo_id, name, value) VALUES (?, 'OLD', 'clear-value')", repoID); err != nil {
839 t.Fatal(err)
840 }
841 if _, err := s.DB.Exec("INSERT INTO push_devices (user_id, token, label) VALUES (?, 'clear-token', '')", uid); err != nil {
842 t.Fatal(err)
843 }
844 n, err := s.ResealSecrets()
845 if err != nil || n != 2 {
846 t.Fatalf("ResealSecrets = %d, %v; want 2", n, err)
847 }
848 for _, v := range raw(t, s) {
849 if !seal.IsSealed(v) {
850 t.Errorf("still clear: %q", v)
851 }
852 }
853 var hash string
854 s.DB.QueryRow("SELECT COALESCE(token_hash, '') FROM push_devices").Scan(&hash)
855 if hash != tokenHash("clear-token") {
856 t.Errorf("token_hash = %q", hash)
857 }
858 if n, _ := s.ResealSecrets(); n != 0 {
859 t.Errorf("second reseal rewrote %d values", n)
860 }
861
862 // Rotation: add a key, reseal, drop the old key; the value still opens.
863 old, err := seal.ReadKeys(path)
864 if err != nil {
865 t.Fatal(err)
866 }
867 next, _ := seal.NewKey()
868 if err := seal.WriteKeys(path, append(old, next)); err != nil {
869 t.Fatal(err)
870 }
871 if n, err := s.ResealSecrets(); err != nil || n != 2 {
872 t.Fatalf("reseal after rotation = %d, %v; want 2", n, err)
873 }
874 if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
875 t.Fatal(err)
876 }
877 use, err := s.SecretKeyUse()
878 if err != nil || use[next.ID] != 2 || len(use) != 1 {
879 t.Fatalf("SecretKeyUse = %v, %v", use, err)
880 }
881 if got, _ := s.BuildSecrets(repoID); got["OLD"] != "clear-value" {
882 t.Fatalf("value after rotation: %v", got)
883 }
884}
885
886func TestSealedValueWithoutKeyFails(t *testing.T) {
887 s, _, _, repoID := keyedStore(t)
888 if err := s.SetBuildSecret(repoID, "X", "v"); err != nil {
889 t.Fatal(err)
890 }
891 s.SetKeyring(nil)
892 if _, err := s.BuildSecrets(repoID); err == nil {
893 t.Fatal("opened a sealed value with no key loaded")
894 }
895}
896```
897
898- [ ] **Step 2: Run them to verify they fail**
899
900Run: `go test ./internal/store/ -run 'TestSecretColumnsAreSealed|TestPushDeviceUpsertBySealedToken|TestResealSecrets|TestSealedValueWithoutKeyFails' -count=1`
901Expected: FAIL to compile, `s.SetKeyring undefined`.
902
903- [ ] **Step 3: Migration 0072**
904
905`internal/store/migrations/0072_push_token_hash.up.sql`:
906
907```sql
908-- APNs tokens are sealed with a random nonce (internal/seal), so two
909-- stores of one token differ; lookups and the re-registration upsert go
910-- by this SHA-256 of the token instead. Rows from before it are filled
911-- by Store.ResealSecrets when the daemon starts.
912ALTER TABLE push_devices ADD COLUMN token_hash TEXT;
913CREATE UNIQUE INDEX push_devices_token_hash ON push_devices(token_hash);
914```
915
916`internal/store/migrations/0072_push_token_hash.down.sql`:
917
918```sql
919DROP INDEX push_devices_token_hash;
920ALTER TABLE push_devices DROP COLUMN token_hash;
921```
922
923- [ ] **Step 4: `Store.secrets` and `internal/store/secrets.go`**
924
925In `store.go`, add the import `"gitbay.org/gitbay/internal/seal"` and a field on `Store` after `logWait`:
926
927```go
928 // secrets seals and opens the secret columns (secrets.go). Nil
929 // stores values as given; only tests leave it nil, since openStore
930 // in cmd/gitbayd refuses to run without a key file.
931 secrets *seal.Keyring
932```
933
934`internal/store/secrets.go`:
935
936```go
937package store
938
939import (
940 "crypto/sha256"
941 "database/sql"
942 "encoding/hex"
943 "errors"
944 "fmt"
945
946 "gitbay.org/gitbay/internal/seal"
947)
948
949// The additional data of each sealed value is its "<table>.<column>".
950const (
951 aadBuildSecret = "build_secrets.value"
952 aadWebhook = "webhooks.secret"
953 aadMirror = "mirrors.token"
954 aadPushToken = "push_devices.token"
955)
956
957type secretColumn struct{ table, column string }
958
959func (c secretColumn) aad() string { return c.table + "." + c.column }
960
961// secretColumns are the columns sealed under the key file (#273).
962var secretColumns = []secretColumn{
963 {"build_secrets", "value"},
964 {"webhooks", "secret"},
965 {"mirrors", "token"},
966 {"push_devices", "token"},
967}
968
969func (s *Store) SetKeyring(k *seal.Keyring) { s.secrets = k }
970
971// sealValue seals v for storage. An empty value stays empty: for
972// webhooks and mirrors it means there is no secret.
973func (s *Store) sealValue(aad, v string) (string, error) {
974 if s.secrets == nil || v == "" {
975 return v, nil
976 }
977 return s.secrets.Seal(aad, v)
978}
979
980// openValue returns a stored value in clear. A value not yet sealed is
981// returned as stored: rows from before sealing existed stay readable
982// until ResealSecrets reaches them.
983func (s *Store) openValue(aad, v string) (string, error) {
984 if !seal.IsSealed(v) {
985 return v, nil
986 }
987 if s.secrets == nil {
988 return "", errors.New("value is sealed and no secret key is loaded")
989 }
990 return s.secrets.Open(aad, v)
991}
992
993// tokenHash is the lookup key for a push device token.
994func tokenHash(token string) string {
995 sum := sha256.Sum256([]byte(token))
996 return hex.EncodeToString(sum[:])
997}
998
999type secretRow struct {
1000 rowid int64
1001 value string
1002}
1003
1004type queryer interface {
1005 Query(query string, args ...any) (*sql.Rows, error)
1006}
1007
1008func secretRows(q queryer, c secretColumn) ([]secretRow, error) {
1009 rows, err := q.Query(fmt.Sprintf("SELECT rowid, %s FROM %s WHERE %s != ''", c.column, c.table, c.column))
1010 if err != nil {
1011 return nil, err
1012 }
1013 defer rows.Close()
1014 var out []secretRow
1015 for rows.Next() {
1016 var r secretRow
1017 if err := rows.Scan(&r.rowid, &r.value); err != nil {
1018 return nil, err
1019 }
1020 out = append(out, r)
1021 }
1022 return out, rows.Err()
1023}
1024
1025// ResealSecrets seals every clear value in the secret columns and
1026// reseals every value not under the key file's current key, then fills
1027// push_devices.token_hash where it is missing. It runs in one write
1028// transaction: every store write of a secret seals inside its own
1029// transaction, so a write either lands before this one and is resealed,
1030// or after it and is sealed under the key this one saw. It returns how
1031// many values it rewrote.
1032func (s *Store) ResealSecrets() (int, error) {
1033 if s.secrets == nil {
1034 return 0, errors.New("no secret key loaded")
1035 }
1036 tx, err := s.DB.Begin()
1037 if err != nil {
1038 return 0, err
1039 }
1040 defer tx.Rollback()
1041 cur, err := s.secrets.CurrentID()
1042 if err != nil {
1043 return 0, err
1044 }
1045 n := 0
1046 for _, c := range secretColumns {
1047 rows, err := secretRows(tx, c)
1048 if err != nil {
1049 return 0, err
1050 }
1051 for _, r := range rows {
1052 if id, ok := seal.KeyID(r.value); ok && id == cur {
1053 continue
1054 }
1055 plain, err := s.openValue(c.aad(), r.value)
1056 if err != nil {
1057 return 0, fmt.Errorf("%s row %d: %w", c.aad(), r.rowid, err)
1058 }
1059 sealed, err := s.secrets.Seal(c.aad(), plain)
1060 if err != nil {
1061 return 0, err
1062 }
1063 if _, err := tx.Exec(fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", c.table, c.column), sealed, r.rowid); err != nil {
1064 return 0, err
1065 }
1066 n++
1067 }
1068 }
1069 rows, err := tx.Query("SELECT id, token FROM push_devices WHERE token_hash IS NULL")
1070 if err != nil {
1071 return 0, err
1072 }
1073 var missing []secretRow
1074 for rows.Next() {
1075 var r secretRow
1076 if err := rows.Scan(&r.rowid, &r.value); err != nil {
1077 rows.Close()
1078 return 0, err
1079 }
1080 missing = append(missing, r)
1081 }
1082 rows.Close()
1083 if err := rows.Err(); err != nil {
1084 return 0, err
1085 }
1086 for _, r := range missing {
1087 plain, err := s.openValue(aadPushToken, r.value)
1088 if err != nil {
1089 return 0, fmt.Errorf("push_devices row %d: %w", r.rowid, err)
1090 }
1091 if _, err := tx.Exec("UPDATE push_devices SET token_hash = ? WHERE id = ?", tokenHash(plain), r.rowid); err != nil {
1092 return 0, err
1093 }
1094 }
1095 return n, tx.Commit()
1096}
1097
1098// SecretKeyUse counts the values in the secret columns by the id of the
1099// key that sealed them ("" for a value still in clear), opening each
1100// one, so a wrong or incomplete key file is an error naming the row.
1101func (s *Store) SecretKeyUse() (map[string]int, error) {
1102 use := map[string]int{}
1103 for _, c := range secretColumns {
1104 rows, err := secretRows(s.DB, c)
1105 if err != nil {
1106 return nil, err
1107 }
1108 for _, r := range rows {
1109 if _, err := s.openValue(c.aad(), r.value); err != nil {
1110 return nil, fmt.Errorf("%s row %d: %w", c.aad(), r.rowid, err)
1111 }
1112 id, _ := seal.KeyID(r.value)
1113 use[id]++
1114 }
1115 }
1116 return use, nil
1117}
1118```
1119
1120- [ ] **Step 5: Seal in the writers, open in the readers**
1121
1122`cisecrets.go` — replace `SetBuildSecret` and `BuildSecrets`:
1123
1124```go
1125// SetBuildSecret stores or replaces one secret. The value never leaves the
1126// server except inside a claimed build's environment. It is sealed inside
1127// the write transaction; see ResealSecrets.
1128func (s *Store) SetBuildSecret(repoID int64, name, value string) error {
1129 tx, err := s.DB.Begin()
1130 if err != nil {
1131 return err
1132 }
1133 defer tx.Rollback()
1134 sealed, err := s.sealValue(aadBuildSecret, value)
1135 if err != nil {
1136 return err
1137 }
1138 if _, err := tx.Exec(`
1139 INSERT INTO build_secrets (repo_id, name, value) VALUES (?, ?, ?)
1140 ON CONFLICT (repo_id, name) DO UPDATE SET value = excluded.value`,
1141 repoID, name, sealed); err != nil {
1142 return err
1143 }
1144 return tx.Commit()
1145}
1146```
1147
1148```go
1149// BuildSecrets returns the values, for injection into a claimed build.
1150func (s *Store) BuildSecrets(repoID int64) (map[string]string, error) {
1151 rows, err := s.DB.Query("SELECT name, value FROM build_secrets WHERE repo_id = ?", repoID)
1152 if err != nil {
1153 return nil, err
1154 }
1155 defer rows.Close()
1156 out := map[string]string{}
1157 for rows.Next() {
1158 var n, v string
1159 if err := rows.Scan(&n, &v); err != nil {
1160 return nil, err
1161 }
1162 if out[n], err = s.openValue(aadBuildSecret, v); err != nil {
1163 return nil, fmt.Errorf("build secret %s: %w", n, err)
1164 }
1165 }
1166 return out, rows.Err()
1167}
1168```
1169
1170(add `import "fmt"` to `cisecrets.go`).
1171
1172`webhooks.go` — `AddWebhook`:
1173
1174```go
1175func (s *Store) AddWebhook(repoID int64, url, secret, events string) (int64, error) {
1176 tx, err := s.DB.Begin()
1177 if err != nil {
1178 return 0, err
1179 }
1180 defer tx.Rollback()
1181 sealed, err := s.sealValue(aadWebhook, secret)
1182 if err != nil {
1183 return 0, err
1184 }
1185 res, err := tx.Exec(
1186 "INSERT INTO webhooks (repo_id, url, secret, events) VALUES (?, ?, ?, ?)",
1187 repoID, url, sealed, events)
1188 if err != nil {
1189 return 0, err
1190 }
1191 id, err := res.LastInsertId()
1192 if err != nil {
1193 return 0, err
1194 }
1195 return id, tx.Commit()
1196}
1197```
1198
1199In `ListWebhooks`, after the `rows.Scan(...)` of `&w.Secret`:
1200
1201```go
1202 if w.Secret, err = s.openValue(aadWebhook, w.Secret); err != nil {
1203 return nil, fmt.Errorf("webhook %d: %w", w.ID, err)
1204 }
1205```
1206
1207In `DueDeliveries`, after its `rows.Scan(...)`:
1208
1209```go
1210 if d.Secret, err = s.openValue(aadWebhook, d.Secret); err != nil {
1211 return nil, fmt.Errorf("webhook %d: %w", d.WebhookID, err)
1212 }
1213```
1214
1215(`webhooks.go` imports `"fmt"` and `"time"`.)
1216
1217`mirrors.go` — `AddMirror`:
1218
1219```go
1220func (s *Store) AddMirror(repoID int64, direction, url, username, token string) (int64, error) {
1221 tx, err := s.DB.Begin()
1222 if err != nil {
1223 return 0, err
1224 }
1225 defer tx.Rollback()
1226 sealed, err := s.sealValue(aadMirror, token)
1227 if err != nil {
1228 return 0, err
1229 }
1230 res, err := tx.Exec(
1231 "INSERT INTO mirrors (repo_id, direction, url, username, token) VALUES (?, ?, ?, ?, ?)",
1232 repoID, direction, url, username, sealed)
1233 if err != nil {
1234 if isUniqueErr(err) {
1235 return 0, ErrExists
1236 }
1237 return 0, err
1238 }
1239 id, err := res.LastInsertId()
1240 if err != nil {
1241 return 0, err
1242 }
1243 return id, tx.Commit()
1244}
1245```
1246
1247`scanMirror` becomes a method that opens the token, and `mirrorQuery` calls `s.scanMirror(rows)`:
1248
1249```go
1250func (s *Store) scanMirror(row interface{ Scan(...any) error }) (Mirror, error) {
1251 var m Mirror
1252 if err := row.Scan(&m.ID, &m.RepoID, &m.Direction, &m.URL, &m.Username, &m.Token,
1253 &m.Dirty, &m.LastSync, &m.LastError); err != nil {
1254 return m, err
1255 }
1256 var err error
1257 if m.Token, err = s.openValue(aadMirror, m.Token); err != nil {
1258 return m, fmt.Errorf("mirror %d: %w", m.ID, err)
1259 }
1260 return m, nil
1261}
1262```
1263
1264(`mirrors.go` imports `"errors"` and `"fmt"`.)
1265
1266`push.go` — `AddPushDevice` body between `defer tx.Rollback()` and `if prev != 0 ...`:
1267
1268```go
1269 h := tokenHash(token)
1270 var prev int64
1271 if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token_hash = ?", h).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) {
1272 return 0, err
1273 }
1274 sealed, err := s.sealValue(aadPushToken, token)
1275 if err != nil {
1276 return 0, err
1277 }
1278 if _, err := tx.Exec(`
1279 INSERT INTO push_devices (user_id, token, token_hash, label) VALUES (?, ?, ?, ?)
1280 ON CONFLICT(token_hash) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`,
1281 userID, sealed, h, label); err != nil {
1282 return 0, err
1283 }
1284 var id int64
1285 if err := tx.QueryRow("SELECT id FROM push_devices WHERE token_hash = ?", h).Scan(&id); err != nil {
1286 return 0, err
1287 }
1288```
1289
1290Extend the doc comment's second sentence: "The token is sealed (secrets.go), so the lookup and the upsert go by its hash."
1291
1292`PushDevices`, after `rows.Scan(...)`:
1293
1294```go
1295 if d.Token, err = s.openValue(aadPushToken, d.Token); err != nil {
1296 return nil, fmt.Errorf("push device %d: %w", d.ID, err)
1297 }
1298```
1299
1300`DuePush`, after `rows.Scan(...)`:
1301
1302```go
1303 if p.Token, err = s.openValue(aadPushToken, p.Token); err != nil {
1304 return nil, fmt.Errorf("push device %d: %w", p.DeviceID, err)
1305 }
1306```
1307
1308`DeletePushDeviceByToken`:
1309
1310```go
1311 _, err := s.DB.Exec("DELETE FROM push_devices WHERE token_hash = ?", tokenHash(token))
1312```
1313
1314(`push.go` adds `"fmt"` to its imports.)
1315
1316- [ ] **Step 6: Run the store tests**
1317
1318Run: `go test ./internal/store/ -count=1`
1319Expected: PASS, including `TestMigrateUpDown` (0072 down drops the index before the column) and the existing `TestPushDevices` (nil keyring, hash lookups).
1320
1321- [ ] **Step 7: Build and vet everything above the store**
1322
1323Run: `go build ./... && go vet ./...`
1324Expected: no output. No caller changed signature.
1325
1326- [ ] **Step 8: Commit**
1327
1328```bash
1329git add internal/store
1330git commit -S -m "store: seal CI secrets, webhook secrets, mirror tokens and device tokens
1331
1332Values are AES-256-GCM under the key file; push devices are looked up
1333by token hash (migration 0072).
1334
1335Ref #273"
1336```
1337
1338### Task 1.4: `openStore` loads the key; `serve` reseals; `admin secrets`
1339
1340**Files:**
1341- Modify: `cmd/gitbayd/main.go:40-66` (`openStore`), `:138-142` (`serve`), `:408-422` (`adminCmd`)
1342- Create: `cmd/gitbayd/secrets.go`, `cmd/gitbayd/testconfig_test.go`, `cmd/gitbayd/secrets_test.go`
1343- Modify tests: `cmd/gitbayd/main_test.go:17`, `cmd/gitbayd/backup_test.go:46-47`
1344
1345**Interfaces:**
1346- Consumes: `seal.Load`, `seal.ReadKeys`, `seal.WriteKeys`, `seal.NewKey`, `Store.SetKeyring`, `Store.ResealSecrets`, `Store.SecretKeyUse`.
1347- Produces: `func testConfig(t *testing.T) config.Config` (test helper, cmd/gitbayd), `func rotateSecrets(cfg config.Config) error`, `func secretsCmd() *cobra.Command`.
1348
1349- [ ] **Step 1: The test helper and failing tests**
1350
1351`cmd/gitbayd/testconfig_test.go`:
1352
1353```go
1354package main
1355
1356import (
1357 "path/filepath"
1358 "testing"
1359
1360 "gitbay.org/gitbay/internal/config"
1361 "gitbay.org/gitbay/internal/seal"
1362)
1363
1364// testConfig is a config with a fresh root and a key file outside it,
1365// the minimum openStore accepts.
1366func testConfig(t *testing.T) config.Config {
1367 t.Helper()
1368 key := filepath.Join(t.TempDir(), "secret.key")
1369 k, err := seal.NewKey()
1370 if err != nil {
1371 t.Fatal(err)
1372 }
1373 if err := seal.WriteKeys(key, []seal.Key{k}); err != nil {
1374 t.Fatal(err)
1375 }
1376 return config.Config{Server: config.Server{Root: t.TempDir(), SecretKeyFile: key}}
1377}
1378```
1379
1380In `main_test.go:17` replace the config line with `cfg := testConfig(t)`.
1381In `backup_test.go:46-47` replace the two lines with:
1382
1383```go
1384 cfg := testConfig(t)
1385 root := cfg.Server.Root
1386```
1387
1388Both files then no longer use `internal/config`; drop that import from
1389each (MR 2 adds it back to `backup_test.go` for `TestArchivePath`).
1390
1391`cmd/gitbayd/secrets_test.go`:
1392
1393```go
1394package main
1395
1396import (
1397 "path/filepath"
1398 "strings"
1399 "testing"
1400
1401 "gitbay.org/gitbay/internal/seal"
1402)
1403
1404func TestOpenStoreRefusesWithoutKeyFile(t *testing.T) {
1405 cfg := testConfig(t)
1406 cfg.Server.SecretKeyFile = filepath.Join(t.TempDir(), "absent.key")
1407 _, err := openStore(cfg)
1408 if err == nil || !strings.Contains(err.Error(), "gitbayd admin secrets init") {
1409 t.Fatalf("openStore without a key file: %v", err)
1410 }
1411}
1412
1413func TestRotateSecrets(t *testing.T) {
1414 cfg := testConfig(t)
1415 before, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
1416 if err != nil {
1417 t.Fatal(err)
1418 }
1419 st, err := openStore(cfg)
1420 if err != nil {
1421 t.Fatal(err)
1422 }
1423 uid, err := st.CreateUser("alice", false)
1424 if err != nil {
1425 t.Fatal(err)
1426 }
1427 repoID, err := st.CreateRepo("user", uid, "app", "public")
1428 if err != nil {
1429 t.Fatal(err)
1430 }
1431 if err := st.SetBuildSecret(repoID, "TOKEN", "v1"); err != nil {
1432 t.Fatal(err)
1433 }
1434 st.Close()
1435
1436 if err := rotateSecrets(cfg); err != nil {
1437 t.Fatalf("rotate: %v", err)
1438 }
1439 after, err := seal.ReadKeys(cfg.Server.SecretKeyFile)
1440 if err != nil {
1441 t.Fatal(err)
1442 }
1443 if len(after) != 1 || after[0].ID == before[0].ID {
1444 t.Fatalf("key file after rotation holds %v, before %v", after, before)
1445 }
1446 st, err = openStore(cfg)
1447 if err != nil {
1448 t.Fatal(err)
1449 }
1450 defer st.Close()
1451 use, err := st.SecretKeyUse()
1452 if err != nil || use[after[0].ID] != 1 || len(use) != 1 {
1453 t.Fatalf("SecretKeyUse after rotation = %v, %v", use, err)
1454 }
1455 if got, _ := st.BuildSecrets(repoID); got["TOKEN"] != "v1" {
1456 t.Fatalf("value after rotation: %v", got)
1457 }
1458}
1459```
1460
1461- [ ] **Step 2: Run them to verify they fail**
1462
1463Run: `go test ./cmd/gitbayd/ -run 'TestOpenStore|TestRotateSecrets|TestBackupDBOnly' -count=1`
1464Expected: FAIL to compile, `undefined: rotateSecrets`.
1465
1466- [ ] **Step 3: `openStore` and `serve`**
1467
1468`openStore` begins with the key file (add imports `"errors"`, `"io/fs"` and `"gitbay.org/gitbay/internal/seal"`):
1469
1470```go
1471func openStore(cfg config.Config) (*store.Store, error) {
1472 // The key file seals the secret columns (#273). Without it the
1473 // database's secrets cannot be read or written, so nothing that
1474 // opens the database runs.
1475 keys, err := seal.Load(cfg.Server.SecretKeyFile)
1476 if errors.Is(err, fs.ErrNotExist) {
1477 return nil, fmt.Errorf("secret key file %s does not exist: create it with gitbayd admin secrets init, or restore it from its off-host copy (backups do not carry it)", cfg.Server.SecretKeyFile)
1478 }
1479 if err != nil {
1480 return nil, fmt.Errorf("secret key file: %w", err)
1481 }
1482 s, err := store.Open(filepath.Join(cfg.Server.Root, "gitbay.db"))
1483 if err != nil {
1484 return nil, err
1485 }
1486 s.SetKeyring(keys)
1487```
1488
1489(the rest of the function is unchanged).
1490
1491In `serve`, after `defer st.Close()` (line 142):
1492
1493```go
1494 // Values stored before sealing existed, or under a key a
1495 // rotation retired, are sealed under the current key before
1496 // anything reads them. A value the key file cannot open
1497 // stops the start here rather than failing each delivery.
1498 if n, err := st.ResealSecrets(); err != nil {
1499 return fmt.Errorf("sealing secrets: %w", err)
1500 } else if n > 0 {
1501 slog.Info("sealed secret values", "count", n)
1502 }
1503```
1504
1505- [ ] **Step 4: `cmd/gitbayd/secrets.go`**
1506
1507```go
1508package main
1509
1510import (
1511 "fmt"
1512 "os"
1513 "sort"
1514 "strings"
1515
1516 "github.com/spf13/cobra"
1517
1518 "gitbay.org/gitbay/internal/config"
1519 "gitbay.org/gitbay/internal/seal"
1520)
1521
1522// secretsCmd manages the key file that seals CI secrets, webhook
1523// secrets, mirror tokens and push device tokens in the database.
1524func secretsCmd() *cobra.Command {
1525 cmd := &cobra.Command{
1526 Use: "secrets",
1527 Short: "the key file that seals secrets stored in the database",
1528 }
1529 cmd.AddCommand(
1530 &cobra.Command{
1531 Use: "init",
1532 Short: "create the key file (server.secret_key_file) with one new key",
1533 RunE: func(cmd *cobra.Command, args []string) error {
1534 cfg, err := config.Load(configPath)
1535 if err != nil {
1536 return err
1537 }
1538 path := cfg.Server.SecretKeyFile
1539 if _, err := os.Stat(path); err == nil {
1540 return fmt.Errorf("%s already exists; gitbayd admin secrets rotate replaces its key", path)
1541 }
1542 k, err := seal.NewKey()
1543 if err != nil {
1544 return err
1545 }
1546 if err := seal.WriteKeys(path, []seal.Key{k}); err != nil {
1547 return err
1548 }
1549 fmt.Printf("wrote %s (key %s). Copy it off this host: backups do not carry it, and a restored database's secrets do not open without it.\n", path, k.ID)
1550 return nil
1551 },
1552 },
1553 &cobra.Command{
1554 Use: "rotate",
1555 Short: "seal every secret under a new key and retire the old ones",
1556 Long: `Adds a new key to the key file, reseals every value under it in one
1557transaction, then removes the old keys from the file. A running daemon
1558re-reads the file when it changes, so no restart is needed. Run as the
1559user that can replace the key file (root, for /etc/gitbay); the file
1560keeps its owner. Copy the new file off the host afterwards.`,
1561 RunE: func(cmd *cobra.Command, args []string) error {
1562 cfg, err := config.Load(configPath)
1563 if err != nil {
1564 return err
1565 }
1566 return rotateSecrets(cfg)
1567 },
1568 },
1569 &cobra.Command{
1570 Use: "check",
1571 Short: "open every sealed value and count them by key",
1572 RunE: func(cmd *cobra.Command, args []string) error {
1573 cfg, err := config.Load(configPath)
1574 if err != nil {
1575 return err
1576 }
1577 st, err := openStore(cfg)
1578 if err != nil {
1579 return err
1580 }
1581 defer st.Close()
1582 use, err := st.SecretKeyUse()
1583 if err != nil {
1584 return err
1585 }
1586 ids := make([]string, 0, len(use))
1587 for id := range use {
1588 ids = append(ids, id)
1589 }
1590 sort.Strings(ids)
1591 for _, id := range ids {
1592 if id == "" {
1593 fmt.Printf("clear: %d values (sealed when the daemon next starts)\n", use[id])
1594 } else {
1595 fmt.Printf("key %s: %d sealed\n", id, use[id])
1596 }
1597 }
1598 if len(ids) == 0 {
1599 fmt.Println("no secrets stored")
1600 }
1601 return nil
1602 },
1603 },
1604 )
1605 return cmd
1606}
1607
1608// rotateSecrets adds a key, reseals under it, then drops the old keys.
1609// Each step leaves a file that opens every stored value: after the first
1610// write the file holds old and new keys; the reseal is one transaction;
1611// the last write happens only after the reseal committed. Interrupted
1612// anywhere, running it again finishes the job.
1613func rotateSecrets(cfg config.Config) error {
1614 path := cfg.Server.SecretKeyFile
1615 old, err := seal.ReadKeys(path)
1616 if err != nil {
1617 return err
1618 }
1619 next, err := seal.NewKey()
1620 if err != nil {
1621 return err
1622 }
1623 if err := seal.WriteKeys(path, append(old, next)); err != nil {
1624 return err
1625 }
1626 st, err := openStore(cfg)
1627 if err != nil {
1628 return err
1629 }
1630 defer st.Close()
1631 n, err := st.ResealSecrets()
1632 if err != nil {
1633 return fmt.Errorf("resealing: %w (the key file holds the old keys and %s; run rotate again)", err, next.ID)
1634 }
1635 if err := seal.WriteKeys(path, []seal.Key{next}); err != nil {
1636 return err
1637 }
1638 retired := make([]string, len(old))
1639 for i, k := range old {
1640 retired[i] = k.ID
1641 }
1642 fmt.Printf("key %s: resealed %d values; retired %s. Copy %s off this host.\n", next.ID, n, strings.Join(retired, ", "), path)
1643 return nil
1644}
1645```
1646
1647In `adminCmd` add `secretsCmd(),` after `backupCmd(),` (line 417).
1648
1649- [ ] **Step 5: Run the package tests**
1650
1651Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/`
1652Expected: PASS.
1653
1654- [ ] **Step 6: Commit**
1655
1656```bash
1657git add cmd/gitbayd
1658git commit -S -m "gitbayd: load the secret key file; admin secrets init, rotate, check
1659
1660serve seals values still in clear, or under a retired key, before it
1661starts listening.
1662
1663Ref #273"
1664```
1665
1666### Task 1.5: e2e harness key and a sealed-at-rest check through a backup
1667
1668**Files:**
1669- Modify: `e2e/ssh_test.go:17-27` (`instance`), `:81-117` (`startInstanceWith`)
1670- Modify: `e2e/acme_test.go:28-39`, `e2e/system_test.go:32-41`, `e2e/backup_test.go:14-157`
1671
1672**Interfaces:**
1673- Produces: `instance.keyFile string`.
1674
1675- [ ] **Step 1: Harness**
1676
1677Add to `instance`:
1678
1679```go
1680 keyFile string // server.secret_key_file, outside root
1681```
1682
1683In `startInstanceWith`, before building `cfg`:
1684
1685```go
1686 inst.keyFile = filepath.Join(t.TempDir(), "secret.key")
1687```
1688
1689and change the config's `[server]` table and its `Sprintf` arguments:
1690
1691```go
1692[server]
1693root = %q
1694site_url = "https://gitbay.test"
1695secret_key_file = %q
1696```
1697
1698```go
1699`, inst.root, inst.keyFile, inst.port, inst.httpPort, inst.gitPort)
1700```
1701
1702After writing the config file and before `inst.proc = exec.Command(...)`:
1703
1704```go
1705 inst.admin(t, "admin", "secrets", "init")
1706```
1707
1708In `acme_test.go` and `system_test.go`, add `secret_key_file = %q` under `site_url` and `inst.keyFile` as the second `Sprintf` argument. In `backup_test.go`'s restore config (line 76-85) do the same.
1709
1710- [ ] **Step 2: Extend `TestAdminBackup`**
1711
1712Add `"bytes"` to its imports. After the issue create (line 37):
1713
1714```go
1715 // A build secret, to show the archive carries it sealed and the key
1716 // file not at all.
1717 if _, errOut, code := inst.ssh(t, aliceKey, "hunter2-at-rest", "repo", "secret", "set", "alice/keep", "DEPLOY_TOKEN"); code != 0 {
1718 t.Fatalf("secret set: %s", errOut)
1719 }
1720```
1721
1722After the transient-state loop (line 66):
1723
1724```go
1725 if strings.Contains(names, "secret.key") {
1726 t.Fatalf("archive carries the key file:\n%s", names)
1727 }
1728 db, err := exec.Command("tar", "-xzOf", archive, "gitbay.db").Output()
1729 if err != nil {
1730 t.Fatal(err)
1731 }
1732 if bytes.Contains(db, []byte("hunter2-at-rest")) {
1733 t.Fatal("the archived database carries the build secret in clear")
1734 }
1735```
1736
1737After the restored instance answers `whoami` (line 151):
1738
1739```go
1740 // With the original key the restored secrets open; with another key
1741 // they do not.
1742 if out, err := exec.Command(inst.gitbayd, "--config", config2, "admin", "secrets", "check").CombinedOutput(); err != nil || !strings.Contains(string(out), ": 1 sealed") {
1743 t.Fatalf("secrets check on the restored instance: %v\n%s", err, out)
1744 }
1745 config3 := filepath.Join(root2, "config-wrong-key.toml")
1746 wrong := strings.Replace(cfg, fmt.Sprintf("secret_key_file = %q", inst.keyFile),
1747 fmt.Sprintf("secret_key_file = %q", filepath.Join(t.TempDir(), "other.key")), 1)
1748 if err := os.WriteFile(config3, []byte(wrong), 0o600); err != nil {
1749 t.Fatal(err)
1750 }
1751 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "init").CombinedOutput(); err != nil {
1752 t.Fatalf("init the wrong key: %v\n%s", err, out)
1753 }
1754 if out, err := exec.Command(inst.gitbayd, "--config", config3, "admin", "secrets", "check").CombinedOutput(); err == nil || !strings.Contains(string(out), "does not hold") {
1755 t.Fatalf("secrets check with the wrong key: %v\n%s", err, out)
1756 }
1757```
1758
1759- [ ] **Step 3: Run the one e2e test**
1760
1761Run: `go test ./e2e -run TestAdminBackup -count=1`
1762Expected: PASS. (The other e2e tests pick up the harness change in CI.)
1763
1764- [ ] **Step 4: Commit**
1765
1766```bash
1767git add e2e
1768git commit -S -m "e2e: a key file per instance; the archive carries secrets sealed and no key
1769
1770Ref #273"
1771```
1772
1773### Task 1.6: install.sh, wiki, changelog
1774
1775**Files:**
1776- Modify: `deploy/install.sh:12-20`
1777- Modify: `.gitbay/wiki/Admin.org` (Install block lines 17-22, `** [server]` 55-64, a new `** Secret key` under `* Backup and restore`)
1778- Modify: `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (inventory rows 17-19, "Outside the database" table, "At rest" table and the paragraph after it, migration count line 5)
1779- Modify: `.gitbay/wiki/Architecture/03-Deployment.org` (file table, after the `config.toml` row)
1780- Modify: `.gitbay/wiki/Architecture/09-Controls.org:59`, `.gitbay/wiki/Architecture/10-Known-Gaps.org` (#273 row)
1781- Modify: `CHANGELOG.org`
1782
1783- [ ] **Step 1: install.sh creates the key once**
1784
1785Replace the remote script with:
1786
1787```sh
1788ssh -p "$port" "root@$host" '
1789 set -eu
1790 chmod 755 /usr/local/bin/gitbayd.new
1791 mv /usr/local/bin/gitbayd.new /usr/local/bin/gitbayd
1792 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml check-config --no-host-checks
1793 # The key that seals secrets in the database. Created on the first
1794 # install, never replaced here; gitbayd refuses to start without it.
1795 if [ ! -e /etc/gitbay/secret.key ]; then
1796 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin secrets init
1797 chown gitbay:gitbay /etc/gitbay/secret.key
1798 fi
1799 systemctl restart gitbayd
1800 sleep 1
1801 systemctl --no-pager --lines=5 status gitbayd
1802'
1803```
1804
1805- [ ] **Step 2: Admin.org**
1806
1807Install block becomes:
1808
1809```sh
1810install -m 755 gitbayd /usr/local/bin/
1811adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
1812install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay
1813gitbayd --config /etc/gitbay/config.toml check-config
1814gitbayd --config /etc/gitbay/config.toml admin secrets init
1815chown gitbay:gitbay /etc/gitbay/secret.key
1816```
1817
1818Under `** [server]`, after `site_url`:
1819
1820```org
1821- =secret_key_file= (default =/etc/gitbay/secret.key=) — the keys that
1822 seal CI secrets, webhook secrets, mirror tokens and push device
1823 tokens in the database. Must be outside =root=, mode 0600, readable
1824 by the daemon's user. See "Secret key" below.
1825```
1826
1827New subsection at the end of `* Backup and restore` (before `* Upgrades`):
1828
1829```org
1830** Secret key
1831
1832CI secrets, webhook secrets, mirror tokens and APNs device tokens are
1833stored sealed: AES-256-GCM under a key in =server.secret_key_file=,
1834each value prefixed with the id of the key that sealed it
1835(=gbs1:<id>:=). The key file is not in the database, not under
1836=server.root=, and therefore in neither the local archives nor the
1837restic snapshots. Keep a copy off the host; without it a restored
1838database's secrets cannot be opened, and gitbayd refuses to start
1839against them.
1840
1841#+begin_src sh
1842gitbayd admin secrets init # once; deploy/install.sh does it on first install
1843gitbayd admin secrets check # open every value, count by key
1844gitbayd admin secrets rotate # new key, reseal, retire the old one (as root)
1845#+end_src
1846
1847- Missing file: every gitbayd process that opens the database refuses
1848 to run and names the path, including =serve= and, in system mode,
1849 =authorized-keys=. =migrate= does not need it.
1850- Wrong key: =serve= stops at startup naming the first row that does
1851 not open; =secrets check= does the same without starting anything.
1852- Upgrade: the first start after the upgrade seals every value still
1853 in clear and logs =sealed secret values=.
1854- Rotation: =rotate= adds a key, reseals every value under it in one
1855 transaction, then removes the old keys. The daemon re-reads the file
1856 when it changes, so it needs no restart. Copy the new file off the
1857 host afterwards.
1858- Push devices are looked up by the SHA-256 of their token
1859 (=push_devices.token_hash=), since two seals of one token differ.
1860```
1861
1862- [ ] **Step 3: Architecture pages**
1863
1864`06-Data-and-Cryptography.org`: in the inventory, the CI secrets note becomes `sealed (AES-256-GCM)`, Integrations `webhook secret and mirror token sealed`, Notifications `device tokens sealed; looked up by SHA-256`. Add a row to "Outside the database":
1865
1866```org
1867| Secret key file | =server.secret_key_file= (=/etc/gitbay/secret.key=) | C |
1868```
1869
1870In "At rest", replace the secrets row with:
1871
1872```org
1873| CI secrets, webhook secrets, mirror tokens, APNs device tokens | AES-256-GCM under a key file outside the database and outside =server.root=; additional data is the column name; key id on each value (=internal/seal=, =internal/store/secrets.go=) |
1874```
1875
1876and replace the paragraph "The code base contains no symmetric encryption. ..." with:
1877
1878```org
1879The database file or a backup read by anyone other than the =gitbay=
1880user discloses no CI secret, webhook secret, mirror token or device
1881token without the key file, which neither carries. Rotation:
1882=gitbayd admin secrets rotate= (Admin wiki).
1883```
1884
1885Update line 5's migration count to the number of files in
1886`internal/store/migrations/` divided by two after this MR.
1887
1888`03-Deployment.org`, file table, after the `config.toml` row:
1889
1890```org
1891| =/etc/gitbay/secret.key= | keys sealing secret columns | 0600, owner =gitbay= (=deploy/install.sh=) |
1892```
1893
1894`09-Controls.org:59`:
1895
1896```org
1897| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and backups (=internal/seal=) |
1898```
1899
1900`10-Known-Gaps.org`: delete the `#273` row.
1901
1902- [ ] **Step 4: CHANGELOG.org**
1903
1904If the file has no `* Unreleased` heading above the latest version, add one under the header paragraph; under it:
1905
1906```org
1907*Upgrade note.* gitbayd needs =server.secret_key_file= (default
1908=/etc/gitbay/secret.key=) and refuses to start without it. Before
1909replacing the binary, run =gitbayd admin secrets init= as root and
1910=chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does
1911both when the file is missing). The first start seals the stored
1912secrets. Copy the key file off the host: backups do not carry it.
1913
1914- CI secrets, webhook secrets, mirror tokens and push device tokens are
1915 stored sealed with AES-256-GCM (#273). =gitbayd admin secrets
1916 init|rotate|check=.
1917```
1918
1919- [ ] **Step 5: Verify and commit**
1920
1921Run: `go build ./... && go vet ./... && go test ./internal/seal/ ./internal/config/ ./internal/store/ ./cmd/gitbayd/ -count=1`
1922Expected: PASS.
1923
1924```bash
1925git add deploy/install.sh .gitbay/wiki CHANGELOG.org
1926git commit -S -m "deploy, wiki: provision and document the secret key file
1927
1928Closes #273"
1929```
1930
1931- [ ] **Step 6: MR**
1932
1933```bash
1934git push -u origin secrets-at-rest
1935gitbay mr create --source secrets-at-rest --target main --title "Seal secret columns under a key file outside the database"
1936```
1937
1938After CI is green: `gitbay mr merge <n> --strategy ff`, delete the
1939branch locally and remotely. The operator steps for bay1 are in
1940"Operator runbook", part A.
1941
1942---
1943
1944# MR 2: encrypted backup archives (branch `backup-age`, closes #274)
1945
1946### Task 2.1: `[backup] age_recipients`
1947
1948**Files:**
1949- Modify: `go.mod`, `go.sum` (`filippo.io/age`)
1950- Modify: `internal/config/config.go` (`Config`, new `Backup` type, `Validate`)
1951- Test: `internal/config/config_test.go`
1952
1953**Interfaces:**
1954- Produces: `Config.Backup Backup` (`toml:"backup"`), `type Backup struct { AgeRecipients []string }`, `func (b Backup) Recipients() ([]age.Recipient, error)`.
1955
1956- [ ] **Step 1: Add the dependency**
1957
1958Run: `go get filippo.io/age@latest && go mod tidy`
1959Expected: `filippo.io/age` in `go.mod`'s require block. Record the version in the commit message.
1960
1961- [ ] **Step 2: Write the failing test**
1962
1963```go
1964func TestBackupRecipients(t *testing.T) {
1965 id, err := age.GenerateX25519Identity()
1966 if err != nil {
1967 t.Fatal(err)
1968 }
1969 cfg, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\""+id.Recipient().String()+"\"]\n"))
1970 if err != nil {
1971 t.Fatal(err)
1972 }
1973 rs, err := cfg.Backup.Recipients()
1974 if err != nil || len(rs) != 1 {
1975 t.Fatalf("Recipients = %v, %v", rs, err)
1976 }
1977 if _, err := Load(writeConfig(t, minimal+"[backup]\nage_recipients = [\"age1notakey\"]\n")); err == nil || !strings.Contains(err.Error(), "backup.age_recipients") {
1978 t.Fatalf("a malformed recipient: %v", err)
1979 }
1980 if cfg, err := Load(writeConfig(t, minimal)); err != nil || len(cfg.Backup.AgeRecipients) != 0 {
1981 t.Fatalf("default: %v, %v", cfg.Backup, err)
1982 }
1983}
1984```
1985
1986(add `"filippo.io/age"` to the test imports).
1987
1988- [ ] **Step 3: Run it to verify it fails**
1989
1990Run: `go test ./internal/config/ -run TestBackupRecipients -count=1`
1991Expected: FAIL, `cfg.Backup undefined`.
1992
1993- [ ] **Step 4: Implement**
1994
1995In `Config`, after `Push`:
1996
1997```go
1998 Backup Backup `toml:"backup"`
1999```
2000
2001After the `Push` type's methods:
2002
2003```go
2004// Backup configures gitbayd admin backup.
2005type Backup struct {
2006 // AgeRecipients, when set, encrypts every archive to these age
2007 // public keys (age1...). The matching identities stay off the host,
2008 // so the host writes archives it cannot read.
2009 AgeRecipients []string `toml:"age_recipients"`
2010}
2011
2012// Recipients parses AgeRecipients.
2013func (b Backup) Recipients() ([]age.Recipient, error) {
2014 var rs []age.Recipient
2015 for _, s := range b.AgeRecipients {
2016 r, err := age.ParseX25519Recipient(s)
2017 if err != nil {
2018 return nil, fmt.Errorf("backup.age_recipients: %q: %w", s, err)
2019 }
2020 rs = append(rs, r)
2021 }
2022 return rs, nil
2023}
2024```
2025
2026In `Validate`, before `// Contradictions.`:
2027
2028```go
2029 if _, err := c.Backup.Recipients(); err != nil {
2030 errs = append(errs, err)
2031 }
2032```
2033
2034Import `"filippo.io/age"`.
2035
2036- [ ] **Step 5: Run and commit**
2037
2038Run: `go test ./internal/config/ -count=1`
2039Expected: PASS.
2040
2041```bash
2042git add go.mod go.sum internal/config
2043v=$(go list -m -f '{{.Version}}' filippo.io/age)
2044git commit -S -m "config: [backup] age_recipients (filippo.io/age $v)
2045
2046Ref #274"
2047```
2048
2049### Task 2.2: age-wrapped archives and `--verify --identity`
2050
2051**Files:**
2052- Modify: `cmd/gitbayd/backup.go:28-64` (`backupCmd`), `:66-151` (`runBackup`), `:187-197` (`verifyBackup` opening)
2053- Test: `cmd/gitbayd/backup_test.go`
2054
2055**Interfaces:**
2056- Consumes: `cfg.Backup.Recipients()` (Task 2.1), `testConfig` (Task 1.4).
2057- Produces: `func archivePath(out string, cfg config.Config, now time.Time) string`; `func verifyBackup(path, identity string) error` (was `verifyBackup(path string)`); `func archiveReader(f io.Reader, path, identity string) (io.Reader, error)`.
2058
2059- [ ] **Step 1: Write the failing tests**
2060
2061```go
2062func TestBackupEncryptedToAgeRecipient(t *testing.T) {
2063 cfg := testConfig(t)
2064 id, err := age.GenerateX25519Identity()
2065 if err != nil {
2066 t.Fatal(err)
2067 }
2068 cfg.Backup.AgeRecipients = []string{id.Recipient().String()}
2069 s, err := openStore(cfg)
2070 if err != nil {
2071 t.Fatal(err)
2072 }
2073 s.Close()
2074
2075 out := filepath.Join(t.TempDir(), "b.tar.gz.age")
2076 if err := runBackup(cfg, out, true); err != nil {
2077 t.Fatal(err)
2078 }
2079 head := make([]byte, 22)
2080 f, err := os.Open(out)
2081 if err != nil {
2082 t.Fatal(err)
2083 }
2084 io.ReadFull(f, head)
2085 f.Close()
2086 if string(head) != "age-encryption.org/v1\n" {
2087 t.Fatalf("archive is not age-encrypted: %q", head)
2088 }
2089
2090 if err := verifyBackup(out, ""); err == nil || !strings.Contains(err.Error(), "--identity") {
2091 t.Fatalf("verify without an identity: %v", err)
2092 }
2093 idFile := filepath.Join(t.TempDir(), "backup-identity.txt")
2094 if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil {
2095 t.Fatal(err)
2096 }
2097 if err := verifyBackup(out, idFile); err != nil {
2098 t.Fatalf("verify with the identity: %v", err)
2099 }
2100 other, _ := age.GenerateX25519Identity()
2101 otherFile := filepath.Join(t.TempDir(), "other.txt")
2102 os.WriteFile(otherFile, []byte(other.String()+"\n"), 0o600)
2103 if err := verifyBackup(out, otherFile); err == nil {
2104 t.Fatal("verify with another identity succeeded")
2105 }
2106}
2107
2108func TestArchivePath(t *testing.T) {
2109 now := time.Date(2026, 9, 27, 9, 0, 0, 0, time.UTC)
2110 plain := testConfig(t)
2111 enc := plain
2112 enc.Backup.AgeRecipients = []string{"age1x"}
2113 for _, c := range []struct {
2114 out string
2115 cfg config.Config
2116 want string
2117 }{
2118 {"", plain, "gitbay-backup-20260927-090000.tar.gz"},
2119 {"", enc, "gitbay-backup-20260927-090000.tar.gz.age"},
2120 {"/b/x.tar.gz", enc, "/b/x.tar.gz.age"},
2121 {"/b/x.tar.gz.age", enc, "/b/x.tar.gz.age"},
2122 {"/b/x.tar.gz", plain, "/b/x.tar.gz"},
2123 } {
2124 if got := archivePath(c.out, c.cfg, now); got != c.want {
2125 t.Errorf("archivePath(%q) = %q, want %q", c.out, got, c.want)
2126 }
2127 }
2128}
2129```
2130
2131(add `"filippo.io/age"`, `"strings"`, `"time"` and
2132`"gitbay.org/gitbay/internal/config"` to the test imports).
2133
2134- [ ] **Step 2: Run them to verify they fail**
2135
2136Run: `go test ./cmd/gitbayd/ -run 'TestBackupEncrypted|TestArchivePath' -count=1`
2137Expected: FAIL to compile (`undefined: archivePath`; `verifyBackup` takes one argument).
2138
2139- [ ] **Step 3: Implement**
2140
2141`backupCmd`: add `identity` to the `var` line, replace the `RunE` body and add a flag:
2142
2143```go
2144 RunE: func(cmd *cobra.Command, args []string) error {
2145 if verify != "" {
2146 return verifyBackup(verify, identity)
2147 }
2148 cfg, err := config.Load(configPath)
2149 if err != nil {
2150 return err
2151 }
2152 return runBackup(cfg, archivePath(out, cfg, time.Now()), dbOnly)
2153 },
2154```
2155
2156```go
2157 cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive")
2158```
2159
2160Append to the `Long` text:
2161
2162```
2163With [backup] age_recipients set, the archive is encrypted to those age
2164public keys and its name ends in .age. --verify then needs --identity
2165<file> holding a matching private key, which is kept off the host.
2166```
2167
2168New function:
2169
2170```go
2171// archivePath is where the archive goes: out, or a timestamped name,
2172// ending in .age when the archive is encrypted.
2173func archivePath(out string, cfg config.Config, now time.Time) string {
2174 if out == "" {
2175 out = fmt.Sprintf("gitbay-backup-%s.tar.gz", now.UTC().Format("20060102-150405"))
2176 }
2177 if len(cfg.Backup.AgeRecipients) > 0 && !strings.HasSuffix(out, ".age") {
2178 out += ".age"
2179 }
2180 return out
2181}
2182```
2183
2184In `runBackup`, replace lines 86-87 (`gz := gzip.NewWriter(f)` and `tw := tar.NewWriter(gz)`) with:
2185
2186```go
2187 var sink io.Writer = f
2188 var enc io.WriteCloser
2189 if len(cfg.Backup.AgeRecipients) > 0 {
2190 rs, err := cfg.Backup.Recipients()
2191 if err != nil {
2192 return err
2193 }
2194 if enc, err = age.Encrypt(f, rs...); err != nil {
2195 return err
2196 }
2197 sink = enc
2198 }
2199 gz := gzip.NewWriter(sink)
2200 tw := tar.NewWriter(gz)
2201```
2202
2203and after `gz.Close()` (line 137-139):
2204
2205```go
2206 if enc != nil {
2207 if err := enc.Close(); err != nil {
2208 return err
2209 }
2210 }
2211```
2212
2213In `verifyBackup`, change the signature to `func verifyBackup(path, identity string) error` and replace `gz, err := gzip.NewReader(f)` with:
2214
2215```go
2216 plain, err := archiveReader(f, path, identity)
2217 if err != nil {
2218 return err
2219 }
2220 gz, err := gzip.NewReader(plain)
2221```
2222
2223New function (import `"bufio"` and `"filippo.io/age"`):
2224
2225```go
2226const ageHeader = "age-encryption.org/v1\n"
2227
2228// archiveReader returns the archive's gzip stream, decrypting it first
2229// when it is an age file.
2230func archiveReader(f io.Reader, path, identity string) (io.Reader, error) {
2231 br := bufio.NewReader(f)
2232 head, _ := br.Peek(len(ageHeader))
2233 if string(head) != ageHeader {
2234 return br, nil
2235 }
2236 if identity == "" {
2237 return nil, fmt.Errorf("%s is encrypted; pass --identity <file> with the private key for one of its recipients", path)
2238 }
2239 idf, err := os.Open(identity)
2240 if err != nil {
2241 return nil, err
2242 }
2243 defer idf.Close()
2244 ids, err := age.ParseIdentities(idf)
2245 if err != nil {
2246 return nil, fmt.Errorf("%s: %w", identity, err)
2247 }
2248 r, err := age.Decrypt(br, ids...)
2249 if err != nil {
2250 return nil, fmt.Errorf("%s: decrypting: %w", path, err)
2251 }
2252 return r, nil
2253}
2254```
2255
2256Update the verify doc comment's first sentence to "verifyBackup reads an archive back, decrypting it with identity when it is encrypted:".
2257
2258- [ ] **Step 4: Run the package tests**
2259
2260Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/`
2261Expected: PASS.
2262
2263- [ ] **Step 5: Commit**
2264
2265```bash
2266git add cmd/gitbayd
2267git commit -S -m "backup: encrypt archives to [backup] age_recipients; --verify --identity
2268
2269Ref #274"
2270```
2271
2272### Task 2.3: scripts, wiki, changelog
2273
2274**Files:**
2275- Modify: `deploy/cloud-init.yaml:96-100` (`age_h`), `:258-259`, `:276-277` (prune globs)
2276- Modify: `.gitbay/wiki/Admin.org` (Configuration reference: new `** [backup]` after `** [push]`; `* Backup and restore` 373-393)
2277- Modify: `.gitbay/wiki/Architecture/06-Data-and-Cryptography.org` (At rest, Backups row), `Architecture/08-Operations.org` (Backup table), `Architecture/09-Controls.org:61`, `Architecture/10-Known-Gaps.org` (#274 row)
2278- Modify: `CHANGELOG.org`
2279
2280- [ ] **Step 1: cloud-init**
2281
2282`age_h`:
2283
2284```sh
2285 age_h() {
2286 f=$(ls -t "$1"/*.tar.gz "$1"/*.tar.gz.age 2>/dev/null | head -1)
2287```
2288
2289Full backup prune (line 259):
2290
2291```sh
2292 ls -1t "$dir"/gitbay-*.tar.gz* | tail -n +8 | xargs -r rm --
2293```
2294
2295Database backup prune (line 277):
2296
2297```sh
2298 ls -1t "$dir"/gitbay-db-*.tar.gz* | tail -n +49 | xargs -r rm --
2299```
2300
2301- [ ] **Step 2: Admin.org**
2302
2303New `** [backup]` section after `** [push]`:
2304
2305```org
2306** [backup]
2307- =age_recipients= (optional) — age public keys (=age1...=). When set,
2308 =admin backup= encrypts every archive to them and appends =.age= to
2309 its name. Generate the pair off the host with =age-keygen=; only the
2310 public key goes here, so the host writes archives it cannot read.
2311 The restic copy is unaffected: it snapshots =/var/lib/gitbay=, not
2312 the archives.
2313```
2314
2315In `* Backup and restore`, after the `--verify` paragraph:
2316
2317```org
2318With =[backup] age_recipients= set the archive is =<name>.tar.gz.age=
2319and =--verify= needs the private key:
2320
2321#+begin_src sh
2322gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt
2323age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root
2324#+end_src
2325
2326The identity lives off the host (with the secret key file and the
2327restic credentials), so verifying an encrypted archive happens there
2328or on a restore host.
2329```
2330
2331- [ ] **Step 3: Architecture pages**
2332
2333`06-Data-and-Cryptography.org`, At rest, Backups row:
2334
2335```org
2336| Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file |
2337```
2338
2339`08-Operations.org`, Full archive and Database only rows: append `; age-encrypted when =[backup] age_recipients= is set` to Contents.
2340
2341`09-Controls.org:61`:
2342
2343```org
2344| Local backups encrypted | in place | age to =[backup] age_recipients= (=cmd/gitbayd/backup.go=); offsite copy by restic |
2345```
2346
2347`10-Known-Gaps.org`: delete the `#274` row.
2348
2349- [ ] **Step 4: CHANGELOG.org** under `* Unreleased`:
2350
2351```org
2352- =gitbayd admin backup= encrypts archives to =[backup] age_recipients=
2353 when set (#274); =--verify= takes =--identity <file>=. Archive names
2354 gain =.age=; the shipped backup scripts and monitor match both.
2355```
2356
2357- [ ] **Step 5: Verify and commit**
2358
2359Run: `go build ./... && go vet ./...`
2360Expected: no output.
2361
2362```bash
2363git add deploy/cloud-init.yaml .gitbay/wiki CHANGELOG.org
2364git commit -S -m "deploy, wiki: encrypted archives in the backup scripts and docs
2365
2366Closes #274"
2367git push -u origin backup-age
2368gitbay mr create --source backup-age --target main --title "Encrypt backup archives to an age recipient"
2369```
2370
2371Merge with `--strategy ff` after CI, delete the branch both places.
2372
2373---
2374
2375# MR 3: verify connectivity, hold moves during a backup (branch `backup-verify-lock`, ref #259)
2376
2377### Task 3.1: `gitutil.FsckConnectivity`
2378
2379**Files:**
2380- Modify: `internal/gitutil/merge.go` (after `PruneNow`, line 68)
2381- Test: `internal/gitutil/fsck_test.go` (create)
2382
2383**Interfaces:**
2384- Produces: `func FsckConnectivity(dir string) error`.
2385
2386- [ ] **Step 1: Failing test**
2387
2388```go
2389package gitutil
2390
2391import (
2392 "os"
2393 "os/exec"
2394 "path/filepath"
2395 "strings"
2396 "testing"
2397)
2398
2399func TestFsckConnectivityFindsAMissingObject(t *testing.T) {
2400 dir := t.TempDir()
2401 git(t, dir, "init", "-q", "-b", "main")
2402 write(t, dir, "a.txt", "a\n")
2403 git(t, dir, "add", "a.txt")
2404 git(t, dir, "commit", "-q", "-m", "one")
2405 if err := FsckConnectivity(dir); err != nil {
2406 t.Fatalf("intact repository: %v", err)
2407 }
2408 out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD:a.txt").Output()
2409 if err != nil {
2410 t.Fatal(err)
2411 }
2412 blob := strings.TrimSpace(string(out))
2413 if err := os.Remove(filepath.Join(dir, ".git", "objects", blob[:2], blob[2:])); err != nil {
2414 t.Fatal(err)
2415 }
2416 if err := FsckConnectivity(dir); err == nil {
2417 t.Fatal("a repository missing a blob passed")
2418 }
2419}
2420```
2421
2422- [ ] **Step 2: Run it**
2423
2424Run: `go test ./internal/gitutil/ -run TestFsckConnectivity -count=1`
2425Expected: FAIL, `undefined: FsckConnectivity`.
2426
2427- [ ] **Step 3: Implement** (in `merge.go`, after `PruneNow`)
2428
2429```go
2430// FsckConnectivity checks that every object reachable from the
2431// repository's refs is present, without reading blob contents. A backup
2432// verify runs it on each archived repository (#259).
2433func FsckConnectivity(dir string) error {
2434 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "fsck", "--connectivity-only", "--no-progress", "--no-dangling")
2435 if out, err := cmd.CombinedOutput(); err != nil {
2436 return fmt.Errorf("fsck --connectivity-only: %v\n%s", err, out)
2437 }
2438 return nil
2439}
2440```
2441
2442- [ ] **Step 4: Run and commit**
2443
2444Run: `go test ./internal/gitutil/ -count=1`
2445Expected: PASS.
2446
2447```bash
2448git add internal/gitutil
2449git commit -S -m "gitutil: FsckConnectivity
2450
2451Ref #259"
2452```
2453
2454### Task 3.2: `internal/backuplock`
2455
2456**Files:**
2457- Create: `internal/backuplock/backuplock.go`, `internal/backuplock/backuplock_test.go`
2458
2459**Interfaces:**
2460- Produces: `const Name = "backup.lock"`, `var ErrBusy error`, `func Hold(root string) (func(), error)`, `func TryShared(root string) (func(), error)`.
2461
2462- [ ] **Step 1: Failing tests**
2463
2464```go
2465package backuplock
2466
2467import (
2468 "errors"
2469 "testing"
2470 "time"
2471)
2472
2473func TestTrySharedRefusedWhileHeld(t *testing.T) {
2474 root := t.TempDir()
2475 release, err := Hold(root)
2476 if err != nil {
2477 t.Fatal(err)
2478 }
2479 if _, err := TryShared(root); !errors.Is(err, ErrBusy) {
2480 t.Fatalf("TryShared during a backup: %v", err)
2481 }
2482 release()
2483 r, err := TryShared(root)
2484 if err != nil {
2485 t.Fatalf("TryShared after the backup: %v", err)
2486 }
2487 r()
2488}
2489
2490func TestSharedHoldersCoexist(t *testing.T) {
2491 root := t.TempDir()
2492 a, err := TryShared(root)
2493 if err != nil {
2494 t.Fatal(err)
2495 }
2496 defer a()
2497 b, err := TryShared(root)
2498 if err != nil {
2499 t.Fatalf("second shared holder: %v", err)
2500 }
2501 b()
2502}
2503
2504// A backup waits for a delete already under way.
2505func TestHoldWaitsForSharedHolder(t *testing.T) {
2506 root := t.TempDir()
2507 shared, err := TryShared(root)
2508 if err != nil {
2509 t.Fatal(err)
2510 }
2511 got := make(chan struct{})
2512 go func() {
2513 release, err := Hold(root)
2514 if err != nil {
2515 t.Error(err)
2516 close(got)
2517 return
2518 }
2519 close(got)
2520 release()
2521 }()
2522 select {
2523 case <-got:
2524 t.Fatal("Hold returned while a shared holder was in")
2525 case <-time.After(100 * time.Millisecond):
2526 }
2527 shared()
2528 select {
2529 case <-got:
2530 case <-time.After(5 * time.Second):
2531 t.Fatal("Hold never returned after the shared holder left")
2532 }
2533}
2534```
2535
2536- [ ] **Step 2: Run them**
2537
2538Run: `go test ./internal/backuplock/ -count=1`
2539Expected: FAIL to compile.
2540
2541- [ ] **Step 3: Implement**
2542
2543```go
2544// Package backuplock keeps repository deletes, renames and transfers
2545// out of a full backup's way (#259). The backup runs in its own process
2546// (gitbayd admin backup) and a delete in the daemon's, so the lock is
2547// flock(2) on a file under server.root: the backup holds it exclusively
2548// from its database snapshot until the last repository is archived, and
2549// each delete or move holds it shared while it runs.
2550package backuplock
2551
2552import (
2553 "errors"
2554 "os"
2555 "path/filepath"
2556 "syscall"
2557)
2558
2559// Name is the lock file under server.root. Backups skip it.
2560const Name = "backup.lock"
2561
2562// ErrBusy is TryShared's answer while a backup holds the lock.
2563var ErrBusy = errors.New("a backup is running; repositories cannot be deleted, renamed or moved until it finishes, usually within minutes")
2564
2565// open opens the lock file read-only, which is all flock needs, so the
2566// daemon's user can lock a file a root-run backup created.
2567func open(root string) (*os.File, error) {
2568 return os.OpenFile(filepath.Join(root, Name), os.O_RDONLY|os.O_CREATE, 0o644)
2569}
2570
2571// Hold takes the lock exclusively, waiting for deletes and moves under
2572// way to finish. Closing the file releases it.
2573func Hold(root string) (func(), error) {
2574 f, err := open(root)
2575 if err != nil {
2576 return nil, err
2577 }
2578 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
2579 f.Close()
2580 return nil, err
2581 }
2582 return func() { f.Close() }, nil
2583}
2584
2585// TryShared takes the lock shared without waiting: ErrBusy while a
2586// backup holds it.
2587func TryShared(root string) (func(), error) {
2588 f, err := open(root)
2589 if err != nil {
2590 return nil, err
2591 }
2592 if err := syscall.Flock(int(f.Fd()), syscall.LOCK_SH|syscall.LOCK_NB); err != nil {
2593 f.Close()
2594 if errors.Is(err, syscall.EWOULDBLOCK) {
2595 return nil, ErrBusy
2596 }
2597 return nil, err
2598 }
2599 return func() { f.Close() }, nil
2600}
2601```
2602
2603- [ ] **Step 4: Run and commit**
2604
2605Run: `go test ./internal/backuplock/ -count=1 -race`
2606Expected: PASS.
2607
2608```bash
2609git add internal/backuplock
2610git commit -S -m "backuplock: flock between a full backup and repository moves
2611
2612Ref #259"
2613```
2614
2615### Task 3.3: deletes and moves refuse during a full backup
2616
2617**Files:**
2618- Modify: `internal/control/repo.go` (`runRepoTransfer` 481-538, `runRepoRename` 540-575, `deleteRepo` 611-626; new `holdOffBackup`)
2619- Modify: `internal/control/org.go` (`runOrgRename` 152-184)
2620- Test: `internal/control/backuplock_test.go` (create)
2621
2622**Interfaces:**
2623- Consumes: `backuplock.TryShared`, `backuplock.Hold`, `backuplock.ErrBusy`.
2624- Produces: `func holdOffBackup(c *Ctx) (func(), int)` in package control.
2625
2626- [ ] **Step 1: Failing test**
2627
2628```go
2629package control
2630
2631import (
2632 "strings"
2633 "testing"
2634
2635 "gitbay.org/gitbay/internal/backuplock"
2636 "gitbay.org/gitbay/internal/protocol"
2637)
2638
2639func TestRepoDeleteAndRenameRefusedDuringBackup(t *testing.T) {
2640 st, repo, uid := newQueueTestRepo(t)
2641 owner, err := st.UserByID(uid)
2642 if err != nil {
2643 t.Fatal(err)
2644 }
2645 root := t.TempDir()
2646 release, err := backuplock.Hold(root)
2647 if err != nil {
2648 t.Fatal(err)
2649 }
2650 for _, argv := range [][]string{
2651 {"repo", "rename", repo.Path(), "renamed"},
2652 {"repo", "delete", repo.Path(), "--yes"},
2653 } {
2654 c, errOut := pruneCtx(st, root, owner)
2655 if code := Dispatch(c, argv); code != protocol.ExitFailure || !strings.Contains(errOut.String(), "a backup is running") {
2656 t.Fatalf("%v during a backup: exit %d, %s", argv, code, errOut)
2657 }
2658 }
2659 if got, err := st.RepoByID(repo.ID); err != nil || got.Name != repo.Name {
2660 t.Fatalf("repository changed during a backup: %+v, %v", got, err)
2661 }
2662 release()
2663
2664 c, errOut := pruneCtx(st, root, owner)
2665 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitOK {
2666 t.Fatalf("delete after the backup: exit %d, %s", code, errOut)
2667 }
2668}
2669```
2670
2671Transfer and org rename get the same call; the test covers rename and
2672delete because a transfer target needs an org fixture this test does
2673not build, and the call is identical.
2674
2675- [ ] **Step 2: Run it**
2676
2677Run: `go test ./internal/control/ -run TestRepoDeleteAndRenameRefusedDuringBackup -count=1`
2678Expected: FAIL, rename exits 0 during the backup.
2679
2680- [ ] **Step 3: Implement**
2681
2682`repo.go`, import `"gitbay.org/gitbay/internal/backuplock"` and add after `deleteRepo`:
2683
2684```go
2685// holdOffBackup keeps a full backup from starting while a repository
2686// directory moves or goes, and refuses while one runs: the backup's
2687// database snapshot names every repository its walk then archives
2688// (#259). The caller defers the returned release.
2689func holdOffBackup(c *Ctx) (func(), int) {
2690 release, err := backuplock.TryShared(c.Cfg.Server.Root)
2691 if err != nil {
2692 return nil, c.fail(protocol.ExitFailure, "%v", err)
2693 }
2694 return release, -1
2695}
2696```
2697
2698Call it with this block:
2699
2700```go
2701 release, code := holdOffBackup(c)
2702 if code >= 0 {
2703 return code
2704 }
2705 defer release()
2706```
2707
2708- `deleteRepo`: first statement of the function.
2709- `runRepoRename`: after the `os.Stat(newDir)` check, before `c.Store.RenameRepo` (line 560). `code` is already declared there by `resolveRepo`, so this call uses `lockCode`:
2710
2711```go
2712 release, lockCode := holdOffBackup(c)
2713 if lockCode >= 0 {
2714 return lockCode
2715 }
2716 defer release()
2717```
2718
2719- `runRepoTransfer`: the same `lockCode` block after the `os.Stat(newDir)` check, before `os.MkdirAll` (line 522).
2720- `org.go` `runOrgRename`: the same `lockCode` block after its `os.Stat(newDir)` check, before `c.Store.RenameOrg` (line 170).
2721
2722Repository creation, forks and imports are not held: a repository the
2723snapshot does not name is reported by `--verify` as extra, which is
2724harmless.
2725
2726- [ ] **Step 4: Run and commit**
2727
2728Run: `go test ./internal/control/ -count=1 && go vet ./internal/control/`
2729Expected: PASS.
2730
2731```bash
2732git add internal/control
2733git commit -S -m "control: repository delete, rename, transfer and org rename wait out a full backup
2734
2735Ref #259"
2736```
2737
2738### Task 3.4: the backup holds the lock; `--verify` checks connectivity
2739
2740**Files:**
2741- Modify: `cmd/gitbayd/backup.go` (`runBackup`, `verifyBackup`)
2742- Test: `cmd/gitbayd/backup_test.go`
2743
2744**Interfaces:**
2745- Consumes: `backuplock.Hold`, `backuplock.Name`, `gitutil.FsckConnectivity`, `archiveReader` (Task 2.2).
2746- Produces: `verifyBackup(path, identity string) error` now also runs `FsckConnectivity` per repository.
2747
2748- [ ] **Step 1: Failing tests**
2749
2750```go
2751func gitIn(t *testing.T, dir string, args ...string) string {
2752 t.Helper()
2753 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
2754 cmd.Env = append(os.Environ(),
2755 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e",
2756 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
2757 out, err := cmd.CombinedOutput()
2758 if err != nil {
2759 t.Fatalf("git %v: %v\n%s", args, err, out)
2760 }
2761 return strings.TrimSpace(string(out))
2762}
2763
2764// verify runs git's connectivity check on every repository the
2765// database names: a repository missing an object fails it.
2766func TestVerifyChecksConnectivity(t *testing.T) {
2767 cfg := testConfig(t)
2768 st, err := openStore(cfg)
2769 if err != nil {
2770 t.Fatal(err)
2771 }
2772 uid, err := st.CreateUser("krz", false)
2773 if err != nil {
2774 t.Fatal(err)
2775 }
2776 if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
2777 t.Fatal(err)
2778 }
2779 st.Close()
2780
2781 work := t.TempDir()
2782 gitIn(t, work, "init", "-q", "-b", "main")
2783 if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
2784 t.Fatal(err)
2785 }
2786 gitIn(t, work, "add", "a.txt")
2787 gitIn(t, work, "commit", "-q", "-m", "one")
2788 dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
2789 gitIn(t, work, "clone", "-q", "--bare", work, dir)
2790
2791 good := filepath.Join(t.TempDir(), "good.tar.gz")
2792 if err := runBackup(cfg, good, false); err != nil {
2793 t.Fatal(err)
2794 }
2795 if err := verifyBackup(good, ""); err != nil {
2796 t.Fatalf("intact archive: %v", err)
2797 }
2798
2799 blob := gitIn(t, dir, "rev-parse", "HEAD:a.txt")
2800 if err := os.Remove(filepath.Join(dir, "objects", blob[:2], blob[2:])); err != nil {
2801 t.Fatal(err)
2802 }
2803 bad := filepath.Join(t.TempDir(), "bad.tar.gz")
2804 if err := runBackup(cfg, bad, false); err != nil {
2805 t.Fatal(err)
2806 }
2807 err = verifyBackup(bad, "")
2808 if err == nil || !strings.Contains(err.Error(), "krz/thing") || !strings.Contains(err.Error(), "connectivity") {
2809 t.Fatalf("archive with a missing blob: %v", err)
2810 }
2811}
2812
2813// A full backup waits for a delete under way, and does not archive its
2814// own lock file.
2815func TestFullBackupWaitsForRepositoryMoves(t *testing.T) {
2816 cfg := testConfig(t)
2817 s, err := openStore(cfg)
2818 if err != nil {
2819 t.Fatal(err)
2820 }
2821 s.Close()
2822 inFlight, err := backuplock.TryShared(cfg.Server.Root)
2823 if err != nil {
2824 t.Fatal(err)
2825 }
2826 out := filepath.Join(t.TempDir(), "b.tar.gz")
2827 done := make(chan error, 1)
2828 go func() { done <- runBackup(cfg, out, false) }()
2829 select {
2830 case err := <-done:
2831 t.Fatalf("backup finished while a delete held the lock: %v", err)
2832 case <-time.After(200 * time.Millisecond):
2833 }
2834 inFlight()
2835 select {
2836 case err := <-done:
2837 if err != nil {
2838 t.Fatal(err)
2839 }
2840 case <-time.After(10 * time.Second):
2841 t.Fatal("backup never started after the delete finished")
2842 }
2843 for _, n := range members(t, out) {
2844 if n == backuplock.Name {
2845 t.Fatalf("archive carries %s", n)
2846 }
2847 }
2848}
2849```
2850
2851(`backup_test.go` imports gain `"os/exec"` and `"gitbay.org/gitbay/internal/backuplock"`; `strings` and `time` came with MR 2.)
2852
2853`TestVerifyChecksConnectivity` relies on a local `git clone --bare`
2854hardlinking loose objects into `dir`, so removing the blob there leaves
2855`work` intact. If git packs instead, the `os.Remove` fails and the test
2856says so.
2857
2858- [ ] **Step 2: Run them**
2859
2860Run: `go test ./cmd/gitbayd/ -run 'TestVerifyChecksConnectivity|TestFullBackupWaitsForRepositoryMoves' -count=1`
2861Expected: FAIL: the bad archive verifies; the backup finishes while the lock is held.
2862
2863- [ ] **Step 3: `runBackup` holds the lock**
2864
2865At the top of `runBackup`, before `openStore`:
2866
2867```go
2868 // Deletes, renames and transfers wait until the walk finishes, so
2869 // every repository the snapshot names is still on disk when the walk
2870 // reaches it (#259). A database-only archive reads no repository.
2871 if !dbOnly {
2872 release, err := backuplock.Hold(cfg.Server.Root)
2873 if err != nil {
2874 return fmt.Errorf("backup lock: %w", err)
2875 }
2876 defer release()
2877 }
2878```
2879
2880Add `backuplock.Name: true` to the `skip` map.
2881
2882- [ ] **Step 4: `verifyBackup` extracts and checks repositories**
2883
2884Replace the function (keeping `archiveReader` from Task 2.2):
2885
2886```go
2887// verifyBackup reads an archive back, decrypting it with identity when
2888// it is encrypted: the database snapshot must pass SQLite's integrity
2889// check, every repository it names must be in the archive, and each of
2890// those must pass git fsck --connectivity-only. Repositories are
2891// extracted to a temporary directory for the check, so it needs free
2892// space for them. A database-only archive is checked for integrity
2893// alone and says so.
2894func verifyBackup(path, identity string) error {
2895 f, err := os.Open(path)
2896 if err != nil {
2897 return err
2898 }
2899 defer f.Close()
2900 plain, err := archiveReader(f, path, identity)
2901 if err != nil {
2902 return err
2903 }
2904 gz, err := gzip.NewReader(plain)
2905 if err != nil {
2906 return fmt.Errorf("%s: not a gzip archive: %w", path, err)
2907 }
2908 tr := tar.NewReader(gz)
2909 tmp, err := os.MkdirTemp("", "gitbay-verify-")
2910 if err != nil {
2911 return err
2912 }
2913 defer os.RemoveAll(tmp)
2914 dbPath := ""
2915 inArchive := map[string]bool{}
2916 members := 0
2917 for {
2918 h, err := tr.Next()
2919 if err == io.EOF {
2920 break
2921 }
2922 if err != nil {
2923 return fmt.Errorf("%s: archive damaged after %d members: %w", path, members, err)
2924 }
2925 members++
2926 switch {
2927 case h.Name == "gitbay.db":
2928 dbPath = filepath.Join(tmp, "gitbay.db")
2929 if err := extractTo(tr, dbPath); err != nil {
2930 return fmt.Errorf("%s: extracting the database: %w", path, err)
2931 }
2932 case strings.HasPrefix(h.Name, "repos/"):
2933 // repos/<owner>/<name>.git/HEAD marks one repository present.
2934 parts := strings.Split(h.Name, "/")
2935 if len(parts) == 4 && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") {
2936 inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true
2937 }
2938 if h.Typeflag != tar.TypeReg {
2939 continue
2940 }
2941 if !filepath.IsLocal(h.Name) {
2942 return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name)
2943 }
2944 if err := extractTo(tr, filepath.Join(tmp, filepath.FromSlash(h.Name))); err != nil {
2945 return fmt.Errorf("%s: extracting %s: %w", path, h.Name, err)
2946 }
2947 }
2948 }
2949 if dbPath == "" {
2950 return fmt.Errorf("%s: no gitbay.db in the archive", path)
2951 }
2952 st, err := store.Open(dbPath)
2953 if err != nil {
2954 return fmt.Errorf("%s: database does not open: %w", path, err)
2955 }
2956 defer st.Close()
2957 var integrity string
2958 if err := st.DB.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil {
2959 return fmt.Errorf("%s: integrity check: %w", path, err)
2960 }
2961 if integrity != "ok" {
2962 return fmt.Errorf("%s: database integrity: %s", path, integrity)
2963 }
2964 repos, err := st.ListAllRepos()
2965 if err != nil {
2966 return err
2967 }
2968 if len(inArchive) == 0 {
2969 fmt.Printf("%s: database only; integrity ok, %d repositories in the database, none in the archive\n", path, len(repos))
2970 return nil
2971 }
2972 var missing []string
2973 for _, r := range repos {
2974 if !inArchive[r.Path()] {
2975 missing = append(missing, r.Path())
2976 }
2977 }
2978 extra := len(inArchive) - (len(repos) - len(missing))
2979 fmt.Printf("%s: integrity ok, %d repositories in the database, %d in the archive\n", path, len(repos), len(inArchive))
2980 if len(missing) > 0 {
2981 return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
2982 }
2983 if extra > 0 {
2984 fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra)
2985 }
2986 var broken []string
2987 for _, r := range repos {
2988 dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git")
2989 if err := gitutil.FsckConnectivity(dir); err != nil {
2990 fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err)
2991 broken = append(broken, r.Path())
2992 }
2993 }
2994 if len(broken) > 0 {
2995 return fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", "))
2996 }
2997 fmt.Printf("connectivity ok on %d repositories\n", len(repos))
2998 return nil
2999}
3000
3001// extractTo writes one archive member to dest, owner-only.
3002func extractTo(r io.Reader, dest string) error {
3003 if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
3004 return err
3005 }
3006 w, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
3007 if err != nil {
3008 return err
3009 }
3010 if _, err := io.Copy(w, r); err != nil {
3011 w.Close()
3012 return err
3013 }
3014 return w.Close()
3015}
3016```
3017
3018Imports gain `"gitbay.org/gitbay/internal/backuplock"` and
3019`"gitbay.org/gitbay/internal/gitutil"`. The "extra" message changes
3020from "deleted after the snapshot" to "created after the snapshot",
3021since a delete can no longer land mid-backup.
3022
3023Update `backupCmd`'s `--verify` flag text:
3024
3025```go
3026 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, and git connectivity of each")
3027```
3028
3029- [ ] **Step 5: Run the package tests**
3030
3031Run: `go test ./cmd/gitbayd/ -count=1 && go vet ./cmd/gitbayd/`
3032Expected: PASS, including `TestBackupDBOnlyOmitsRepositories` (its repository is not in the database, so no fsck runs on its HEAD-only directory).
3033
3034- [ ] **Step 6: e2e**
3035
3036In `e2e/backup_test.go`, after the transient-state checks:
3037
3038```go
3039 if out := inst.admin(t, "admin", "backup", "--verify", archive); !strings.Contains(out, "connectivity ok on 1 repositories") {
3040 t.Fatalf("verify: %s", out)
3041 }
3042```
3043
3044Run: `go test ./e2e -run TestAdminBackup -count=1`
3045Expected: PASS.
3046
3047- [ ] **Step 7: Commit**
3048
3049```bash
3050git add cmd/gitbayd e2e/backup_test.go
3051git commit -S -m "backup: hold repository moves off during a full backup; verify git connectivity
3052
3053Ref #259"
3054```
3055
3056### Task 3.5: wiki: behaviour and the restore drill procedure
3057
3058**Files:**
3059- Modify: `.gitbay/wiki/Admin.org` (`* Backup and restore`: the `--verify` paragraph; new `** Restore drill`)
3060- Modify: `.gitbay/wiki/Architecture/08-Operations.org:55-70`
3061- Modify: `.gitbay/wiki/Threat-Model.org:218-220`
3062
3063- [ ] **Step 1: Admin.org**
3064
3065Replace the `--verify` paragraph with:
3066
3067```org
3068=--verify= reads an archive back: the snapshot must pass SQLite's
3069integrity check, every repository the snapshot names must be in the
3070archive, and each must pass =git fsck --connectivity-only=. It
3071extracts the repositories to a temporary directory for that, so it
3072needs free space the size of the repositories. A database-only archive
3073is checked for integrity and says so. Exit is non-zero on damage, a
3074missing repository or a missing object.
3075
3076A full backup holds =<root>/backup.lock= from its database snapshot to
3077its last repository. While it runs, =repo delete=, =repo rename=,
3078=repo transfer=, =admin repo delete= and =org rename= refuse with "a
3079backup is running"; retry when it finishes. Database-only backups take
3080no lock.
3081```
3082
3083Add a new subsection after `** Secret key`:
3084
3085```org
3086** Restore drill
3087
3088A restore onto a clean host, run on a schedule and recorded below.
3089The disaster it rehearses is losing bay1, so the local archives are
3090gone with it and the sources are the offsite restic repository and
3091what the operator keeps off the host (=~/.config/gitbay/=: =offsite.env=,
3092=secret.key=, =config.toml=, =backup-identity.txt=). The steps are in
3093the data-at-rest plan's operator runbook
3094(=docs/plans/2026-09-27-data-at-rest-and-backup.md=).
3095
3096Time to service runs from the clean host's first root login to the
3097first successful =git clone= over SSH from it. The recovery point is
3098the time of the newest restic snapshot restored.
3099
3100| Date | Host | Snapshot restored (UTC) | Time to service | DB integrity | Connectivity | LFS | Release assets | Host key | Secrets | Notes |
3101|------+------+-------------------------+-----------------+--------------+--------------+-----+----------------+----------+---------+-------|
3102```
3103
3104- [ ] **Step 2: Architecture/08 and Threat-Model**
3105
3106`08-Operations.org`: replace the `--verify` bullet (lines 60-62) with:
3107
3108```org
3109- =gitbayd admin backup --verify= checks SQLite integrity, that every
3110 repository the database names is present, and =git fsck
3111 --connectivity-only= on each (=backup.go=).
3112- Repository deletes, renames and transfers refuse while a full backup
3113 runs (=internal/backuplock=), so the snapshot and the walk agree.
3114```
3115
3116Replace the "Recovery time" bullet with:
3117
3118```org
3119- Recovery time: see the Admin wiki's Restore drill table.
3120```
3121
3122`Threat-Model.org:218-220` becomes:
3123
3124```org
3125- Backups are consistent per the DB-snapshot-first ordering, and
3126 repository deletes and moves wait out a full backup; a push during
3127 one leaves only unreferenced objects (see [[Admin]]).
3128```
3129
3130- [ ] **Step 3: Commit and MR**
3131
3132```bash
3133git add .gitbay/wiki
3134git commit -S -m "wiki: backup verify, backup lock, restore drill record
3135
3136Ref #259"
3137git push -u origin backup-verify-lock
3138gitbay mr create --source backup-verify-lock --target main --title "Backup verify checks git connectivity; moves wait out a backup"
3139```
3140
3141Merge with `--strategy ff` after CI, delete the branch both places.
3142#259 stays open until the drill below is recorded.
3143
3144---
3145
3146# Operator runbook (cmc)
3147
3148Run on bay1 and a clean host. Nothing here is automated by the MRs.
3149One forge write per shell call; bay1 root is `ssh -p 2222 root@gitbay.org`.
3150
3151## A. After MR 1 deploys
3152
31531. `make deploy` runs `deploy/install.sh`, which creates
3154 `/etc/gitbay/secret.key` (missing on bay1) before the restart.
3155 Confirm: `ssh -p 2222 root@gitbay.org 'ls -l /etc/gitbay/secret.key; journalctl -u gitbayd -n 50 | grep "sealed secret values"'`
3156 → mode `-rw-------`, owner `gitbay gitbay`, one log line with a count.
31572. `ssh -p 2222 root@gitbay.org 'gitbayd --config /etc/gitbay/config.toml admin secrets check'`
3158 → one `key <id>: N sealed` line, no `clear:` line.
31593. Escrow: `scp -P 2222 root@gitbay.org:/etc/gitbay/secret.key ~/.config/gitbay/secret.key && chmod 600 ~/.config/gitbay/secret.key`.
3160 Also copy `/etc/gitbay/config.toml` to `~/.config/gitbay/config.toml`
3161 (mode 600) if no copy exists off the host; the drill needs it.
31624. Check CI builds that use secrets (blotter, hutch, orgo) still run,
3163 and a webhook delivery still verifies.
3164
3165## B. After MR 2 deploys
3166
31671. On the laptop: `age-keygen -o ~/.config/gitbay/backup-identity.txt`
3168 (mode 600). Note the printed `age1...` public key.
31692. On bay1, add to `/etc/gitbay/config.toml`:
3170 ```toml
3171 [backup]
3172 age_recipients = ["age1..."]
3173 ```
3174 then `gitbayd --config /etc/gitbay/config.toml check-config --no-host-checks`.
31753. bay1's installed `/usr/local/bin/gitbay-backup.sh`,
3176 `/usr/local/bin/gitbay-db-backup.sh` and `/usr/local/bin/gitbay-monitor.sh`
3177 predate the cloud-init change (cloud-init runs once). Apply the same
3178 glob edits as Task 2.3 Step 1 by hand.
31794. Before relying on encryption, find how `/var/lib/gitbay-stage` (the
3180 staged database restic copies) is produced. If it reads a local
3181 archive, it must decrypt, which the host cannot; switch it to its own
3182 `VACUUM INTO` or an unencrypted database-only snapshot kept under
3183 `/var/lib/gitbay-stage` only. If it snapshots the live database
3184 directly, nothing changes.
31855. After the next hourly run: `ls -l /var/backups/gitbay/db | tail -2`
3186 shows `.tar.gz.age`; the monitor's `db_snapshot_h` stays under 2.
3187 Copy one archive to the laptop and run
3188 `gitbayd admin backup --verify <file> --identity ~/.config/gitbay/backup-identity.txt`
3189 (a local gitbayd build; `--verify` reads no config).
31906. Old unencrypted archives age out of the 7/48 rotation on their own.
3191
3192## C. Restore drill (after MR 3 deploys; closes #259)
3193
3194Record every timestamp as you go. Start the clock at step 2.
3195
31961. Pick the snapshot: `set -a; . ~/.config/gitbay/offsite.env; set +a; restic $RESTIC_OPTS snapshots --latest 1`.
3197 Note its time (recovery point). `restic $RESTIC_OPTS ls latest /var/lib/gitbay-stage`
3198 to find the staged database file name.
31992. Provision a clean Ubuntu 24.04 host (throwaway VPS or local VM) with
3200 `deploy/cloud-init.yaml`. First root login: **clock starts**.
32013. Before gitbayd ever starts, block outbound traffic so the restored
3202 instance cannot send mail, deliver webhooks, push mirrors or call
3203 APNs: `ufw default deny outgoing; ufw allow out 53; ufw allow out to <restic endpoint> port 443; ufw reload`.
3204 (Allow the restic endpoint only for the restore, then remove it.)
32054. Install restic, restore: `restic $RESTIC_OPTS restore latest --target / --include /var/lib/gitbay --include /var/lib/gitbay-stage`.
3206 Replace `/var/lib/gitbay/gitbay.db` with the staged copy (the live
3207 file in the snapshot may be mid-write); remove any `gitbay.db-wal`
3208 and `gitbay.db-shm`. `chown -R gitbay:gitbay /var/lib/gitbay`.
32095. Config and key: copy `~/.config/gitbay/config.toml` to
3210 `/etc/gitbay/config.toml` and `~/.config/gitbay/secret.key` to
3211 `/etc/gitbay/secret.key` (`chown gitbay:gitbay`, mode 600). In the
3212 config for the drill only: `site_url` to `http://<drill-ip>:8080`,
3213 `[http] addr = ":8080"`, `tls = "off"`, remove `[mail]`, set
3214 `registration.mode = "closed"`, `[push] enabled = false`, remove
3215 `[backup]` (step 7's archive is local and read back at once). If
3216 `[lfs] root` is set outside `server.root`, note it: neither the
3217 archive nor this restore carries those objects.
32186. Install the gitbayd binary of the tag bay1 runs (`/healthz` names the
3219 commit) with `deploy/install.sh <drill-ip> 2222`; it will not create
3220 a key because one is present.
32217. Checks, each recorded in the table:
3222 - Database integrity and connectivity: as `gitbay`,
3223 `gitbayd --config /etc/gitbay/config.toml admin backup --out /tmp/drill.tar.gz && gitbayd admin backup --verify /tmp/drill.tar.gz`
3224 → `integrity ok`, `connectivity ok on N repositories`; N equals
3225 `gitbayd admin stats --json` repository count on bay1 at the
3226 snapshot.
3227 - Secrets: `gitbayd --config /etc/gitbay/config.toml admin secrets check`
3228 → every value under one key, no error. The journal shows no
3229 `sealing secrets` failure.
3230 - LFS: `cd /var/lib/gitbay/lfs && find . -type f | while read f; do [ "$(sha256sum < "$f" | cut -c1-64)" = "$(basename "$f")" ] || echo "BAD $f"; done` → no output; object count against bay1's.
3231 - Release assets: every row's file exists with its digest:
3232 ```sh
3233 sqlite3 /var/lib/gitbay/gitbay.db "SELECT COALESCE(u.username, o.name) || '/' || r.name || '.git/gitbay-releases/' || a.release_id || '/' || a.name, a.sha256 FROM release_assets a JOIN releases rl ON rl.id = a.release_id JOIN repos r ON r.id = rl.repo_id LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id" |
3234 while IFS='|' read p sum; do [ "$(sha256sum < "/var/lib/gitbay/repos/$p" | cut -c1-64)" = "$sum" ] || echo "BAD $p"; done
3235 ```
3236 → no output.
3237 - Host key: `ssh-keyscan -p 22 <drill-ip>` fingerprint equals
3238 `ssh-keyscan -p 22 gitbay.org`'s.
3239 - Config: `gitbayd --config /etc/gitbay/config.toml check-config` → `config ok`.
32408. Service: from the laptop, `ssh -p 22 git@<drill-ip> whoami` →
3241 `cmc`; `git clone ssh://git@<drill-ip>/krz/gitbay.git` succeeds.
3242 **Clock stops** at the clone.
32439. Record on `.gitbay/wiki/Admin.org`, Restore drill table: date, host,
3244 snapshot time, time to service (step 2 → step 8), each check's
3245 result, and anything that needed a manual fix in Notes. Update:
3246 - `Architecture/10-Known-Gaps.org`: delete the `#259` row; the
3247 "measured recovery time" question row reads the measured figure
3248 and the date.
3249 - `Architecture/09-Controls.org:102`:
3250 `| Restore tested | in place | drill <date>, Admin wiki "Restore drill" |`.
3251 - `Architecture/08-Operations.org`: the "Recovery time" bullet names
3252 the figure.
3253 Branch `restore-drill-record`, one signed commit ending
3254 `Closes #259`, MR, ff merge, delete the branch.
325510. Destroy the drill host. Repeat the drill every quarter and after
3256 any change to `cmd/gitbayd/backup.go` or the restic job, adding a
3257 row each time.
3258
3259---
3260
3261## Open questions
3262
32631. How `/var/lib/gitbay-stage` is populated on bay1 is not in the
3264 repository. If the staging step reads the local archives, enabling
3265 `age_recipients` breaks the restic path (runbook B.4 checks this
3266 before it matters).
32672. Is `/etc/gitbay/config.toml` kept anywhere off the host today? The
3268 repository does not say; runbook A.3 creates a copy, and the drill
3269 depends on it.
32703. Drill cadence: the plan proposes quarterly and after backup changes;
3271 #259 says only "on a schedule".
32724. Where the clean host runs (throwaway VPS or local VM) is left to the
3273 operator; the runbook works for either.
32745. `lfs.root` on bay1: if it points outside `server.root`, LFS objects
3275 are in neither the archive nor the restic snapshot of
3276 `/var/lib/gitbay`. The drill records it; fixing it is not in this
3277 plan.
32786. Out of scope, noted while reading: `webhook add` takes `--secret`
3279 on argv (`internal/control/webhook.go:16-23`), against the
3280 stdin-only rule for secrets.
3281
3282## Self-review
3283
3284- #273: encryption of the four columns (Task 1.3), key file outside the
3285 database and root (1.2), key id prefix (1.1), rotation (1.4
3286 `rotate`), existing clear rows sealed at startup (1.4 `serve`,
3287 `ResealSecrets`), missing key behaviour (1.4 `openStore`, documented
3288 1.6), backups do not carry it (validation 1.2, e2e 1.5), install
3289 provisioning (1.6).
3290- #274: age recipients config (2.1), encryption (2.2), `--verify
3291 --identity` (2.2), restic path unaffected by the archives and checked
3292 for its staging step (runbook B.4), scripts (2.3).
3293- #259: `--verify` connectivity (3.1, 3.4), delete/rename/transfer held
3294 (3.2, 3.3, 3.4), drill covering database integrity, connectivity,
3295 LFS, release assets, config, host keys, secrets and the key, with time
3296 to service on the Admin page (3.5, runbook C).
3297- Names used across tasks: `seal.Keyring`/`Load`/`Seal`/`Open`/
3298 `CurrentID`/`KeyID`/`IsSealed`/`NewKey`/`ReadKeys`/`WriteKeys`;
3299 `Store.SetKeyring`/`ResealSecrets`/`SecretKeyUse`; `testConfig`;
3300 `archivePath`/`archiveReader`/`verifyBackup(path, identity)`;
3301 `backuplock.Hold`/`TryShared`/`ErrBusy`/`Name`; `holdOffBackup`;
3302 `gitutil.FsckConnectivity`. Consistent.
docs/plans/2026-09-27-server-hardening.md added +3686
@@ -0,0 +1,3686 @@
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# Open questions
3640
36411. **System SSH mode.** With `ssh.mode = "system"` every session is a
3642 separate `gitbayd shell` process, so (a) the pack limiter cannot
3643 count SSH clones across sessions — this plan passes `nil` and says
3644 so on the Admin page — and (b) audit rows written there are not
3645 copied to the journal, because that process's stderr is the SSH
3646 client. The same applies to host `gitbayd admin …` commands (stderr
3647 is the operator's terminal). Options: file-lock slots under
3648 `<root>/packslots/` for (a), and `log/syslog` (journald collects it)
3649 for (b). gitbay.org runs embedded mode, so neither is needed there.
3650 Decide whether system mode needs them.
36512. **Default pack limits.** 3 / 2 / 32 / 60s are an estimate from the
3652 one measured full clone (~1.5 cores). The runbook's benchmark
3653 decides whether they stand.
36543. **receive-pack and the limit.** index-pack on a large push is also
3655 CPU-bound, but pushes need an account with write access and
3656 killing or queueing receive-pack risks post-receive. This plan keeps
3657 pushes outside the limit. Confirm.
36584. **bay1's mail relay and git version** are not visible from the
3659 source tree; runbook steps 1 and 2 answer them before the matching
3660 MRs merge.
3661
3662# Self-review
3663
3664- Coverage against the issues: #281 MinVersion + Admin (MR 1). #280
3665 require_tls default by locality, implicit TLS (MR 2). #279 resolve
3666 and check before each sync, pin for git, http(s) only per
3667 `ValidateURL` (MR 3). #282 chmod 0600, SO_PEERCRED behind build
3668 tags, per-push token minted in `runGit` and required by hookd, works
3669 in both SSH modes through SQLite (MR 4). #275 refusals audited with
3670 per-actor limit, hash chain with `actor_ref`, `gitbayd admin audit
3671 verify`, journal copy from the daemon (MR 5). #262 global and
3672 per-principal limit, bounded queue, cancellation while queued (done /
3673 request context / stop) and while running (SSH ctx, HTTP request
3674 context), ls-refs and info/refs outside, knobs with 4-core defaults,
3675 benchmark script and Performance section, results via runbook (MR 6).
3676- Signatures used across tasks: `packlimit.New(max, per, queue int, wait time.Duration)`
3677 and `Limits.PackLimits() (max, per, queue int, wait time.Duration)`
3678 match; `Acquire(done <-chan struct{}, principal string)` is called
3679 with `done` (SSH), `s.until(r)` (HTTP), `nil` (git://, tests).
3680 `Exec` gains `packs` in MR 6 only; MR 5's test call is updated in
3681 Task 6.3 Step 1. `CreatePushToken` returns the raw token and
3682 `DeletePushToken` takes the raw token in both sshd and tests.
3683- Migrations 0069/0070 are within plan 3's range; renumber if another
3684 plan has taken them by then.
3685- No new route, template, ReadOnly command, control command or stdin
3686 reader, so no registry rows.
docs/plans/2026-09-27-web-ux.md added +3045
@@ -0,0 +1,3045 @@
1# Web UX sweep 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 out the architecture-review web findings (#261), the two
6web UX-review issues (#263, #271), the empty-state sweep (#270), the
7missing API-token page (#264), the MR range-diff view (#269), and the
8wiki non-page-link 404 (#283).
9
10**Architecture:** No new subsystems. Every write goes through
11`s.runControl`/`s.runControlCode` into the existing control registry
12(`internal/httpd/control.go`), the same rule every other web write
13already follows — this plan fixes the three handlers that did not
14(`pinToggle`, `watchToggle`, and adds a mute state to the latter). Reads
15either dispatch into a command (range-diff, whose text output the CLI
16and iOS already render, so the web reuses it rather than re-implementing
17revision resolution) or read the store directly, matching the existing
18convention for GET handlers (`accountPage` already reads
19`ListSSHKeys`/`ListPGPKeys` directly). Template copy changes are text-only;
20one new page (`mrrangediff.html`) and one new settings section
21(`Settings → Tokens`) are added following the existing settings-page and
22repo-page patterns.
23
24**Tech stack:** Go, `html/template`, the existing SQLite store, cobra
25(`cmd/gitbay`).
26
27**Spec:** none — this plan is written directly from the issue texts
28(`.gitbay/wiki` doc drift, architecture-review findings) and the current
29source; there is no separate design doc.
30
31## Global constraints
32
33- Five MRs (one skipped: this plan issues #261/#263/#269/#270/#271/#283
34 are covered by five MRs; #264 is a sixth), each on its own branch off
35 `main`. Commits are signed (the repository refuses unsigned ones);
36 messages end with `Ref #N`, and the last commit closing an issue ends
37 with `Closes #N`. No attribution to any assistant, model or AI
38 anywhere: commits, MR bodies, comments.
39- `gitbay mr create --source <branch> --target main --title "..."`;
40 merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
41 delete the branch locally and on the remote. If the merge reports the
42 branch is behind, rebase onto `main`, force-push, merge again.
43- Locally: `go build ./...`, `go vet ./...`, the unit tests of touched
44 packages, and at most the one e2e test being written
45 (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full
46 suite (`go test ./...`), including `TestMainWidthClass`,
47 `e2e/readonly_test.go`'s `readArgs` coverage, and the `cmd/gitbay`
48 coverage test over `pass()` registrations — none of this plan's tasks
49 add a new control command, so none of those three registries gain a
50 new required row, but a new page template (`mrrangediff.html`) **does**
51 need a row in `TestMainWidthClass`'s width map
52 (`internal/web/web_test.go`).
53- No migrations in this plan (no schema changes).
54- Web writes dispatch through control commands
55 (`internal/httpd/control.go`: `runControl`/`runControlCode`/
56 `runControlStdin`); a handler that calls the store directly for a
57 *write* is exactly the bug #261 reports for `pinToggle` and
58 `watchToggle`, and this plan does not introduce a new instance of it.
59 Reads may call the store directly (the existing convention throughout
60 `internal/httpd`) or dispatch when the logic they need (e.g. revision
61 resolution for range-diff) already lives in a command.
62- Secrets travel on stdin or, for the one case that already carries one
63 in a URL by design (the emailed login link), never in a place the
64 code cannot document and cache-guard; never logged.
65- Wiki pages live in `.gitbay/wiki/`: `Parity.org`, `API.org`,
66 `Threat-Model.org`. Update the page in the same MR that changes the
67 behaviour it describes.
68- Writing style: plain, direct, no hype; UI copy uses the register fixed
69 in MR 4 (Task 4.1) everywhere else it appears afterward.
70- CLAUDE.md's rules apply throughout: surgical changes only, no
71 unrelated refactors, no speculative flexibility.
72
73## Order and dependencies
74
751. **`web-audit-fixes`** (branch `web-audit-fixes`) — closes #261.
76 Independent. Landing this first matters because MR 6 (#271's mute
77 option) depends on the toggle-dispatch fix here.
782. **`web-settings-commands`** (branch `web-settings-commands`) — closes
79 #263. Independent of 1. Lands after `cli-ux-help` (CLI UX plan),
80 which carries the auth summary and help part of #263.
813. **`web-mr-range-diff`** (branch `web-mr-range-diff`) — closes #269.
82 Independent.
834. **`web-empty-states`** (branch `web-empty-states`) — closes #270.
84 Independent, but touches `mrs.html` and `globalsearch.html`; land
85 before MR 6 to avoid the same files diverging on two branches at
86 once (MR 6 does not touch either).
875. **`web-ux-small-fixes`** (branch `web-ux-small-fixes`) — closes #271.
88 Depends on MR 1 (`repo watch`/`repo mute` dispatch and the cycling
89 toggle it introduces; #271's Muted option builds directly on it).
906. **`wiki-raw-links`** (branch `wiki-raw-links`) — closes #283.
91 Independent; small, can land anywhere, placed last only because it
92 is unrelated to the rest.
937. **`web-api-tokens`** (branch `web-api-tokens`) — closes #264.
94 Depends on plan 1 (`credentials-and-sessions`, #257): that plan
95 changes `token create`'s own default scope to `read`. This plan's
96 Task 7.1 makes the *web form* always send an explicit `--scope`
97 value regardless of what the command defaults to, so this MR does
98 not have to wait for plan 1 to land — but merge it after plan 1 to
99 pick up the release-note and any command-message changes plan 1
100 makes to `token create`. If plan 1 has not landed yet, this MR still
101 works correctly (the form never relies on the flag's default); note
102 in the MR description that it does not depend on plan 1 having
103 merged, only on eventually being consistent with it.
104
105---
106
107# MR 1: architecture-review small fixes (branch `web-audit-fixes`)
108
109Closes #261.
110
111### Task 1.1: run `foreign_key_check` inside the migration transaction, before commit
112
113**Files:**
114- Modify: `internal/store/store.go:182-259` (`step`, the `fkOff` branch)
115- Test: `internal/store/store_test.go` (create the case if no existing
116 migration test exercises an `fkOff` step; check first)
117
118**Interfaces:**
119- No signature changes; `step`'s behaviour changes only.
120
121- [ ] **Step 1: Confirm there is no existing FK-violation test to build on**
122
123Run: `grep -n "foreign_key_check\|fkOff\|foreign_keys: off" internal/store/store_test.go`
124If nothing matches, the test below is new.
125
126- [ ] **Step 2: Write the failing test**
127
128Add to `internal/store/store_test.go`:
129
130```go
131// A migration marked "-- foreign_keys: off" must have its
132// foreign_key_check run before the transaction commits, not after —
133// otherwise a violation is reported once the bad schema and
134// user_version are already persisted (#261).
135func TestFKOffMigrationChecksBeforeCommit(t *testing.T) {
136 dir := t.TempDir()
137 st, err := Open(dir + "/test.db")
138 if err != nil {
139 t.Fatal(err)
140 }
141 defer st.Close()
142
143 // A minimal two-step schema: a parent table, then a child that
144 // references it, or el se this migration wouldn't exercise anything.
145 // Reach in through the exported entry point rather than duplicating
146 // migration internals: two ad hoc migrations appended to the real
147 // list would require touching the embedded migration files, so this
148 // test instead runs the real migration set up to its current head
149 // and then drives step() through a synthetic single-migration
150 // upgrade using the unexported hook the package already has for
151 // tests, if one exists.
152 if err := st.MigrateUp(); err != nil {
153 t.Fatal(err)
154 }
155 before, err := st.DB.Query("PRAGMA user_version")
156 if err != nil {
157 t.Fatal(err)
158 }
159 before.Close()
160
161 // Insert a row through a raw statement that a fkOff rebuild would
162 // have to preserve or complain about: a milestone with no matching
163 // repo_id (the deliberately impossible case a corrupt migration
164 // would produce).
165 if _, err := st.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil {
166 t.Fatal(err)
167 }
168 if _, err := st.DB.Exec(
169 "INSERT INTO milestones (repo_id, title, state, created_at) VALUES (99999, 'orphan', 'open', datetime('now'))"); err != nil {
170 t.Fatal(err)
171 }
172 if _, err := st.DB.Exec("PRAGMA foreign_keys = ON"); err != nil {
173 t.Fatal(err)
174 }
175
176 // A no-op fkOff step (rewriting milestones to itself) must now
177 // refuse — before it commits, not after — because the orphan row
178 // fails foreign_key_check. Confirm today's ordering leaves the
179 // schema version bumped despite the row it can never satisfy: this
180 // is the bug. Run the check directly the way step() will, and
181 // compare against the version left behind.
182 versionBefore := currentUserVersion(t, st)
183 err = st.runFKOffStepForTest(
184 "UPDATE sqlite_master SET name = name WHERE 0", versionBefore+1)
185 if err == nil {
186 t.Fatal("expected foreign_key_check to refuse the orphaned row")
187 }
188 if got := currentUserVersion(t, st); got != versionBefore {
189 t.Fatalf("user_version changed to %d despite the refused check (should stay %d)", got, versionBefore)
190 }
191}
192
193func currentUserVersion(t *testing.T, st *Store) int {
194 t.Helper()
195 var v int
196 if err := st.DB.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
197 t.Fatal(err)
198 }
199 return v
200}
201```
202
203This calls an unexported `runFKOffStepForTest` that does not exist yet —
204it is Step 3's job to expose the already-unexported `step` closure's
205`fkOff` path under a name the test package can call. `step` is currently
206a closure local to `MigrateTo`; Step 3 promotes it to a package-level
207method so this test (and the real migration loop) can call the same
208code.
209
210- [ ] **Step 2: Run it and see it fail to compile**
211
212Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
213Expected: FAIL to compile (`runFKOffStepForTest` undefined).
214
215- [ ] **Step 3: Promote `step` to a method and fix the ordering**
216
217In `internal/store/store.go`, replace the `step` closure inside
218`MigrateTo` (the whole `step := func(sqlText string, newVersion int,
219fkOff bool) (retErr error) { ... }` block at lines 182-259) with a call
220to a new method, and move its body there:
221
222```go
223func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr error) {
224 if !fkOff {
225 tx, err := s.DB.Begin()
226 if err != nil {
227 return err
228 }
229 defer tx.Rollback()
230 if _, err := tx.Exec(sqlText); err != nil {
231 return err
232 }
233 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
234 return err
235 }
236 return tx.Commit()
237 }
238
239 // A script whose first line is "-- foreign_keys: off" rebuilds a
240 // table that other tables reference (labels, milestones): with
241 // foreign keys on, the rebuild-by-rename loses the children's
242 // rows. PRAGMA foreign_keys is a no-op inside a transaction, and
243 // the pool gives no guarantee that a pragma set on one connection
244 // is seen by the connection Begin() draws next, so the whole step
245 // — pragma off, transaction, foreign_key_check, commit, pragma on —
246 // runs on a single pinned connection. The check runs before commit:
247 // checking after would report a violation once the bad schema and
248 // user_version were already persisted.
249 ctx := context.Background()
250 conn, err := s.DB.Conn(ctx)
251 if err != nil {
252 return err
253 }
254 defer conn.Close()
255 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
256 return err
257 }
258 // The connection goes back to the pool when this returns, so every
259 // path out of here has to put foreign keys back on first.
260 restoreFK := func() error {
261 _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
262 return err
263 }
264 defer func() {
265 if err := restoreFK(); err != nil && retErr == nil {
266 retErr = err
267 }
268 }()
269 tx, err := conn.BeginTx(ctx, nil)
270 if err != nil {
271 return err
272 }
273 defer tx.Rollback()
274 if _, err := tx.Exec(sqlText); err != nil {
275 return err
276 }
277 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
278 return err
279 }
280 // foreign_key_check works with enforcement off: it inspects the
281 // data directly rather than consulting the pragma. Running it here,
282 // inside the transaction, means a violation rolls back the whole
283 // rebuild (deferred tx.Rollback fires) instead of leaving the bad
284 // schema and version committed.
285 rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check")
286 if err != nil {
287 return err
288 }
289 if rows.Next() {
290 var table string
291 var rowid sql.NullInt64
292 var referredTable string
293 var fkid int
294 if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
295 rows.Close()
296 return err
297 }
298 rows.Close()
299 return fmt.Errorf("foreign_key_check failed after migration: %s", table)
300 }
301 if err := rows.Err(); err != nil {
302 rows.Close()
303 return err
304 }
305 rows.Close()
306 return tx.Commit()
307}
308
309// runFKOffStepForTest exposes migrateStep's fkOff path to the package's
310// own tests, which need to drive one step in isolation rather than the
311// whole migration list MigrateTo runs.
312func (s *Store) runFKOffStepForTest(sqlText string, newVersion int) error {
313 return s.migrateStep(sqlText, newVersion, true)
314}
315```
316
317Update `MigrateTo`'s two loops to call the method instead of the removed
318closure:
319
320```go
321 for cur < target {
322 m := ms[cur]
323 if err := s.migrateStep(m.up, m.version, m.upFKOff); err != nil {
324 return fmt.Errorf("migration %d up: %w", m.version, err)
325 }
326 cur = m.version
327 }
328 for cur > target {
329 m := ms[cur-1]
330 if err := s.migrateStep(m.down, m.version-1, m.downFKOff); err != nil {
331 return fmt.Errorf("migration %d down: %w", m.version, err)
332 }
333 cur = m.version - 1
334 }
335```
336
337`runFKOffStepForTest` is exported to the test file only in the sense
338that it is an ordinary method in a `_test.go`-adjacent non-test file, so
339it ships in the binary; that is acceptable here since it is a one-line
340wrapper with no side effect beyond calling the real path, and keeping it
341out of the production file would mean either duplicating `migrateStep`
342in a test-only file or using an unexported test hook pattern the package
343does not otherwise have. If review prefers it test-only, move it to
344`internal/store/storetest_export_test.go` (package `store`) instead —
345functionally identical either way.
346
347- [ ] **Step 4: Run the test**
348
349Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
350Expected: PASS (the orphan row now fails the check before commit, and
351`user_version` is left unchanged because `tx.Rollback()` fires).
352
353- [ ] **Step 5: Run the full package**
354
355Run: `go test ./internal/store -count=1`
356Expected: PASS — this is a reordering, not a behaviour change, for every
357migration that does not already violate its own foreign keys.
358
359- [ ] **Step 6: Commit**
360
361```bash
362git add internal/store/store.go internal/store/store_test.go
363git commit -m "store: run foreign_key_check inside the migration transaction, before commit" -m "Ref #261"
364```
365
366### Task 1.2: `pinToggle` and `watchToggle` dispatch through their commands
367
368**Files:**
369- Modify: `internal/httpd/accounts.go:241-250` (`pinToggle`)
370- Modify: `internal/httpd/notifyweb.go:61-72` (`watchToggle`)
371- Test: `internal/httpd/accounts_test.go` or `internal/httpd/account_test.go`
372 (check which file already has repo-toggle tests; add beside them)
373
374**Interfaces:**
375- Consumes: `s.runControl` (`internal/httpd/control.go:25`, already used
376 by `bookmarkToggle`, the correct existing model for this fix).
377- Produces: no new exported names; `watchToggle`'s behaviour becomes a
378 three-way cycle (`""` → `watching` → `muted` → `""`), which Task 5.x
379 in MR 5 (`web-ux-small-fixes`) builds on to expose "Muted" as a
380 reachable state rather than adding a new endpoint.
381
382- [ ] **Step 1: Write the failing tests**
383
384Add to `internal/httpd/accounts_test.go` (create the file if repo pin/watch
385tests do not already live somewhere; check with
386`grep -rln "pinToggle\|watchToggle" internal/httpd/*_test.go` first and
387add beside whatever that finds):
388
389```go
390package httpd
391
392import (
393 "net/http/httptest"
394 "testing"
395
396 "gitbay.org/gitbay/internal/config"
397 "gitbay.org/gitbay/internal/store"
398)
399
400// Pinning writes through the repo pin command, not the store directly,
401// so it carries the same audit trail and write budget as every other
402// mutating command (#261).
403func TestPinToggleDispatchesRepoPin(t *testing.T) {
404 st, err := store.Open(":memory:")
405 if err != nil {
406 t.Fatal(err)
407 }
408 defer st.Close()
409 if err := st.MigrateUp(); err != nil {
410 t.Fatal(err)
411 }
412 uid, err := st.CreateUser("alice", false)
413 if err != nil {
414 t.Fatal(err)
415 }
416 u := store.User{ID: uid, Username: "alice"}
417 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
418 t.Fatal(err)
419 }
420
421 s := New(config.Default(), st)
422 req := httptest.NewRequest("POST", "/alice/app/pin", nil)
423 req.SetPathValue("owner", "alice")
424 req.SetPathValue("repo", "app")
425 rr := httptest.NewRecorder()
426 s.pinToggle(rr, req, u)
427
428 repo, err := st.RepoByPath("alice/app")
429 if err != nil {
430 t.Fatal(err)
431 }
432 if !st.IsPinned(uid, repo.ID) {
433 t.Fatal("pin did not take effect")
434 }
435
436 rr2 := httptest.NewRecorder()
437 s.pinToggle(rr2, req, u)
438 if st.IsPinned(uid, repo.ID) {
439 t.Fatal("second toggle should have unpinned")
440 }
441}
442
443// The watch button cycles default, watching, muted — the three states
444// repo watch/repo mute/repo unwatch already support — rather than the
445// two the store-writing version offered (#261, #271).
446func TestWatchToggleCyclesThroughMuted(t *testing.T) {
447 st, err := store.Open(":memory:")
448 if err != nil {
449 t.Fatal(err)
450 }
451 defer st.Close()
452 if err := st.MigrateUp(); err != nil {
453 t.Fatal(err)
454 }
455 uid, err := st.CreateUser("alice", false)
456 if err != nil {
457 t.Fatal(err)
458 }
459 u := store.User{ID: uid, Username: "alice"}
460 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
461 t.Fatal(err)
462 }
463 repo, err := st.RepoByPath("alice/app")
464 if err != nil {
465 t.Fatal(err)
466 }
467
468 s := New(config.Default(), st)
469 req := httptest.NewRequest("POST", "/alice/app/watch", nil)
470 req.SetPathValue("owner", "alice")
471 req.SetPathValue("repo", "app")
472
473 click := func() string {
474 rr := httptest.NewRecorder()
475 s.watchToggle(rr, req, u)
476 return st.RepoWatchState(repo.ID, uid)
477 }
478 if got := click(); got != "watching" {
479 t.Fatalf("first click: got %q, want watching", got)
480 }
481 if got := click(); got != "muted" {
482 t.Fatalf("second click: got %q, want muted", got)
483 }
484 if got := click(); got != "" {
485 t.Fatalf("third click: got %q, want default (unwatched)", got)
486 }
487}
488```
489
490(Check `CreateRepo`'s exact signature with
491`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` before
492using it — adjust argument order/names to match if it differs from the
493guess above.)
494
495- [ ] **Step 2: Run and see them fail**
496
497Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1`
498Expected: FAIL — pin toggles once but not twice cleanly is unlikely to
499be the failure; more likely the watch test fails because today's
500`watchToggle` only ever sets `"watching"` or clears it, never `"muted"`.
501
502- [ ] **Step 3: Fix `pinToggle`**
503
504In `internal/httpd/accounts.go`, replace:
505
506```go
507// pinToggle pins or unpins the repo for the logged-in viewer.
508func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
509 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
510 if !ok {
511 return
512 }
513 if s.st.IsPinned(u.ID, repo.ID) {
514 s.st.UnpinRepo(u.ID, repo.ID)
515 } else {
516 s.st.PinRepo(u.ID, repo.ID)
517 }
518 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
519}
520```
521
522with:
523
524```go
525// pinToggle pins or unpins the repo for the logged-in viewer, through
526// repo pin/repo unpin — the same commands the CLI runs — rather than
527// writing the store directly (#261).
528func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
529 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
530 if !ok {
531 return
532 }
533 verb := "pin"
534 if s.st.IsPinned(u.ID, repo.ID) {
535 verb = "unpin"
536 }
537 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
538 s.setFlash(w, msg)
539 }
540 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
541}
542```
543
544- [ ] **Step 4: Fix `watchToggle`**
545
546In `internal/httpd/notifyweb.go`, replace:
547
548```go
549// watchToggle turns watching a repository on and off from its header,
550// the way the pin button does.
551func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
552 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
553 if !ok {
554 return
555 }
556 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" {
557 s.st.ClearRepoWatch(repo.ID, u.ID)
558 } else {
559 s.st.SetRepoWatch(repo.ID, u.ID, "watching")
560 }
561 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
562}
563```
564
565with:
566
567```go
568// watchToggle cycles the viewer's watch state on a repository: default,
569// watching, muted, back to default — through repo watch/repo mute/repo
570// unwatch, the same commands the CLI runs (#261, #271).
571func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
572 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
573 if !ok {
574 return
575 }
576 next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
577 verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
578 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
579 s.setFlash(w, msg)
580 }
581 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
582}
583```
584
585- [ ] **Step 5: Run**
586
587Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1 && go test ./internal/httpd -count=1`
588Expected: PASS. If an existing test asserted the old two-state watch
589behaviour, update its expectation to the three-state cycle rather than
590reverting the fix.
591
592- [ ] **Step 6: Commit**
593
594```bash
595git add internal/httpd/accounts.go internal/httpd/notifyweb.go internal/httpd/accounts_test.go
596git commit -m "web: pin and watch toggles dispatch through repo pin/watch/mute/unwatch" -m "Ref #261"
597```
598
599### Task 1.3: Cache-Control: no-store on the login-link consuming request
600
601**Files:**
602- Modify: `internal/httpd/accounts.go:123-157` (`login`)
603- Test: `internal/httpd/logincookie_test.go` (add beside its existing
604 login tests)
605
606- [ ] **Step 1: Write the failing test**
607
608Add to `internal/httpd/logincookie_test.go`:
609
610```go
611// The login link's token rides in the query string — the one
612// documented exception to "never in a URL" — so the response that
613// consumes it must never be cached by an intermediary that might log
614// or replay the URL (#261).
615func TestLoginNoStoreHeader(t *testing.T) {
616 st, err := store.Open(":memory:")
617 if err != nil {
618 t.Fatal(err)
619 }
620 defer st.Close()
621 if err := st.MigrateUp(); err != nil {
622 t.Fatal(err)
623 }
624 s := New(config.Default(), st)
625 rr := httptest.NewRecorder()
626 req := httptest.NewRequest("GET", "/login?token=bogus", nil)
627 s.login(rr, req)
628 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
629 t.Errorf("Cache-Control = %q, want no-store", got)
630 }
631}
632```
633
634Check the file's existing imports (`config`, `store`, `httptest`, `testing`)
635before adding — they are almost certainly already present given the
636file already tests `/login`.
637
638- [ ] **Step 2: Run and see it fail**
639
640Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1`
641Expected: FAIL (`Cache-Control` header absent).
642
643- [ ] **Step 3: Set the header**
644
645In `internal/httpd/accounts.go`, at the top of `login`:
646
647```go
648func (s *Server) login(w http.ResponseWriter, r *http.Request) {
649 // token, when present, is a single-use secret in the query string —
650 // the documented exception to "never in a URL" (Threat-Model). No
651 // cache may keep a copy of this response.
652 w.Header().Set("Cache-Control", "no-store")
653 token := r.URL.Query().Get("token")
654```
655
656- [ ] **Step 4: Run**
657
658Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1 && go test ./internal/httpd -count=1`
659Expected: PASS.
660
661- [ ] **Step 5: Commit**
662
663```bash
664git add internal/httpd/accounts.go internal/httpd/logincookie_test.go
665git commit -m "web: Cache-Control: no-store on the login-link request" -m "Ref #261"
666```
667
668### Task 1.4: doc drift — API.org, Parity.org, Threat-Model.org
669
670**Files:**
671- Modify: `.gitbay/wiki/API.org` (the token-refusal line, near "Git
672 transport commands and the token commands are refused by name.")
673- Modify: `.gitbay/wiki/Parity.org` (the "Batched review is not built."
674 line; the watch/pin dispatch paragraph at lines 249-251)
675- Modify: `.gitbay/wiki/Threat-Model.org` (the "never as command
676 arguments... or query strings" line)
677
678No test — these are prose fixes; CI has no wiki-content check beyond
679what already exists (link and page-name tests in
680`internal/httpd/wiki_test.go`, untouched by this task).
681
682- [ ] **Step 1: Fix API.org's incorrect claim about token commands**
683
684The 401 message (`internal/httpd/api.go:131`, unchanged by this task)
685already reads `missing bearer token; mint one over SSH: token create
686--name <n>` — it does not say token commands are refused on the API,
687because they are not: `token create`/`token list`/`token revoke` all
688dispatch normally through `POST /api/v1/cmd` like any other command
689(minting a token from a token is exactly what a full-scope token can do,
690per `#234`/the "one registry" rule). Replace the false claim:
691
692```
693Commands that emit raw text rather than an envelope (=help=, =mr diff=)
694come wrapped as ={"output": "..."}=. Git transport commands are refused
695by name; the token commands are not — a full-scope token can mint,
696list and revoke tokens the same way it can run anything else.
697```
698
699(Replaces the sentence "Git transport commands and the token commands
700are refused by name.")
701
702- [ ] **Step 2: Fix Parity.org's stale "batched review" claim**
703
704Find (near "view. Batched review is not built."):
705
706```
707=mr range-diff= compares two heads: the iOS client
708shows it from a revision to the one before, as text; the web has no
709view. Batched review is not built.
710```
711
712`mr comment --pending`/`--discard` and `PublishPendingComments`
713(`internal/control/mr.go:161-162,1044,1052`) are the batched-review
714mechanism, and the web's diff-comment form already composes pending
715comments before publishing (`internal/httpd/mractions.go`'s
716`mrDiffCommentSubmit`, unchanged by this task — confirm with
717`grep -n "diff-comment\|PendingComments" internal/httpd/*.go`). Replace:
718
719```
720=mr range-diff= compares two heads: the iOS client shows it from a
721revision to the one before, as text; the web renders the same view
722(krz/gitbay#269). Batched review — draft diff comments held with
723=mr comment --pending= and sent together with =--comment=/=--discard=
724or a verdict — is built and the web uses it: composing a review comments
725before publishing them is the same round trip as the CLI's =--pending=
726flag.
727```
728
729Leave the `mr range-diff` web-view claim as `krz/gitbay#269` for now;
730MR 3 of this plan (`web-mr-range-diff`) lands the actual view and
731updates this sentence again to drop the issue reference — do not
732pre-empt that here, since this task's branch may merge before or after
733MR 3 and the wiki text must describe what is actually deployed at each
734point. (If MR 3 has already merged when this task is done, skip the
735issue-reference wording and write the view as already existing instead;
736check `ls internal/web/templates/mrrangediff.html` first.)
737
738- [ ] **Step 3: Fix the pin/watch dispatch paragraph**
739
740Find (lines 249-251):
741
742```
743The web's watch and pin controls write the store directly instead of
744dispatching =repo watch= and =repo pin=. That is why the web cannot
745mute: its toggle knows watching and default only.
746```
747
748Replace:
749
750```
751The web's watch and pin controls dispatch =repo pin=/=repo unpin= and
752=repo watch=/=repo mute=/=repo unwatch=, the same commands the CLI runs
753(krz/gitbay#261). The single watch button cycles default, watching and
754muted.
755```
756
757And update the `mute` row in the capability table (around line 189)
758from:
759
760```
761| mute | yes | no | yes |
762```
763
764to:
765
766```
767| mute | yes | yes | yes |
768```
769
770- [ ] **Step 4: Fix Threat-Model.org's "never in a URL" claim**
771
772Find (in "What gitbay never does"):
773
774```
775- *Put secrets in argv, URLs, or logs.* Import and mirror credentials,
776 registration invites, and API tokens travel on stdin or in request
777 bodies, never as command arguments (visible in =/proc=) or query
778 strings. Tokens are stored only as SHA-256 hashes.
779```
780
781Replace with (documenting the one deliberate exception and its
782mitigations from Task 1.3):
783
784```
785- *Put secrets in argv, URLs, or logs, with one documented exception.*
786 Import and mirror credentials, registration invites, and API tokens
787 travel on stdin or in request bodies, never as command arguments
788 (visible in =/proc=) or query strings. The one exception is the
789 emailed login link, =/login?token=...=: single-use, 15-minute expiry,
790 and the response that consumes it carries =Cache-Control: no-store= so
791 no intermediary keeps a copy. An operator running gitbay behind a
792 reverse proxy should configure that proxy to strip the query string
793 from its own access logs. Tokens are stored only as SHA-256 hashes.
794```
795
796- [ ] **Step 5: Commit**
797
798```bash
799git add .gitbay/wiki/API.org .gitbay/wiki/Parity.org .gitbay/wiki/Threat-Model.org
800git commit -m "wiki: fix API token-refusal claim, batched-review status, watch/pin dispatch, login-link URL exception" -m "Ref #261"
801```
802
803### Task 1.5: open MR 1
804
805- [ ] **Step 1: Push and open the MR**
806
807```bash
808git push -u origin web-audit-fixes
809gitbay mr create --source web-audit-fixes --target main --title "Architecture review small fixes: FK check, web toggles, doc drift"
810```
811
812- [ ] **Step 2: Wait for CI, merge, delete the branch**
813
814```bash
815gitbay mr merge <n> --strategy ff
816git branch -d web-audit-fixes
817git push origin --delete web-audit-fixes
818```
819
820The last commit in this branch (Task 1.4's) should be amended in message
821only if not already — reference `Closes #261` there instead of `Ref
822#261` before pushing, since this MR closes the issue in full.
823
824---
825
826# MR 2: settings page quotes working commands (branch `web-settings-commands`)
827
828Closes #263.
829
830### Task 2.1: fix the two known-wrong quoted commands
831
832**Files:**
833- Modify: `internal/web/templates/account.html:186,199-200`
834
835- [ ] **Step 1: Fix the `whoami` line**
836
837Find (`account.html:199-200`):
838
839```html
840<p class="meta">All of it works from stock OpenSSH too:
841<code>ssh git@{{.Host}} auth whoami</code>.</p>
842```
843
844`auth` is a CLI-only grouping (`cmd/gitbay/main.go`'s `authCmd`); the
845server command is `whoami` (`internal/control/identity.go:18`). Replace:
846
847```html
848<p class="meta">All of it works from stock OpenSSH too:
849<code>ssh git@{{.Host}} whoami</code>.</p>
850```
851
852- [ ] **Step 2: Show both forms for the token line**
853
854Find (`account.html:194-197`):
855
856```html
857<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
858gitbay web sessions list # browser sessions
859gitbay admin ... # instance administration</pre>
860```
861
862The CLI form (`gitbay auth token create ...`) and the literal stock-SSH
863form (`ssh git@host token create ...`) differ because `token` is nested
864under the CLI-only `auth` group but is a top-level server command
865(`internal/control/token.go`). Replace the pre block and the sentence
866after it:
867
868```html
869<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
870gitbay web sessions list # browser sessions
871gitbay admin ... # instance administration</pre>
872<p class="meta">All of it works from stock OpenSSH too, with the CLI's
873grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,
874<code>ssh git@{{.Host}} token create --name laptop</code>.</p>
875```
876
877(This merges the "works from stock OpenSSH" sentence that Step 1 edited
878with the new token example, so it appears once rather than twice —
879remove the now-duplicate sentence Step 1 produced and keep this single
880combined one instead. After this step, the section reads: the `<pre>`
881block, then one `<p class="meta">` with both stock-SSH examples.)
882
883- [ ] **Step 3: Fix `gitbay account export`**
884
885Find (`account.html:186`, in the Export section):
886
887```html
888<p class="meta">Your profile, repositories, issues and merge requests as one
889JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
890never included; a replayed bundle's emails arrive unverified.</p>
891```
892
893`account` is not a real top-level CLI command — the real path is `auth
894export` (`cmd/gitbay/main.go:471`, nested under `authCmd`), as
895`privacy.html:20` already correctly says. Replace:
896
897```html
898<p class="meta">Your profile, repositories, issues and merge requests as one
899JSON bundle, the same one <code>gitbay auth export</code> writes. Keys are
900never included; a replayed bundle's emails arrive unverified.</p>
901```
902
903- [ ] **Step 4: Commit (folded into Task 2.3, which adds the test these
904 fixes make pass — do not commit yet; Task 2.2 and 2.3 come first so
905 the fixes and their proof land together)**
906
907Skip committing here; continue to Task 2.2.
908
909### Task 2.2: auth summary and help — done in the CLI UX plan
910
911The auth summary ("whoami, SSH and PGP keys, email, API tokens") and
912the registry-layout `gitbay auth --help` are Task 2.3 of
913`docs/plans/2026-09-27-cli-ux.md` (MR `cli-ux-help`, Ref #267), which
914adds `nounAliases` to `internal/control/help.go` so `help auth` renders
915in one pass. Land `cli-ux-help` before this MR; nothing to do here.
916Check after rebasing: `go run ./cmd/gitbay auth --help` lists email and
917token commands.
918
919### Task 2.3: a test that runs every quoted command through the registry
920
921**Files:**
922- Create: `cmd/gitbay/templatecmds_test.go`
923
924**Interfaces:**
925- Consumes: `newRoot()` (`cmd/gitbay/main.go:30`, unexported — this test
926 must live in package `main`), `control.Lookup`
927 (`internal/control/control.go:102`), `web.Pages`/`web.TemplateSource`
928 (`internal/web/web.go:271,277`).
929
930- [ ] **Step 1: Write the test**
931
932```go
933package main
934
935import (
936 "regexp"
937 "strings"
938 "testing"
939
940 "gitbay.org/gitbay/internal/control"
941 "gitbay.org/gitbay/internal/web"
942)
943
944// quotedRe finds the two shapes a command appears in on a page: inline
945// in <code>, or one per line in a <pre class="quickstart"> quickstart
946// block. Both need (?s) so a multi-line <pre> is captured as one match.
947var quotedRe = regexp.MustCompile(`(?s)<code>(.*?)</code>|<pre class="quickstart"[^>]*>(.*?)</pre>`)
948
949// commandArgv reads the literal words at the front of a quoted command
950// line — the part naming the command rather than its arguments — and
951// stops at the first flag, template action, or literal ellipsis, since
952// those mark the boundary between "what command" and "what argument".
953func commandArgv(rest string) []string {
954 var argv []string
955 for _, tok := range strings.Fields(rest) {
956 if strings.HasPrefix(tok, "-") || strings.Contains(tok, "{{") || strings.Contains(tok, "...") {
957 break
958 }
959 argv = append(argv, tok)
960 }
961 return argv
962}
963
964// TestTemplateQuotedCommandsResolve runs every command quoted in a web
965// template through the same registry the server uses, so a renamed
966// command fails CI instead of shipping a dead instruction (#263).
967//
968// A line starting "gitbay " is checked against the CLI's own command
969// tree with cobra's Find, since the CLI's grouping words (like "auth")
970// are not part of the server's argv. A line starting "ssh git@{{.Host}}
971// " is checked directly against control.Lookup, since that is exactly
972// the argv the server receives.
973func TestTemplateQuotedCommandsResolve(t *testing.T) {
974 root := newRoot()
975 for _, name := range web.Pages() {
976 src, err := web.TemplateSource(name)
977 if err != nil {
978 t.Fatalf("%s: %v", name, err)
979 }
980 for _, m := range quotedRe.FindAllStringSubmatch(src, -1) {
981 block := m[1] + m[2]
982 for _, line := range strings.Split(block, "\n") {
983 if i := strings.Index(line, "#"); i >= 0 {
984 line = line[:i]
985 }
986 line = strings.TrimSpace(line)
987 switch {
988 case strings.HasPrefix(line, "gitbay "):
989 argv := commandArgv(strings.TrimPrefix(line, "gitbay "))
990 if len(argv) == 0 {
991 continue
992 }
993 found, _, err := root.Find(argv)
994 if err != nil || found == root {
995 t.Errorf("%s: %q: gitbay %s does not resolve (%v)", name, line, strings.Join(argv, " "), err)
996 }
997 case strings.HasPrefix(line, "ssh git@{{.Host}} "):
998 argv := commandArgv(strings.TrimPrefix(line, "ssh git@{{.Host}} "))
999 if len(argv) == 0 {
1000 continue
1001 }
1002 if _, _, ok := control.Lookup(argv); !ok {
1003 t.Errorf("%s: %q: %s is not in the control registry", name, line, strings.Join(argv, " "))
1004 }
1005 }
1006 }
1007 }
1008 }
1009}
1010```
1011
1012- [ ] **Step 2: Run and see it fail on the two known bugs**
1013
1014Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1015Expected: FAIL on `account.html`'s `ssh git@{{.Host}} auth whoami` (not
1016in the registry — `auth` is not a server path) and `gitbay account
1017export` (not a real CLI path — the top-level command is `auth`, not
1018`account`), unless Task 2.1's edits are already applied (do Task 2.1 and
1019Task 2.2 first if not already committed, then this test should already
1020pass on those — if it still fails, the fixes in Task 2.1 or 2.2 are
1021incomplete).
1022
1023- [ ] **Step 3: Confirm it passes with Tasks 2.1 and 2.2 applied**
1024
1025Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1026Expected: PASS. If it fails on a *different* template than
1027`account.html`, that is a genuine additional bug this test caught —
1028fix the template's text the same way (correct the command to what
1029`control.Lookup`/cobra's tree actually accepts), do not weaken the test.
1030As of this plan being written, every other quoted command in the
1031templates (`admin.html`, `adminusers.html`, `issues.html`, `landing.html`,
1032`login.html`, `mrs.html`, `mrnew.html`, `privacy.html`, `registered.html`,
1033plus the ones this task edits) was checked by hand against
1034`cmd/gitbay/main.go` and `internal/control/*.go` and resolves correctly —
1035see the research notes in this plan's Order section — but the test is
1036the source of truth, not that hand check.
1037
1038- [ ] **Step 4: Run the full package**
1039
1040Run: `go build ./... && go vet ./... && go test ./cmd/gitbay -count=1`
1041Expected: PASS.
1042
1043- [ ] **Step 5: Commit everything for this MR**
1044
1045```bash
1046git add internal/web/templates/account.html cmd/gitbay/main.go cmd/gitbay/templatecmds_test.go
1047git commit -m "web, cli: fix two dead quoted commands; test every quoted command against the registry" -m "Closes #263"
1048```
1049
1050### Task 2.4: open MR 2
1051
1052```bash
1053git push -u origin web-settings-commands
1054gitbay mr create --source web-settings-commands --target main --title "Settings page: working stock-OpenSSH commands, registry-checked"
1055```
1056
1057Wait for CI, `gitbay mr merge <n> --strategy ff`, delete the branch both
1058places.
1059
1060---
1061
1062# MR 3: MR range-diff page (branch `web-mr-range-diff`)
1063
1064Closes #269.
1065
1066### Task 3.1: `/{owner}/{repo}/mrs/{n}/range-diff`
1067
1068**Files:**
1069- Create: `internal/httpd/mrrangediff.go`
1070- Create: `internal/web/templates/mrrangediff.html`
1071- Modify: `internal/httpd/routes.go` (add the route beside
1072 `/{owner}/{repo}/mrs/{n}` at line 102, in the always-registered block)
1073- Modify: `internal/web/web_test.go` (`TestMainWidthClass`'s `wide` map)
1074- Test: `internal/httpd/mrrangediff_test.go`
1075
1076**Interfaces:**
1077- Consumes: `mrArgs` (`internal/httpd/mractions.go:37`), `s.repoFor`,
1078 `s.runControlCode`, `s.webViewer`.
1079- Produces: `func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request)`
1080
1081- [ ] **Step 1: Write the failing test**
1082
1083```go
1084package httpd
1085
1086import (
1087 "net/http/httptest"
1088 "strings"
1089 "testing"
1090
1091 "gitbay.org/gitbay/internal/config"
1092 "gitbay.org/gitbay/internal/store"
1093)
1094
1095// The range-diff page dispatches mr range-diff and renders its text
1096// output, the same comparison the CLI and iOS already show (#269).
1097func TestMRRangeDiffPageRendersCommandOutput(t *testing.T) {
1098 st, err := store.Open(":memory:")
1099 if err != nil {
1100 t.Fatal(err)
1101 }
1102 defer st.Close()
1103 if err := st.MigrateUp(); err != nil {
1104 t.Fatal(err)
1105 }
1106 uid, err := st.CreateUser("alice", false)
1107 if err != nil {
1108 t.Fatal(err)
1109 }
1110 u := store.User{ID: uid, Username: "alice"}
1111 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
1112 t.Fatal(err)
1113 }
1114
1115 s := New(config.Default(), st)
1116 req := httptest.NewRequest("GET", "/alice/app/mrs/1/range-diff", nil)
1117 req.SetPathValue("owner", "alice")
1118 req.SetPathValue("repo", "app")
1119 req.SetPathValue("n", "1")
1120 rr := httptest.NewRecorder()
1121 s.mrRangeDiff(rr, req)
1122
1123 // No merge request 1 exists yet, so this must 404 rather than error.
1124 if rr.Code != 404 {
1125 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
1126 }
1127 _ = strings.TrimSpace // placeholder import use removed once a real MR fixture is added below
1128}
1129```
1130
1131(`CreateRepo`'s signature: confirm with
1132`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` and adjust
1133the call above to match — the guess here follows the shape used
1134elsewhere in this plan's other tests.)
1135
1136- [ ] **Step 2: Run and see it fail to compile**
1137
1138Run: `go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1`
1139Expected: FAIL to compile (`s.mrRangeDiff` undefined).
1140
1141- [ ] **Step 3: Write the handler**
1142
1143Create `internal/httpd/mrrangediff.go`:
1144
1145```go
1146package httpd
1147
1148import (
1149 "net/http"
1150 "strconv"
1151
1152 "gitbay.org/gitbay/internal/protocol"
1153 "gitbay.org/gitbay/internal/store"
1154)
1155
1156// mrRangeDiff renders what changed between two revisions of a merge
1157// request — the same comparison `mr range-diff` prints on the CLI and
1158// the iOS app already show — so a reviewer whose approval a force-push
1159// staled can see what moved without leaving the browser (#269).
1160func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) {
1161 p, ok := s.repoFor(w, r, "")
1162 if !ok {
1163 return
1164 }
1165 p.Tab = "merge requests"
1166 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1167 if err != nil {
1168 s.notFound(w, r)
1169 return
1170 }
1171 m, err := s.st.MRByNumber(p.Repo.ID, n)
1172 if err != nil {
1173 s.notFound(w, r)
1174 return
1175 }
1176
1177 viewer := s.webViewer(r)
1178 argv := mrArgs(r, "range-diff")
1179 if from := r.URL.Query().Get("from"); from != "" {
1180 argv = append(argv, "--from", from)
1181 }
1182 if to := r.URL.Query().Get("to"); to != "" {
1183 argv = append(argv, "--to", to)
1184 }
1185 out, msg, code := s.runControlCode(viewer, argv)
1186 if code == protocol.ExitNotFound {
1187 s.notFound(w, r)
1188 return
1189 }
1190 errMsg := ""
1191 if code != protocol.ExitOK {
1192 errMsg = msg
1193 }
1194 s.render(w, "mrrangediff.html", struct {
1195 repoPage
1196 MR store.MR
1197 Diff string
1198 Error string
1199 }{p, m, out, errMsg})
1200}
1201```
1202
1203- [ ] **Step 4: Write the template**
1204
1205Create `internal/web/templates/mrrangediff.html`:
1206
1207```html
1208{{define "width"}}wide{{end}}
1209{{define "title"}}range-diff · !{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
1210{{define "content"}}
1211<h1>Range-diff <span class="issuenumber">!{{.MR.Number}}</span></h1>
1212<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}">back to !{{.MR.Number}} {{.MR.Title}}</a></p>
1213{{if .Error}}<p class="error" role="alert">{{.Error}}</p>
1214{{else if .Diff}}<pre class="code buildlog" tabindex="0">{{.Diff}}</pre>
1215{{else}}<p class="empty-note">Nothing to compare: this merge request has one revision.</p>{{end}}
1216{{end}}
1217```
1218
1219- [ ] **Step 5: Register the route**
1220
1221In `internal/httpd/routes.go`, right after the existing
1222`/{owner}/{repo}/mrs/{n}` route (line 102, in the block registered
1223regardless of `web.mode`, so range-diff reads the same way the MR page
1224itself does — anonymously on a public repository):
1225
1226```go
1227 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
1228 Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}/range-diff", Handler: s.mrRangeDiff},
1229```
1230
1231- [ ] **Step 6: Add the width-map row**
1232
1233In `internal/web/web_test.go`, `TestMainWidthClass`, add
1234`"mrrangediff.html": true` to the `wide` map (alongside `"mrs.html"` and
1235`"build.html"`, which it resembles).
1236
1237- [ ] **Step 7: Run**
1238
1239Run: `go build ./... && go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1 && go test ./internal/web -run TestMainWidthClass -count=1`
1240Expected: PASS.
1241
1242- [ ] **Step 8: Commit**
1243
1244```bash
1245git add internal/httpd/mrrangediff.go internal/httpd/mrrangediff_test.go internal/httpd/routes.go internal/web/templates/mrrangediff.html internal/web/web_test.go
1246git commit -m "web: range-diff page for a merge request's revisions" -m "Ref #269"
1247```
1248
1249### Task 3.2: revisions list with a "compare to previous" link per row
1250
1251**Files:**
1252- Modify: `internal/web/templates/mr.html:136-138`
1253- Test: `internal/httpd/mrpage_test.go`
1254
1255- [ ] **Step 1: Write the failing test**
1256
1257Add to `internal/httpd/mrpage_test.go`:
1258
1259```go
1260// Each revision after the first carries a link comparing it to the one
1261// before, so a reviewer does not have to type mr range-diff by hand
1262// (#269).
1263func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
1264 var sb strings.Builder
1265 revs := []store.MRHead{
1266 {SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
1267 {SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
1268 }
1269 if err := web.Render(&sb, "mr.html", mrPageData{
1270 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
1271 }); err != nil {
1272 t.Fatalf("render: %v", err)
1273 }
1274 out := sb.String()
1275 for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
1276 if !strings.Contains(out, want) {
1277 t.Errorf("missing %q in:\n%s", want, out)
1278 }
1279 }
1280 if strings.Contains(out, "gitbay mr range-diff") {
1281 t.Error("still quotes the CLI command instead of linking the new page")
1282 }
1283}
1284```
1285
1286- [ ] **Step 2: Run and see it fail**
1287
1288Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1`
1289Expected: FAIL (no "compare to previous" text yet).
1290
1291- [ ] **Step 3: Replace the one-liner with a revisions list**
1292
1293Find (`mr.html:136-138`):
1294
1295```html
1296 {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
1297 <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
1298 </div>
1299```
1300
1301Replace with:
1302
1303```html
1304 </div>
1305 {{if .Revisions}}<div class="grp">
1306 <h2>Revisions</h2>
1307 {{range $i, $rv := .Revisions}}<p class="row none">{{add $i 1}}. <code>{{short $rv.SHA}}</code> {{when $rv.CreatedAt}}{{if $i}} · <a href="{{$base}}/range-diff?from={{(index $.Revisions (sub $i 1)).SHA}}&amp;to={{$rv.SHA}}">compare to previous</a>{{end}}</p>
1308 {{end}}
1309 </div>{{end}}
1310```
1311
1312(The closing `</div>` that used to end the Reviews `grp` stays where it
1313is — this adds a new sibling `grp` right after it, using `add`/`sub`,
1314already registered template funcs in `internal/web/web.go`.)
1315
1316- [ ] **Step 4: Run**
1317
1318Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1 && go test ./internal/httpd -count=1`
1319Expected: PASS.
1320
1321- [ ] **Step 5: Commit**
1322
1323```bash
1324git add internal/web/templates/mr.html internal/httpd/mrpage_test.go
1325git commit -m "web: list each MR revision with a compare-to-previous link" -m "Ref #269"
1326```
1327
1328### Task 3.3: update Parity
1329
1330**Files:**
1331- Modify: `.gitbay/wiki/Parity.org`
1332
1333- [ ] **Step 1: Fix the range-diff line**
1334
1335Find:
1336
1337```
1338=mr range-diff= compares two heads: the iOS client
1339shows it from a revision to the one before, as text; the web has no
1340view. Batched review is not built.
1341```
1342
1343(If MR 1's Task 1.4 already changed this sentence to reference
1344`krz/gitbay#269`, edit that version instead — the end state either way
1345is:)
1346
1347```
1348=mr range-diff= compares two heads: the iOS client shows it from a
1349revision to the one before, as text; the web renders the same view, with
1350a "compare to previous" link on each revision after the first. Batched
1351review — draft diff comments held with =mr comment --pending= and sent
1352together with =--comment=/=--discard= or a verdict — is built and the
1353web uses it.
1354```
1355
1356- [ ] **Step 2: Commit**
1357
1358```bash
1359git add .gitbay/wiki/Parity.org
1360git commit -m "wiki: Parity reflects the web range-diff view" -m "Closes #269"
1361```
1362
1363### Task 3.4: open MR 3
1364
1365```bash
1366git push -u origin web-mr-range-diff
1367gitbay mr create --source web-mr-range-diff --target main --title "Web: MR range-diff page"
1368```
1369
1370Wait for CI, merge (`--strategy ff`), delete the branch both places.
1371
1372---
1373
1374# MR 4: empty states and contribution hints sweep (branch `web-empty-states`)
1375
1376Closes #270.
1377
1378This MR is one register applied across templates. The table below is
1379the full set of strings this task changes — every empty-state or
1380contribution-hint string identified in the issue and confirmed against
1381the current template source. Implement exactly this table; do not
1382invent additional wording beyond it.
1383
1384| File:line | Current | New |
1385|---|---|---|
1386| `mrs.html:29` (no query) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code>` | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests` (the create instruction moves to the new "New merge request"/fork/sign-in line — Task 4.2 — and is never a CLI command on the web) |
1387| `mrs.html:28` (search, no match) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching "{{.Query}}"` | unchanged (already follows the register: lower-case, no CLI command, states the fact) |
1388| `mr.html:136` (reviews, none) | `none yet` | unchanged — "none yet" is correct register for a section that can still gain entries (open MR); Task 4.1's rule is "no 'yet' on a *finished* item", and reviews on an open MR are not finished |
1389| `mr.html:143` (reviewers, none) | `nobody yet` | `no reviewers` (drops "yet" for consistency with the rest of the sweep's noun-first register even though the MR could still be open — "nobody yet" reads as a placeholder guess about who *will* review, which is not information the page has; "no reviewers" states the fact plainly, matching `dashboard.html`'s "No open merge requests") |
1390| `mr.html:174` (labels, none, on a merged/closed MR) | `none yet` | `no labels` when `.MR.State` is `merged` or `closed` (a finished item gets no "yet"); keep `none yet` when open |
1391| `builds.html:53` | `no builds — push a commit with a <code>.gitbay/ci.yml</code>` | `no builds` when the viewer cannot push (`.CanWrite` false or absent); `no builds — push a commit with a .gitbay/ci.yml` (drop `<code>`, since a filename is not a command) stays for a viewer who can push. Never plain "no builds" for a writer, since that leaves them without the one instruction the page can give them |
1392| `dashboard.html:29` | `Nothing pinned yet. Press Pin on a repository.` | `nothing pinned — press Pin on a repository you visit` |
1393| `dashboard.html:42` (`Empty` value for MRs queue) | `No open merge requests` | unchanged (already matches the register: capital first word is this partial's own convention — see Step 1 below — lower-case the whole sweep *within* `<li class="empty">` items, leave `queue` partial's own `Empty` string as-is since it is Title Case by that partial's design, confirmed by reading the `queue` template define before changing it) |
1394| `notifications.html:21` (all read) | `nothing here yet` | `no unread notifications` when `.All` is false is already separate; the `.All` branch (nothing at all, read or unread, in the *whole* inbox) becomes `nothing to show` |
1395| `notifications.html:21` (unread, default) | `nothing unread — <a>show all</a>` | `no unread notifications — <a href="/notifications?all=1">show all</a>` (already close; #265, a different plan, covers the CLI side of this exact wording — keep the two in step: `no unread notifications` matches what plan 6's CLI task sets for `notifications list`) |
1396| `globalsearch.html:47` | shown only when `.Query` is empty | shown always, as a permanent caption under the search input (Task 4.3) |
1397
1398- [ ] **Step 1: Read the `queue` partial before touching `dashboard.html`**
1399
1400Run: `grep -n '{{define "queue"}}' -A 15 internal/web/templates/dashboard.html`
1401Confirm whether its `Empty` value is rendered as given (in which case
1402`"No open merge requests"` stays capitalised by the caller's choice) or
1403lower-cased by the partial itself. Write down which, then leave that
1404line's casing exactly as the partial expects — do not change
1405`dashboard.html:42-43`'s `"Empty"` values in this task; the table above
1406already reflects "unchanged" for it.
1407
1408### Task 4.1: apply the table
1409
1410**Files:**
1411- Modify: `internal/web/templates/mrs.html:28-29`
1412- Modify: `internal/web/templates/mr.html:143,174`
1413- Modify: `internal/web/templates/builds.html:53`
1414- Modify: `internal/web/templates/dashboard.html:29`
1415- Modify: `internal/web/templates/notifications.html:21`
1416- Test: `internal/httpd/mrpage_test.go`, `internal/httpd/buildpages_test.go`
1417 (or wherever a `builds.html` render test already lives — check with
1418 `grep -rln '"builds.html"' internal/httpd/*_test.go`), a new or
1419 existing dashboard render test, a new or existing notifications test.
1420
1421- [ ] **Step 1: Write the failing tests**
1422
1423Add to `internal/httpd/mrpage_test.go`:
1424
1425```go
1426// A finished merge request states an empty label list as a fact, not a
1427// promise something is still coming (#270).
1428func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
1429 var sb strings.Builder
1430 if err := web.Render(&sb, "mr.html", mrPageData{
1431 repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
1432 }); err != nil {
1433 t.Fatalf("render: %v", err)
1434 }
1435 if !strings.Contains(sb.String(), "no labels") {
1436 t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
1437 }
1438}
1439
1440func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
1441 var sb strings.Builder
1442 if err := web.Render(&sb, "mr.html", mrPageData{
1443 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
1444 }); err != nil {
1445 t.Fatalf("render: %v", err)
1446 }
1447 if strings.Contains(sb.String(), "nobody yet") {
1448 t.Error(`reviewers empty state should read "no reviewers"`)
1449 }
1450 if !strings.Contains(sb.String(), "no reviewers") {
1451 t.Error(`missing "no reviewers"`)
1452 }
1453}
1454```
1455
1456Add to whichever file already renders `builds.html` (or create
1457`internal/httpd/buildslist_test.go` if none does; check first with the
1458grep in the Files list above):
1459
1460```go
1461// A writer sees the instruction to add CI; a reader without push access
1462// sees only the fact, since the instruction is not theirs to act on
1463// (#270).
1464func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
1465 var sb strings.Builder
1466 if err := web.Render(&sb, "builds.html", buildsPageData{
1467 repoPage: testRepoPage(), CanWrite: false,
1468 }); err != nil {
1469 t.Fatalf("render: %v", err)
1470 }
1471 out := sb.String()
1472 if !strings.Contains(out, "no builds") {
1473 t.Error(`missing "no builds"`)
1474 }
1475 if strings.Contains(out, "ci.yml") {
1476 t.Error("a reader without push access should not see the push instruction")
1477 }
1478}
1479```
1480
1481(`buildsPageData` may not exist as a named type the way `mrPageData`
1482does for `mr.html` — check
1483`grep -n '"builds.html"' internal/httpd/web.go` for the anonymous
1484struct `builds` (the list handler, not `build`, the single-build one)
1485renders with, and mirror its fields the way `mrPageData` mirrors
1486`mr.html`'s, the same pattern `mrpage_test.go` already uses.)
1487
1488- [ ] **Step 2: Run and see them fail**
1489
1490Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1`
1491Expected: FAIL.
1492
1493- [ ] **Step 3: `mr.html` reviewers and labels**
1494
1495Find (`mr.html:143`):
1496
1497```html
1498 {{else}}<p class="none">nobody yet</p>{{end}}
1499```
1500
1501(in the Reviewers `grp`). Replace:
1502
1503```html
1504 {{else}}<p class="none">no reviewers</p>{{end}}
1505```
1506
1507Find (`mr.html:174`, in the Labels `grp`):
1508
1509```html
1510 {{else}}<p class="none">none yet</p>{{end}}
1511```
1512
1513Replace:
1514
1515```html
1516 {{else}}<p class="none">{{if or (eq .MR.State "merged") (eq .MR.State "closed")}}no labels{{else}}none yet{{end}}</p>{{end}}
1517```
1518
1519- [ ] **Step 4: `builds.html`**
1520
1521Read the current line first: `grep -n "no builds" internal/web/templates/builds.html`.
1522Replace it (adjust the exact surrounding markup to match what that grep
1523shows; the text change is):
1524
1525```html
1526{{else}}<li class="empty">no builds{{if .CanWrite}} — push a commit with a <code>.gitbay/ci.yml</code>{{end}}</li>{{end}}
1527```
1528
1529Check whether `builds.html`'s page struct already carries `CanWrite`
1530(`grep -n "CanWrite" internal/httpd/builds.go internal/web/templates/builds.html`);
1531if it does not, add it the way `mr.html`'s does
1532(`s.canWriteRepo(r, p.Repo)` in the handler, a new `CanWrite bool` field
1533in the render struct).
1534
1535- [ ] **Step 5: `dashboard.html`**
1536
1537Find (`dashboard.html:29`):
1538
1539```html
1540 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
1541```
1542
1543Replace:
1544
1545```html
1546 {{else}}<p class="none">nothing pinned — press Pin on a repository you visit</p>{{end}}
1547```
1548
1549- [ ] **Step 6: `notifications.html`**
1550
1551Find (`notifications.html:21`):
1552
1553```html
1554{{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1555```
1556
1557Replace:
1558
1559```html
1560{{else}}<li class="empty">{{if .All}}nothing to show{{else}}no unread notifications — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1561```
1562
1563- [ ] **Step 7: `mrs.html`**
1564
1565Find (`mrs.html:28-29`):
1566
1567```html
1568{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1569{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}
1570```
1571
1572Replace (the create instruction moves to Task 4.2's contribution-hint
1573line, so the empty state itself states only the fact):
1574
1575```html
1576{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1577{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests</li>{{end}}{{end}}
1578```
1579
1580- [ ] **Step 8: Run**
1581
1582Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1 && go test ./internal/httpd -count=1`
1583Expected: PASS. Fix any pre-existing test that asserted the old strings
1584("nobody yet", "none yet" on a merged MR's labels, "Nothing pinned yet",
1585"nothing here yet", "nothing unread", the old `mrs.html` CLI-command
1586text) to expect the new ones — these are exactly the tests this sweep
1587is supposed to change.
1588
1589- [ ] **Step 9: Commit**
1590
1591```bash
1592git add internal/web/templates/mr.html internal/web/templates/builds.html internal/web/templates/dashboard.html internal/web/templates/notifications.html internal/web/templates/mrs.html internal/httpd/mrpage_test.go internal/httpd/*_test.go
1593git commit -m "web: one empty-state register — no CLI commands, no 'yet' on finished items" -m "Ref #270"
1594```
1595
1596### Task 4.2: MR list contribution hint by access level
1597
1598**Files:**
1599- Modify: `internal/httpd/web.go:2061-2126` (`mrs` handler, add `CanWrite`)
1600- Modify: `internal/web/templates/mrs.html:16`
1601- Test: `internal/httpd/mrslist_test.go` (create, or add beside an
1602 existing `mrs.html` render test if one exists — check first)
1603
1604- [ ] **Step 1: Write the failing test**
1605
1606```go
1607package httpd
1608
1609import (
1610 "net/http"
1611 "net/http/httptest"
1612 "strings"
1613 "testing"
1614 "time"
1615
1616 "gitbay.org/gitbay/internal/config"
1617 "gitbay.org/gitbay/internal/store"
1618)
1619
1620// A repository's MR list offers the right next step by access level: a
1621// writer gets "New merge request", a signed-in reader without push gets
1622// a fork link, and a signed-out visitor gets a sign-in prompt (#270).
1623func TestMRsListContributionHintByAccess(t *testing.T) {
1624 st, err := store.Open(":memory:")
1625 if err != nil {
1626 t.Fatal(err)
1627 }
1628 defer st.Close()
1629 if err := st.MigrateUp(); err != nil {
1630 t.Fatal(err)
1631 }
1632 owner, err := st.CreateUser("alice", false)
1633 if err != nil {
1634 t.Fatal(err)
1635 }
1636 reader, err := st.CreateUser("bob", false)
1637 if err != nil {
1638 t.Fatal(err)
1639 }
1640 if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
1641 t.Fatal(err)
1642 }
1643
1644 cfg := config.Default()
1645 cfg.Web.Mode = "accounts"
1646 s := New(cfg, st)
1647
1648 // mrs reads the viewer through s.viewer(r), which resolves a
1649 // session cookie (internal/httpd/accounts.go:37-47) rather than
1650 // taking the viewer as a parameter the way a POST handler test
1651 // does. Give a real viewer a real session; leave the request
1652 // cookie-less for the anonymous case.
1653 sessionFor := func(uid int64) *http.Cookie {
1654 tok, hash, err := store.NewToken()
1655 if err != nil {
1656 t.Fatal(err)
1657 }
1658 if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
1659 t.Fatal(err)
1660 }
1661 return s.sessionCookieFor(tok)
1662 }
1663
1664 get := func(uid int64) string {
1665 req := httptest.NewRequest("GET", "/alice/app/mrs", nil)
1666 req.SetPathValue("owner", "alice")
1667 req.SetPathValue("repo", "app")
1668 if uid != 0 {
1669 req.AddCookie(sessionFor(uid))
1670 }
1671 rr := httptest.NewRecorder()
1672 s.mrs(rr, req)
1673 return rr.Body.String()
1674 }
1675 anonymous := get(0)
1676 if !strings.Contains(anonymous, "Sign in to propose a change") {
1677 t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous)
1678 }
1679 if strings.Contains(anonymous, "New merge request") {
1680 t.Error("signed-out visitor should not see New merge request")
1681 }
1682
1683 readerOut := get(reader)
1684 if !strings.Contains(readerOut, "Fork this repository to propose a change") {
1685 t.Errorf("reader without push: missing fork hint:\n%s", readerOut)
1686 }
1687
1688 ownerOut := get(owner)
1689 if !strings.Contains(ownerOut, "New merge request") {
1690 t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
1691 }
1692}
1693```
1694
1695- [ ] **Step 2: Run and see it fail**
1696
1697Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1`
1698Expected: FAIL (no such text yet — `mrs.html:16` still only checks
1699`.Viewer`).
1700
1701- [ ] **Step 3: Add `CanWrite` to the `mrs` page struct**
1702
1703In `internal/httpd/web.go`, in `mrs` (around line 2061), after
1704`p.Tab = "merge requests"`:
1705
1706```go
1707 canWrite := s.canWriteRepo(r, p.Repo)
1708```
1709
1710and add `CanWrite bool` to the anonymous struct passed to `s.render`,
1711with `canWrite` in the corresponding position of the literal.
1712
1713- [ ] **Step 4: Update `mrs.html`**
1714
1715Find (`mrs.html:16`):
1716
1717```html
1718{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
1719```
1720
1721Replace:
1722
1723```html
1724{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
1725{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
1726{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1727```
1728
1729- [ ] **Step 5: Run**
1730
1731Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1 && go test ./internal/httpd -count=1`
1732Expected: PASS.
1733
1734- [ ] **Step 6: Commit**
1735
1736```bash
1737git add internal/httpd/web.go internal/web/templates/mrs.html internal/httpd/mrslist_test.go
1738git commit -m "web: MR list offers a fork link or a sign-in prompt to visitors who cannot open one directly" -m "Ref #270"
1739```
1740
1741### Task 4.3: search scope caption always visible; tab zero-count rule
1742
1743**Files:**
1744- Modify: `internal/web/templates/globalsearch.html:45-48`
1745- Modify: `internal/web/templates/layout.html:71-72` (comment only — see
1746 Step 2)
1747- Test: `internal/httpd/searchweb_test.go` (or wherever a
1748 `globalsearch.html` render test already lives)
1749
1750- [ ] **Step 1: Write the failing test**
1751
1752```go
1753// The scope sentence is a permanent caption, not a first-visit-only
1754// hint: a visitor who has already searched still needs to know what a
1755// search here does and does not cover (#270).
1756func TestGlobalSearchScopeCaptionAlwaysShown(t *testing.T) {
1757 var sb strings.Builder
1758 if err := web.Render(&sb, "globalsearch.html", struct {
1759 basePage
1760 Query, Kind string
1761 Results []searchHit
1762 QueryErr string
1763 }{Query: "gitbay"}); err != nil {
1764 t.Fatalf("render: %v", err)
1765 }
1766 if !strings.Contains(sb.String(), "File contents are searched per repository") {
1767 t.Error("scope caption missing once a query is present")
1768 }
1769}
1770```
1771
1772(Check the real render struct's field names and the `searchHit` type
1773name with `grep -n '"globalsearch.html"' internal/httpd/*.go` and match
1774them exactly — the struct above is a best guess at the shape from
1775reading the template, not a verified signature.)
1776
1777- [ ] **Step 2: Run and see it fail**
1778
1779Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1`
1780Expected: FAIL (the caption is currently inside the `{{else}}` branch
1781that only renders when `.Query` is empty).
1782
1783- [ ] **Step 3: Move the caption out of the conditional**
1784
1785Find (`globalsearch.html:45-48`):
1786
1787```html
1788{{/* The count line above already says nothing matched, so this one
1789 carries the way out instead of repeating it. */}}
1790{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1791{{else}}
1792<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1793{{end}}
1794```
1795
1796Replace with (the scope sentence moves out to render unconditionally,
1797right after the search form, and the "no results" hint keeps its own
1798conditional unchanged):
1799
1800```html
1801{{/* The count line above already says nothing matched, so this one
1802 carries the way out instead of repeating it. */}}
1803{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1804{{end}}
1805<p class="meta">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1806```
1807
1808(Dropping the outer `{{if .QueryErr}}...{{else if .Query}}...{{else}}...{{end}}`'s
1809final `{{else}}` branch this way requires re-reading the template's
1810actual brace nesting before editing — the three-way `{{if
1811.QueryErr}}{{else if .Query}}{{else}}{{end}}` collapses to a two-way
1812`{{if .QueryErr}}{{else}}...{{end}}` once the "no query yet" case no
1813longer needs its own branch for this sentence. Read
1814`globalsearch.html`'s full `{{if}}/{{else}}` structure before editing —
1815line numbers above are from this plan's research and may have shifted.)
1816
1817- [ ] **Step 4: Run**
1818
1819Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1 && go test ./internal/httpd -count=1`
1820Expected: PASS.
1821
1822- [ ] **Step 5: Document the tab zero-count rule (no code change: the
1823 current behaviour is already the rule)**
1824
1825Reading `layout.html:71-74`: `Issues` and `Merge requests` already hide
1826their count badge at zero (`{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}`,
1827same for `OpenMRs`); `Builds`, `Releases`, `Wiki` and `Settings` never
1828carry a count at all. The inconsistency the issue names ("Issues 9, then
1829Merge requests with no count") is two tabs following the same rule
1830producing different-looking output depending on the data, not a code
1831bug — but `dashboard.html`'s pin row (`<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} ...`)
1832always prints the number, including `0`, which genuinely is a different
1833rule from the tabs'. Fix that inconsistency by hiding a zero the same
1834way the tabs do. Find (`dashboard.html:27`, inside the pin row):
1835
1836```html
1837<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b>
1838```
1839
1840Replace:
1841
1842```html
1843<b{{if .Issues}} class="wants"{{end}}>{{if .Issues}}{{.Issues}}{{else}}0{{end}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{if .MRs}}{{.MRs}}{{else}}0{{end}} <span class="vh">open merge requests</span></b>
1844```
1845
1846Wait — re-read this before implementing: this keeps `0` printed, which
1847does not change anything (`{{.Issues}}` and `{{if .Issues}}{{.Issues}}{{else}}0{{end}}`
1848render identically for an int, since Go's `%v`-style template output of
1849`0` is already `"0"`). The dashboard pin row is not actually
1850inconsistent with the tabs in a way a template edit can fix: it is a
1851`<b>` badge that always shows a resting value (like a count chip
1852elsewhere in the app, e.g. label/milestone counts), whereas the tabs
1853hide their `<i>` badge entirely at zero because an empty `<i>` there
1854would look like stray punctuation next to the tab word. These are
1855two different UI elements with two different, both-reasonable rules.
1856Do not change `dashboard.html` in this task. Instead add a one-line
1857comment at `layout.html:69` (just above the `<nav class="tabs">`)
1858recording the decision so a future pass does not "fix" this again:
1859
1860```html
1861 {{/* A tab's own count badge is omitted at zero (an empty <i> reads as
1862 stray punctuation next to the tab word); the dashboard pin row's
1863 count chip always shows its number, zero included, the same as
1864 every other count chip in the app. Two elements, two rules,
1865 decided once here (#270). */}}
1866 <nav class="tabs" aria-label="Repository">
1867```
1868
1869- [ ] **Step 6: Run the full package once more**
1870
1871Run: `go test ./internal/httpd ./internal/web -count=1`
1872Expected: PASS.
1873
1874- [ ] **Step 7: Commit**
1875
1876```bash
1877git add internal/web/templates/globalsearch.html internal/web/templates/layout.html internal/httpd/searchweb_test.go
1878git commit -m "web: search scope caption is permanent; document the tab zero-count rule" -m "Closes #270"
1879```
1880
1881### Task 4.4: open MR 4
1882
1883```bash
1884git push -u origin web-empty-states
1885gitbay mr create --source web-empty-states --target main --title "Web: empty-state and contribution-hint sweep"
1886```
1887
1888Wait for CI, merge, delete the branch both places.
1889
1890---
1891
1892# MR 5: UX review small fixes (branch `web-ux-small-fixes`)
1893
1894Closes #271. Depends on MR 1 (`repo watch`/`repo mute` dispatch and the
1895cycling toggle).
1896
1897### Task 5.1: issue form gains milestone and assignee
1898
1899**Files:**
1900- Modify: `internal/web/templates/issuenew.html`
1901- Modify: `internal/httpd/accounts.go:455-477` (`issueCreateSubmit`)
1902- Test: `internal/httpd/accounts_test.go` or wherever an existing
1903 `issueCreateSubmit` test lives (`grep -rln "issueCreateSubmit" internal/httpd/*_test.go`)
1904
1905**Ground truth from reading the code:** `issue create`
1906(`internal/control/issue.go:18-31`) takes only `--title`, `--body`/
1907`--file`, and `--format` — no `--milestone`/`--assignee` flags.
1908`issueCreateSubmit` (`internal/httpd/accounts.go:455-477`) already
1909handles this shape for labels: it creates the issue first (decoding the
1910created issue's number via `dispatchIntoStdin` into `control.Created`),
1911then, only if the labels field was non-empty, makes a second dispatch
1912(`issue label ... --add ...`) with that number. Milestone and assignee
1913follow the same two-step shape, using the existing commands `issue
1914milestone <owner/name> <n> <title>` and `issue assign <owner/name> <n>
1915[--add <user>]` (`internal/control/issue.go:101-109` for assign; the
1916milestone command's exact path is confirmed by
1917`internal/httpd/issueactions.go`'s `issueMilestoneSubmit`, which already
1918calls `issueArgs(r, "milestone", title)`).
1919
1920- [ ] **Step 1: Write the failing test**
1921
1922```go
1923// The new-issue form takes milestone and assignee, the same as the
1924// issue page's own edit controls already do (#271).
1925func TestIssueCreateFormHasMilestoneAndAssignee(t *testing.T) {
1926 var sb strings.Builder
1927 if err := web.Render(&sb, "issuenew.html", struct {
1928 basePage
1929 Repo store.Repo
1930 Milestones []string
1931 Draft *draft
1932 }{Repo: store.Repo{OwnerName: "alice", Name: "app"}}); err != nil {
1933 t.Fatalf("render: %v", err)
1934 }
1935 out := sb.String()
1936 if !strings.Contains(out, `name="milestone"`) {
1937 t.Error("no milestone field")
1938 }
1939 if !strings.Contains(out, `name="assignee"`) {
1940 t.Error("no assignee field")
1941 }
1942}
1943```
1944
1945(Match the render struct to whatever `issueCreateForm` actually passes —
1946read its handler first, per Step 1, and adjust field names here.)
1947
1948- [ ] **Step 2: Run and see it fail**
1949
1950Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1`
1951Expected: FAIL.
1952
1953- [ ] **Step 3: Add the fields to the form**
1954
1955Add to `issuenew.html`, alongside the existing labels input (matching
1956its markup style exactly — an `<input>` with the same classes/attributes
1957the labels field uses, adjusted for name and placeholder):
1958
1959```html
1960<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p>
1961<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignee, one username (optional)"></p>
1962```
1963
1964Place these after the existing labels `<input>` and before the submit
1965button, matching the vertical rhythm (`<p>` wrapping) the rest of the
1966form uses.
1967
1968- [ ] **Step 4: Wire them into the handler as follow-up dispatches**
1969
1970In `internal/httpd/accounts.go`, `issueCreateSubmit` (lines 455-477),
1971add two more follow-up dispatches after the existing labels one, using
1972the same `n` (the created issue's number, already decoded from
1973`created.Number`):
1974
1975```go
1976 if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
1977 s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
1978 }
1979 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
1980 s.runControl(u, []string{"issue", "milestone", repoPath, fmt.Sprint(n), milestone})
1981 }
1982 if assignee := strings.TrimSpace(r.FormValue("assignee")); assignee != "" {
1983 s.runControl(u, []string{"issue", "assign", repoPath, fmt.Sprint(n), "--add", assignee})
1984 }
1985 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
1986```
1987
1988(The first block — the existing labels dispatch — is unchanged; the
1989milestone and assignee blocks are new, inserted between it and the
1990final redirect.)
1991
1992- [ ] **Step 5: Run**
1993
1994Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1 && go test ./internal/httpd -count=1`
1995Expected: PASS.
1996
1997- [ ] **Step 6: Commit**
1998
1999```bash
2000git add internal/web/templates/issuenew.html internal/httpd/accounts.go internal/httpd/*_test.go
2001git commit -m "web: new-issue form takes milestone and assignee" -m "Ref #271"
2002```
2003
2004### Task 5.2: "Muted" reachable on the watch control
2005
2006MR 1 (Task 1.2) already made `watchToggle` cycle default → watching →
2007muted → default, closing the functional half of this. This task is the
2008UI half: the header button's label and title must describe all three
2009states (today it only ever renders "Watch" or "Watching").
2010
2011**Files:**
2012- Modify: `internal/web/templates/layout.html:64`
2013- Test: a `layout.html` render test, or a repo-page test that already
2014 checks the watch button (`grep -rln 'aria-pressed.*Watch\|"Watching"' internal/httpd/*_test.go`)
2015
2016- [ ] **Step 1: Write the failing test**
2017
2018```go
2019// The watch button names all three states it cycles through, including
2020// muted, which MR 1 made reachable (#271).
2021func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
2022 var sb strings.Builder
2023 rp := testRepoPage()
2024 rp.Watch = "muted"
2025 if err := web.Render(&sb, "dashboard.html", struct {
2026 repoPage
2027 }{rp}); err != nil {
2028 t.Fatalf("render: %v", err)
2029 }
2030 if !strings.Contains(sb.String(), "Muted") {
2031 t.Error(`watch button does not render "Muted" for a muted repo`)
2032 }
2033}
2034```
2035
2036`dashboard.html` is not a repo page and will not carry the header at
2037all — use whatever page template this plan's other tasks already found
2038does render `field $ "Repo"` (any `repoPage`-embedding page works,
2039e.g. `mr.html`); adjust the render call to a page that actually shows
2040the header (check with `grep -n 'field \$ "Repo"' internal/web/templates/layout.html`
2041and pick any page in the `repoPage` family, such as `mrs.html`, matching
2042whatever fixture data that page's own tests already use).
2043
2044- [ ] **Step 2: Run and see it fail**
2045
2046Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1`
2047Expected: FAIL.
2048
2049- [ ] **Step 3: Update the button**
2050
2051Find (`layout.html:64`):
2052
2053```html
2054 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
2055```
2056
2057Replace:
2058
2059```html
2060 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if ne (str $ "Watch") ""}}true{{else}}false{{end}}" title="{{if eq (str $ "Watch") "watching"}}Watching: every issue, request and build. Click to mute.{{else if eq (str $ "Watch") "muted"}}Muted: nothing from this repository. Click to stop muting.{{else}}Only what involves you. Click to watch everything.{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else if eq (str $ "Watch") "muted"}}Muted{{else}}Watch{{end}}</button></form>
2061```
2062
2063- [ ] **Step 4: Run**
2064
2065Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1 && go test ./internal/httpd -count=1`
2066Expected: PASS.
2067
2068- [ ] **Step 5: Commit**
2069
2070```bash
2071git add internal/web/templates/layout.html internal/httpd/*_test.go
2072git commit -m "web: watch button names all three states, muted included" -m "Ref #271"
2073```
2074
2075### Task 5.3: rail and phone "More" menu render from one list
2076
2077**Files:**
2078- Modify: `internal/web/web.go` (add `railItem` type, `railOptItems`
2079 func, register it in `funcs`)
2080- Modify: `internal/web/templates/layout.html:26,30-31,37-40`
2081- Test: `internal/web/web_test.go` (`TestRailIconsAreLabelled` already
2082 parses `layout.html`; add a focused new test rather than folding into
2083 that one)
2084
2085- [ ] **Step 1: Write the failing test**
2086
2087Add to `internal/web/web_test.go`:
2088
2089```go
2090// The main rail and the phone "More" menu render New repository,
2091// Settings, Admin and Log out from one list, so adding a destination in
2092// one place reaches both (#271).
2093func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
2094 items := railOptItems(struct {
2095 Tab string
2096 Admin bool
2097 }{Tab: "admin", Admin: true})
2098 if len(items) != 4 {
2099 t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
2100 }
2101 if items[2].Name != "Admin" || !items[2].Show {
2102 t.Errorf("Admin item: %+v", items[2])
2103 }
2104 if !items[2].Current {
2105 t.Error("Admin item should be Current when Tab is admin")
2106 }
2107
2108 nonAdmin := railOptItems(struct {
2109 Tab string
2110 Admin bool
2111 }{Tab: "account"})
2112 if nonAdmin[2].Show {
2113 t.Error("Admin item should not Show for a non-admin viewer")
2114 }
2115 if !nonAdmin[1].Current {
2116 t.Error("Settings item should be Current when Tab is account")
2117 }
2118}
2119```
2120
2121- [ ] **Step 2: Run and see it fail to compile**
2122
2123Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2124Expected: FAIL (`railOptItems` undefined).
2125
2126- [ ] **Step 3: Add the type and function**
2127
2128In `internal/web/web.go`, near the other template-data helpers (before
2129the `funcs` map, so it can be referenced there):
2130
2131```go
2132// railItem is one destination the rail's icon strip and the phone
2133// "More" menu both render — from this one list, so a destination added
2134// here reaches both instead of the two being hand-kept in step (#271).
2135type railItem struct {
2136 Href string
2137 Icon string
2138 Name string
2139 Current bool
2140 Count int64 // unused by railOptItems; present so "raillink" can read it uniformly
2141 Show bool
2142}
2143
2144// railField and railBool read a named field off the page value the
2145// layout was given — the same reflection str/field already do for the
2146// repo header, duplicated narrowly here rather than exported, since
2147// railOptItems is their only other caller.
2148func railField(v any, name string) string {
2149 rv := reflect.ValueOf(v)
2150 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2151 rv = rv.Elem()
2152 }
2153 if rv.Kind() != reflect.Struct {
2154 return ""
2155 }
2156 f := rv.FieldByName(name)
2157 if !f.IsValid() || f.Kind() != reflect.String {
2158 return ""
2159 }
2160 return f.String()
2161}
2162
2163func railBool(v any, name string) bool {
2164 rv := reflect.ValueOf(v)
2165 for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2166 rv = rv.Elem()
2167 }
2168 if rv.Kind() != reflect.Struct {
2169 return false
2170 }
2171 f := rv.FieldByName(name)
2172 return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
2173}
2174
2175// railOptItems is the rail's "New repository", "Settings", "Admin" and
2176// "Log out" destinations, in the order the rail shows them. v is the
2177// page value the layout renders (any page struct that embeds
2178// basePage), read by field name since the layout has no single common
2179// type for every page.
2180func railOptItems(v any) []railItem {
2181 tab := railField(v, "Tab")
2182 admin := railBool(v, "Admin")
2183 return []railItem{
2184 {Href: "/new", Icon: "plus", Name: "New repository", Show: true},
2185 {Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
2186 {Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
2187 {Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
2188 }
2189}
2190```
2191
2192Add `"reflect"` to the file's imports if not already present (it almost
2193certainly is, since `str`/`field` already use it).
2194
2195Register it in the `funcs` map (anywhere in the literal, e.g. beside
2196`"initial"`):
2197
2198```go
2199 "railOptItems": railOptItems,
2200```
2201
2202- [ ] **Step 4: Run the new test**
2203
2204Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2205Expected: PASS.
2206
2207- [ ] **Step 5: Use it in `layout.html`**
2208
2209Find (lines 21-32):
2210
2211```html
2212 <ul class="raillist">
2213 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2214 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2215 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2216 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2217 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2218 </ul>
2219 <span class="railgap"></span>
2220 <ul class="raillist">
2221 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
2222 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
2223 </ul>
2224```
2225
2226Replace:
2227
2228```html
2229 <ul class="raillist">
2230 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2231 <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2232 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2233 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2234 <li class="railopt">{{template "raillink" (index (railOptItems .) 0)}}</li>{{end}}
2235 </ul>
2236 <span class="railgap"></span>
2237 <ul class="raillist">
2238 {{if .Viewer}}<li class="railopt">{{template "raillink" (index (railOptItems .) 1)}}</li>
2239 {{if (index (railOptItems .) 2).Show}}<li class="railopt">{{template "raillink" (index (railOptItems .) 2)}}</li>{{end}}{{end}}
2240 </ul>
2241```
2242
2243Find (lines 34-42):
2244
2245```html
2246 {{if .Viewer}}<details class="railmore">
2247 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2248 <div class="raildrop">
2249 <a href="/new">{{template "icon" "plus"}} New repository</a>
2250 <a href="/settings">{{template "icon" "gear"}} Settings</a>
2251 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
2252 <a href="/logout">{{template "icon" "signout"}} Log out</a>
2253 </div>
2254 </details>
2255```
2256
2257Replace:
2258
2259```html
2260 {{if .Viewer}}<details class="railmore">
2261 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2262 <div class="raildrop">
2263 {{range railOptItems .}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}}
2264 </div>
2265 </details>
2266```
2267
2268- [ ] **Step 6: Run**
2269
2270Run: `go test ./internal/web -count=1 && go test ./internal/httpd -count=1`
2271Expected: PASS. `TestRailIconsAreLabelled` must still pass unchanged —
2272`raillink`'s template still receives the same field names (`Href`,
2273`Icon`, `Name`, `Current`, `Count`), now off a `railItem` struct instead
2274of a `dict` map, which `html/template`'s field access treats
2275identically.
2276
2277- [ ] **Step 7: Commit**
2278
2279```bash
2280git add internal/web/web.go internal/web/web_test.go internal/web/templates/layout.html
2281git commit -m "web: rail and phone More menu render New repository/Settings/Admin/Log out from one list" -m "Ref #271"
2282```
2283
2284### Task 5.4: "Discussion" heading before the comment thread
2285
2286**Files:**
2287- Modify: `internal/web/templates/mr.html` (inside the `conversation` view)
2288- Modify: `internal/web/templates/issue.html`
2289- Test: existing render tests in `mrpage_test.go`; a new or existing
2290 `issue.html` render test
2291
2292- [ ] **Step 1: Write the failing tests**
2293
2294Add to `internal/httpd/mrpage_test.go`:
2295
2296```go
2297// A heading precedes the comment thread, so a screen-reader user
2298// skimming by heading does not fall from the aside's groups straight
2299// into the first comment with no landmark (#271).
2300func TestMRPageHasDiscussionHeading(t *testing.T) {
2301 var sb strings.Builder
2302 if err := web.Render(&sb, "mr.html", mrPageData{
2303 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
2304 }); err != nil {
2305 t.Fatalf("render: %v", err)
2306 }
2307 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
2308 t.Error("no Discussion heading")
2309 }
2310}
2311```
2312
2313Add the equivalent for `issue.html` in whatever file already tests it
2314(`grep -rln '"issue.html"' internal/httpd/*_test.go`).
2315
2316- [ ] **Step 2: Run and see them fail**
2317
2318Run: `go test ./internal/httpd -run TestMRPageHasDiscussionHeading -count=1`
2319Expected: FAIL.
2320
2321- [ ] **Step 3: `mr.html`**
2322
2323Find, inside the `{{if eq .View "conversation"}}` block, right after
2324`<div class="prose">`:
2325
2326```html
2327{{if eq .View "conversation"}}
2328<div class="prose">
2329{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2330```
2331
2332Replace:
2333
2334```html
2335{{if eq .View "conversation"}}
2336<div class="prose">
2337<h2>Discussion</h2>
2338{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2339```
2340
2341- [ ] **Step 4: `issue.html`**
2342
2343Find, right after `<div class="mainside">`:
2344
2345```html
2346<div class="mainside">
2347{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2348```
2349
2350Replace:
2351
2352```html
2353<div class="mainside">
2354<h2>Discussion</h2>
2355{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2356```
2357
2358- [ ] **Step 5: Run**
2359
2360Run: `go test ./internal/httpd -count=1`
2361Expected: PASS.
2362
2363- [ ] **Step 6: Commit**
2364
2365```bash
2366git add internal/web/templates/mr.html internal/web/templates/issue.html internal/httpd/*_test.go
2367git commit -m "web: Discussion heading before the comment thread on issue and MR pages" -m "Ref #271"
2368```
2369
2370### Task 5.5: build page's "Live" note says the page updates itself
2371
2372**Files:**
2373- Modify: `internal/web/templates/build.html:15`
2374- Test: `internal/httpd/builds_test.go` (or wherever a live-build render
2375 test already exists — check with `grep -rln '"Live"' internal/httpd/*_test.go`)
2376
2377- [ ] **Step 1: Write the failing test**
2378
2379```go
2380// The Live note says the page updates itself, in plain words, rather
2381// than the more technical "streams here" (#271).
2382func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
2383 var sb strings.Builder
2384 if err := web.Render(&sb, "build.html", buildView{
2385 repoPage: testRepoPage(), Live: true,
2386 }); err != nil {
2387 t.Fatalf("render: %v", err)
2388 }
2389 if !strings.Contains(sb.String(), "This page updates itself") {
2390 t.Error(`Live note does not say the page updates itself`)
2391 }
2392}
2393```
2394
2395- [ ] **Step 2: Run and see it fail**
2396
2397Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1`
2398Expected: FAIL.
2399
2400- [ ] **Step 3: Update the note**
2401
2402Find (`build.html:15`):
2403
2404```html
2405{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2406```
2407
2408Replace:
2409
2410```html
2411{{if .Live}}<p class="meta">This page updates itself until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2412```
2413
2414- [ ] **Step 4: Run**
2415
2416Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1 && go test ./internal/httpd -count=1`
2417Expected: PASS.
2418
2419- [ ] **Step 5: Commit**
2420
2421```bash
2422git add internal/web/templates/build.html internal/httpd/builds_test.go
2423git commit -m "web: build page's Live note says the page updates itself" -m "Closes #271"
2424```
2425
2426### Task 5.6: open MR 5
2427
2428```bash
2429git push -u origin web-ux-small-fixes
2430gitbay mr create --source web-ux-small-fixes --target main --title "Web UX review small fixes: issue form, mute, rail list, Discussion heading"
2431```
2432
2433Wait for CI, merge, delete the branch both places.
2434
2435---
2436
2437# MR 6: wiki non-page links go to `_raw` (branch `wiki-raw-links`)
2438
2439Closes #283.
2440
2441### Task 6.1: `rewriteWikiLinks` sends a non-page file link to `_raw`
2442
2443**Files:**
2444- Modify: `internal/httpd/wiki.go:250-253`
2445- Test: `internal/httpd/wiki_test.go`
2446
2447**Interfaces:**
2448- No signature changes — `rewriteWikiLinks`'s parameters and the
2449 `isPage`/`isFile` predicates it already takes are unchanged; only its
2450 href branch's internal logic changes.
2451
2452- [ ] **Step 1: Write the failing test**
2453
2454Add to `internal/httpd/wiki_test.go`, in `TestRewriteWikiLinksInSubfolder`
2455(extend the existing test rather than adding a new one — it already sets
2456up exactly the `pages`/`files` fixtures this needs):
2457
2458```go
2459 in := template.HTML(`<a href="Identity.org">i</a><a href="Admin.org">a</a>` +
2460 `<a href="b.svg">diagram</a>` +
2461 `<img src="b.svg"><img src="diagrams/a.svg"><a href="https://x.test/">x</a>`)
2462 out := string(rewriteWikiLinks(in, p, "Architecture/Trust",
2463 func(s string) bool { return pages[s] }, func(s string) bool { return files[s] }))
2464 for _, want := range []string{
2465 `href="/krz/gitbay/wiki/Architecture/Identity"`,
2466 `href="/krz/gitbay/wiki/Admin"`,
2467 `href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2468 `src="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2469 `src="/krz/gitbay/wiki/_raw/diagrams/a.svg"`,
2470 `href="https://x.test/"`,
2471 } {
2472```
2473
2474(This adds the `<a href="b.svg">` link to the input and the matching
2475`href="...wiki/_raw/Architecture/b.svg"` expectation to the existing
2476`for _, want := range` loop — the surrounding test body, `p`, `pages`
2477and `files` setup, and the final `if !strings.Contains` check stay
2478exactly as they are.)
2479
2480- [ ] **Step 2: Run and see it fail**
2481
2482Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2483Expected: FAIL — the new href expectation
2484(`href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`) is missing; today's
2485code rewrites that link to `href="/krz/gitbay/wiki/Architecture/b.svg"`
2486instead (a page-style link to a file that is not a page, which 404s —
2487the bug #283 reports).
2488
2489- [ ] **Step 3: Fix `rewriteWikiLinks`**
2490
2491Find (`internal/httpd/wiki.go:250-253`):
2492
2493```go
2494 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2495 n.Attr[i].Val = base + "/" + target
2496 }
2497```
2498
2499Replace:
2500
2501```go
2502 // A plain link is usually to another page, but a link to
2503 // an existing non-page file (an .svg, .txt, .pdf) must
2504 // go to _raw the same as an image src, or it 404s
2505 // against the page route (#283).
2506 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2507 if raw, rok := wikiResolve(page, v, isFile); rok && isFile(raw) && !isPage(target) {
2508 n.Attr[i].Val = base + "/_raw/" + raw
2509 } else {
2510 n.Attr[i].Val = base + "/" + target
2511 }
2512 }
2513```
2514
2515- [ ] **Step 4: Run**
2516
2517Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2518Expected: PASS.
2519
2520- [ ] **Step 5: Run the package**
2521
2522Run: `go test ./internal/httpd -count=1`
2523Expected: PASS.
2524
2525- [ ] **Step 6: Commit**
2526
2527```bash
2528git add internal/httpd/wiki.go internal/httpd/wiki_test.go
2529git commit -m "wiki: a link to an existing non-page file resolves to _raw, not the page route" -m "Closes #283"
2530```
2531
2532### Task 6.2: open MR 6
2533
2534```bash
2535git push -u origin wiki-raw-links
2536gitbay mr create --source wiki-raw-links --target main --title "Wiki: links to non-page files resolve to _raw"
2537```
2538
2539Wait for CI, merge, delete the branch both places.
2540
2541---
2542
2543# MR 7: API token page (branch `web-api-tokens`)
2544
2545Closes #264. Depends on plan 1 (`credentials-and-sessions`, #257) per
2546the "Order and dependencies" section above — implementable and testable
2547independently, merge after plan 1 lands.
2548
2549### Task 7.1: `Settings → Tokens`: create, list, revoke
2550
2551**Files:**
2552- Modify: `internal/httpd/account.go` (`accountPage`, `accountSubmit`)
2553- Modify: `internal/httpd/flash.go` (a token-shown-once cookie, parallel
2554 to the existing flash cookie)
2555- Modify: `internal/web/templates/account.html`
2556- Test: `internal/httpd/account_test.go`
2557
2558**Interfaces:**
2559- Consumes: `store.ListAPITokens`/`RevokeAPIToken`
2560 (`internal/store/tokens.go:53,74`, read/delete directly, matching how
2561 `accountPage` already reads keys and PGP keys); `s.runControl`
2562 dispatching `token create` (a write, so it goes through the command,
2563 matching every other write on this page).
2564- Produces: `func (s *Server) setTokenFlash(w http.ResponseWriter, msg string)`,
2565 `func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string`
2566 (`internal/httpd/flash.go`).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Add to `internal/httpd/account_test.go`:
2571
2572```go
2573// The settings page lists a user's API tokens with scope and expiry,
2574// and creating one shows the token exactly once, never in the URL
2575// (#264).
2576func TestAccountPageListsTokens(t *testing.T) {
2577 st, err := store.Open(":memory:")
2578 if err != nil {
2579 t.Fatal(err)
2580 }
2581 defer st.Close()
2582 if err := st.MigrateUp(); err != nil {
2583 t.Fatal(err)
2584 }
2585 uid, err := st.CreateUser("alice", false)
2586 if err != nil {
2587 t.Fatal(err)
2588 }
2589 if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2590 t.Fatal(err)
2591 }
2592
2593 s := New(config.Default(), st)
2594 rr := httptest.NewRecorder()
2595 req := httptest.NewRequest("GET", "/settings", nil)
2596 s.accountPage(rr, req, store.User{ID: uid, Username: "alice"})
2597
2598 body := rr.Body.String()
2599 if !strings.Contains(body, "laptop") || !strings.Contains(body, "read") {
2600 t.Fatalf("token row missing: %s", body)
2601 }
2602 if strings.Contains(body, "somehash") {
2603 t.Fatal("the page printed a token hash")
2604 }
2605}
2606
2607// Creating a token always sends an explicit --scope, defaulting the
2608// form to read regardless of what token create itself defaults to, so
2609// this page's behaviour does not depend on that command's default
2610// (#264, #257).
2611func TestAccountSubmitTokenCreateDefaultsToReadScope(t *testing.T) {
2612 st, err := store.Open(":memory:")
2613 if err != nil {
2614 t.Fatal(err)
2615 }
2616 defer st.Close()
2617 if err := st.MigrateUp(); err != nil {
2618 t.Fatal(err)
2619 }
2620 uid, err := st.CreateUser("alice", false)
2621 if err != nil {
2622 t.Fatal(err)
2623 }
2624 u := store.User{ID: uid, Username: "alice"}
2625 s := New(config.Default(), st)
2626
2627 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
2628 if rr.Code != http.StatusSeeOther {
2629 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2630 }
2631 if !strings.Contains(strings.Join(rr.Result().Header.Values("Set-Cookie"), ";"), "gitbay_token=") {
2632 t.Fatal("no token-shown cookie set")
2633 }
2634 tokens, err := st.ListAPITokens(uid)
2635 if err != nil || len(tokens) != 1 {
2636 t.Fatalf("tokens: %v %v", tokens, err)
2637 }
2638 if tokens[0].Scope != "read" {
2639 t.Errorf("scope = %q, want read", tokens[0].Scope)
2640 }
2641}
2642
2643// Revoking a token requires the name typed back, the same guard every
2644// other removal on this page uses.
2645func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
2646 st, err := store.Open(":memory:")
2647 if err != nil {
2648 t.Fatal(err)
2649 }
2650 defer st.Close()
2651 if err := st.MigrateUp(); err != nil {
2652 t.Fatal(err)
2653 }
2654 uid, err := st.CreateUser("alice", false)
2655 if err != nil {
2656 t.Fatal(err)
2657 }
2658 if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2659 t.Fatal(err)
2660 }
2661 u := store.User{ID: uid, Username: "alice"}
2662 s := New(config.Default(), st)
2663
2664 submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
2665 if tokens, _ := st.ListAPITokens(uid); len(tokens) != 1 {
2666 t.Fatal("token revoked without confirmation")
2667 }
2668
2669 rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
2670 if rr.Code != http.StatusSeeOther {
2671 t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2672 }
2673 if tokens, _ := st.ListAPITokens(uid); len(tokens) != 0 {
2674 t.Fatal("token not revoked")
2675 }
2676}
2677```
2678
2679- [ ] **Step 2: Run and see them fail**
2680
2681Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1`
2682Expected: FAIL to compile (no `token-create`/`token-revoke` cases, no
2683token rows on the page).
2684
2685- [ ] **Step 3: Add the token-shown-once cookie**
2686
2687In `internal/httpd/flash.go`, alongside `flashCookie`/`setFlash`/`takeFlash`:
2688
2689```go
2690// tokenFlashCookie carries a freshly minted API token to the settings
2691// page exactly once. A cookie, not the ?m= query parameter the other
2692// account forms use for their success text, because a token is a
2693// secret and must never ride a URL a browser might history, bookmark,
2694// or hand to a proxy's access log (#264).
2695const tokenFlashCookie = "gitbay_token"
2696
2697// setTokenFlash queues a freshly minted token's display text for the
2698// next render of the settings page.
2699func (s *Server) setTokenFlash(w http.ResponseWriter, msg string) {
2700 if msg == "" {
2701 return
2702 }
2703 http.SetCookie(w, &http.Cookie{
2704 Name: tokenFlashCookie, Value: url.QueryEscape(msg), Path: "/settings",
2705 HttpOnly: true, SameSite: http.SameSiteLaxMode,
2706 Secure: s.cfg.HTTP.TLS != "off",
2707 MaxAge: 60,
2708 })
2709}
2710
2711// takeTokenFlash returns the queued token text, if any, and clears it.
2712func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2713 c, err := r.Cookie(tokenFlashCookie)
2714 if err != nil || c.Value == "" {
2715 return ""
2716 }
2717 http.SetCookie(w, s.clearCookie(tokenFlashCookie, http.SameSiteLaxMode))
2718 msg, err := url.QueryUnescape(c.Value)
2719 if err != nil {
2720 return ""
2721 }
2722 return msg
2723}
2724```
2725
2726`clearCookie` takes only `name` and `sameSite` and hard-codes `Path:
2727"/"` (`internal/httpd/flash.go:101-108`) — confirm this still clears a
2728cookie set with `Path: "/settings"` (it does: browsers key deletion on
2729name+domain+path, and `"/settings"` is under `"/"`... actually a
2730`Path=/` clearing cookie does **not** delete a `Path=/settings` cookie —
2731paths must match exactly for deletion semantics in most browsers).
2732Fix this by setting `Path: "/settings"` on both the set and the clear:
2733add a `path` parameter to a small local variant, or simplest, write
2734`takeTokenFlash`'s clear inline instead of reusing `clearCookie`:
2735
2736```go
2737func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2738 c, err := r.Cookie(tokenFlashCookie)
2739 if err != nil || c.Value == "" {
2740 return ""
2741 }
2742 http.SetCookie(w, &http.Cookie{
2743 Name: tokenFlashCookie, Value: "", Path: "/settings",
2744 HttpOnly: true, SameSite: http.SameSiteLaxMode,
2745 Secure: s.cfg.HTTP.TLS != "off", MaxAge: -1,
2746 })
2747 msg, err := url.QueryUnescape(c.Value)
2748 if err != nil {
2749 return ""
2750 }
2751 return msg
2752}
2753```
2754
2755(Use this version; drop the `clearCookie` call from the draft above.)
2756
2757- [ ] **Step 4: Add token data to `accountPage`**
2758
2759In `internal/httpd/account.go`, add a view type near `accountDevice`:
2760
2761```go
2762// accountToken is one API token as the settings page shows it: never
2763// the token itself, only what identifies and describes it.
2764type accountToken struct {
2765 Name string
2766 Scope string
2767 Created string
2768 Expires string // "never" or a formatted timestamp
2769 LastUsed string // "never" or a formatted timestamp
2770}
2771```
2772
2773In `accountPage`, alongside the existing `devices` collection:
2774
2775```go
2776 var tokens []accountToken
2777 if list, err := s.st.ListAPITokens(u.ID); err == nil {
2778 for _, tk := range list {
2779 expires, lastUsed := "never", "never"
2780 if tk.ExpiresAt != nil {
2781 expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
2782 }
2783 if tk.LastUsedAt != nil {
2784 lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
2785 }
2786 tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
2787 }
2788 }
2789```
2790
2791Add `Tokens []accountToken` and `TokenShown string` to the struct passed
2792to `s.render(w, "account.html", ...)`, with `tokens` and
2793`s.takeTokenFlash(w, r)` in the corresponding literal positions.
2794
2795- [ ] **Step 5: Add `token-create` and `token-revoke` to `accountSubmit`**
2796
2797In `internal/httpd/account.go`, `accountSubmit`'s `switch`, add two
2798cases (alongside `email-primary` and before `theme`, or anywhere in the
2799switch — order does not matter):
2800
2801```go
2802 case "token-create":
2803 name := strings.TrimSpace(r.FormValue("name"))
2804 if name == "" {
2805 back("name the token", "")
2806 return
2807 }
2808 scope := r.FormValue("scope")
2809 if scope != "full" {
2810 scope = "read" // this page's own default, regardless of what token create defaults to (#257, #264)
2811 }
2812 argv := []string{"token", "create", "--name", name, "--scope", scope}
2813 if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
2814 argv = append(argv, "--ttl", ttl)
2815 }
2816 out, msg, ok := s.runControl(u, argv)
2817 if !ok {
2818 back(msg, "")
2819 return
2820 }
2821 s.setTokenFlash(w, out)
2822 http.Redirect(w, r, "/settings#tokens", http.StatusSeeOther)
2823 return
2824 case "token-revoke":
2825 name := r.FormValue("name")
2826 if ok, msg := confirmed(r, name); !ok {
2827 back(msg, "")
2828 return
2829 }
2830 if _, msg, ok := s.runControl(u, []string{"token", "revoke", name}); !ok {
2831 back(msg, "")
2832 return
2833 }
2834 back("", "token revoked")
2835```
2836
2837(This `switch` does not use `back`'s redirect-with-query pattern for
2838`token-create`'s success path, since the token's display text cannot go
2839through `?m=`; it returns directly after the redirect, matching the
2840early-return shape every other case already uses.)
2841
2842- [ ] **Step 6: Add the Tokens section to `account.html`**
2843
2844Add a new `<section id="tokens">` — placed before the existing `<section
2845id="cli">` (which the sidebar's existing anchor list under "On the
2846command line" leaves in place; add a `<li><a href="#tokens">API
2847tokens</a></li>` to that anchor list too, alongside the other section
2848links):
2849
2850```html
2851<section id="tokens"><h2>API tokens</h2>
2852<p class="meta">A token signs in the iOS app, or a script, without your
2853password. A phone app needs full scope to comment and merge; full scope
2854on an admin account can administer the instance, so give a token the
2855narrowest scope and shortest lifetime the job needs.</p>
2856{{if .TokenShown}}<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}}
2857<table>
2858<tr><th>Name</th><th>Scope</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
2859{{range .Tokens}}<tr>
2860 <td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created}}</td><td>{{.Expires}}</td><td>{{.LastUsed}}</td>
2861 <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td>
2862</tr>{{else}}<tr><td colspan="6">no tokens</td></tr>{{end}}
2863</table>
2864<form method="post" action="/settings" class="actions">
2865<input type="hidden" name="field" value="token-create">
2866<input type="text" name="name" aria-label="Token name" placeholder="name, e.g. iphone" required>
2867<select name="scope" aria-label="Scope">
2868 <option value="read" selected>read</option>
2869 <option value="full">full</option>
2870</select>
2871<input type="text" name="ttl" aria-label="Expires after" placeholder="expires after, e.g. 30d (optional)">
2872<button type="submit" class="btn">Create token</button>
2873</form>
2874</section>
2875```
2876
2877- [ ] **Step 7: Run**
2878
2879Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1 && go test ./internal/httpd -count=1`
2880Expected: PASS.
2881
2882- [ ] **Step 8: Commit**
2883
2884```bash
2885git add internal/httpd/flash.go internal/httpd/account.go internal/web/templates/account.html internal/httpd/account_test.go
2886git commit -m "web: Settings → Tokens — create, list, revoke API tokens" -m "Ref #264"
2887```
2888
2889### Task 7.2: `registered.html` next steps as a numbered list
2890
2891**Files:**
2892- Modify: `internal/web/templates/registered.html`
2893- Test: a render test in whatever file covers signup
2894 (`grep -rln '"registered.html"' internal/httpd/*_test.go`; create one
2895 if none exists)
2896
2897- [ ] **Step 1: Write the failing test**
2898
2899```go
2900func TestRegisteredPageNumberedStepsAndTokenMention(t *testing.T) {
2901 var sb strings.Builder
2902 if err := web.Render(&sb, "registered.html", struct {
2903 basePage
2904 Username, Message, Host string
2905 }{Username: "alice", Host: "gitbay.org"}); err != nil {
2906 t.Fatalf("render: %v", err)
2907 }
2908 out := sb.String()
2909 if !strings.Contains(out, "<ol>") {
2910 t.Error("next steps are not a numbered list")
2911 }
2912 if !strings.Contains(out, "Settings → Tokens") {
2913 t.Error("no mention of Settings → Tokens for the iOS app")
2914 }
2915}
2916```
2917
2918- [ ] **Step 2: Run and see it fail**
2919
2920Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1`
2921Expected: FAIL.
2922
2923- [ ] **Step 3: Rewrite the section**
2924
2925Find (`registered.html:7-10`):
2926
2927```html
2928<h2>On the web</h2>
2929<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
2930link, and paste the code under <a href="/settings">Settings</a>. Then +
2931creates your first repository.</p>
2932```
2933
2934Replace:
2935
2936```html
2937<h2>On the web</h2>
2938<ol>
2939<li>Copy the verification code from the mail you were just sent.</li>
2940<li><a href="/login">Sign in</a> with an emailed link.</li>
2941<li>Paste the code in <a href="/settings#emails">Settings → Email</a>.</li>
2942</ol>
2943<p>Then + creates your first repository. Using the iOS app? Create a
2944token in <a href="/settings#tokens">Settings → Tokens</a>.</p>
2945```
2946
2947(`#emails` matches the existing section id in `account.html`; confirm
2948with `grep -n 'id="email' internal/web/templates/account.html` — it may
2949be `id="emails"` plural or singular, match whichever is actually there.)
2950
2951- [ ] **Step 4: Run**
2952
2953Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1 && go test ./internal/httpd -count=1`
2954Expected: PASS.
2955
2956- [ ] **Step 5: Commit**
2957
2958```bash
2959git add internal/web/templates/registered.html internal/httpd/*_test.go
2960git commit -m "web: registered page's next steps as a numbered list, with a token mention for the iOS app" -m "Ref #264"
2961```
2962
2963### Task 7.3: update Parity
2964
2965**Files:**
2966- Modify: `.gitbay/wiki/Parity.org`
2967
2968- [ ] **Step 1: Fix the API token mint row**
2969
2970Find:
2971
2972```
2973| API token mint | yes | no | no |
2974```
2975
2976Replace:
2977
2978```
2979| API token mint | yes | yes | no |
2980```
2981
2982(The iOS side — linking to this page from sign-in — is filed separately
2983in `krz/gitbay-ios`, per the issue text, so its column stays `no` here.)
2984
2985- [ ] **Step 2: Commit**
2986
2987```bash
2988git add .gitbay/wiki/Parity.org
2989git commit -m "wiki: Parity reflects the web API-token page" -m "Closes #264"
2990```
2991
2992### Task 7.4: open MR 7
2993
2994```bash
2995git push -u origin web-api-tokens
2996gitbay mr create --source web-api-tokens --target main --title "Web: Settings → Tokens page"
2997```
2998
2999Wait for CI, merge, delete the branch both places.
3000
3001---
3002
3003## Self-review
3004
3005**Spec coverage** (against the seven issue texts):
3006- #261: FK check ordering (Task 1.1), pin/watch dispatch (Task 1.2),
3007 Cache-Control (Task 1.3), all four doc-drift bullets (Task 1.4). ✓.
3008- #263: whoami line, token line both forms, auth summary, registry test
3009 (Tasks 2.1-2.3). ✓.
3010- #264: create/list/revoke with confirmfield, scope defaults to read on
3011 this page regardless of the command default, registered.html numbered
3012 steps + token mention, Parity (Tasks 7.1-7.3). ✓.
3013- #269: range-diff page, compare-to-previous per row, Parity (Tasks
3014 3.1-3.3). ✓.
3015- #270: the empty-state table (Task 4.1), MR list contribution hint
3016 (Task 4.2), search caption + tab zero-count rule (Task 4.3). ✓.
3017- #271: issue form fields, Muted reachable, rail/More unification,
3018 Discussion heading, Live note (Tasks 5.1-5.5). ✓.
3019- #283: `_raw` fix and test (Task 6.1). ✓.
3020
3021**Placeholder scan:** no task stops short of real code. The three points
3022this plan's first draft could not pin down from the code — `group()`'s
3023help-rendering mechanism (Task 2.2), `issue create`'s flag set (Task
30245.1), and how an `internal/httpd` test authenticates a GET as a given
3025viewer (Task 4.2) — were each resolved by reading the relevant source
3026(`cmd/gitbay/main.go`'s `group()`/`serverHelp()`, `internal/control/help.go`'s
3027`runHelp()`, `internal/control/issue.go`'s `issue create`/`issue
3028milestone`/`issue assign` registrations, and `internal/httpd/logincookie_test.go`'s
3029`sessionCookieFor` plus `store.CreateWebSession`) before this plan was
3030finished; the tasks above carry the resolved code directly, not a
3031placeholder.
3032
3033**Type consistency:** `railItem` (Task 5.3) is used identically in
3034`web.go` and `layout.html`; `accountToken` (Task 7.1) fields match the
3035template's `.Name`/`.Scope`/`.Created`/`.Expires`/`.LastUsed` access;
3036`mrRangeDiff`'s render struct (Task 3.1) matches `mrrangediff.html`'s
3037`.MR`/`.Diff`/`.Error`.
3038
3039## Open questions
3040
3041None outstanding — the three research gaps found while first drafting
3042this plan (Task 2.2's help mechanism, Task 4.2's session-cookie test
3043fixture, Task 5.1's `issue create` flag set) were each resolved by
3044reading the relevant source before this plan was finished; see
3045"Placeholder scan" above for what was read.