Commit b711b70da5

b711b70da54e208c33f53f8c7375e3fc11e1f107

parent: 4e0958a163

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

cmc <hello@cleberg.net> · 2026-09-28 06:12 UTC

plans: apply decisions on the open questions

Required contexts turn require-checks on; builds lose host loopback and an nftables rule limits them to 22/80/443 on the host; GITBAY_SSH carries a non-default port. Offsite backup moves to Cloudflare R2 with a separate keys repository; the restore drill is deferred. CLI help and usage print the path as typed. Confirmed decisions recorded in the credentials and hardening plans.

Ref #257 #258 #259 #260 #262 #267 #273 #274

Layout: unified · split

docs/plans/2026-09-27-ci-trust-and-build-reporting.md +820 −200
@@ -5,22 +5,27 @@
55**Goal:** Untrusted builds get a disposable home and never feed a
66trusted build (#255, #258); `ci/*` statuses belong to the build
77subsystem 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).
8longer share the runner's source address, and reaches no port on the
9runner's host but the forge's public 22, 80 and 443 (#260); a failed
10build names its step, exit and duration on the CLI and the web (#266).
1011
1112**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.
13the instance's public ssh destination (with the port when it is not
1422). The runner picks the build home by trust (persistent per
15repository for trusted builds, fresh and removed for untrusted ones),
16keeps a loopback runner's builds off the host's loopback, and reports
17the failed step on `runner done`. An nftables table on the runner host,
18loaded by a oneshot unit the runner service requires, limits what the
19runner's uid may reach on the host itself. The server refuses `ci/`
20contexts in `status set`, restricts tree and commit reuse to trusted
21builds on the same declared image, adds a `required_contexts`
22repository setting that turns `require_checks` on and that `MergeGates`
23treats as pending until reported, stores the failed step and reason on
24the build, and cuts the log at its `$ <step>` lines for `build log
25--step` and the build page.
2126
2227**Tech Stack:** Go, SQLite (hand-written SQL), `html/template`, rootless
23podman with pasta, systemd.
28podman with pasta, nftables, systemd.
2429
2530**Spec:** the issues themselves: krz/gitbay#255, #258, #260, #266 (texts
2631in the session's `issues.txt`), plus the decisions recorded under
@@ -102,11 +107,30 @@ in the session's `issues.txt`), plus the decisions recorded under
102107 untrusted queue: a fork head that lands on a branch by fast-forward is
103108 built again as trusted. `required_contexts` is a list in the
104109 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 `repo settings require-contexts <owner/name> [<context>...]`. Setting
111 a non-empty list also turns `require_checks` on, in the same settings
112 update; setting it empty clears the list and leaves `require_checks`
113 as it was. `require-checks off` keeps the list, which then does
114 nothing until the gate is on again. `repo settings show` prints
115 `require checks` and `required contexts` side by side (today it prints
116 neither), and on the web settings page the required-checks box is
117 ticked after contexts are saved and its hint lists them. The gate
118 itself only ever reads `require_checks`. A missing required context
119 makes the combined check `pending` and appears as `<context>=missing`
120 in the unmet sentence and in `checks_missing`.
121- **#258, default image.** Tree reuse keys on the job's declared
122 `image:`. A job naming none is stored with `image = ''` and matches
123 other such builds whatever the runner defaulted to; bumping a runner's
124 `-image` does not invalidate them. This is documented on the CI page
125 rather than fixed. Reuse is decided at queue time in `queueJobs`,
126 before any runner is chosen, and the default image belongs to
127 whichever runner claims the build: bay1 and the laptop runner already
128 have different defaults. Reporting the resolved image on claim or
129 `runner done` would record it after the fact, but a queue-time
130 comparison would still have nothing to compare against, so jobs
131 without an image would never be reused at all. The remedy is on the
132 repository's side (name the image in `ci.yml`) or the operator's
133 (`build trigger`, which never reuses, after bumping `-image`).
110134- **#260.** Reading the code: the bay1 runner polls `git@127.0.0.1`
111135 (`deploy/gitbay-runner.override.conf:78`); `buildSSH`
112136 (`cmd/gitbay-runner/main.go:471-486`) sends podman builds to
@@ -119,25 +143,57 @@ in the session's `issues.txt`), plus the decisions recorded under
119143 runner stays on loopback and its builds lose loopback: under podman,
120144 when the runner's remote is loopback, containers run with
121145 `--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.
146 public destination, which the server sends in the claim as `ssh`. A
147 build then reaches the host only as an internet client does.
148- **#260, `GITBAY_SSH` form.** `git@<site host>` when `[ssh] port` is 22
149 (or unset, which config validation treats as 22), and
150 `git@<site host>:<port>` otherwise, built with `net.JoinHostPort` so
151 an IPv6 literal is bracketed. hutch and orgo build
152 `ssh://$GITBAY_SSH/<owner>/<name>.git`, which is a valid URL in both
153 forms, so nothing changes for them on 22 and they work unchanged on
154 another port. A script that runs a command uses `ssh
155 ssh://$GITBAY_SSH …`, which OpenSSH accepts with or without the port;
156 the Users page says so. The port is the daemon's `[ssh] port`, the
157 one it listens on; an instance behind a port-mapping NAT is not
158 modelled.
159- **#260, host egress.** Under rootless podman with pasta, a build's
160 connections are made by pasta on the host from sockets owned by the
161 runner's uid (`ci-runner`), the same uid the runner's own ssh runs as.
162 An nftables table (`deploy/gitbay-runner-egress.nft`, loaded by
163 `gitbay-runner-egress.service`, which `gitbay-runner.service`
164 requires) matches output packets with `meta skuid "ci-runner"` that
165 leave through `lo` — every packet to one of the host's own addresses,
166 loopback or public, does — and allows only 127.0.0.1:22 (the runner's
167 poll, clone and log stream), port 53 on loopback (the host resolver
168 pasta forwards a build's DNS to), and 22, 80 and 443 on the public
169 addresses. Everything else on the host is rejected: the admin sshd on
170 2222 on every address, and every service bound to loopback. Traffic
171 to other hosts is not matched, so outbound internet stays open (a
172 fork's merge request to a Go repository must fetch its modules). The
173 rule applies to all builds, trusted and untrusted, and to
174 `-isolation none` builds too, since they run as the same uid. uid
175 alone cannot tell a build from its runner, so 127.0.0.1:22 stays open
176 to the uid; `--no-map-gw` is what keeps builds off loopback. The two
177 are separate layers and both ship. The runner does not start without
178 the rule (`Requires=`), following `runner-podman-setup.sh`'s rule that
179 a host that is not ready fails rather than runs builds unconfined;
180 `make deploy-runner` loads the rule and checks, as `ci-runner`, that
181 127.0.0.1:22 answers and 2222 does not, before restarting the runner.
182 The laptop runner (macOS, brew) is not covered.
183- **Finding for #260.** On gitbay.org the limiter's failure count is
184 unreachable by an unknown key: `authenticate` admits an unknown key as
185 an anonymous `register` session whenever `registration.mode` is not
186 `closed` (`internal/sshd/sshd.go:139-144`), and `fail` is only called
187 on the closed path (`sshd.go:145`). With registration closed,
188 `authenticate` checks `allow` before it looks at the key
189 (`sshd.go:123-129`), so once an address has `ssh_auth_rate` failures
190 in the window every key from it is refused, the runner's included, and
191 `success` (`sshd.go:149`) is never reached to clear the count; below
192 the limit a success clears it. A unit test in `internal/sshd` records
193 both modes (Task 3.3). No throttling test runs on production; the
194 runbook measures, from inside a scratch build, the source address the
195 forge sees and that 2222 and 127.0.0.1 are unreachable, and #260
196 closes when that is recorded on the CI wiki page.
141197- **#266.** Duration is not stored: `Build.Elapsed()`
142198 (`internal/store/builds.go:420`) already derives it from `started_at`
143199 and `finished_at`. Migration 0065 adds `failed_step` (1-based, 0 for
@@ -161,7 +217,7 @@ in the session's `issues.txt`), plus the decisions recorded under
161217|---|---|---|---|---|
162218| 1 | `ci-untrusted-home` | #255 | — | — |
163219| 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`) |
220| 3 | `runner-source-address` | Ref #260 (closed by the runbook result commit on the CI page) | — | Part 1 merged (both edit `runRunnerNext`'s payload and `stepEnv`) |
165221| 4 | `build-failure-report` | #266 | 0065 | Part 1 merged (both change `run()`); Part 3 merged (both change `runStepsPodman`) |
166222
167223#255 goes first. Parts 1–4 land and deploy in order; each runner deploy
@@ -179,10 +235,15 @@ Other plans (all `docs/plans/2026-09-27-*.md`):
179235- Plan 5 (web-ux, #261) covers documentation drift. Two items seen here
180236 and left alone: `deploy/gitbay-runner.override.conf:27-30` names
181237 `cmc/ci-smoke`, which no longer exists; `cmd/gitbay-runner/main.go:513`
182 repeats its comment line.
238 repeats its comment line. Part 3 adds a `[Unit]` section at the top of
239 the same drop-in; if plan 5 edits its comment, whoever lands second
240 rebases.
183241- Plan 1 (credentials-and-sessions, #256) closes a removed key's
184242 connections; the runner's `runner log` session is one such
185 connection, and no code here depends on it.
243 connection, and no code here depends on it. Plan 1's key expiry
244 (#277) may add a check to `authenticate`; Part 3's sshd test uses an
245 unexpiring key and asserts only the limiter's behaviour, so it holds
246 either way.
186247
187248## File map
188249
@@ -192,7 +253,7 @@ Other plans (all `docs/plans/2026-09-27-*.md`):
192253| `internal/control/buildlog.go` (create) | 4 | `LogSection`, `SplitBuildLog`, `FailedSection`, `tailLines` |
193254| `internal/control/status.go` | 2 | `ci/` refusal |
194255| `internal/control/mr.go`, `output.go` | 2 | `require-contexts`, `MergeGates`, `GatesOut.ChecksMissing` |
195| `internal/control/repo.go` | 2 | `repo settings show` prints required contexts |
256| `internal/control/repo.go` | 2 | `repo settings show` prints require checks and required contexts |
196257| `internal/store/builds.go` | 2, 4 | trust and image on reuse; failed step columns |
197258| `internal/store/repos.go` | 2 | `RepoSettings.RequiredContexts` |
198259| `internal/store/migrations/0065_build_failure.{up,down}.sql` | 4 | columns |
@@ -204,6 +265,9 @@ Other plans (all `docs/plans/2026-09-27-*.md`):
204265| `internal/web/templates/build.html`, `mr.html`, `settings.html` | 2, 4 | steps, gates row, form |
205266| `internal/web/static/style.css` | 4 | `pre.buildlog` wraps; step folds |
206267| `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 |
268| `internal/sshd/sshd_test.go` | 3 | limiter behaviour by registration mode |
269| `deploy/gitbay-runner-egress.nft` (create), `deploy/gitbay-runner-egress.service` (create), `deploy/runner-egress-check.sh` (create) | 3 | host egress rule, its unit, the post-load check |
270| `deploy/gitbay-runner.override.conf`, `deploy/runner-podman-setup.sh`, `Makefile` | 3 | runner requires the rule; nftables installed; `deploy-runner` ships, loads and checks it |
207271| `.gitbay/wiki/…` | all | as listed per task |
208272
209273---
@@ -930,8 +994,9 @@ Replace `internal/store/builds.go:453-477` with:
930994// than a commit: a rebase that changes nothing in the tree has already
931995// been built (#177). Only a trusted build on the image the job names
932996// 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.
997// not stand for the repository's own (#258). A job naming no image
998// matches builds that named none, whichever default the runner used;
999// the CI wiki page says so. An empty tree never matches.
9351000func (s *Store) SuccessBuildForTree(repoID int64, tree, job, image string) (Build, bool, error) {
9361001 if tree == "" {
9371002 return Build{}, false, nil
@@ -1000,14 +1065,20 @@ Ref #258"
10001065- Modify: `internal/control/checksgate_test.go` (helper refactor, two tests)
10011066- Test: `internal/control/mr_test.go` (append)
10021067- 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`
1068- Modify: `internal/httpd/settings.go:106-107`, `:228-229`; `internal/web/templates/settings.html:73-78` (the require-checks form, its hint at line 75); `internal/web/templates/mr.html:124`
1069- Modify: `internal/httpd/mrpage_test.go` (`TestMRGatesRender`), `e2e/settingsweb_test.go:58`
10051070
10061071**Interfaces:**
10071072- Produces:
10081073 - `RepoSettings.RequiredContexts []string` (`json:"required_contexts,omitempty"`)
10091074 - `GatesOut.ChecksMissing []string` (`json:"checks_missing,omitempty"`)
1010 - command `repo settings require-contexts <owner/name> [<context>...]`
1075 - command `repo settings require-contexts <owner/name> [<context>...]`:
1076 a non-empty list is stored and sets `RequireChecks = true` in the
1077 same update; an empty list clears the contexts and leaves
1078 `RequireChecks` alone.
1079 - `repo settings show` human output gains `require checks` and
1080 `required contexts` rows (JSON already carries `require_checks` and
1081 gains `required_contexts`).
10111082
10121083- [ ] **Step 1: Write the failing tests**
10131084
@@ -1122,42 +1193,88 @@ func TestRequiredContextsReportedPass(t *testing.T) {
11221193}
11231194```
11241195
1125Append to `internal/control/mr_test.go` (add imports `slices`,
1126`protocol`, `store` if absent):
1196Append to `internal/control/mr_test.go` (add `"slices"` to its imports;
1197`mrTestCtx` is that file's helper):
11271198
11281199```go
1129// require-contexts stores a deduplicated list, refuses a context with
1130// whitespace, and clears with no contexts (#258).
1200// require-contexts stores a deduplicated list and turns require_checks
1201// on with it; an empty list clears the contexts and leaves
1202// require_checks as it was. A context with whitespace is refused (#258).
11311203func TestRequireContextsSetsAndClears(t *testing.T) {
11321204 st, repo, uid := newQueueTestRepo(t)
11331205 alice := store.User{ID: uid, Username: "alice"}
1134 run := func(args ...string) int {
1206 dispatch := func(args ...string) int {
11351207 t.Helper()
1136 c, _ := pruneCtx(st, t.TempDir(), alice)
1137 return Dispatch(c, append([]string{"repo", "settings", "require-contexts", repo.Path()}, args...))
1208 c, _, _ := mrTestCtx(st, alice)
1209 return Dispatch(c, args)
11381210 }
1139 if code := run("lint", "ext/deploy", "lint"); code != protocol.ExitOK {
1211 contexts := func(names ...string) int {
1212 t.Helper()
1213 return dispatch(append([]string{"repo", "settings", "require-contexts", repo.Path()}, names...)...)
1214 }
1215 settings := func() store.RepoSettings {
1216 t.Helper()
1217 got, err := st.RepoByID(repo.ID)
1218 if err != nil {
1219 t.Fatal(err)
1220 }
1221 return got.Settings
1222 }
1223
1224 if settings().RequireChecks {
1225 t.Fatal("require_checks on in a new repository")
1226 }
1227 if code := contexts("lint", "ext/deploy", "lint"); code != protocol.ExitOK {
11401228 t.Fatalf("set: exit %d", code)
11411229 }
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)
1230 if s := settings(); !slices.Equal(s.RequiredContexts, []string{"lint", "ext/deploy"}) || !s.RequireChecks {
1231 t.Fatalf("stored %v, require_checks %v; want [lint ext/deploy], on", s.RequiredContexts, s.RequireChecks)
11451232 }
1146 if code := run("bad context"); code != protocol.ExitUsage {
1233 if code := contexts("bad context"); code != protocol.ExitUsage {
11471234 t.Fatalf("a context with a space: exit %d", code)
11481235 }
1149 if code := run(); code != protocol.ExitOK {
1236 if code := contexts(); code != protocol.ExitOK {
11501237 t.Fatalf("clear: exit %d", code)
11511238 }
1152 if got, _ := st.RepoByID(repo.ID); len(got.Settings.RequiredContexts) != 0 {
1153 t.Fatalf("not cleared: %v", got.Settings.RequiredContexts)
1239 if s := settings(); len(s.RequiredContexts) != 0 || !s.RequireChecks {
1240 t.Fatalf("after clearing: contexts %v, require_checks %v; want none, still on", s.RequiredContexts, s.RequireChecks)
1241 }
1242 if code := dispatch("repo", "settings", "require-checks", repo.Path(), "off"); code != protocol.ExitOK {
1243 t.Fatalf("require-checks off: exit %d", code)
1244 }
1245 if code := contexts(); code != protocol.ExitOK {
1246 t.Fatalf("clear again: exit %d", code)
1247 }
1248 if settings().RequireChecks {
1249 t.Fatal("clearing the list turned require_checks on")
1250 }
1251}
1252
1253// settings show prints the checks gate beside the contexts it waits for,
1254// so a list that turned the gate on is visible where the gate is (#258).
1255func TestSettingsShowRequiredContexts(t *testing.T) {
1256 st, repo, uid := newQueueTestRepo(t)
1257 alice := store.User{ID: uid, Username: "alice"}
1258 c, _, _ := mrTestCtx(st, alice)
1259 if code := Dispatch(c, []string{"repo", "settings", "require-contexts", repo.Path(), "ext/deploy", "lint"}); code != protocol.ExitOK {
1260 t.Fatalf("require-contexts: exit %d", code)
1261 }
1262 c, out, _ := mrTestCtx(st, alice)
1263 if code := Dispatch(c, []string{"repo", "settings", "show", repo.Path()}); code != protocol.ExitOK {
1264 t.Fatalf("settings show: exit %d", code)
1265 }
1266 got := strings.Join(strings.Fields(out.String()), " ")
1267 for _, want := range []string{"require checks true", "required contexts ext/deploy, lint"} {
1268 if !strings.Contains(got, want) {
1269 t.Errorf("settings show lacks %q:\n%s", want, out.String())
1270 }
11541271 }
11551272}
11561273```
11571274
11581275- [ ] **Step 2: Run them and see them fail**
11591276
1160Run: `go test ./internal/control -run 'TestRequire|TestRequiredContext' -count=1`
1277Run: `go test ./internal/control -run 'TestRequire|TestRequiredContext|TestSettingsShowRequiredContexts' -count=1`
11611278Expected: build failure (`s.RequiredContexts undefined`).
11621279
11631280- [ ] **Step 3: Store and output types**
@@ -1167,7 +1284,8 @@ Expected: build failure (`s.RequiredContexts undefined`).
11671284```go
11681285 RequireChecks bool `json:"require_checks,omitempty"`
11691286 // RequiredContexts are statuses require_checks waits for whether or
1170 // not they have reported; one that has not is pending (#258).
1287 // not they have reported; one that has not is pending. Setting a
1288 // non-empty list turns RequireChecks on (#258).
11711289 RequiredContexts []string `json:"required_contexts,omitempty"`
11721290```
11731291
@@ -1184,8 +1302,8 @@ Registration, after `require-checks` at `mr.go:49`:
11841302
11851303```go
11861304 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)",
1305 Summary: "name the statuses the checks gate waits for, and turn the gate on",
1306 Usage: "repo settings require-contexts <owner/name> [<context>...] (none clears the list)",
11891307 Examples: []string{"repo settings require-contexts krz/gitbay ci/build ci/test"},
11901308 Run: runRequireContexts})
11911309```
@@ -1217,19 +1335,27 @@ func runRequireContexts(c *Ctx, args []string) int {
12171335 if code >= 0 {
12181336 return code
12191337 }
1220 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.RequiredContexts = contexts })
1338 // Naming contexts asks for the gate, so it turns require_checks on in
1339 // the same update. Clearing the list leaves the gate as it was.
1340 s, err := c.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) {
1341 s.RequiredContexts = contexts
1342 if len(contexts) > 0 {
1343 s.RequireChecks = true
1344 }
1345 })
12211346 if err != nil {
12221347 return c.fail(protocol.ExitFailure, "%v", err)
12231348 }
12241349 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())
1350 if len(contexts) > 0 {
1351 fmt.Fprintf(w, "required contexts on %s: %s; require_checks on\n", repo.Path(), strings.Join(contexts, ", "))
12271352 return
12281353 }
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())
1354 gate := "off"
1355 if s.RequireChecks {
1356 gate = "on"
12321357 }
1358 fmt.Fprintf(w, "required contexts cleared on %s; require_checks %s\n", repo.Path(), gate)
12331359 })
12341360}
12351361```
@@ -1243,7 +1369,8 @@ the end of `if set.RequireChecks { … }`) with:
12431369 // Checks: with require_checks, every status the head carries must be
12441370 // green, a head something was going to report on must carry some, and
12451371 // every required context must have reported: one that has not is
1246 // pending whatever the others say (#258).
1372 // pending whatever the others say (#258). Setting contexts turns
1373 // require_checks on; turned off again, the list is kept and unread.
12471374 statuses, err := st.ListCommitStatuses(repo.ID, headSHA)
12481375 if err != nil {
12491376 return g, err
@@ -1283,12 +1410,20 @@ the end of `if set.RequireChecks { … }`) with:
12831410 }
12841411```
12851412
1286`repo.go:710-717`, add a field after `"protected tags"`:
1413`repo.go:710-717`, the `repo settings show` fields: today they print
1414neither the checks gate nor anything about it. Add two rows after
1415`"require mr"` (line 713), so the gate and the list that turned it on
1416read together:
12871417
12881418```go
1419 "require mr", strconv.FormatBool(repo.Settings.RequireMR),
1420 "require checks", strconv.FormatBool(repo.Settings.RequireChecks),
12891421 "required contexts", strings.Join(repo.Settings.RequiredContexts, ", "),
12901422```
12911423
1424(`fields` skips a row whose value is empty, so a repository with no
1425contexts shows no `required contexts` row.)
1426
12921427- [ ] **Step 6: Run the control tests**
12931428
12941429Run: `go vet ./... && go test ./internal/control ./internal/store -count=1`
@@ -1320,13 +1455,20 @@ and in `fieldLabel` after `require-checks`:
13201455 return "required contexts"
13211456```
13221457
1323`internal/web/templates/settings.html`, after the `require-checks`
1324form (line 78):
1458`internal/web/templates/settings.html`: the require-checks hint (line
145975) names the contexts the gate waits for, so a box ticked by saving
1460contexts says why:
1461
1462```html
1463 <div><label for="require-checks">Required checks</label><p class="hint">Requires CI to succeed.{{with .Repo.Settings.RequiredContexts}} Also waits for {{range $i, $c := .}}{{if $i}}, {{end}}<code>{{$c}}</code>{{end}} until they report{{if not $.Repo.Settings.RequireChecks}}, once this is on{{end}}.{{end}}</p></div>
1464```
1465
1466and after the `require-checks` form (line 78):
13251467
13261468```html
13271469<form method="post" action="{{$base}}" class="setform">
13281470 <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>
1471 <div><label for="contexts">Required contexts</label><p class="hint">Statuses the checks gate waits for until they report, separated by spaces. Saving any turns required checks on; saving none leaves it as it is.</p></div>
13301472 <div><input type="text" id="contexts" name="contexts" value="{{range $i, $c := .Repo.Settings.RequiredContexts}}{{if $i}} {{end}}{{$c}}{{end}}" autocomplete="off"></div>
13311473 <div><button type="submit" class="btn">Save</button></div>
13321474</form>
@@ -1348,15 +1490,23 @@ first `for` loop:
13481490 }
13491491```
13501492
1351In `e2e/settingsweb_test.go`, after
1352`post(url.Values{"field": {"require-checks"}, …})`:
1493In `e2e/settingsweb_test.go`, replace line 58,
1494`post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}})`,
1495with a contexts post, so the `"require_checks":true` the test already
1496expects from `settings show --json` (line 69) now comes from the
1497contexts turning the gate on:
13531498
13541499```go
1355 post(url.Values{"field": {"require-contexts"}, "contexts": {"ext/deploy lint"}})
1500 // Saving required contexts turns the checks gate on, and the page
1501 // shows it ticked with the contexts in its hint (#258).
1502 if body := post(url.Values{"field": {"require-contexts"}, "contexts": {"ext/deploy lint"}}); !strings.Contains(body, `id="require-checks" name="require-checks" value="on" checked`) ||
1503 !strings.Contains(body, `Also waits for <code>ext/deploy</code>, <code>lint</code> until they report.`) {
1504 t.Fatalf("required contexts did not show as turning required checks on:\n%s", body)
1505 }
13561506```
13571507
13581508and add `` `"required_contexts":["ext/deploy","lint"]` `` to the
1359`settings show --json` want list.
1509`settings show --json` want list at line 69.
13601510
13611511Run: `go test ./internal/httpd ./internal/web -count=1 && go test ./e2e -run TestRepoSettingsWeb -count=1`
13621512Expected: PASS.
@@ -1365,7 +1515,7 @@ Expected: PASS.
13651515
13661516```bash
13671517git 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
1518git commit -S -m "repo settings: required contexts turn the checks gate on, pending until reported
13691519
13701520Ref #258"
13711521```
@@ -1391,8 +1541,11 @@ passed. Report under another prefix, such as =ext/=.
13911541=repo settings require-contexts <repo> ext/deploy ci/test= names
13921542statuses the checks gate waits for whether or not they have reported:
13931543one 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.
1544=ext/deploy=missing=. Naming any context turns =require-checks= on;
1545with no contexts the command clears the list and leaves
1546=require-checks= as it was. =require-checks off= keeps the list, which
1547waits for nothing until the gate is on again. =repo settings show=
1548prints both.
13961549```
13971550
13981551- [ ] **Step 2: CI.org**
@@ -1401,13 +1554,20 @@ In the *Dedupe* bullet, after "…naming the build it came from (#177).",
14011554add: "Only a trusted build counts, and for tree reuse only one on the
14021555image the job names: a fork's green build does not stand for the
14031556repository's own, so its commit is built again when it lands on a
1404branch (#258)."
1557branch (#258). A job that names no =image:= is compared as naming
1558none: its reuse does not notice the runner's default image changing,
1559because reuse is decided when the push is queued, before any runner
1560claims the build, and runners can differ in their default. Name the
1561image in =ci.yml= to tie reuse to it; after an operator changes a
1562runner's =-image=, =build trigger= builds a job afresh, since a
1563triggered build is never reused."
14051564
14061565- [ ] **Step 3: Users.org**
14071566
14081567After "…a =ci/<job>= commit status, which =repo settings
14091568require-checks= can gate merges on." add: "=repo settings
1410require-contexts= names statuses the gate waits for until they report."
1569require-contexts= names statuses the gate waits for until they report,
1570and turns the gate on."
14111571
14121572- [ ] **Step 4: Parity.org**
14131573
@@ -1459,57 +1619,83 @@ with `--strategy ff` once CI is green; delete the branch both places.
14591619
14601620---
14611621
1462# Part 3: separate the runner's source address from its builds (branch `runner-source-address`, #260)
1622# Part 3: separate the runner's source address from its builds, and limit what builds reach on its host (branch `runner-source-address`, #260)
14631623
14641624### Task 3.1: the claim carries the instance's public ssh destination
14651625
14661626**Files:**
1467- Modify: `internal/control/build.go` (the payload from Task 1.1; a helper beside `runRunnerNext`)
1627- Modify: `internal/control/build.go` (imports; the payload from Task 1.1; a helper beside `runRunnerNext`)
14681628- Test: `internal/control/runnernext_test.go` (append)
14691629
14701630**Interfaces:**
1471- Produces: `runner next --json` payload `"ssh": "git@<site host>"`, omitted when `site_url` is empty.
1631- Produces: `runner next --json` payload `"ssh": "git@<site host>"`, or
1632 `"git@<site host>:<port>"` when `[ssh] port` is neither 0 nor 22;
1633 omitted when `site_url` is empty.
14721634
14731635- [ ] **Step 1: Write the failing test**
14741636
14751637```go
14761638// A build on the daemon's own host is given the public destination, not
1477// the loopback address its runner polls (#260).
1639// the loopback address its runner polls. The port rides along only when
1640// it is not 22, so ssh://$GITBAY_SSH/<owner>/<name>.git is a valid URL
1641// either way (#260).
14781642func TestRunnerNextCarriesPublicSSH(t *testing.T) {
14791643 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())
1644 for _, tc := range []struct {
1645 port int
1646 want string
1647 }{
1648 {0, `"ssh":"git@x.test"`},
1649 {22, `"ssh":"git@x.test"`},
1650 {2022, `"ssh":"git@x.test:2022"`},
1651 } {
1652 if _, err := st.CreateBuild(repo.ID, "unit", baseSHA, "main", "[]", "", "", true); err != nil {
1653 t.Fatal(err)
1654 }
1655 c, out := runnerCtx(st, uid, root) // site_url https://x.test
1656 c.Cfg.SSH.Port = tc.port
1657 c.JSON = true
1658 if code := runRunnerNext(c, nil); code != protocol.ExitOK {
1659 t.Fatalf("port %d: runner next: exit %d, output:\n%s", tc.port, code, out.String())
1660 }
1661 if !strings.Contains(out.String(), tc.want) {
1662 t.Fatalf("port %d: claim lacks %s:\n%s", tc.port, tc.want, out.String())
1663 }
14901664 }
14911665}
14921666```
14931667
1668Each pass queues one build and claims it, as in Task 1.1's test.
1669`runnerCtx` builds its `config.Config` by hand, so `SSH.Port` starts at
16700; config validation refuses 0 on a real instance (`config.go:367`),
1671and 0 is read as 22.
1672
14941673- [ ] **Step 2: Run it and see it fail**
14951674
14961675Run: `go test ./internal/control -run TestRunnerNextCarriesPublicSSH -count=1`
1497Expected: FAIL, `claim lacks the public destination`.
1676Expected: FAIL, `port 0: claim lacks "ssh":"git@x.test"`.
14981677
14991678- [ ] **Step 3: Implement**
15001679
1501Add after `maxOrphanSkip`:
1680Add `"net"` to `build.go`'s imports (`strconv` is already there), and
1681after `maxOrphanSkip`:
15021682
15031683```go
15041684// publicSSH is the instance's ssh destination as anyone outside reaches
15051685// it. A runner on the daemon's own host polls over loopback and hands
15061686// its builds this instead, so no build connects from the runner's source
1507// address (#260). Empty when site_url is not set.
1687// address (#260). The port is added only when it is not 22: hutch and
1688// orgo build ssh://$GITBAY_SSH/... URLs, valid in both forms. Empty when
1689// site_url is not set.
15081690func publicSSH(c *Ctx) string {
1509 if host := c.Cfg.SiteHost(); host != "" {
1510 return "git@" + host
1691 host := c.Cfg.SiteHost()
1692 if host == "" {
1693 return ""
1694 }
1695 if p := c.Cfg.SSH.Port; p != 0 && p != 22 {
1696 return "git@" + net.JoinHostPort(host, strconv.Itoa(p))
15111697 }
1512 return ""
1698 return "git@" + host
15131699}
15141700```
15151701
@@ -1560,7 +1746,8 @@ Replace `TestStepEnvCarriesInstanceAddress` (`env_test.go:190-212`) with:
15601746// A build that talks back to the instance needs an address that works
15611747// from where it runs. A runner polling over loopback keeps its podman
15621748// 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).
1749// destination from the claim, port included when it is not 22; any other
1750// remote is used as it is (#260).
15641751func TestStepEnvCarriesInstanceAddress(t *testing.T) {
15651752 env := stepEnv(job{}, "/tmp/buildhome", "git@gitbay.org")
15661753 if !containsEnv(env, "GITBAY_SSH=git@gitbay.org") {
@@ -1569,6 +1756,7 @@ func TestStepEnvCarriesInstanceAddress(t *testing.T) {
15691756 for _, tc := range []struct{ remote, isolation, public, want string }{
15701757 {"git@127.0.0.1", isolationNone, "git@gitbay.org", "git@127.0.0.1"},
15711758 {"git@127.0.0.1", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
1759 {"git@127.0.0.1", isolationPodman, "git@gitbay.test:2022", "git@gitbay.test:2022"},
15721760 {"git@localhost", isolationPodman, "git@gitbay.org", "git@gitbay.org"},
15731761 {"git@127.0.0.1", isolationPodman, "", "git@127.0.0.1"},
15741762 {"git@gitbay.org", isolationPodman, "git@other.test", "git@gitbay.org"},
@@ -1648,7 +1836,10 @@ func (r *runner) buildSSH(public string) string {
16481836// source address, so a build sharing the runner's could throttle its
16491837// polling (#260). --no-map-gw removes the mapping: the build reaches the
16501838// host only at its public address, as any client on the internet does,
1651// and keeps its outbound access.
1839// and keeps its outbound access. The host's nftables table
1840// (deploy/gitbay-runner-egress.nft) then limits it to 22, 80 and 443
1841// there; it cannot tell a build from the runner by uid, so it leaves
1842// 127.0.0.1:22 open, and this flag is what keeps builds off it.
16521843func (r *runner) buildNetwork() []string {
16531844 if !r.loopbackRemote() {
16541845 return nil
@@ -1679,13 +1870,413 @@ git commit -S -m "runner: builds off the host's loopback when the runner polls o
16791870Ref #260"
16801871```
16811872
1682### Task 3.3: egress policy in the wiki, and the MR
1873### Task 3.3: the limiter's behaviour, by registration mode
1874
1875This test records what the code does today (see "Finding for #260"
1876under Decisions), so it passes on its first run. It is the evidence the
1877issue's on-production throttling test was meant to give, without
1878touching production. If it fails, the limiter differs from what
1879Decisions and the wiki say: stop and correct that text, not the test.
16831880
16841881**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`
1882- Test: `internal/sshd/sshd_test.go` (append; every import it needs is already in the file)
1883
1884**Interfaces:**
1885- Consumes: `(*Server).authenticate`, `rateLimiter.seen` (package-internal).
1886
1887- [ ] **Step 1: Write the test**
1888
1889Append to `internal/sshd/sshd_test.go`:
1890
1891```go
1892// authMeta is the connection metadata authenticate reads: only the
1893// remote address.
1894type authMeta struct {
1895 ssh.ConnMetadata
1896 addr net.Addr
1897}
1898
1899func (m authMeta) RemoteAddr() net.Addr { return m.addr }
1900
1901func authKey(t *testing.T) ssh.PublicKey {
1902 t.Helper()
1903 pub, _, err := ed25519.GenerateKey(rand.Reader)
1904 if err != nil {
1905 t.Fatal(err)
1906 }
1907 k, err := ssh.NewPublicKey(pub)
1908 if err != nil {
1909 t.Fatal(err)
1910 }
1911 return k
1912}
1913
1914// authServer is a Server holding what authenticate uses: a store with a
1915// runner account's key, the registration mode, and a limiter of three
1916// failures a minute.
1917func authServer(t *testing.T, mode string) (*Server, ssh.PublicKey) {
1918 t.Helper()
1919 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
1920 if err != nil {
1921 t.Fatal(err)
1922 }
1923 t.Cleanup(func() { st.Close() })
1924 if err := st.MigrateUp(); err != nil {
1925 t.Fatal(err)
1926 }
1927 uid, err := st.CreateUser("ci", false)
1928 if err != nil {
1929 t.Fatal(err)
1930 }
1931 runner := authKey(t)
1932 if err := st.AddSSHKey(uid, ssh.FingerprintSHA256(runner), runner.Type(), runner.Marshal(), "runner", ""); err != nil {
1933 t.Fatal(err)
1934 }
1935 cfg := config.Default()
1936 cfg.Registration.Mode = mode
1937 return &Server{cfg: cfg, st: st, authLimiter: newRateLimiter(3, time.Minute)}, runner
1938}
1939
1940var (
1941 fromLoopback = authMeta{addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 40000}}
1942 fromPublic = authMeta{addr: &net.TCPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 40000}}
1943)
1944
1945// With registration closed an unknown key counts against its address.
1946// Below the limit a known key's success clears the count. At the limit
1947// authenticate refuses before it looks at the key, so the runner's own
1948// key from that address is refused too and its success never runs to
1949// clear anything, until the window passes. Another address is not
1950// affected. This is why a build must not share the runner's source
1951// address (#260).
1952func TestAuthLockoutHoldsAgainstTheRunnersKey(t *testing.T) {
1953 s, runner := authServer(t, "closed")
1954 stranger := authKey(t)
1955 failTimes := func(n int) {
1956 t.Helper()
1957 for i := 0; i < n; i++ {
1958 if _, err := s.authenticate(fromLoopback, stranger); err == nil {
1959 t.Fatal("unknown key admitted with registration closed")
1960 }
1961 }
1962 }
1963
1964 failTimes(2)
1965 if _, err := s.authenticate(fromLoopback, runner); err != nil {
1966 t.Fatalf("runner below the limit: %v", err)
1967 }
1968 failTimes(2)
1969 if _, err := s.authenticate(fromLoopback, runner); err != nil {
1970 t.Fatalf("runner after its success cleared the count: %v", err)
1971 }
1972
1973 failTimes(3)
1974 for i := 0; i < 2; i++ {
1975 if _, err := s.authenticate(fromLoopback, runner); err == nil || !strings.Contains(err.Error(), "too many") {
1976 t.Fatalf("attempt %d from a locked-out address: %v, want refused", i+1, err)
1977 }
1978 }
1979 if _, err := s.authenticate(fromPublic, runner); err != nil {
1980 t.Fatalf("another address was locked out too: %v", err)
1981 }
1982
1983 s.authLimiter.seen["127.0.0.1"].start = time.Now().Add(-2 * time.Minute)
1984 if _, err := s.authenticate(fromLoopback, runner); err != nil {
1985 t.Fatalf("runner after the window passed: %v", err)
1986 }
1987}
1988
1989// With registration open or by invite, an unknown key is admitted to run
1990// register and never counts, so no number of unknown-key attempts locks
1991// the runner's address out. gitbay.org runs open registration (#260).
1992func TestAuthUnknownKeyCountsOnlyWhenClosed(t *testing.T) {
1993 for _, mode := range []string{"open", "invite"} {
1994 s, runner := authServer(t, mode)
1995 for i := 0; i < 10; i++ {
1996 p, err := s.authenticate(fromLoopback, authKey(t))
1997 if err != nil || p.Extensions["anon-key"] == "" {
1998 t.Fatalf("%s: unknown key %d: %v %+v", mode, i+1, err, p)
1999 }
2000 }
2001 if _, err := s.authenticate(fromLoopback, runner); err != nil {
2002 t.Fatalf("%s: runner refused after unknown keys: %v", mode, err)
2003 }
2004 }
2005}
2006```
2007
2008- [ ] **Step 2: Run it**
2009
2010Run: `go vet ./internal/sshd && go test ./internal/sshd -run 'TestAuth' -count=1`
2011Expected: PASS.
2012
2013- [ ] **Step 3: Commit**
2014
2015```bash
2016git add internal/sshd/sshd_test.go
2017git commit -S -m "sshd: test the auth limiter's lockout by registration mode
2018
2019Ref #260"
2020```
2021
2022### Task 3.4: host egress rule for the runner's uid
2023
2024No Go code. The check script is the test: `make deploy-runner` runs it
2025after loading the rule and before restarting the runner, and runbook R3
2026runs the whole path against the scratch repository before merge.
2027
2028**Files:**
2029- Create: `deploy/gitbay-runner-egress.nft`, `deploy/gitbay-runner-egress.service`, `deploy/runner-egress-check.sh`
2030- Modify: `deploy/gitbay-runner.override.conf` (a `[Unit]` section before `[Service]` at line 25)
2031- Modify: `deploy/runner-podman-setup.sh` (after `podman --version`, line 29)
2032- Modify: `Makefile:69-85` (`deploy-runner`)
2033
2034**Interfaces:**
2035- Produces: nftables table `inet gitbay_runner`; unit
2036 `gitbay-runner-egress.service`, required by `gitbay-runner.service`;
2037 rule file at `/etc/gitbay-runner/egress.nft` on the runner host.
2038
2039- [ ] **Step 1: The rule**
2040
2041Create `deploy/gitbay-runner-egress.nft`:
2042
2043```
2044#!/usr/sbin/nft -f
2045# Host egress for CI builds (#260). Loaded by gitbay-runner-egress.service,
2046# which gitbay-runner.service requires, so the runner does not start
2047# without it. `make deploy-runner` installs it as
2048# /etc/gitbay-runner/egress.nft.
2049#
2050# Under rootless podman with pasta, a build's connections are made by
2051# pasta on the host, from sockets owned by the runner's user, ci-runner.
2052# nftables sees them exactly as it sees the runner's own ssh, so this
2053# table cannot tell a build from its runner. It limits what that user
2054# reaches on this host, and the runner needs little: 127.0.0.1:22, to
2055# poll, clone and stream logs.
2056#
2057# Every packet to one of the host's own addresses, loopback or public,
2058# leaves through lo, so the output hook sees host-bound traffic as
2059# oifname "lo". Traffic to other hosts is not matched: builds keep
2060# outbound internet access, trusted or not (go mod download needs it).
2061#
2062# What ci-runner may reach on this host:
2063# 127.0.0.1:22 the forge over loopback, for the runner. Builds do
2064# not reach loopback at all: the runner starts them
2065# with pasta's gateway mapping off (--no-map-gw).
2066# loopback :53 the host's resolver, which pasta forwards a
2067# build's DNS to when the host's nameserver is a
2068# loopback address.
2069# public 22/80/443 the forge, as anyone on the internet reaches it.
2070# Everything else is rejected: the admin sshd on 2222 on every address,
2071# and any service bound to loopback. -isolation none builds run as the
2072# same user and get the same rule.
2073#
2074# The account name is resolved when the file is loaded. A restart of
2075# nftables.service (flush ruleset) removes this table; `systemctl
2076# reload gitbay-runner-egress` puts it back.
2077
2078table inet gitbay_runner
2079delete table inet gitbay_runner
2080
2081table inet gitbay_runner {
2082 chain output {
2083 type filter hook output priority filter; policy accept;
2084 oifname "lo" meta skuid "ci-runner" jump host
2085 }
2086
2087 chain host {
2088 ip daddr 127.0.0.1 tcp dport 22 accept
2089 ip daddr 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 accept
2090 ip6 daddr ::1 meta l4proto { tcp, udp } th dport 53 accept
2091 ip daddr != 127.0.0.0/8 tcp dport { 22, 80, 443 } accept
2092 ip6 daddr != ::1 tcp dport { 22, 80, 443 } accept
2093 counter reject
2094 }
2095}
2096```
2097
2098The first `table` line creates the table if it is missing so the
2099`delete` never fails; the file then replaces it in one transaction, so
2100a reload never leaves a moment without the rule. The uid match is in
2101the base chain's one rule rather than a `!=` accept, because a packet
2102with no socket (a kernel-sent reset) matches neither `==` nor `!=` on
2103`skuid` and would otherwise fall through to the reject.
2104
2105- [ ] **Step 2: The unit**
2106
2107Create `deploy/gitbay-runner-egress.service`:
2108
2109```
2110# Loads the CI runner's host egress rule (#260,
2111# deploy/gitbay-runner-egress.nft). gitbay-runner.service requires this
2112# unit, so the runner starts only with the rule in force; stopping this
2113# unit removes the table and stops the runner with it.
2114#
2115# Ordered after nftables.service and ufw.service: either may rewrite the
2116# ruleset at boot, and nftables.service's default config starts with
2117# flush ruleset. A missing unit in After= is ignored.
2118#
2119# Reload re-reads the file and replaces the table in one transaction; it
2120# does not restart the runner, which a restart of this unit would
2121# (Requires= propagates restarts). `make deploy-runner` reloads.
2122[Unit]
2123Description=Host egress rule for CI builds
2124After=nftables.service ufw.service
2125Before=gitbay-runner.service
2126
2127[Service]
2128Type=oneshot
2129RemainAfterExit=yes
2130ExecStart=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
2131ExecReload=/usr/sbin/nft -f /etc/gitbay-runner/egress.nft
2132ExecStop=/usr/sbin/nft delete table inet gitbay_runner
2133
2134[Install]
2135WantedBy=multi-user.target
2136```
2137
2138- [ ] **Step 3: The runner requires it**
2139
2140In `deploy/gitbay-runner.override.conf`, insert before `[Service]`
2141(line 25):
2142
2143```
2144[Unit]
2145# The host egress rule (#260, gitbay-runner-egress.nft) limits what this
2146# unit's user reaches on the host: 127.0.0.1:22 for the runner, the
2147# forge's public 22, 80 and 443 for builds, nothing else. Required, so
2148# the runner does not start without it: a table that failed to load must
2149# not mean builds reach the admin sshd.
2150Requires=gitbay-runner-egress.service
2151After=gitbay-runner-egress.service
2152```
2153
2154- [ ] **Step 4: The check**
2155
2156Create `deploy/runner-egress-check.sh`:
2157
2158```sh
2159#!/bin/sh
2160# Check the CI runner's host egress rule (#260) as the runner's user:
2161# the forge over loopback on 22 must answer (the runner polls there),
2162# and the admin sshd on 2222 must not, on loopback or the public
2163# address. `make deploy-runner` runs this after loading the rule and
2164# before restarting the runner, and stops on a failure.
2165#
2166# ssh -p 2222 root@bay1 'sh -s' < deploy/runner-egress-check.sh
2167set -eu
2168
2169RUNNER_USER="${RUNNER_USER:-ci-runner}"
2170public=$(hostname -I | awk '{print $1}')
2171
2172probe() {
2173 su -s /bin/bash "$RUNNER_USER" -c "timeout 5 bash -c 'exec 3<>/dev/tcp/$1/$2'" 2>/dev/null
2174}
2175
2176nft list table inet gitbay_runner >/dev/null
2177
2178for dest in 127.0.0.1:22 "$public:22"; do
2179 if ! probe "${dest%:*}" "${dest##*:}"; then
2180 echo "$RUNNER_USER cannot reach $dest: the egress rule would stop the runner" >&2
2181 exit 1
2182 fi
2183done
2184for dest in 127.0.0.1:2222 "$public:2222"; do
2185 if probe "${dest%:*}" "${dest##*:}"; then
2186 echo "$RUNNER_USER reaches $dest: the egress rule is not in force" >&2
2187 exit 1
2188 fi
2189done
2190echo "egress for $RUNNER_USER: 127.0.0.1:22 and $public:22 open, 2222 refused"
2191```
2192
2193`hostname -I` lists the host's addresses, IPv4 first on bay1; the
2194first is the public one there.
2195
2196- [ ] **Step 5: nftables on the host**
2197
2198In `deploy/runner-podman-setup.sh`, after the podman install block
2199(after `podman --version`, line 29):
2200
2201```sh
2202# nft loads the runner's host egress rule (#260,
2203# deploy/gitbay-runner-egress.nft), which `make deploy-runner` ships and
2204# the runner's unit requires. Without nft the runner does not start.
2205echo "==> installing nftables"
2206if ! command -v nft >/dev/null 2>&1; then
2207 apt-get update
2208 DEBIAN_FRONTEND=noninteractive apt-get install -y nftables
2209fi
2210nft --version
2211```
2212
2213- [ ] **Step 6: `make deploy-runner` ships, loads and checks it**
2214
2215Replace `Makefile:69-85` with:
2216
2217```make
2218deploy-runner: preflight
2219 @echo "==> building $(RUNNER_BIN)"
2220 $(CROSS) go build -trimpath -ldflags='$(LDFLAGS)' -o $(RUNNER_BIN) ./cmd/gitbay-runner
2221 @echo "==> pushing runner to $(HOST)"
2222 ./deploy/copy.sh $(HOST) $(PORT) $(RUNNER_BIN) /usr/local/bin/gitbay-runner.new
2223 ssh -p $(PORT) root@$(HOST) 'mkdir -p /etc/systemd/system/gitbay-runner.service.d /etc/gitbay-runner'
2224 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner.override.conf /etc/systemd/system/gitbay-runner.service.d/override.conf
2225 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.service /etc/systemd/system/gitbay-runner-prune.service
2226 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-prune.timer /etc/systemd/system/gitbay-runner-prune.timer
2227 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.nft /etc/gitbay-runner/egress.nft
2228 ./deploy/copy.sh $(HOST) $(PORT) deploy/gitbay-runner-egress.service /etc/systemd/system/gitbay-runner-egress.service
2229 @echo "==> loading the egress rule"
2230 ssh -p $(PORT) root@$(HOST) 'set -eu; \
2231 nft -c -f /etc/gitbay-runner/egress.nft; \
2232 systemctl daemon-reload; \
2233 systemctl enable gitbay-runner-egress.service; \
2234 systemctl reload-or-restart gitbay-runner-egress.service'
2235 ssh -p $(PORT) root@$(HOST) 'sh -s' < deploy/runner-egress-check.sh
2236 ssh -p $(PORT) root@$(HOST) 'set -eu; \
2237 chmod 755 /usr/local/bin/gitbay-runner.new; \
2238 mv /usr/local/bin/gitbay-runner.new /usr/local/bin/gitbay-runner; \
2239 systemctl enable --now gitbay-runner-prune.timer; \
2240 systemctl restart gitbay-runner; \
2241 systemctl --no-pager --lines=3 status gitbay-runner; \
2242 systemctl --no-pager list-timers gitbay-runner-prune.timer'
2243```
2244
2245`nft -c` checks the file without applying it, so a syntax error stops
2246the deploy with the old table and the old runner in place. On the first
2247deploy `reload-or-restart` starts the unit, and starting a required unit
2248does not restart the runner that requires it; later deploys reload it.
2249The check runs before the runner restarts, so a failure stops the
2250deploy with the old binary in place, but the new table is already
2251loaded and applies to the running runner too. If the check says the
2252rule blocks 127.0.0.1:22, remove the table with `ssh -p 2222
2253root@gitbay.org nft delete table inet gitbay_runner` (the unit stays
2254active, so the runner is not stopped with it), fix the rule, and deploy
2255again. Runbook R3 runs this path on the scratch runner before merge.
2256
2257- [ ] **Step 7: Verify locally**
2258
2259Run: `sh -n deploy/runner-egress-check.sh && sh -n deploy/runner-podman-setup.sh && make -n deploy-runner HOST=example.test`
2260Expected: no syntax errors; the dry run lists the two new copies, the
2261`nft -c` / `reload-or-restart` block, the check, then the runner block.
2262
2263- [ ] **Step 8: Commit**
2264
2265```bash
2266git add deploy/gitbay-runner-egress.nft deploy/gitbay-runner-egress.service deploy/runner-egress-check.sh deploy/gitbay-runner.override.conf deploy/runner-podman-setup.sh Makefile
2267git commit -S -m "runner host: builds reach only the forge's public ports on it
2268
2269Ref #260"
2270```
2271
2272### Task 3.5: egress policy in the wiki, and the MR
2273
2274**Files:**
2275- Modify: `.gitbay/wiki/Threat-Model.org` ("The CI runner", after the *Images* bullet at line 169)
2276- Modify: `.gitbay/wiki/CI.org` (new section before "* The table", line 55)
2277- Modify: `.gitbay/wiki/Users.org:551-555` (the `GITBAY_SSH` sentence)
2278- Modify: `.gitbay/wiki/Admin.org` (after "It is idempotent.", line 668)
2279- Modify: `.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org:70` (Network row), `04-Trust-Boundaries.org:27` (TB7), `09-Controls.org:91`
16892280
16902281- [ ] **Step 1: Threat-Model**
16912282
@@ -1693,18 +2284,27 @@ Add a bullet after *Images are provisioned…*:
16932284
16942285```org
16952286- *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.
2287 merge request to a Go repository has to fetch its modules. On the
2288 runner's host, only the forge's public ports 22, 80 and 443, exactly
2289 as anyone on the internet reaches them. Two layers keep it there. A
2290 runner that polls the daemon over loopback starts its containers with
2291 pasta's gateway mapping off, so a build does not reach the host's
2292 loopback, and =GITBAY_SSH= names the public address; that keeps the
2293 runner's source address, =127.0.0.1=, one no build connects from. And
2294 an nftables table (=deploy/gitbay-runner-egress.nft=) rejects every
2295 connection the runner's user makes to the host's own addresses except
2296 =127.0.0.1:22=, DNS on loopback, and 22, 80 and 443 on the public
2297 address: the operator's sshd on 2222 and anything bound to loopback
2298 are closed to builds. Under rootless podman a build's connections are
2299 made by pasta as the runner's user, so the table cannot tell a build
2300 from its runner and leaves =127.0.0.1:22= open; the gateway mapping
2301 is what closes it to builds. The runner does not start without the
2302 table. The SSH auth limiter counts failures per source address and,
2303 once an address is over the limit, refuses every key from it until
2304 the window passes, the runner's included; with registration open or
2305 by invite an unknown key never counts (krz/gitbay#260). Under
2306 =-isolation none= a build runs on the host and shares its loopback;
2307 the table still applies, since it runs as the same user.
17082308```
17092309
17102310- [ ] **Step 2: CI.org**
@@ -1714,11 +2314,12 @@ Add before `* The table`:
17142314```org
17152315* What a build can reach
17162316
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).
2317Builds have outbound internet access, trusted and untrusted alike. On
2318the runner's host they reach only the forge's public ports 22, 80 and
2319443: not the host's loopback, not the operator's sshd. The forge is
2320reached at its public address, the one in =GITBAY_SSH=. See the
2321Threat-Model page, "What a build can reach", for how and why
2322(krz/gitbay#260).
17222323```
17232324
17242325- [ ] **Step 3: Users.org**
@@ -1727,30 +2328,50 @@ Replace "(=git@gitbay.org= from a runner elsewhere; inside a container
17272328on the server's own runner the host is at a private address the runner
17282329fills in)" with "(=git@gitbay.org=, the instance's public address, from
17292330a runner elsewhere and from a container on the server's own runner
1730alike)".
2331alike; =git@host:port= on an instance whose ssh is not on 22, so use it
2332as =ssh://$GITBAY_SSH/owner/name.git= or =ssh ssh://$GITBAY_SSH …=,
2333which work in both forms)".
17312334
1732- [ ] **Step 4: Architecture**
2335- [ ] **Step 4: Admin.org**
17332336
1734`07-CI-and-Supply-Chain.org` Network row:
2337After "…verifies rootless podman actually runs as that user. It is
2338idempotent." add:
17352339
17362340```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) |
2341It also installs nftables. =make deploy-runner= ships
2342=deploy/gitbay-runner-egress.nft= to =/etc/gitbay-runner/egress.nft=
2343with =gitbay-runner-egress.service=, which loads it and which the
2344runner's unit requires; it checks the file with =nft -c=, reloads the
2345unit, and runs =deploy/runner-egress-check.sh= as =ci-runner= before
2346restarting the runner: =127.0.0.1:22= and the public 22 must answer,
23472222 must not. The table limits the runner's user to =127.0.0.1:22=,
2348DNS on loopback, and 22, 80 and 443 on the host's public address; the
2349Threat-Model page says why. A restart of =nftables.service= flushes it;
2350=systemctl reload gitbay-runner-egress= restores it.
17382351```
17392352
1740`04-Trust-Boundaries.org` TB7: replace "the network is open (#260)"
1741with "outbound is open and the host's loopback is not reachable
2353- [ ] **Step 5: Architecture**
2354
2355`07-CI-and-Supply-Chain.org:70` Network row:
2356
2357```org
2358| Network | pasta; outbound open; a loopback runner's builds run with =--no-map-gw= (=main.go=); on the host only public 22/80/443 (=gitbay-runner-egress.nft=, #260) |
2359```
2360
2361`04-Trust-Boundaries.org:27` TB7: replace "the network is open (#260)"
2362with "outbound is open; on the host only the forge's public ports
17422363(#260)". `09-Controls.org:91`:
17432364
17442365```org
1745| Build network egress restricted | partial | host loopback closed to builds; outbound open by decision (#260) |
2366| Build network egress restricted | partial | host: loopback closed, public 22/80/443 only (=gitbay-runner-egress.nft=); internet outbound open by decision (#260) |
17462367```
17472368
17482369The Known-Gaps row for #260 and its "What can a build reach…" question
1749stay until the runbook's R3 results are recorded.
2370stay until the runbook's R3 results are recorded on the CI page.
17502371
1751- [ ] **Step 5: Verify, commit, MR**
2372- [ ] **Step 6: Verify, commit, MR**
17522373
1753Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control -count=1`
2374Run: `go build ./... && go vet ./... && go test ./cmd/gitbay-runner ./internal/control ./internal/sshd -count=1`
17542375Expected: PASS.
17552376
17562377```bash
@@ -1759,7 +2380,7 @@ git commit -S -m "wiki: what a build can reach
17592380
17602381Ref #260"
17612382git 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"
2383gitbay mr create --source runner-source-address --target main --title "runner: keep builds off the runner's source address and the host's other ports"
17632384```
17642385
17652386Before merging, run the runbook's R3 on the scratch repository. Merge
@@ -3031,33 +3652,11 @@ with `--strategy ff` and delete the branch both places.
30313652 pasta exposes the host at `169.254.1.2` as its `--map-host-loopback`
30323653 default; newer podman instead passes `--map-guest-addr 169.254.1.2`,
30333654 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?
3655 does is not in the repository. Runbook R3 step 0 measures it before
3656 Part 3 deploys; if `--no-map-gw` is refused or leaves `127.0.0.1`
3657 reachable, stop and revisit Part 3 before merging. The nftables
3658 table does not cover this path: a build's connection to 127.0.0.1:22
3659 through pasta is ci-runner's, like the runner's own poll.
30613660
30623661---
30633662
@@ -3140,20 +3739,31 @@ not poll with several ssh calls a tick. Operator ssh is
31403739 before runners, and that `<workdir>/home` can be deleted after the
31413740 runner upgrade. Nothing further in the wiki; Part 1's MR updated it.
31423741
3143### R3. Part 3 (#260): pasta check, source addresses, throttling
3742### R3. Part 3 (#260): pasta check, egress rule, measurement from a build
31443743
31450. Before merging Part 3, on bay1 as the runner user:
3744No throttling test runs on production; Task 3.3's unit test covers the
3745limiter. What is measured here is what a build sees.
3746
37470. Before merging Part 3, on bay1: re-run the host setup (idempotent;
3748 it now installs nftables), then check pasta as the runner user:
31463749
31473750 ```sh
3148 ssh -p 2222 root@gitbay.org "podman --version; pasta --version | head -1"
3751 ssh -p 2222 root@gitbay.org 'sh -s' < deploy/runner-podman-setup.sh
3752 ssh -p 2222 root@gitbay.org "podman --version; pasta --version | head -1; nft --version; hostname -I"
31493753 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\"'"
31503754 ```
31513755
3152 Expected: `proxy.golang.org` resolves, `public-ok` prints. If podman
3153 rejects the option, stop (open question 1).
3756 Expected: `proxy.golang.org` resolves, `public-ok` prints, and
3757 `hostname -I` lists the public IPv4 address first (the check script
3758 takes the first). If podman rejects the option, stop (open question
3759 1).
315437601. `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
3761 `make deploy-runner`. Its output shows `==> loading the egress rule`
3762 and then `egress for ci-runner: 127.0.0.1:22 and <public>:22 open,
3763 2222 refused` before the runner restarts. If the check fails, follow
3764 Task 3.4 Step 6 (remove the table, fix, deploy again) before
3765 anything else: the running runner is under the new table.
37662. On `cmc/ci-scratch` `main`, a probe job (keep each step under 4096
31573767 bytes):
31583768
31593769 ```yaml
@@ -3162,12 +3772,10 @@ not poll with several ssh calls a tick. Operator ssh is
31623772 steps:
31633773 - echo "GITBAY_SSH=$GITBAY_SSH"
31643774 - |
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
3775 host=${GITBAY_SSH#*@}; host=${host%:*}
3776 for t in 127.0.0.1:22 127.0.0.1:2222 169.254.1.2:22 $host:22 $host:80 $host:443 $host:2222 proxy.golang.org:443; do
3777 timeout 5 bash -c "exec 3<>/dev/tcp/${t%:*}/${t##*:}" 2>/dev/null && echo "open $t" || echo "closed $t"
3778 done
31713779 - bash -c 'exec 3<>/dev/tcp/${GITBAY_SSH#*@}/22; sleep 90'
31723780 ```
31733781
@@ -3177,26 +3785,29 @@ not poll with several ssh calls a tick. Operator ssh is
31773785 ssh -p 2222 root@gitbay.org "ss -tn state established '( sport = :22 )'"
31783786 ```
31793787
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.
3788 Note the peer address of the build's connection and of the runner's
3789 `runner log` session. Expected: the runner's is `127.0.0.1`, the
3790 build's is the host's public address, never `127.0.0.1`.
37914. Expected build log: `GITBAY_SSH=git@gitbay.org`; `closed
3792 127.0.0.1:22`, `closed 127.0.0.1:2222`; `169.254.1.2:22` as
3793 measured (open only if podman maps that address to the public one);
3794 `open gitbay.org:22`, `:80`, `:443`; `closed gitbay.org:2222`;
3795 `open proxy.golang.org:443`.
37965. The runner kept polling: `gitbay admin runners` shows a recent poll
3797 for the bay1 key, and the probe build finished with its log.
319037986. Remove the R1 drop-in. Trigger one real trusted job that talks back
31913799 (`gitbay build trigger krz/orgo <its release or pages job>` only if
31923800 one is due; otherwise wait for the next hutch/orgo scheduled job) and
31933801 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).
38027. Record the result, one commit on a branch `wiki-260-results` with
3803 `Closes #260`: in CI.org under "What a build can reach", a dated
3804 paragraph with the podman, pasta and nft versions, the source
3805 address the forge saw for a build and for the runner (step 3), and
3806 the reachability list from step 4. In
3807 `Architecture/10-Known-Gaps.org`, remove the `#260` row and answer
3808 "What can a build reach on the host's network?" with the date and a
3809 pointer to the CI page. `09-Controls` stays `partial` (outbound is
3810 open by decision). MR, `--strategy ff`, delete the branch.
32003811
32013812### R4. Part 4 (#266): validate the failure report
32023813
@@ -3229,11 +3840,16 @@ not poll with several ssh calls a tick. Operator ssh is
32293840
32303841- **Coverage.** #255: explicit trust (1.1), disposable untrusted home
32313842 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
3843 `ci/` refused (2.1), reuse by trust and declared image, the default
3844 image's limit documented (2.2, 2.4), required contexts turning the
3845 gate on, shown by `settings show` and the web page, missing as
3846 pending in `MergeGates` (2.3), wiki (2.4). #260: public destination
3847 with the port off 22 (3.1), builds off loopback (3.2), the limiter's
3848 lockout as a unit test instead of a production test (3.3), the host
3849 egress table with its unit, check and deploy wiring (3.4), egress
3850 policy in Threat-Model, CI and Admin (3.5), the source address and
3851 reachability measured from a scratch build and recorded on the CI
3852 page (R3), with the scratch-repository rule (R1). #266: runner names the step (4.3), stored
32373853 step and duration (4.1; duration derived), `build show` (4.4),
32383854 `build log --step`/`--tail` (4.4), web `<details>` per step with the
32393855 failed one open, `id="failed"`, "Jump to failure", duration beside
@@ -3248,3 +3864,7 @@ not poll with several ssh calls a tick. Operator ssh is
32483864 `LogSection` (4.4) are used by `logSteps` (4.5). `SuccessBuildForTree`
32493865 gains `image` in 2.2 and its one caller changes there.
32503866 `GatesOut.ChecksMissing` (2.3) is rendered in `mr.html` (2.3).
3867 `publicSSH` (3.1) fills the claim's `ssh`, read as `job.SSH` (3.2).
3868 The table name `inet gitbay_runner` and the path
3869 `/etc/gitbay-runner/egress.nft` match across the rule, the unit, the
3870 check script and the Makefile (3.4).
docs/plans/2026-09-27-cli-ux.md +807 −112
@@ -19,8 +19,19 @@ statuses" (`internal/httpd/builds.go`); both move into
1919a `Ctx.Term` set from the CLI's `--term=<cols>[,color]`; `c.program()`
2020already picks `"gitbay"` or `"ssh git@<host>"` from it for the `--help`
2121path, 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.
22yet call it. Separately, eighteen CLI commands resolve to a server path
23that differs from what cobra's tree spells (`cmd/gitbay/main.go`'s
24`serverPath` annotations): everything under `auth` except a few whose
25noun already matches the registry (`auth email ...` -> `email ...`,
26`auth export` -> `account export`, `auth keys ...` -> `keys ...`, `auth
27pgp ...` -> `pgp ...`, `auth token ...` -> `token ...`, `auth whoami` ->
28`whoami`), and `repo topics list` -> `repo topics`. Printing the
29registered path verbatim for one of these gives a command that does not
30exist — `gitbay keys remove <fp>` is `unknown command "keys"`. #267
31decided the CLI sends its own invoking path and the server prints that
32instead of the registered one wherever they differ; the CLI's `auth`
33grouping, still not a registry path at all, gets the same treatment for
34the several registry prefixes it gathers.
2435
2536**Tech stack:** Go, `golang.org/x/crypto/ssh`, cobra.
2637
@@ -697,52 +708,177 @@ Wait for CI, merge with `--strategy ff`, delete the branch both places.
697708
698709# Part 2: help and usage print the form the caller typed (branch `cli-ux-help`, closes #267)
699710
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.
711### Task 2.1: the CLI sends the path it typed; usage and help print it
712
713`c.usage()` prints the bare *registered* usage (`usage: keys remove
714<fingerprint>`), with neither the `gitbay` nor the `ssh git@host` prefix
715`c.program()`/`helpVerb` already use for `--help` — and for eighteen
716commands (the ones listed in Architecture above) the registered path is
717not even something a caller can type: `gitbay keys remove <fp>` is
718`unknown command "keys"`, because the real command is `gitbay auth keys
719remove <fp>`. Fix both: give every usage/help line the program prefix,
720mark a leading `<owner/name>` optional at a terminal (the CLI fills it
721in from the clone's origin remote, `cmd/gitbay/ssh.go`'s `withRepo`;
722stock ssh never does), and have the CLI tell the server what it was
723actually typed as, so the server can print that instead of the
724registered path wherever the two differ.
725
726The CLI already tells the server one thing about the calling session
727this way: `--term=<cols>[,color]`, prepended to the command line by
728`runSSHPaged` and stripped off `argv[0]` by `Dispatch` before `Lookup`
729(`internal/control/control.go`). A second, sibling prefix, `--path=<cli
730path>`, carries what cobra resolved the call to
731(`cobra.Command.CommandPath()`, minus the leading `gitbay `). It travels
732as its own `--path=` argument rather than a new field packed into the
733`--term=` value: that value's `cols[,color]` grammar has no room for a
734string containing spaces (a CLI path always does), and a second prefix
735is one more `strings.CutPrefix` in the same loop, not a new mini-parser.
736Only the gitbay CLI ever sends it — stock ssh has no notion of a "path
737it resolved to" that differs from what was typed, because what was typed
738*is* the dispatch path — so `Dispatch` never invents one, and the field
739stays empty for the web and the API exactly like `Term` does.
708740
709741**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`)
742- Modify: `internal/control/control.go` (`Ctx.CLIPath`, `Dispatch`, `usage`, `usageWith`)
743- Modify: `internal/control/help.go` (`cliUsage`, `shownAs`, `cmdUsage`, `helpVerb`, `helpNoun`)
744- Modify: `internal/control/control_test.go` (`TestArgumentRefusalsNameTheUsage`, new `TestPathArgument`)
713745- Test: `internal/control/help_test.go`
746- Modify: `cmd/gitbay/ssh.go` (`cliPathOf`, `withCLIPath`, new)
747- Modify: `cmd/gitbay/main.go` (`pass`, `runServerHelp`, `runPass`, `group`, `serverHelp`)
748- Test: `cmd/gitbay/term_test.go` (the two new pure helpers)
749- Test: `cmd/gitbay/serverpath_test.go` (create)
750- Test: `e2e/cliusage_test.go` (create)
714751
715752**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.
753- Produces: `Ctx.CLIPath string`; `func cliUsage(usage string) string`;
754 `func (c *Ctx) shownAs(registered, full string) string`; `func (c
755 *Ctx) cmdUsage() string`; `func cliPathOf(cmd *cobra.Command) string`;
756 `func withCLIPath(cliPath string, argv []string) []string`.
757- Consumes (Task 2.2, 2.3): `Ctx.CLIPath`, `shownAs`, `withCLIPath`, `cliPathOf`.
719758
720- [ ] **Step 1: Write the failing test**
759- [ ] **Step 1: Write the failing test for the transport**
760
761```go
762// TestPathArgument: --path= is read only as a leading argument (in
763// either order with --term=), and never over HTTP — mirrors
764// TestTermArgument, the mechanism it rides alongside.
765func TestPathArgument(t *testing.T) {
766 cases := []struct {
767 name string
768 viaAPI bool
769 argv []string
770 want string
771 wantArgv []string
772 }{
773 {"leading", false, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
774 {"after term", false, []string{"--term=80", "--path=auth keys remove", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
775 {"before term", false, []string{"--path=auth keys remove", "--term=80", "keys", "remove", "abc"}, "auth keys remove", []string{"abc"}},
776 {"over HTTP", true, []string{"--path=auth keys remove", "keys", "remove", "abc"}, "", []string{"abc"}},
777 }
778 for _, tc := range cases {
779 c := &Ctx{Scope: "git", ViaAPI: tc.viaAPI, Stdout: io.Discard, Stderr: io.Discard}
780 Dispatch(c, tc.argv)
781 if c.CLIPath != tc.want {
782 t.Errorf("%s: CLIPath %q, want %q", tc.name, c.CLIPath, tc.want)
783 }
784 if !slices.Equal(c.Argv, tc.wantArgv) {
785 t.Errorf("%s: Argv %q, want %q", tc.name, c.Argv, tc.wantArgv)
786 }
787 }
788}
789```
790
791- [ ] **Step 2: Run and see it fail**
792
793Run: `go test ./internal/control -run TestPathArgument -count=1`
794Expected: FAIL to compile (`c.CLIPath undefined`).
795
796- [ ] **Step 3: Add the field and route it through `Dispatch`**
797
798In `internal/control/control.go`, `Ctx` gains a field next to `Term`:
799
800```go
801 // CLIPath is the path the gitbay CLI actually resolved this call to
802 // (cobra.Command.CommandPath(), from a leading --path=), when it
803 // differs from the registered path being dispatched (#267) — auth's
804 // several groupings and repo topics list, today. Empty for stock
805 // ssh, the web and the API: nothing but the gitbay CLI sends one.
806 CLIPath string
807```
808
809`Dispatch` strips both leading pseudo-flags in a loop, in whichever
810order the caller sent them, replacing the single `--term=` check:
811
812```go
813 // A leading --term=<v> selects terminal output for this session, the
814 // same as GITBAY_TERM; a leading --path=<v> carries the CLI's own
815 // invoking path when it differs from the one being dispatched
816 // (#267). Both come off before Lookup, in whichever order the
817 // caller sent them: Lookup matches argv against a command's Path,
818 // and either prefix in front would never match one. Over HTTP both
819 // are dropped unread: the web and the API render no terminal and
820 // have no CLI path of their own.
821 for len(argv) > 0 {
822 if v, ok := strings.CutPrefix(argv[0], "--term="); ok {
823 if !c.ViaAPI {
824 c.Term = ParseTerm(v)
825 }
826 argv = argv[1:]
827 continue
828 }
829 if v, ok := strings.CutPrefix(argv[0], "--path="); ok {
830 if !c.ViaAPI {
831 c.CLIPath = v
832 }
833 argv = argv[1:]
834 continue
835 }
836 break
837 }
838 if len(argv) == 0 {
839 return c.fail(protocol.ExitUsage, "no command given; try: ssh <host> help")
840 }
841```
842
843- [ ] **Step 4: Run**
844
845Run: `go test ./internal/control -run "TestPathArgument|TestTermArgument" -count=1`
846Expected: PASS.
847
848- [ ] **Step 5: Write the failing test for rendering**
721849
722850```go
723851func TestCmdUsagePrefixesTheProgram(t *testing.T) {
724 c := &Ctx{Cmd: Command{Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
852 c := &Ctx{Cmd: Command{Path: []string{"keys", "remove"}, Usage: "keys remove <fingerprint>"}, Cfg: config.Config{Server: config.Server{SiteURL: "https://forge.test"}}}
725853 if got := c.cmdUsage(); got != "ssh git@forge.test keys remove <fingerprint>" {
726854 t.Errorf("ssh form: %q", got)
727855 }
728856 c.Term = Term{Cols: 100}
729857 if got := c.cmdUsage(); got != "gitbay keys remove <fingerprint>" {
730 t.Errorf("cli form: %q", got)
858 t.Errorf("cli form, no CLIPath sent: %q", got)
859 }
860 c.CLIPath = "auth keys remove"
861 if got := c.cmdUsage(); got != "gitbay auth keys remove <fingerprint>" {
862 t.Errorf("cli form, mismatched registered path: %q", got)
731863 }
732864
733 c2 := &Ctx{Cmd: Command{Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
865 c2 := &Ctx{Cmd: Command{Path: []string{"repo", "tree"}, Usage: "repo tree <owner/name> [<path>] [--ref <ref>]"}, Term: Term{Cols: 100}}
734866 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
735867 t.Errorf("optional owner/name: %q", got)
736868 }
869 c2.CLIPath = "repo tree" // matches the registered path: a no-op
870 if got := c2.cmdUsage(); got != "gitbay repo tree [<owner/name>] [<path>] [--ref <ref>]" {
871 t.Errorf("matching CLIPath changes nothing: %q", got)
872 }
737873}
738874```
739875
740- [ ] **Step 2: Run and see it fail**
876- [ ] **Step 6: Run and see it fail**
741877
742878Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
743879Expected: FAIL to compile (`c.cmdUsage undefined`).
744880
745- [ ] **Step 3: Implement `cliUsage` and `cmdUsage` in `internal/control/help.go`**
881- [ ] **Step 7: Implement `cliUsage`, `shownAs` and `cmdUsage` in `internal/control/help.go`**
746882
747883```go
748884// cliUsage marks a leading <owner/name> optional in a CLI-rendered usage
@@ -753,13 +889,30 @@ func cliUsage(usage string) string {
753889 return strings.Replace(usage, "<owner/name>", "[<owner/name>]", 1)
754890}
755891
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.
892// shownAs returns how a registered path should print to this caller:
893// the CLI path it sent (Ctx.CLIPath) standing in for the leading
894// portion that corresponds to registered, with full's remainder kept
895// as-is; or full unchanged for stock ssh, the API, or a caller whose
896// CLI path already agrees with the registered one. registered must be
897// a genuine leading substring of full (a command's own registered path
898// always is, against its own Usage or a sibling's full path).
899func (c *Ctx) shownAs(registered, full string) string {
900 if c.CLIPath == "" || c.CLIPath == registered {
901 return full
902 }
903 return c.CLIPath + strings.TrimPrefix(full, registered)
904}
905
906// cmdUsage is the registered usage as this call should see it: the CLI
907// path this session actually typed when it differs from the registered
908// one (#267), the gitbay form otherwise, the ssh form when there is no
909// terminal — with a leading <owner/name> marked optional at a terminal.
910// Every usage message — the --help path and a wrong-argument refusal
911// alike — goes through this, so a caller never sees a command it
912// cannot actually run.
761913func (c *Ctx) cmdUsage() string {
762 shape := c.Cmd.Usage
914 registered := joinPath(c.Cmd.Path)
915 shape := c.shownAs(registered, c.Cmd.Usage)
763916 if c.Term.Cols > 0 {
764917 shape = cliUsage(shape)
765918 }
@@ -767,12 +920,12 @@ func (c *Ctx) cmdUsage() string {
767920}
768921```
769922
770- [ ] **Step 4: Run**
923- [ ] **Step 8: Run**
771924
772925Run: `go test ./internal/control -run TestCmdUsagePrefixesTheProgram -count=1`
773926Expected: PASS.
774927
775- [ ] **Step 5: Route `usage`/`usageWith` through it**
928- [ ] **Step 9: Route `usage`/`usageWith` through it**
776929
777930In `internal/control/control.go`:
778931
@@ -790,42 +943,137 @@ func (c *Ctx) usageWith(msg string) int {
790943}
791944```
792945
793Also use it in `helpVerb` (`internal/control/help.go`), which today
794recomputes the same "cut at ` [--`" shape independently:
946- [ ] **Step 10: `helpVerb` gets the same treatment**
947
948`helpVerb` (`internal/control/help.go`) recomputes a "cut at ` [--`"
949shape independently, and lists sibling commands under SEE ALSO by their
950full registered path. Both go through `shownAs` now:
795951
796952```go
797 shape := cmd.Usage
953func (c *Ctx) helpVerb(w io.Writer, cmd Command, below []Command) {
954 fmt.Fprintln(w, cmd.Summary)
955 fmt.Fprintln(w)
956 c.heading(w, "USAGE")
957 // Cutting at the first optional flag drops the rest of the usage
958 // syntax behind "[flags]" — safe only for what is actually optional.
959 // A required flag (repo delete --yes) or an alternative
960 // (notifications read <id>... | --all) has no " [--" to cut at, so
961 // the usage prints whole.
962 registered := joinPath(cmd.Path)
963 shape := c.shownAs(registered, cmd.Usage)
964 if c.Term.Cols > 0 {
965 shape = cliUsage(shape)
966 }
798967 if i := strings.Index(shape, " [--"); i >= 0 {
799968 shape = shape[:i] + " [flags]"
800969 }
801970 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
971 fmt.Fprintln(w)
972 c.heading(w, "FLAGS")
973 rows := make([][2]string, 0, len(cmd.Flags)+1)
974 for _, f := range cmd.Flags {
975 name := f.Name
976 if f.Arg != "" {
977 name += " " + f.Arg
978 }
979 desc := f.Desc
980 if f.Default != "" {
981 desc += " (default " + f.Default + ")"
982 }
983 rows = append(rows, [2]string{name, desc})
984 }
985 rows = append(rows, [2]string{"--json", "machine-readable output"})
986 wide := 0
987 for _, r := range rows {
988 wide = max(wide, cells(r[0]))
989 }
990 for _, r := range rows {
991 c.wrapLine(w, " "+pad(r[0], wide)+" ", r[1])
992 }
993 if len(cmd.Examples) > 0 {
994 fmt.Fprintln(w)
995 c.heading(w, "EXAMPLES")
996 for _, ex := range cmd.Examples {
997 c.wrapLine(w, " "+c.program()+" ", ex)
998 }
999 }
1000 if len(below) > 0 {
1001 fmt.Fprintln(w)
1002 c.heading(w, "SEE ALSO")
1003 for _, b := range below {
1004 fmt.Fprintf(w, " %s %s\n", c.program(), c.shownAs(registered, joinPath(b.Path)))
1005 }
1006 }
1007}
8021008```
8031009
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:
1010(Only the `USAGE` and `SEE ALSO` lines change; `FLAGS`/`EXAMPLES` are
1011reproduced above unchanged, for the diff to apply against the current
1012file — do not re-type them from scratch.)
1013
1014- [ ] **Step 11: `helpNoun` gets the same treatment**
1015
1016`helpNoun` prints `{program} {prefix} <verb> ...` and, per command, the
1017verb relative to `prefix`; the prefix itself needs the same swap (Task
10182.3 also gives it an `override` map, threaded through here empty for
1019every noun but the CLI-only `auth` alias):
8091020
8101021```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]"
1022func (c *Ctx) helpNoun(w io.Writer, prefix string, cmds []Command, override map[string]string) {
1023 head := nounSummaries[strings.Fields(prefix)[0]]
1024 fmt.Fprintln(w, head)
1025 fmt.Fprintln(w)
1026 c.heading(w, "USAGE")
1027 display := c.shownAs(prefix, prefix)
1028 fmt.Fprintf(w, " %s %s <verb> ...\n", c.program(), display)
1029 rowText := func(cmd Command) string {
1030 full := joinPath(cmd.Path)
1031 if d, ok := override[full]; ok {
1032 return d
1033 }
1034 return strings.TrimPrefix(full, prefix+" ")
1035 }
1036 wide := 0
1037 for _, cmd := range cmds {
1038 wide = max(wide, cells(rowText(cmd)))
1039 }
1040 for _, section := range []struct {
1041 title string
1042 read bool
1043 }{{"READ", true}, {"WRITE", false}} {
1044 first := true
1045 for _, cmd := range cmds {
1046 if cmd.ReadOnly != section.read {
1047 continue
1048 }
1049 if first {
1050 fmt.Fprintln(w)
1051 c.heading(w, section.title)
1052 first = false
1053 }
1054 fmt.Fprintf(w, " %s %s\n", pad(rowText(cmd), wide), cmd.Summary)
1055 }
8171056 }
818 fmt.Fprintf(w, " %s %s\n", c.program(), shape)
1057 fmt.Fprintln(w)
1058 fmt.Fprintf(w, "%s %s <verb> --help for flags.\n", c.program(), display)
1059}
8191060```
8201061
821- [ ] **Step 6: Update the test this changes**
1062`runHelp`'s one call site becomes `c.helpNoun(w, prefix, matched, nil)`
1063for now; Task 2.3 gives it a real map for the `auth` alias. A `nil`
1064map's zero value behaves like an empty one — `override[full]` on a
1065`nil` map is always `"", false` — so every other noun is unaffected.
1066
1067- [ ] **Step 12: Update the test `usage`/`usageWith` changes**
8221068
8231069`TestArgumentRefusalsNameTheUsage` in `internal/control/control_test.go`
8241070asserts `errOut.String()` contains the bare `"usage: " +
8251071strings.Join(argv, " ")`; with no `Cfg.Server.SiteURL` and no `Term` set
8261072on 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:
1073empty host — `hostOf("")` returns `""`). None of these four commands is
1074one of the eighteen with a mismatched CLI path, and the test sends no
1075`--path=`, so `c.CLIPath` stays empty throughout — set a `SiteURL` and
1076assert the plain ssh-prefixed registered form:
8291077
8301078```go
8311079func TestArgumentRefusalsNameTheUsage(t *testing.T) {
@@ -848,18 +1096,353 @@ func TestArgumentRefusalsNameTheUsage(t *testing.T) {
8481096(Add `"gitbay.org/gitbay/internal/config"` to the file's imports if it
8491097is not already there.)
8501098
851- [ ] **Step 7: Run the package**
1099- [ ] **Step 13: Run the package**
8521100
8531101Run: `go test ./internal/control -count=1`
8541102Expected: PASS. Any other test asserting a bare `"usage: <path>..."` with
8551103no program prefix needs the same treatment — `grep -rn '"usage: '
856internal/control/*_test.go` finds them all.
1104internal/control/*_test.go` finds them all; a `help`-rendering test
1105asserting a bare `"gitbay keys ..."` line for one of the eighteen
1106commands needs the CLI-prefixed form instead.
1107
1108- [ ] **Step 14: The CLI side — write the failing tests for the two pure helpers**
1109
1110`cmd/gitbay/ssh.go` needs a way to read a cobra command's own path, and
1111a way to fold it onto a server command line, both pure and cheap to
1112unit test the way `termValue`/`pagerArgv`/`pages` already are (`cmd/gitbay/term_test.go`):
1113
1114```go
1115func TestCLIPathOf(t *testing.T) {
1116 root := &cobra.Command{Use: "gitbay"}
1117 auth := &cobra.Command{Use: "auth"}
1118 keys := &cobra.Command{Use: "keys"}
1119 remove := &cobra.Command{Use: "remove"}
1120 keys.AddCommand(remove)
1121 auth.AddCommand(keys)
1122 root.AddCommand(auth)
1123 if got := cliPathOf(remove); got != "auth keys remove" {
1124 t.Errorf("cliPathOf = %q", got)
1125 }
1126}
1127
1128func TestWithCLIPath(t *testing.T) {
1129 if got := withCLIPath("", []string{"keys", "remove", "abc"}); !slices.Equal(got, []string{"keys", "remove", "abc"}) {
1130 t.Errorf("empty cliPath: %v", got)
1131 }
1132 got := withCLIPath("auth keys remove", []string{"keys", "remove", "abc"})
1133 want := []string{"--path=auth keys remove", "keys", "remove", "abc"}
1134 if !slices.Equal(got, want) {
1135 t.Errorf("got %v, want %v", got, want)
1136 }
1137}
1138```
1139
1140Add these to `cmd/gitbay/term_test.go`, alongside `TestTermValue` and
1141`TestPagerArgv`; add `"slices"` and `"github.com/spf13/cobra"` to its
1142imports if not already there.
1143
1144- [ ] **Step 15: Run and see them fail**
1145
1146Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1147Expected: FAIL to compile (`cliPathOf`/`withCLIPath` undefined).
1148
1149- [ ] **Step 16: Implement the two helpers in `cmd/gitbay/ssh.go`**
1150
1151Add `"github.com/spf13/cobra"` to the file's imports, then:
1152
1153```go
1154// cliPathOf is the path this cobra command was actually reached by,
1155// stripped of the root's own name: "auth keys remove" for a command
1156// nested under auth > keys > remove. It is sent to the server as
1157// --path=, so usage and help can print what the caller can actually
1158// run even where that differs from the registered path being
1159// dispatched (cmd.Annotations[serverPath]) — #267.
1160func cliPathOf(cmd *cobra.Command) string {
1161 return strings.TrimPrefix(cmd.CommandPath(), "gitbay ")
1162}
1163
1164// withCLIPath prepends --path=<cliPath> to a server command line, the
1165// same way runSSHPaged prepends --term=: a leading pseudo-flag Dispatch
1166// strips before Lookup, never confused for a real argument. Empty
1167// cliPath is a no-op — nothing to add for a caller with no cobra tree
1168// of its own to have resolved.
1169func withCLIPath(cliPath string, argv []string) []string {
1170 if cliPath == "" {
1171 return argv
1172 }
1173 return append([]string{"--path=" + cliPath}, argv...)
1174}
1175```
1176
1177- [ ] **Step 17: Run**
1178
1179Run: `go test ./cmd/gitbay -run "TestCLIPathOf|TestWithCLIPath" -count=1`
1180Expected: PASS.
1181
1182- [ ] **Step 18: Thread `cliPath` through `pass`, `runServerHelp`, `runPass`**
1183
1184In `cmd/gitbay/main.go`:
1185
1186```go
1187func pass(use string, o passOpts) *cobra.Command {
1188 return &cobra.Command{
1189 Use: use,
1190 Short: summaries[strings.Join(o.server, " ")],
1191 Annotations: map[string]string{
1192 serverPath: strings.Join(o.server, " "),
1193 stdinMode: o.stdinModeName(),
1194 stdinWhat: o.stdinWhat,
1195 },
1196 DisableFlagParsing: true,
1197 RunE: func(cmd *cobra.Command, args []string) error {
1198 // The registry is the only place flags are written down, so
1199 // --help asks the server rather than reprinting the one-line
1200 // summary cobra holds.
1201 cliPath := cliPathOf(cmd)
1202 for _, a := range args {
1203 if a == "--help" || a == "-h" {
1204 os.Exit(runServerHelp(o, cliPath))
1205 }
1206 }
1207 os.Exit(runPass(o, cliPath, args))
1208 return nil
1209 },
1210 }
1211}
1212```
1213
1214```go
1215// runServerHelp prints the registry's usage for one command.
1216func runServerHelp(o passOpts, cliPath string) int {
1217 t, err := resolveTarget()
1218 if err != nil {
1219 fmt.Fprintln(os.Stderr, "gitbay:", err)
1220 return protocol.ExitFailure
1221 }
1222 return runSSH(t, withCLIPath(cliPath, append([]string{"help"}, o.server...)), strings.NewReader(""))
1223}
1224
1225func runPass(o passOpts, cliPath string, args []string) int {
1226```
1227
1228(`runPass`'s body is otherwise unchanged; only its signature gains
1229`cliPath string` as the second parameter, and its final line becomes:)
1230
1231```go
1232 return runSSHPaged(t, withCLIPath(cliPath, append(o.server, args...)), stdin, pages(o.server, args))
1233```
1234
1235- [ ] **Step 19: Thread `cliPath` through `group`/`serverHelp`**
1236
1237```go
1238func group(use, short string, subs ...*cobra.Command) *cobra.Command {
1239 c := &cobra.Command{Use: use, Short: short}
1240 c.AddCommand(subs...)
1241 // A noun's help is the server's, like a command's: the registry is
1242 // the only place flags are written down, and cobra's subcommand list
1243 // carried none (#130). Offline, or for a noun the server does not
1244 // know by that name, cobra's own tree still prints.
1245 local := c.HelpFunc()
1246 c.SetHelpFunc(func(cmd *cobra.Command, args []string) {
1247 if !serverHelp(use, cliPathOf(cmd)) {
1248 local(cmd, args)
1249 }
1250 })
1251 return c
1252}
1253
1254// serverHelp prints the registry's usage for a prefix and reports whether
1255// it did. cliPath is this invocation's own resolved cobra path (empty for
1256// a noun whose CLI path already matches its registered prefix). At a
1257// terminal it goes through the terminal-aware path, so it gets the same
1258// --term=<cols>[,color] treatment (and layout) as any other command;
1259// piped, it stays a quiet capture, so a network or lookup failure falls
1260// back to cobra's local help without noise.
1261func serverHelp(prefix, cliPath string) bool {
1262 t, err := resolveTarget()
1263 if err != nil {
1264 return false
1265 }
1266 argv := withCLIPath(cliPath, []string{"help", prefix})
1267 if term.IsTerminal(int(os.Stdout.Fd())) {
1268 return runSSH(t, argv, strings.NewReader("")) == 0
1269 }
1270 out, code := sshCapture(t, argv)
1271 if code != 0 || out == "" {
1272 return false
1273 }
1274 fmt.Print(out)
1275 return true
1276}
1277```
1278
1279- [ ] **Step 20: Build**
1280
1281Run: `go build ./... && go vet ./...`
1282Expected: builds clean. `keysAdd.RunE`/`pgpAdd.RunE` in `authCmd()`
1283(`cmd/gitbay/main.go`) are hand-built, not `pass()`-generated, so they
1284do not pick up `cliPath` from this step — Task 2.2 gives them the same
1285treatment where it already rewrites their bodies.
1286
1287- [ ] **Step 21: The cobra tree is the one place the eighteen mismatches
1288 are allowed to be listed — a coverage test, not a hand check**
1289
1290A future command wired with a `serverPath` that does not match its own
1291`CommandPath()` is exactly this defect happening again; nothing should
1292have to remember to re-check it by hand. `TestEveryCommandIsReachable`
1293(`cmd/gitbay/coverage_test.go`) already walks `newRoot()` comparing
1294`Annotations[serverPath]` against the registry — this test walks the
1295same tree comparing it against `cliPathOf`, and pins today's known set
1296so any change to it (a new mismatch, or one of these being fixed to
1297match) shows up as a diff a reviewer has to look at.
1298
1299```go
1300package main
1301
1302import (
1303 "slices"
1304 "strings"
1305 "testing"
1306
1307 "github.com/spf13/cobra"
1308)
1309
1310// TestServerPathMismatches pins the commands whose CLI path differs from
1311// the server path they dispatch (#267) — cmdUsage/help print the CLI
1312// path for exactly these, from cliPathOf, not the registered one. A new
1313// mismatch changes this list; update it deliberately, alongside the
1314// wiki's Parity page if it changes what a stock-ssh caller must type.
1315func TestServerPathMismatches(t *testing.T) {
1316 want := []string{
1317 "auth email add", "auth email list", "auth email primary",
1318 "auth email remove", "auth email verify",
1319 "auth export",
1320 "auth keys add", "auth keys label", "auth keys list", "auth keys remove",
1321 "auth pgp add", "auth pgp list", "auth pgp remove",
1322 "auth token create", "auth token list", "auth token revoke",
1323 "auth whoami",
1324 "repo topics list",
1325 }
1326
1327 var got []string
1328 var walk func(*cobra.Command)
1329 walk = func(c *cobra.Command) {
1330 if p := c.Annotations[serverPath]; p != "" {
1331 if cli := cliPathOf(c); cli != p {
1332 got = append(got, cli)
1333 }
1334 }
1335 for _, sub := range c.Commands() {
1336 walk(sub)
1337 }
1338 }
1339 root := newRoot()
1340 root.InitDefaultHelpCmd()
1341 walk(root)
1342 slices.Sort(got)
1343
1344 if !slices.Equal(got, want) {
1345 t.Errorf("mismatched CLI paths = %v\nwant %v", got, want)
1346 }
1347}
1348```
1349
1350- [ ] **Step 22: Run**
1351
1352Run: `go test ./cmd/gitbay -run TestServerPathMismatches -count=1`
1353Expected: PASS (the eighteen are already there today; this step only
1354adds the guard, it changes no behavior).
1355
1356- [ ] **Step 23: One end-to-end proof, real ssh and the real binary**
1357
1358Every other test here is a unit test against `Ctx`/cobra values built
1359by hand; this is the one e2e test for this task (Global Constraints
1360caps it at one), proving the wiring — `runSSHPaged`'s `--path=` prepend,
1361the server's `--path=` parse, `cmdUsage`'s substitution — actually
1362reaches an instance over real ssh, for the CLI and for stock ssh alike.
1363Model it on `e2e/term_test.go`'s `TestTermEnvSelectsTerminalOutput`.
1364
1365```go
1366package e2e
8571367
858- [ ] **Step 8: Commit**
1368import (
1369 "strings"
1370 "testing"
1371)
1372
1373// The CLI sends its own invoking path so usage and help print a command
1374// that exists — gitbay auth keys remove, never the unregistered gitbay
1375// keys remove (#267). Stock ssh, which never sends one, keeps seeing
1376// the registered path: it is the only one it could ever type.
1377func TestCLIUsagePrintsTheInvokingPath(t *testing.T) {
1378 t.Parallel()
1379 inst := startInstance(t)
1380 key := inst.newKey(t, "alice")
1381 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub",
1382 "--email", "alice@example.test", "--verified")
1383
1384 c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
1385 c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
1386 "--port", instPort(inst),
1387 "--ssh-option", "-i", "--ssh-option", key,
1388 "--ssh-option", "-oIdentitiesOnly=yes",
1389 "--ssh-option", "-oStrictHostKeyChecking=no",
1390 "--ssh-option", "-oUserKnownHostsFile="+inst.sshDir+"/kh",
1391 "--ssh-option", "-oBatchMode=yes",
1392 "--default")
1393
1394 // A mismatched command: the CLI path (auth keys remove) differs from
1395 // the registered one (keys remove). No fingerprint given, an actual
1396 // wrong-argument refusal.
1397 _, errOut, code := c.run(t, "", "", "auth", "keys", "remove")
1398 if code == 0 || !strings.Contains(errOut, "usage: gitbay auth keys remove") {
1399 t.Errorf("mismatched command: exit %d, stderr %q", code, errOut)
1400 }
1401 if strings.Contains(errOut, "usage: gitbay keys remove") {
1402 t.Errorf("mismatched command leaked the registered path: %q", errOut)
1403 }
1404
1405 // A matching command: no CLI/registered difference, still the gitbay
1406 // form (it is a terminal-adjacent test binary run, isTTY is false
1407 // here, so this exercises the non-terminal ssh form instead —
1408 // assert on the registered path itself, which is all cmdUsage can
1409 // tell apart in that mode).
1410 _, errOut2, code2 := c.run(t, "", "", "repo", "show")
1411 if code2 == 0 || !strings.Contains(errOut2, "usage: ") || !strings.Contains(errOut2, "repo show") {
1412 t.Errorf("matching command: exit %d, stderr %q", code2, errOut2)
1413 }
1414
1415 // Stock ssh, no CLI involved: the registered path, because it is the
1416 // only one this caller could have typed.
1417 _, errOut3, code3 := inst.ssh(t, key, "", "keys", "remove")
1418 if code3 == 0 || !strings.Contains(errOut3, "usage: ssh git@") || !strings.Contains(errOut3, "keys remove") {
1419 t.Errorf("stock ssh: exit %d, stderr %q", code3, errOut3)
1420 }
1421 if strings.Contains(errOut3, "auth keys remove") {
1422 t.Errorf("stock ssh should never see the CLI-only auth prefix: %q", errOut3)
1423 }
1424}
1425```
1426
1427(`instPort`/`inst.sshDir`/`c.run`/`inst.ssh` are whatever `e2e/cli_test.go`
1428and `e2e/term_test.go` already expose — read both before writing this
1429file and use their actual helper names and signatures rather than the
1430ones guessed here; `TestCLI` in `e2e/cli_test.go` is the fullest existing
1431example of standing up a `cli` value against a live `instance`.)
1432
1433- [ ] **Step 24: Run the one e2e test**
1434
1435Run: `go test ./e2e -run TestCLIUsagePrintsTheInvokingPath -count=1`
1436Expected: PASS.
1437
1438- [ ] **Step 25: Run everything this task touched, commit**
1439
1440Run: `go build ./... && go vet ./... && go test ./internal/control ./cmd/gitbay -count=1`
1441Expected: PASS.
8591442
8601443```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"
1444git add internal/control/help.go internal/control/control.go internal/control/control_test.go internal/control/help_test.go cmd/gitbay/ssh.go cmd/gitbay/main.go cmd/gitbay/term_test.go cmd/gitbay/serverpath_test.go e2e/cliusage_test.go
1445git commit -m "usage, help: print the CLI's own invoking path where it differs from the registered one, the ssh form otherwise" -m "Ref #267"
8631446```
8641447
8651448### Task 2.2: `--help` check in `keys add` and `pgp add`
@@ -907,30 +1490,56 @@ Expected: FAIL or timeout (stdin read attempted).
9071490- [ ] **Step 3: Implement**
9081491
9091492`keysAdd.RunE` and `pgpAdd.RunE` in `cmd/gitbay/main.go` each gain the
910same loop `pass()` already has, before resolving the target:
1493same loop `pass()` already has, before resolving the target. They also
1494pick up `cliPath` here (Task 2.1 gave `runServerHelp` a second
1495parameter but could not touch these two hand-built `RunE`s, since this
1496task is what rewrites their bodies): `auth keys add` and `auth pgp add`
1497are two of the eighteen commands whose CLI path differs from the
1498registered one, so their own usage refusals need it exactly like every
1499`pass()`-generated command's do.
9111500
9121501```go
9131502 keysAdd.RunE = func(cmd *cobra.Command, args []string) error {
1503 cliPath := cliPathOf(cmd)
9141504 for _, a := range args {
9151505 if a == "--help" || a == "-h" {
916 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}))
1506 os.Exit(runServerHelp(passOpts{server: []string{"keys", "add"}}, cliPath))
9171507 }
9181508 }
9191509 t, err := resolveTarget()
920 ...
1510 if err != nil {
1511 return err
1512 }
1513 in, err := stdinPayload(os.Stdin, "an SSH public key", false)
1514 if err != nil {
1515 return err
1516 }
1517 os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"keys", "add"}, args...)), in))
1518 return nil
1519 }
9211520```
9221521
9231522and, for `pgpAdd`:
9241523
9251524```go
9261525 RunE: func(cmd *cobra.Command, args []string) error {
1526 cliPath := cliPathOf(cmd)
9271527 for _, a := range args {
9281528 if a == "--help" || a == "-h" {
929 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}))
1529 os.Exit(runServerHelp(passOpts{server: []string{"pgp", "add"}}, cliPath))
9301530 }
9311531 }
9321532 t, err := resolveTarget()
933 ...
1533 if err != nil {
1534 return err
1535 }
1536 in, err := stdinPayload(os.Stdin, "an armored OpenPGP public key", false)
1537 if err != nil {
1538 return err
1539 }
1540 os.Exit(runSSH(t, withCLIPath(cliPath, append([]string{"pgp", "add"}, args...)), in))
1541 return nil
1542 },
9341543```
9351544
9361545- [ ] **Step 4: Run**
@@ -950,7 +1559,7 @@ git add cmd/gitbay/main.go cmd/gitbay/main_test.go
9501559git commit -m "auth keys add, pgp add: check --help before reading stdin" -m "Ref #267"
9511560```
9521561
953### Task 2.3: `auth --help` renders with the registry layout
1562### Task 2.3: `auth --help` renders with the registry layout, in the CLI's own paths
9541563
9551564`auth` is a CLI-only grouping — no registry command's path starts with
9561565`auth`, so `gitbay auth --help` asks the server for help on prefix
@@ -959,27 +1568,44 @@ falls back to cobra's own subcommand listing, which carries no flags or
9591568examples (the reason `group()` exists at all, per its own comment).
9601569Give the registry an alias table for CLI-only groupings so `auth`
9611570renders the same READ/WRITE, aligned-summary layout every real noun
962gets.
1571gets — and, since none of the rows it gathers (`keys add`, `account
1572export`, ...) are commands a caller can actually type, each row prints
1573the CLI path it really takes (`auth keys add`, `auth export`), the same
1574substitution Task 2.1 gave a single command's own usage line. Unlike
1575Task 2.1's mismatches, which are one registered path to one CLI path,
1576`auth` gathers several unrelated registered prefixes into one grouping,
1577and one of them (`account export` -> `auth export`) does not even keep
1578the same word count — the alias table has to carry the CLI form
1579alongside each registered prefix explicitly; it cannot be derived by
1580pattern-matching the prefix the way Task 2.1's single-command swap is.
9631581
9641582**Files:**
965- Modify: `internal/control/help.go` (`runHelp`, `nounSummaries`)
1583- Modify: `internal/control/help.go` (`runHelp`, `nounAliases`, `nounSummaries`)
9661584- Modify: `cmd/gitbay/main.go` (`authCmd`'s `group("auth", ...)` description)
9671585- Test: `internal/control/help_test.go`
9681586
9691587**Interfaces:**
970- Produces: `var nounAliases map[string][]string` — a CLI-only noun name to the real registry prefixes it gathers.
1588- Produces: `type nounAlias struct { Registered, CLI string }`; `var
1589 nounAliases map[string][]nounAlias` — a CLI-only noun name to the
1590 registered prefixes it gathers, each paired with the CLI path that
1591 reaches it.
9711592
972- [ ] **Step 1: Write the failing test**
1593- [ ] **Step 1: Write the failing tests**
1594
1595Two: the CLI form (a caller that sent `--path=auth`, as `gitbay auth
1596--help` now does per Task 2.1's `group`/`serverHelp` change), and the
1597ssh form (a caller that sent nothing, which cannot run an `auth
1598whatever` command and must not be told to).
9731599
9741600```go
9751601func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
9761602 var out bytes.Buffer
977 c := &Ctx{Stdout: &out, Term: Term{Cols: 100}}
1603 c := &Ctx{Stdout: &out, Term: Term{Cols: 100}, CLIPath: "auth"}
9781604 if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
9791605 t.Fatalf("exit %d", code)
9801606 }
9811607 got := out.String()
982 for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
1608 for _, want := range []string{"auth whoami", "auth keys list", "auth pgp add", "auth token create", "auth export"} {
9831609 if !strings.Contains(got, want) {
9841610 t.Errorf("missing %q in:\n%s", want, got)
9851611 }
@@ -988,11 +1614,28 @@ func TestHelpRendersAnAliasedNounWithTheRegistryLayout(t *testing.T) {
9881614 t.Errorf("auth did not resolve: %s", got)
9891615 }
9901616}
1617
1618func TestHelpRendersAnAliasedNounInRegisteredFormOverSSH(t *testing.T) {
1619 var out bytes.Buffer
1620 c := &Ctx{Stdout: &out} // no Term, no CLIPath: exactly stock ssh
1621 if code := runHelp(c, []string{"auth"}); code != protocol.ExitOK {
1622 t.Fatalf("exit %d", code)
1623 }
1624 got := out.String()
1625 for _, want := range []string{"whoami", "keys list", "pgp add", "token create", "account export"} {
1626 if !strings.Contains(got, want) {
1627 t.Errorf("missing %q in:\n%s", want, got)
1628 }
1629 }
1630 if strings.Contains(got, "auth keys list") {
1631 t.Errorf("stock ssh should not see the CLI-only auth prefix: %s", got)
1632 }
1633}
9911634```
9921635
993- [ ] **Step 2: Run and see it fail**
1636- [ ] **Step 2: Run and see them fail**
9941637
995Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1`
1638Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
9961639Expected: FAIL (`no command matches "auth"`).
9971640
9981641- [ ] **Step 3: Implement the alias table and the lookup change**
@@ -1000,13 +1643,33 @@ Expected: FAIL (`no command matches "auth"`).
10001643In `internal/control/help.go`, near `nounSummaries`:
10011644
10021645```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"},
1646// nounAlias is one bucket of registered commands, reachable under a
1647// CLI-only noun that is not itself a registry path (auth, gathering
1648// several unrelated registry prefixes): Registered is what runHelp
1649// matches against the registry, CLI is the path a gitbay caller
1650// actually types to reach it — not always Registered with the alias's
1651// own name stitched on (account export -> auth export drops a word),
1652// so the two are paired explicitly rather than derived.
1653type nounAlias struct {
1654 Registered string
1655 CLI string
1656}
1657
1658// nounAliases groups a CLI-only noun into the real prefixes it gathers,
1659// so `help auth` renders with the same layout a real noun gets instead
1660// of falling back to whatever a caller does when help fails. A stock
1661// ssh caller — the only one who could ever ask for a bare "auth" and
1662// get nothing back from the registry — sees the Registered forms
1663// unchanged; the CLI, having sent its own path, sees CLI.
1664var nounAliases = map[string][]nounAlias{
1665 "auth": {
1666 {"account export", "auth export"},
1667 {"whoami", "auth whoami"},
1668 {"keys", "auth keys"},
1669 {"email", "auth email"},
1670 {"pgp", "auth pgp"},
1671 {"token", "auth token"},
1672 },
10101673}
10111674```
10121675
@@ -1016,14 +1679,31 @@ and add, to `nounSummaries`:
10161679 "auth": "whoami, SSH and PGP keys, email, API tokens",
10171680```
10181681
1019In `runHelp`, widen the match to every aliased prefix:
1682In `runHelp`, widen the match to every aliased prefix, and — only for a
1683caller that sent its own `CLIPath` — build the per-row override
1684`helpNoun` (Task 2.1) now accepts:
10201685
10211686```go
10221687func runHelp(c *Ctx, args []string) int {
10231688 prefix := joinPath(args)
10241689 prefixes := []string{prefix}
1690 override := map[string]string{}
10251691 if aliased, ok := nounAliases[prefix]; ok {
1026 prefixes = aliased
1692 prefixes = nil
1693 for _, a := range aliased {
1694 prefixes = append(prefixes, a.Registered)
1695 }
1696 if c.CLIPath != "" {
1697 for _, cmd := range registry {
1698 p := joinPath(cmd.Path)
1699 for _, a := range aliased {
1700 if p == a.Registered || strings.HasPrefix(p, a.Registered+" ") {
1701 override[p] = a.CLI + strings.TrimPrefix(p, a.Registered)
1702 break
1703 }
1704 }
1705 }
1706 }
10271707 }
10281708 var matched []Command
10291709 for _, cmd := range registry {
@@ -1035,15 +1715,41 @@ func runHelp(c *Ctx, args []string) int {
10351715 }
10361716 }
10371717 }
1718 if len(matched) == 0 {
1719 return c.fail(protocol.ExitNotFound, "no command matches %q; try: help", prefix)
1720 }
1721 slices.SortFunc(matched, func(a, b Command) int { return strings.Compare(joinPath(a.Path), joinPath(b.Path)) })
1722 entries := make([]helpEntry, len(matched))
1723 for i, cmd := range matched {
1724 entries[i] = helpEntry{Path: joinPath(cmd.Path), Summary: cmd.Summary, Usage: cmd.Usage, Flags: cmd.Flags, Examples: cmd.Examples}
1725 }
1726 return c.emit(entries, func(w io.Writer) {
1727 switch {
1728 case prefix == "":
1729 for _, e := range entries {
1730 summary := e.Summary
1731 if c.Term.Cols > 0 {
1732 if avail := c.Term.Cols - max(cells(e.Path), 24) - 1; avail > 0 {
1733 summary = clip(summary, avail)
1734 }
1735 }
1736 fmt.Fprintf(w, "%-24s %s\n", e.Path, summary)
1737 }
1738 case joinPath(matched[0].Path) == prefix:
1739 c.helpVerb(w, matched[0], matched[1:])
1740 default:
1741 c.helpNoun(w, prefix, matched, override)
1742 }
1743 })
1744}
10381745```
10391746
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.
1747`matched[0].Path` never equals `"auth"` literally (nothing in the
1748registry is named that), so an aliased noun always takes the
1749`helpNoun` branch. `override` stays an empty (non-nil) map for every
1750ordinary noun — `override[full]` misses for every row, and `helpNoun`
1751falls back to its plain `strings.TrimPrefix` — so this changes nothing
1752for `help repo` or any other real prefix.
10471753
10481754- [ ] **Step 4: Sync the CLI's own description**
10491755
@@ -1058,7 +1764,7 @@ paths under the `auth` heading.
10581764
10591765- [ ] **Step 5: Run**
10601766
1061Run: `go test ./internal/control -run TestHelpRendersAnAliasedNounWithTheRegistryLayout -count=1`
1767Run: `go test ./internal/control -run TestHelpRendersAnAliasedNoun -count=1`
10621768Expected: PASS.
10631769
10641770- [ ] **Step 6: Run both packages**
@@ -1070,21 +1776,9 @@ Expected: PASS.
10701776
10711777```bash
10721778git 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"
1779git commit -m "help: auth (and any future CLI-only grouping) renders with the registry layout, in the CLI's own paths" -m "Ref #267"
10741780```
10751781
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
10881782### Task 2.4: verb-phrase summaries
10891783
10901784Six commands' one-line summaries are bare nouns rather than a phrase
@@ -1813,10 +2507,14 @@ Wait for CI, merge with `--strategy ff`, delete the branch both places.
18132507 matches current behavior (`internal/control/dashboard.go`'s `section`
18142508 helper, `internal/control/control.go`'s `emit`) — no task needed.
18152509- #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).
2510 re-implemented) and now also `--path`/`Ctx.CLIPath`, its own invoking
2511 path — the same mechanism, a sibling prefix — used by
2512 `usage()`/`usageWith()`/`helpVerb`/`helpNoun` so the eighteen commands
2513 whose CLI path differs from the registered one print a command that
2514 exists (Task 2.1); `[<owner/name>]` optional from the CLI (Task 2.1);
2515 `--help` check in `keys add`/`pgp add`, now also sending their own
2516 `cliPath` (Task 2.2); `auth` rendered with the registry layout, each
2517 row in the CLI's own path (Task 2.3); verb-phrase summaries (Task 2.4).
18202518- #268: unregistered-key message (Task 3.1); `issue create` flags
18212519 (Task 3.2); `mr show` plurals (Task 3.3); `repo readme` (Task 3.4);
18222520 `repo show` mirror time (Task 3.5).
@@ -1836,14 +2534,11 @@ reads the named test file's other tests first, per each step's own
18362534instruction, before writing the step.
18372535
18382536**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.
2537are used with the same names in Tasks 1.2 and 1.3. `Ctx.CLIPath`,
2538`cmdUsage`/`cliUsage`/`shownAs` and the CLI-side `cliPathOf`/
2539`withCLIPath` (Task 2.1) are used with the same names and signatures in
2540Task 2.2 (`keysAdd.RunE`/`pgpAdd.RunE` sending their own `cliPath`) and
2541Task 2.3 (`helpNoun`'s `override` map, the alias table's `CLI` field
2542built from the same substitution `shownAs` performs for a single
2543command). `PickReadme` (Task 3.4) is the only name introduced for that
2544logic and is used consistently in its own task.
docs/plans/2026-09-27-credentials-and-sessions.md +26 −41
@@ -24,8 +24,8 @@ cap.
2424client for e2e.
2525
2626**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`.
27decisions on #256 and #257 are recorded there, and the later ones in
28"Decisions" at the end of this plan).
2929
3030## Global constraints
3131
@@ -3512,45 +3512,30 @@ gitbay mr create --source weblogin-limit --target main --title "web login over S
35123512
35133513---
35143514
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.
3515## Decisions (2026-09-28)
3516
35171. **Expiring SSH keys and minting.** Confirmed: an expiring key is
3518 refused the minting commands like an expiring token (MR 3,
3519 Task 3.2).
35202. **Which commands mint.** Confirmed: the issue's five plus
3521 `repo runner add`, `admin user create`, `email verify` and
3522 `admin email verify`, pinned by `TestMintingCommandsMarked`.
35233. **LFS transfer tokens.** Filed as #285; not in this plan.
35244. **Removing the key you are on.** Confirmed: the session's own
3525 connection is cut after the removal commits.
35265. **Idle window.** A constant (`store.WebSessionIdle`, 12 h); make it
3527 configurable only when someone needs another value.
3528
3529Documented limits, stated on the Threat-Model page in MR 1:
3530
3531- With `ssh.mode = "system"` each exec is its own forced-command
3532 process; the per-exec check applies, a running one is not cut. bay1
3533 runs embedded.
3534- A control command already inside its store write when the key is
3535 revoked finishes the write and its output is lost; git transports are
3536 killed and commands watching `Done` stop.
3537
3538The `token list` future-expiry display is #286.
35543539
35553540## Self-review
35563541
docs/plans/2026-09-27-data-at-rest-and-backup.md +434 −81
@@ -5,8 +5,10 @@
55**Goal:** Seal the four secret columns under a key file outside the
66database (#273), encrypt backup archives to an age recipient (#274),
77and 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.
8deletions wait for a running backup (#259). The operator runbook moves
9the offsite copy from Scaleway to Cloudflare R2 under bucket locks,
10adds a separate keys repository for `apns.p8` and the secret key file,
11and holds the restore drill, which is deferred.
1012
1113**Architecture:** A new `internal/seal` package holds AES-256-GCM keys
1214read from `server.secret_key_file` (default `/etc/gitbay/secret.key`,
@@ -30,7 +32,11 @@ decisions recorded in the brief: AES-GCM, key file under `/etc/gitbay`
3032mode 0600 excluded from backups, key id prefix on each value, rotation
3133command, re-encryption of existing rows; age recipients, `--verify`
3234takes an identity file; the clean-host drill is an operator runbook
33recorded on the Admin wiki page.
35recorded on the Admin wiki page, deferred by the operator (#259 stays
36open), repeated quarterly and after any backup code change; the
37offsite restic repository moves to Cloudflare R2, and `apns.p8` and
38`secret.key` go to a separate small restic repository on R2 with its
39own bucket and password.
3440
3541## Global Constraints
3642
@@ -79,8 +85,9 @@ recorded on the Admin wiki page.
7985 comments; the last key seals, all keys open. Mode must be 0600 or
8086 stricter; anything group- or world-readable is refused.
8187 - `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.
88 (validation error), so neither the archive nor the main restic
89 snapshot of `/var/lib/gitbay` can carry it. Its offsite copy is the
90 separate keys repository (runbook D), never the main one.
8491 - A missing key file is fatal for every process that opens the
8592 database through `openStore` (serve, shell, authorized-keys, host
8693 admin commands), with a message naming the path and
@@ -95,7 +102,18 @@ recorded on the Admin wiki page.
95102| 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 |
96103| 2 | `backup-age` | Closes #274 | `[backup] age_recipients`, age-wrapped archives, `--verify --identity`, backup script globs, wiki |
97104| 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 |
105| — | `offsite-r2-wiki` (operator) | Ref the R2 move issue (runbook D.1) | Admin, Threat-Model and Architecture pages describe R2 with bucket locks and the keys repository, after Scaleway is retired |
106| — | `restore-drill-record` (operator, deferred) | Closes #259 | the first drill's numbers in the Admin page, Known-Gaps and Controls rows closed |
107
108#259 stays open after MR 3: its commits say `Ref #259`, and only the
109drill record closes it. The operator has deferred the drill.
110
111The offsite job is not in the repository. `/usr/local/bin/gitbay-offsite`
112and its `gitbay-offsite.service`/`.timer` exist only on bay1;
113`deploy/cloud-init.yaml` does not template them (its
114`gitbay-backup.sh` carries only the comment "To ship offsite, add an
115rclone/s3 upload of $out here."). The move to R2 is therefore a
116runbook section (D) with the script edits written out, not a code MR.
99117
100118MR 2 and MR 3 both edit `cmd/gitbayd/backup.go`; land them in order.
101119MR 2's `testConfig` helper comes from MR 1.
@@ -1834,9 +1852,11 @@ stored sealed: AES-256-GCM under a key in =server.secret_key_file=,
18341852each value prefixed with the id of the key that sealed it
18351853(=gbs1:<id>:=). The key file is not in the database, not under
18361854=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.
1855main restic repository. Its offsite copy is a separate restic
1856repository that holds only keys (see "Offsite copies"), with its own
1857bucket and password, so a leak of the main backup does not expose the
1858key that opens its secrets. Without the key a restored database's
1859secrets cannot be opened, and gitbayd refuses to start against them.
18401860
18411861#+begin_src sh
18421862gitbayd admin secrets init # once; deploy/install.sh does it on first install
@@ -1853,12 +1873,17 @@ gitbayd admin secrets rotate # new key, reseal, retire the old one (as root)
18531873 in clear and logs =sealed secret values=.
18541874- Rotation: =rotate= adds a key, reseals every value under it in one
18551875 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.
1876 when it changes, so it needs no restart. Back the new file up to the
1877 keys repository afterwards.
18581878- Push devices are looked up by the SHA-256 of their token
18591879 (=push_devices.token_hash=), since two seals of one token differ.
18601880```
18611881
1882If runbook D (the keys repository) has not run when this MR lands,
1883leave out the sentences naming the keys repository here and in
1884`06-Data-and-Cryptography.org` below; the `offsite-r2-wiki` MR adds
1885them.
1886
18621887- [ ] **Step 3: Architecture pages**
18631888
18641889`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":
@@ -1878,7 +1903,8 @@ and replace the paragraph "The code base contains no symmetric encryption. ..."
18781903```org
18791904The database file or a backup read by anyone other than the =gitbay=
18801905user discloses no CI secret, webhook secret, mirror token or device
1881token without the key file, which neither carries. Rotation:
1906token without the key file, which neither carries; the key file's
1907only offsite copy is a separate keys repository. Rotation:
18821908=gitbayd admin secrets rotate= (Admin wiki).
18831909```
18841910
@@ -1894,7 +1920,7 @@ Update line 5's migration count to the number of files in
18941920`09-Controls.org:59`:
18951921
18961922```org
1897| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and backups (=internal/seal=) |
1923| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
18981924```
18991925
19001926`10-Known-Gaps.org`: delete the `#273` row.
@@ -1909,7 +1935,8 @@ If the file has no `* Unreleased` heading above the latest version, add one unde
19091935replacing the binary, run =gitbayd admin secrets init= as root and
19101936=chown gitbay:gitbay /etc/gitbay/secret.key= (=deploy/install.sh= does
19111937both when the file is missing). The first start seals the stored
1912secrets. Copy the key file off the host: backups do not carry it.
1938secrets. Back the key file up separately: =admin backup= archives do
1939not carry it (see the Admin wiki, "Secret key").
19131940
19141941- CI secrets, webhook secrets, mirror tokens and push device tokens are
19151942 stored sealed with AES-256-GCM (#273). =gitbayd admin secrets
@@ -2308,8 +2335,9 @@ New `** [backup]` section after `** [push]`:
23082335 =admin backup= encrypts every archive to them and appends =.age= to
23092336 its name. Generate the pair off the host with =age-keygen=; only the
23102337 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.
2338 The restic copy is unaffected: the offsite job stages its own
2339 =VACUUM INTO= of the live database and snapshots =/var/lib/gitbay=,
2340 not the archives.
23132341```
23142342
23152343In `* Backup and restore`, after the `--verify` paragraph:
@@ -2333,7 +2361,7 @@ or on a restore host.
23332361`06-Data-and-Cryptography.org`, At rest, Backups row:
23342362
23352363```org
2336| Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file |
2364| Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file, whose offsite copy is a separate keys repository |
23372365```
23382366
23392367`08-Operations.org`, Full archive and Database only rows: append `; age-encrypted when =[backup] age_recipients= is set` to Contents.
@@ -3085,12 +3113,15 @@ Add a new subsection after `** Secret key`:
30853113```org
30863114** Restore drill
30873115
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
3116A restore onto a clean host, run quarterly and after any change to the
3117backup code (=cmd/gitbayd/backup.go=, the offsite job), and recorded
3118below. The disaster it rehearses is losing bay1, so the local archives
3119are gone with it and the sources are the main offsite restic
3120repository (repositories, LFS, the staged database, =config.toml=),
3121the keys repository (=secret.key=, =apns.p8=), and the operator's
3122password manager (=offsite.env=, the keys repository's password and
3123token, =backup-identity.txt=). The steps are in the data-at-rest
3124plan's operator runbook
30943125(=docs/plans/2026-09-27-data-at-rest-and-backup.md=).
30953126
30963127Time to service runs from the clean host's first root login to the
@@ -3101,7 +3132,11 @@ the time of the newest restic snapshot restored.
31013132|------+------+-------------------------+-----------------+--------------+--------------+-----+----------------+----------+---------+-------|
31023133```
31033134
3104- [ ] **Step 2: Architecture/08 and Threat-Model**
3135- [ ] **Step 2: Architecture/08, Known-Gaps and Threat-Model**
3136
3137`10-Known-Gaps.org:17`, the `#259` row's description becomes
3138`No restore has been exercised; the drill is written (Admin wiki) and not yet run`.
3139The row stays until the drill is recorded.
31053140
31063141`08-Operations.org`: replace the `--verify` bullet (lines 60-62) with:
31073142
@@ -3131,7 +3166,7 @@ Replace the "Recovery time" bullet with:
31313166
31323167```bash
31333168git add .gitbay/wiki
3134git commit -S -m "wiki: backup verify, backup lock, restore drill record
3169git commit -S -m "wiki: backup verify, backup lock, restore drill procedure
31353170
31363171Ref #259"
31373172git push -u origin backup-verify-lock
@@ -3139,14 +3174,19 @@ gitbay mr create --source backup-verify-lock --target main --title "Backup verif
31393174```
31403175
31413176Merge with `--strategy ff` after CI, delete the branch both places.
3142#259 stays open until the drill below is recorded.
3177#259 stays open after this MR: every commit in it says `Ref #259`, and
3178only the drill record (runbook C, deferred) closes it.
31433179
31443180---
31453181
31463182# Operator runbook (cmc)
31473183
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`.
3184Run on bay1, the laptop and (for C) a clean host. Nothing here is
3185automated by the MRs. One forge write per shell call; bay1 root is
3186`ssh -p 2222 root@gitbay.org`.
3187
3188D does not depend on any MR and can run first; A.3 and C use the keys
3189repository it creates.
31503190
31513191## A. After MR 1 deploys
31523192
@@ -3156,16 +3196,18 @@ One forge write per shell call; bay1 root is `ssh -p 2222 root@gitbay.org`.
31563196 → mode `-rw-------`, owner `gitbay gitbay`, one log line with a count.
315731972. `ssh -p 2222 root@gitbay.org 'gitbayd --config /etc/gitbay/config.toml admin secrets check'`
31583198 → 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.
31993. Back the key up to the keys repository (D.9) with `secret.key` as
3200 the file and `secret-key` as the tag. If D has not run yet, do D.2,
3201 D.3 (keys bucket), D.4 (keys token) and D.9 first. After every
3202 `admin secrets rotate`, repeat this step.
316232034. Check CI builds that use secrets (blotter, hutch, orgo) still run,
31633204 and a webhook delivery still verifies.
31643205
31653206## B. After MR 2 deploys
31663207
316732081. On the laptop: `age-keygen -o ~/.config/gitbay/backup-identity.txt`
3168 (mode 600). Note the printed `age1...` public key.
3209 (mode 600). Note the printed `age1...` public key. Put a copy of the
3210 identity in the password manager.
316932112. On bay1, add to `/etc/gitbay/config.toml`:
31703212 ```toml
31713213 [backup]
@@ -3176,12 +3218,10 @@ One forge write per shell call; bay1 root is `ssh -p 2222 root@gitbay.org`.
31763218 `/usr/local/bin/gitbay-db-backup.sh` and `/usr/local/bin/gitbay-monitor.sh`
31773219 predate the cloud-init change (cloud-init runs once). Apply the same
31783220 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.
32214. The offsite job needs no change. `/usr/local/bin/gitbay-offsite`
3222 builds `/var/lib/gitbay-stage` from live data (`sqlite3 gitbay.db
3223 "VACUUM INTO ..."` and `cp -a /etc/gitbay/config.toml`), not from the
3224 local archives, so encrypting the archives does not reach restic.
318532255. After the next hourly run: `ls -l /var/backups/gitbay/db | tail -2`
31863226 shows `.tar.gz.age`; the monitor's `db_snapshot_h` stays under 2.
31873227 Copy one archive to the laptop and run
@@ -3189,32 +3229,46 @@ One forge write per shell call; bay1 root is `ssh -p 2222 root@gitbay.org`.
31893229 (a local gitbayd build; `--verify` reads no config).
319032306. Old unencrypted archives age out of the 7/48 rotation on their own.
31913231
3192## C. Restore drill (after MR 3 deploys; closes #259)
3232## C. Restore drill (deferred; closes #259)
3233
3234Deferred by the operator. Run it after MR 3 deploys and D is complete,
3235then quarterly, and after any change to `cmd/gitbayd/backup.go` or the
3236offsite job (`/usr/local/bin/gitbay-offsite`, its env file, the R2 lock
3237rules). Each run adds a row to the Admin page's table; the first one
3238closes #259.
31933239
31943240Record every timestamp as you go. Start the clock at step 2.
31953241
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.
32421. On the laptop, export the `gitbay r2-prune` variables from the
3243 password manager (D.5), then:
3244 `restic snapshots --tag gitbay --latest 1`. Note its time (recovery
3245 point). `restic ls latest /var/lib/gitbay-stage` shows the staged
3246 database's file name and `config.toml`.
319932472. Provision a clean Ubuntu 24.04 host (throwaway VPS or local VM) with
32003248 `deploy/cloud-init.yaml`. First root login: **clock starts**.
320132493. Before gitbayd ever starts, block outbound traffic so the restored
32023250 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`,
3251 APNs: `ufw default deny outgoing; ufw allow out 53; ufw allow out to <account-id>.r2.cloudflarestorage.com port 443; ufw reload`.
3252 (ufw resolves the name once; allow R2 only for the restore, then
3253 remove the rule.)
32544. Install restic, and with the same variables exported on the drill
3255 host: `restic restore latest --target / --include /var/lib/gitbay --include /var/lib/gitbay-stage`.
3256 The snapshot excludes `gitbay.db`, `gitbay.db-wal`, `gitbay.db-shm`
3257 and `hook.sock`: copy the staged database from
3258 `/var/lib/gitbay-stage/` to `/var/lib/gitbay/gitbay.db`.
3259 `chown -R gitbay:gitbay /var/lib/gitbay`. LFS objects are under
3260 `/var/lib/gitbay/lfs` (inside `server.root`) and come back with it.
32615. Config and keys: copy `/var/lib/gitbay-stage/config.toml` to
3262 `/etc/gitbay/config.toml`. From the laptop, with the `gitbay r2-keys`
3263 variables exported (D.5):
3264 ```sh
3265 restic dump --tag secret-key latest /secret.key | ssh root@<drill-ip> -p 2222 'umask 077; cat > /etc/gitbay/secret.key; chown gitbay:gitbay /etc/gitbay/secret.key'
3266 restic dump --tag apns latest /apns.p8 | ssh root@<drill-ip> -p 2222 'umask 077; cat > /etc/gitbay/apns.p8; chown gitbay:gitbay /etc/gitbay/apns.p8'
3267 ```
3268 In the config for the drill only: `site_url` to `http://<drill-ip>:8080`,
32133269 `[http] addr = ":8080"`, `tls = "off"`, remove `[mail]`, set
32143270 `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.
3271 `[backup]` (step 7's archive is local and read back at once).
321832726. Install the gitbayd binary of the tag bay1 runs (`/healthz` names the
32193273 commit) with `deploy/install.sh <drill-ip> 2222`; it will not create
32203274 a key because one is present.
@@ -3226,7 +3280,8 @@ Record every timestamp as you go. Start the clock at step 2.
32263280 snapshot.
32273281 - Secrets: `gitbayd --config /etc/gitbay/config.toml admin secrets check`
32283282 → every value under one key, no error. The journal shows no
3229 `sealing secrets` failure.
3283 `sealing secrets` failure. `sha256sum /etc/gitbay/apns.p8` equals
3284 bay1's.
32303285 - 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.
32313286 - Release assets: every row's file exists with its digest:
32323287 ```sh
@@ -3251,33 +3306,322 @@ Record every timestamp as you go. Start the clock at step 2.
32513306 - `Architecture/08-Operations.org`: the "Recovery time" bullet names
32523307 the figure.
32533308 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.
3309 `Closes #259`, MR, ff merge, delete the branch. Later drills add a
3310 row with `Ref #259`.
331110. Destroy the drill host.
3312
3313## D. Offsite backup to Cloudflare R2; keys repository
3314
3315Today: `gitbay-offsite.service` (timer nightly, about 00:19 UTC) runs
3316`/usr/local/bin/gitbay-offsite`, which sources `/etc/gitbay/offsite.env`
3317(`RESTIC_REPOSITORY` and the rest), builds `/var/lib/gitbay-stage`
3318with `sqlite3 gitbay.db "VACUUM INTO ..."` and `cp -a
3319/etc/gitbay/config.toml`, runs `restic backup --tag gitbay` of the stage
3320and `/var/lib/gitbay` excluding `gitbay.db`, `gitbay.db-wal`,
3321`gitbay.db-shm` and `hook.sock`, then `restic check` up to three
3322attempts. The target is Scaleway `fr-par` with append-only credentials;
3323forget and prune run from the laptop. `/etc/gitbay/apns.p8` and
3324`/etc/gitbay/offsite.env` are in no restic repository.
3325
3326After D:
3327
3328- The main repository is on R2, bucket `gitbay-offsite`. `config.toml`
3329 stays in it through the stage, as today.
3330- `apns.p8` and `secret.key` are in a separate restic repository,
3331 bucket `gitbay-keys`, with its own password and token. Neither the
3332 password nor the token is ever on bay1: the laptop streams the files
3333 out of bay1 into it, so a leak of the main backup or of bay1's
3334 `offsite.env` does not reach the key that opens the sealed secrets.
3335- `offsite.env` stays on bay1 because the nightly job sources it. Its
3336 only copy off bay1 is the password manager; neither repository
3337 carries it. The keys repository's password and token exist only in
3338 the password manager.
3339
3340What protects history changes. R2 has no append-only token (token
3341permissions are Admin R/W, Admin R, Object R/W, Object R, optionally
3342scoped to buckets), and restic needs to write and delete under
3343`locks/`, so the host holds Object R/W on its bucket. Bucket lock
3344rules refuse deletion and overwrite of matching objects for a period or
3345indefinitely, whatever the token, and changing them needs an Admin
3346token or the dashboard, neither of which is on bay1. So a compromised
3347host cannot delete or overwrite anything younger than the retention
3348period `RET`; unlike the Scaleway key, it can delete snapshots and data
3349older than `RET`. Prune from the laptop can likewise only remove what
3350is past `RET`.
3351
3352`RET` is 90 days in the commands below (open question 1). The lock
3353rules:
3354
3355| Prefix | Rule | Why |
3356|---|---|---|
3357| `data/` | `RET` days | pack files; prune deletes unused ones once past `RET` |
3358| `index/` | `RET` days | prune replaces index files (see D.6) |
3359| `snapshots/` | `RET` days | forget deletes snapshot files once past `RET` |
3360| `keys/` | indefinite | restic deletes a key file only on `restic key remove` |
3361| `config` | indefinite | written once at `restic init`; the repository cannot be opened without it |
3362| `locks/` | none | restic creates and deletes a lock on every command |
3363
3364`config` is not in the four prefixes named when this was decided; it
3365is one object that restic never deletes and without which nothing in
3366the repository opens, so it is locked too. With `keys/` indefinite, a
3367repository password change adds a key but cannot remove the old one.
3368
3369The keys bucket uses `--retention-indefinite` on every prefix except
3370`locks/` and is never forgotten or pruned: a retired secret key still
3371opens the database in every main snapshot sealed under it, and the
3372snapshots are a few hundred bytes.
3373
33741. File the issue the wiki MR (D.14) references:
3375 `gitbay issue create krz/gitbay --title "Offsite backup: move from Scaleway to Cloudflare R2" --body "Runbook D of docs/plans/2026-09-27-data-at-rest-and-backup.md: R2 with bucket lock rules, a separate keys repository, Scaleway retired after 30 nights in parallel."`
3376 Note the number.
33772. Buckets, from the laptop (`wrangler login` as the account owner):
3378 ```sh
3379 npx wrangler r2 bucket create gitbay-offsite-scratch --location weur
3380 npx wrangler r2 bucket create gitbay-offsite --location weur
3381 npx wrangler r2 bucket create gitbay-keys --location weur
3382 ```
3383 `weur` is a location hint that keeps the data in western Europe as
3384 Scaleway `fr-par` did.
33853. Lock rules. Scratch uses one day so D.6 can see a rule expire;
3386 `gitbay-offsite` uses `RET`:
3387 ```sh
3388 lock() { # bucket retention-flag...
3389 b=$1; shift
3390 for p in data index snapshots; do
3391 npx wrangler r2 bucket lock add "$b" --name "restic-$p" --prefix "$p/" "$@"
3392 done
3393 npx wrangler r2 bucket lock add "$b" --name restic-keys --prefix keys/ --retention-indefinite
3394 npx wrangler r2 bucket lock add "$b" --name restic-config --prefix config --retention-indefinite
3395 npx wrangler r2 bucket lock list "$b"
3396 }
3397 lock gitbay-offsite-scratch --retention-days 1
3398 lock gitbay-offsite --retention-days 90
3399 lock gitbay-keys --retention-indefinite
3400 ```
3401 → five rules on each bucket, none covering `locks/`. No other object
3402 in a restic repository starts with `config`, so that prefix matches
3403 the one object.
34044. Tokens, in the dashboard (R2 → Manage API tokens), each "Object Read
3405 & Write" and scoped to one bucket:
3406 - `gitbay-host` → `gitbay-offsite`. Goes to bay1.
3407 - `gitbay-prune` → `gitbay-offsite`. Laptop only: forget, prune,
3408 check, restore.
3409 - `gitbay-keys` → `gitbay-keys`. Laptop only.
3410 - `gitbay-scratch` → `gitbay-offsite-scratch`. Laptop only; revoke
3411 after D.6.
3412 `gitbay-host` and `gitbay-prune` have the same rights; they are
3413 separate so either can be revoked alone. The lock rules, not the
3414 token, protect history. Store each access key id and secret in the
3415 password manager.
34165. One password manager entry per repository, each holding the
3417 variables restic reads:
3418 ```sh
3419 RESTIC_REPOSITORY=s3:https://<account-id>.r2.cloudflarestorage.com/<bucket>
3420 RESTIC_PASSWORD=<openssl rand -base64 32, generated once per repository>
3421 AWS_ACCESS_KEY_ID=<token access key id>
3422 AWS_SECRET_ACCESS_KEY=<token secret access key>
3423 AWS_DEFAULT_REGION=auto
3424 ```
3425 Entries: `gitbay r2-host` (bucket `gitbay-offsite`, token
3426 `gitbay-host`), `gitbay r2-prune` (same bucket and password, token
3427 `gitbay-prune`), `gitbay r2-keys` (bucket `gitbay-keys`, its own
3428 password, token `gitbay-keys`), `gitbay r2-scratch`. If the current
3429 `offsite.env` sets anything else (`RESTIC_OPTS`, which the Admin
3430 wiki's commands use), carry it into `r2-host` and `r2-prune`. The
3431 commands below assume the named entry's variables are exported in
3432 the shell.
34336. Validate on the scratch bucket (`gitbay r2-scratch`), from the
3434 laptop, before anything real depends on it. Record each result in
3435 the issue from D.1.
3436 ```sh
3437 restic init
3438 mkdir -p /tmp/r2t && head -c 50M /dev/urandom > /tmp/r2t/a
3439 restic backup --tag gitbay /tmp/r2t # snapshot 1
3440 head -c 50M /dev/urandom > /tmp/r2t/b
3441 restic backup --tag gitbay /tmp/r2t # snapshot 2
3442 rm /tmp/r2t/a
3443 restic backup --tag gitbay /tmp/r2t # snapshot 3
3444 restic check # passes; lock files come and go
3445 restic forget --keep-last 1 # expect a refusal: snapshots 1-2 are younger than a day
3446 restic check
3447 restic prune --max-unused unlimited # record what it deletes or is refused
3448 restic check
3449 ```
3450 Expected on day 0: backup and check succeed (locks/ is unlocked);
3451 forget fails to delete the two snapshot files and says so; check
3452 still passes afterwards. Record whether prune tries to delete an
3453 index file and fails. After 24 hours:
3454 ```sh
3455 restic forget --keep-last 1
3456 restic prune --max-unused unlimited
3457 restic check --read-data
3458 ```
3459 → forget removes snapshots 1 and 2, prune removes the pack holding
3460 only `a`'s data and the superseded index files, and `check
3461 --read-data` passes. Then `restic unlock` succeeds on a stale lock
3462 left by interrupting a backup with Ctrl-C.
3463 If prune on day 0 or day 1 fails on an index file younger than the
3464 rule, restic's index rewrite is deleting files the lock keeps, and
3465 prune would fail every run on the real bucket. Stop there and bring
3466 the result back: the choice is between an index rule shorter than
3467 the data rule, running prune only when no index file is younger
3468 than `RET`, or leaving `index/` unlocked (it can be rebuilt from
3469 `data/` with `restic repair index`). Do not continue to D.7 until
3470 one is chosen.
3471 `--max-unused unlimited` keeps prune from repacking partly used
3472 packs, since a repack deletes the old pack and that pack may be
3473 younger than `RET`.
34747. Initialise the real repositories from the laptop:
3475 `restic init` with `gitbay r2-prune` exported, and again with
3476 `gitbay r2-keys`.
34778. Install the host env file and run both targets. On bay1, paste the
3478 `gitbay r2-host` entry into the file over stdin (Ctrl-D ends it):
3479 ```sh
3480 ssh -p 2222 root@gitbay.org 'umask 077; cat > /etc/gitbay/offsite-r2.env'
3481 ```
3482 Keep the current script as `/usr/local/bin/gitbay-offsite.scaleway`
3483 (`cp -a`). Edit `/usr/local/bin/gitbay-offsite`: leave the staging
3484 lines (`VACUUM INTO`, `cp -a config.toml`) as they are and run once;
3485 move the `. /etc/gitbay/offsite.env`, the `restic backup` and the
3486 `restic check` retry loop into a function called once per env file,
3487 each call in a subshell so one target's variables cannot reach the
3488 other. With the flags the job uses today, the section after staging
3489 reads:
3490 ```sh
3491 target() {
3492 set -a; . "$1"; set +a
3493 restic backup --tag gitbay \
3494 --exclude /var/lib/gitbay/gitbay.db \
3495 --exclude /var/lib/gitbay/gitbay.db-wal \
3496 --exclude /var/lib/gitbay/gitbay.db-shm \
3497 --exclude /var/lib/gitbay/hook.sock \
3498 /var/lib/gitbay-stage /var/lib/gitbay || return 1
3499 for i in 1 2 3; do
3500 restic check && return 0
3501 done
3502 return 1
3503 }
3504 rc=0
3505 (target /etc/gitbay/offsite.env) || rc=1
3506 (target /etc/gitbay/offsite-r2.env) || rc=1
3507 exit $rc
3508 ```
3509 Compare with `diff -u /usr/local/bin/gitbay-offsite.scaleway /usr/local/bin/gitbay-offsite`
3510 and keep anything the current restic lines carry that this section
3511 does not (`$RESTIC_OPTS`, a sleep between check attempts, the exact
3512 exclude spelling). A failure on one target no longer stops the
3513 other; the unit still fails if either did. The job now uploads
3514 twice: check `TimeoutStartSec` in `systemctl cat gitbay-offsite.service`
3515 against twice the last run's duration
3516 (`journalctl -u gitbay-offsite -n 200`).
35179. Keys repository, from the laptop with `gitbay r2-keys` exported.
3518 The file goes from bay1 to R2 through a pipe and is never written on
3519 the laptop:
3520 ```sh
3521 ssh -p 2222 root@gitbay.org cat /etc/gitbay/apns.p8 | restic backup --stdin --stdin-filename apns.p8 --tag apns
3522 restic dump --tag apns latest /apns.p8 | sha256sum
3523 ssh -p 2222 root@gitbay.org sha256sum /etc/gitbay/apns.p8
3524 ```
3525 → the two digests match. After MR 1 deploys (A.3) and after every
3526 rotation, the same with `secret.key` and `--tag secret-key`. After
3527 an APNs key change, the same with `apns.p8`.
352810. First full backup: `ssh -p 2222 root@gitbay.org 'systemctl start gitbay-offsite.service; journalctl -u gitbay-offsite -n 50 --no-pager'`
3529 → both targets back up and check. From the laptop with
3530 `gitbay r2-prune`: `restic snapshots` shows one `gitbay` snapshot
3531 with the stage and `/var/lib/gitbay`; `restic ls latest /var/lib/gitbay-stage`
3532 lists the database copy and `config.toml`.
353311. Run both targets for 30 nights. Each week, from the laptop with
3534 `gitbay r2-prune`: `restic snapshots --tag gitbay --latest 7` (one
3535 per night) and `restic check --read-data-subset 1/4`, a different
3536 quarter each week. At the end: `restic check --read-data` on R2
3537 (R2 does not charge egress).
353812. Laptop forget and prune for R2, from the laptop with
3539 `gitbay r2-prune`: the forget policy used for Scaleway today plus
3540 `--keep-within 90d`, so no snapshot younger than `RET` is ever
3541 forgotten, then `restic prune --max-unused unlimited` (or what D.6
3542 settled on). Nothing is removable before day 90; the first prune
3543 that deletes anything is after that.
354413. Retire Scaleway after the 30 nights and a clean `check --read-data`:
3545 - On bay1: `mv /etc/gitbay/offsite-r2.env /etc/gitbay/offsite.env`
3546 (the Scaleway file is replaced) and drop the second `target` call
3547 from the script, so the job runs `(target /etc/gitbay/offsite.env)`
3548 alone. Run the unit once and check the journal.
3549 - Replace the `offsite.env` copy in the password manager with
3550 `gitbay r2-host`; remove the Scaleway entries once the bucket is
3551 gone.
3552 - Revoke the Scaleway append-only key. Keep the Scaleway bucket
3553 until R2 holds 90 days of snapshots, then delete it with the
3554 Scaleway owner credentials from the laptop.
3555 - Revoke `gitbay-scratch`. The scratch bucket's `keys/` and
3556 `config` rules are indefinite: remove its five rules
3557 (`npx wrangler r2 bucket lock remove gitbay-offsite-scratch --name <rule>`),
3558 then empty and delete the bucket in the dashboard.
355914. Wiki MR, branch `offsite-r2-wiki`, commit ending `Ref #<D.1 issue>`
3560 (and `Closes #<D.1 issue>` if the move is finished):
3561 - `Admin.org`, `** Offsite copies`, the first paragraph becomes:
3562 ```org
3563 bay1 also takes a nightly restic snapshot of =/var/lib/gitbay= and
3564 =/var/lib/gitbay-stage= (a =VACUUM INTO= copy of the database and
3565 =config.toml=) to a Cloudflare R2 bucket. R2 has no append-only
3566 token, so the host's token can write and delete objects; bucket
3567 lock rules refuse deletion and overwrite of =data/=, =index/= and
3568 =snapshots/= for 90 days, and of =keys/= and =config= for good,
3569 whatever the token. Changing the rules needs an Admin token or
3570 the dashboard, neither of which is on bay1. A compromised host
3571 cannot remove anything younger than 90 days; it can remove older
3572 snapshots. Forgetting, pruning and rewriting run from the
3573 operator's machine, and can likewise only remove what is past 90
3574 days.
3575
3576 =apns.p8= and =secret.key= are in a separate restic repository in
3577 its own bucket, with its own password and token, neither of which
3578 is on bay1; the operator streams the files into it from bay1. A
3579 leak of the main backup therefore does not carry the key that
3580 opens its secrets. =offsite.env= is in neither repository.
3581 ```
3582 - `Admin.org`, `*** Removing a repository's history from every
3583 snapshot`: after the sentence ending "rather than forgetting the
3584 snapshots: everything else in them stays restorable.", add
3585 "Snapshots and packs younger than 90 days are locked:
3586 =rewrite --forget= and =prune= cannot remove them until they
3587 age out." The `~/.config/gitbay/offsite.env` sourcing line
3588 becomes "export the =gitbay r2-prune= entry from the password
3589 manager".
3590 - `Architecture/08-Operations.org:53`: "to object storage" → "to
3591 Cloudflare R2"; lines 63-65 become "The host's R2 token can
3592 delete, but bucket lock rules keep everything younger than 90
3593 days; the lock rules are changed only off the host
3594 (documented: Admin wiki)."
3595 - `Architecture/09-Controls.org:101`:
3596 `| Backups offsite and delete-locked | in place | restic to R2; bucket lock rules, 90 days on data, index and snapshots (documented) |`
3597 - `Architecture/04-Trust-Boundaries.org:30`: "append-only offsite
3598 backup credentials" → "offsite backup under R2 bucket locks".
3599 - `Threat-Model.org:195`: "restic append-only credentials" →
3600 "restic under R2 bucket locks".
3601 - `Architecture/diagrams/diagrams.py:165`: `"restic · append-only key"`
3602 → `"restic · R2 bucket locks"`; regenerate with
3603 `python3 .gitbay/wiki/Architecture/diagrams/diagrams.py .gitbay/wiki/Architecture/diagrams`
3604 and commit the SVGs.
3605 - If MR 1 landed without the keys-repository sentences (Task 1.6),
3606 add them to `Admin.org` `** Secret key` and
3607 `Architecture/06-Data-and-Cryptography.org` here.
32583608
32593609---
32603610
32613611## Open questions
32623612
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`
36131. The lock retention `RET`: the runbook uses 90 days. It is the
3614 window a compromised host cannot touch and the minimum age of
3615 anything prune can remove, so it should be at least as long as the
3616 shortest period the current forget policy keeps (that policy is on
3617 the laptop, not in the repository).
36182. Out of scope, noted while reading: `webhook add` takes `--secret`
32793619 on argv (`internal/control/webhook.go:16-23`), against the
32803620 stdin-only rule for secrets.
36213. Out of scope: the offsite job (`/usr/local/bin/gitbay-offsite`, its
3622 unit and timer) is not in the repository and `deploy/cloud-init.yaml`
3623 does not template it, so a host built from cloud-init has no offsite
3624 backup until the operator installs one by hand.
32813625
32823626## Self-review
32833627
@@ -3286,14 +3630,23 @@ Record every timestamp as you go. Start the clock at step 2.
32863630 `rotate`), existing clear rows sealed at startup (1.4 `serve`,
32873631 `ResealSecrets`), missing key behaviour (1.4 `openStore`, documented
32883632 1.6), backups do not carry it (validation 1.2, e2e 1.5), install
3289 provisioning (1.6).
3633 provisioning (1.6), offsite copy only in the separate keys repository
3634 (runbook A.3, D.9).
32903635- #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).
3636 --identity` (2.2), restic path unaffected by the archives because the
3637 offsite job stages from live data (runbook B.4), scripts (2.3).
32933638- #259: `--verify` connectivity (3.1, 3.4), delete/rename/transfer held
32943639 (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).
3640 LFS, release assets, config, host keys, secrets and the keys, with time
3641 to service on the Admin page (3.5, runbook C). The drill is deferred;
3642 MR 3 says `Ref #259` and #259 stays open until the first drill is
3643 recorded. Cadence: quarterly and after any backup code change.
3644- Offsite move: R2 buckets, bucket-scoped tokens for host, prune and
3645 keys, lock rules on `data/`, `index/`, `snapshots/`, `keys/` (and
3646 `config`) with `locks/` open, validation on a scratch bucket with the
3647 same rules, both targets in parallel for 30 nights, Scaleway retired,
3648 wiki MR (runbook D). No code MR: the offsite job is not in the
3649 repository or in `deploy/cloud-init.yaml`.
32973650- Names used across tasks: `seal.Keyring`/`Load`/`Seal`/`Open`/
32983651 `CurrentID`/`KeyID`/`IsSealed`/`NewKey`/`ReadKeys`/`WriteKeys`;
32993652 `Store.SetKeyring`/`ResealSecrets`/`SecretKeyUse`; `testConfig`;
docs/plans/2026-09-27-server-hardening.md +14 −13
@@ -3636,28 +3636,29 @@ steps are run by hand. Operator ssh is `ssh -p 2222 root@gitbay.org`.
36363636- limits: new `pack_*` settings with non-zero defaults; a burst of
36373637 clones now queues and, past the queue, is refused with 503 / exit 1.
36383638
3639# Open questions
3639# Decisions and remaining questions
3640
3641Decided 2026-09-28:
3642
3643- **receive-pack stays outside the pack limit.**
3644- **bay1's relay and git**, checked: git 2.47.3 (`http.curloptResolve`
3645 needs 2.37); relay is AWS mail manager on port 587 and negotiates
3646 STARTTLS (TLS 1.3, certificate verified). MRs 2 and 3 are not blocked;
3647 runbook steps 1 and 2 stay as the check for other operators.
3648
3649Remaining:
36403650
364136511. **System SSH mode.** With `ssh.mode = "system"` every session is a
36423652 separate `gitbayd shell` process, so (a) the pack limiter cannot
36433653 count SSH clones across sessions — this plan passes `nil` and says
36443654 so on the Admin page — and (b) audit rows written there are not
36453655 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.
3656 client. The same applies to host `gitbayd admin …` commands. gitbay.org
3657 runs embedded mode; the plan documents the limit and adds nothing
3658 for system mode.
365136592. **Default pack limits.** 3 / 2 / 32 / 60s are an estimate from the
36523660 one measured full clone (~1.5 cores). The runbook's benchmark
36533661 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.
36613662
36623663# Self-review
36633664