.gitbay/wiki/Admin.org

0a69a8facd64e67e42ec04fa11dd4a0304e40d79
gitbay/.gitbay/wiki/Admin.org rendered · source · history · blame · raw

667 lines · 33525 bytes

  1#+title: gitbay admin guide
  2
  3One static binary (=gitbayd=), one SQLite file, bare repositories on
  4disk, and the system =git=. Schema migrations run automatically on
  5startup and on every admin command.
  6
  7* Install
  8
  9Build from source (=go build ./cmd/gitbayd=), install via the vanity
 10module path (=go install gitbay.org/gitbay/cmd/gitbayd@latest=), or use
 11a release build: =deploy/release.sh <tag>= cross-compiles reproducible
 12linux/amd64, linux/arm64, and darwin/arm64 binaries with a SHA256SUMS
 13manifest (CGO off, trimpath, stripped — byte-identical per commit and
 14toolchain).
 15
 16#+begin_src sh
 17install -m 755 gitbayd /usr/local/bin/
 18adduser --system --group --home /var/lib/gitbay --shell /usr/sbin/nologin gitbay
 19install -d -o gitbay -g gitbay -m 750 /var/lib/gitbay
 20gitbayd --config /etc/gitbay/config.toml check-config
 21#+end_src
 22
 23=deploy/= in the source tree has a cloud-init file, a hardened systemd
 24unit, and a nightly backup timer. Run as the unprivileged =gitbay= user;
 25the unit's =AmbientCapabilities=CAP_NET_BIND_SERVICE= covers ports
 2622/80/443 without root.
 27
 28** The SSH port decision
 29
 30- =ssh.mode = "embedded"= (default): gitbayd itself listens, normally on
 31  22 — move the host's admin sshd to another port. Remotes read
 32  =git@host:owner/repo= with no port gymnastics.
 33- =ssh.mode = "system"=: the host sshd owns 22 and invokes gitbayd via
 34  =AuthorizedKeysCommand=:
 35  #+begin_example
 36  AuthorizedKeysCommand /usr/local/bin/gitbayd --config /etc/gitbay/config.toml authorized-keys %t %k
 37  AuthorizedKeysCommandUser gitbay
 38  #+end_example
 39  sshd requires that binary to be root-owned and not group/world
 40  writable. Unknown keys fail authentication inside sshd, so system mode
 41  requires =registration.mode = "closed"= (check-config enforces this).
 42
 43* Configuration reference
 44
 45=/etc/gitbay/config.toml=. =check-config= validates and names every
 46contradiction; =--no-host-checks= skips port/path probes.
 47=gitbayd admin config show= prints the configuration in effect as TOML,
 48every default filled in and =smtp_pass= redacted. A file that fails
 49validation still prints, followed by the contradiction.
 50
 51** [server]
 52- =root= (default =/var/lib/gitbay=) — repositories, database, host
 53  keys, ACME cache all live here.
 54- =site_url= (required) — canonical =https://host=; drives ACME, clone
 55  URLs, mail links.
 56- =source_repo= (optional, =owner/name=) — the repository this instance
 57  develops itself in. Startup warns when the running build's commit is
 58  not on that repository's default branch, which is how a binary built
 59  from an unmerged branch stops being invisible. Leave it unset unless
 60  the instance hosts its own source.
 61
 62** [ssh]
 63- =mode= — =embedded= | =system= (above).
 64- =port= (22) — embedded listener port.
 65- =host_keys= — list of private key paths; empty generates an ed25519
 66  key at =<root>/ssh/host_ed25519=.
 67
 68** [http]
 69- =addr= (=:443=), =tls= — =acme= | =files= | =off=.
 70- =acme=: certificates via TLS-ALPN-01 on the HTTPS port, cached at
 71  =<root>/acme=; =acme_email= for the CA account; =acme_http_addr=
 72  (=:80=, ="off"= to disable) adds HTTP-01 and an https redirect —
 73  failing to bind it is a warning, not fatal. Requires an =https://=
 74  site_url with a public DNS name.
 75- =files=: =cert_file= + =key_file=.
 76- =off=: plain HTTP — development, or behind a TLS-terminating proxy.
 77
 78=trusted_proxies= lists the addresses or CIDRs of reverse proxies in
 79front of the daemon. A request from one of them is attributed, for API
 80rate limiting, to the last =X-Forwarded-For= hop that is not itself a
 81trusted proxy; from anyone else the header is ignored. Empty, the
 82default, is right when gitbayd terminates TLS itself.
 83
 84** [web]
 85- =mode= — =view_only= (default) | =accounts=. In view_only the mutating
 86  web routes are never registered; in accounts, browser sessions are
 87  minted over SSH (=web login=), and users with write access can create
 88  repos, comment, and make simple file edits (which commit unsigned,
 89  honestly). =password_auth= is reserved and currently rejected.
 90
 91** [registration]
 92- =mode= — =closed= (default) | =invite= | =open=. invite/open require
 93  [mail]. See the user guide for the flows.
 94
 95- =pending_expiry= (empty, never) — a duration such as ="168h"=; a
 96  self-registered account still unverified after that long is removed,
 97  hourly and at start, audited as =pending.expired=.
 98
 99** [mail]
100- =smtp_host= (host:port, 587 assumed), =from=, optional =smtp_user= /
101  =smtp_pass=. STARTTLS when offered. Required for invite/open
102  registration and self-service =email add=; in closed mode you may omit
103  it entirely and assert addresses by hand (below).
104
105** [api]
106- =enabled= (false) — the JSON API surface; see [[API]]. Off
107  means no credential-bearing HTTP endpoint exists at all.
108
109** [webhooks]
110- =allow_local= (false) — permit webhook targets on loopback/private
111  addresses. Leave off unless you know why you need it (SSRF).
112
113** [limits]
114- =clone_timeout= (3600s) — cap on =repo import= fetches.
115- =max_blob_bytes= (100MB) — cap on raw file serving over the web.
116- =max_asset_bytes= (512MB) — cap per uploaded release asset.
117- =max_snippet_bytes= (1MB) — cap per snippet file.
118- =max_repos_per_user= (0, unlimited) — repositories an account may own
119  directly; =repo create=, =fork= and =import= refuse past it.
120  Organizations are not capped.
121- =max_bytes_per_user= (0, unlimited) — disk the account's own
122  repositories may take; a push may be no larger than what is left.
123- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
124
125** [git_daemon]
126- =enabled= (false), =port= (9418) — the anonymous =git://= listener.
127  Serves only public repositories that additionally ran
128  =repo settings git-daemon <repo> on=.
129
130** [mirrors]
131- =pull_interval_minutes= (15) — how often pull mirrors fetch their
132  upstream. Push mirrors sync shortly after each local ref update.
133  Mirror URLs pass the same SSRF rules as webhook targets.
134
135** [go_import]
136Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
137Requests with =?go-get=1= at or under the module path answer with the
138go-import meta tag pointing at the repository's HTTPS clone URL, so
139=go install host/module/cmd/...@latest= resolves. The repository should
140be public (the module path itself confirms it exists).
141
142* Users, email, invites
143
144Every =gitbayd admin= subcommand except =backup=, =gc= and the one-shot
145backfills is a wrapper that dispatches the registry command of the same
146name as the host: an admin context with no account behind it, so its
147audit rows carry no actor and =source: host=. The same commands run in an
148instance admin's SSH session (=ssh git@<host> admin ...=) and write the
149same rows with the key fingerprint as source. One implementation, two
150credentials.
151
152#+begin_src sh
153gitbayd admin user create alice --key alice.pub --email a@example.org --verified [--admin]
154gitbayd admin email verify alice a@example.org   # admin assertion, no SMTP needed
155gitbayd admin invite --email b@example.org       # mails a code; prints it if no SMTP
156#+end_src
157
158"Verified" means SMTP-confirmed or host-admin-asserted; the database
159records which. Verified emails are what make commit signatures
160meaningful — an unverified address never produces a =verified= badge.
161
162* Audit and account control
163
164The audit log is the security feed (events are the product feed): every
165successful mutating command with its argv and source credential (SSH key
166fingerprint or API), registrations, admin actions, force-pushes, and
167auth failures/throttling. Secrets never appear — they travel on stdin,
168never in argv.
169
170#+begin_src sh
171gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json]
172ssh git@<host> audit ...             # the same, from an admin session (SSH only)
173ssh git@<host> admin user list [--state active|pending|disabled|admin]
174ssh git@<host> admin user show <name>   # keys, emails, orgs, tokens, sessions
175ssh git@<host> admin user limits <name> [--repos n|default] [--bytes n|default]   # per-account caps
176ssh git@<host> admin user promote <name>   # grant instance admin
177ssh git@<host> admin user demote <name>    # remove it; the last admin is refused
178gitbayd admin user promote <name>    # host-local: recovery when no admin key is reachable
179gitbayd admin user disable <name>    # suspend: SSH, web, API all refused;
180gitbayd admin user enable <name>     #   sessions dropped, nothing deleted
181gitbayd admin user delete <name> --yes  # only for accounts anchoring nothing:
182                                     #   refused (with each blocker named) while
183                                     #   the account owns repos, authored
184                                     #   issues/MRs/comments/reviews, or is an
185                                     #   org's only admin
186#+end_src
187
188=--actor= takes a username, or =-= for rows with no actor: host commands
189and auth failures. =--action= is a prefix, so =cmd repo= catches every
190repository command and =admin= every host or admin-session action.
191=--since= is a duration back from now (=30m=, =24h=, =7d=) or a date.
192
193=admin user list= pages by username (=--limit=, =--cursor=) and carries
194each account's state and =last_seen=, the newest use of any of its SSH
195keys or API tokens. =admin user show= adds the keys with their last use,
196each address with how it was verified, PGP keys, org roles, the owned
197repository count, API token names, and live browser sessions. Both are
198SSH-only and refused to non-admins, like =audit=.
199
200Promotion needs an active account: a pending or disabled one is refused.
201Demotion is refused when it would leave no admin, over SSH and on the
202host alike, so the host-local =promote= is the way back in when the only
203admin key is lost.
204
205Instance admin carries no right on anyone's repository: policy does not
206consult it, and a private repository still answers not-found to an
207admin. Moderation goes through explicit overrides that skip the access
208check and write their own audit row:
209
210#+begin_src sh
211ssh git@<host> admin repo list [--owner o] [--visibility public|private]  # size, last push
212ssh git@<host> admin repo archive|unarchive <owner/name>
213ssh git@<host> admin repo visibility <owner/name> public|private
214ssh git@<host> admin repo delete <owner/name> --yes
215#+end_src
216
217Each lands in the audit log as =admin repo.<action>= naming the
218repository, on top of the =cmd= row every mutating command gets.
219
220=limits.ssh_auth_rate= (10) throttles per-IP authentication *failures*
221per minute — successful auths never count and clear the slate.
222=limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every
223push.
224
225=limits.write_rate= (60) bounds *mutating commands per account per
226minute*. It is counted in the dispatcher, so SSH, the JSON API and the
227web spend one budget and a caller cannot refresh it by changing surface;
228=limits.api_rate= stays in front of it, bounding a network source rather
229than an account. A command is one token whatever it writes, so a bundle
230import costs one and only a loop of separate commands spends the budget.
231Read-only commands, the runner protocol (a build streams its log in many
232small writes) and the host CLI are exempt. Refusals exit 4 and say when
233to retry. A negative value turns the limit off; it matters most with
234=registration = "open"=, where every write also queues notification mail
235and webhook deliveries.
236
237* Queues
238
239Every background worker keeps a backlog and a failure state. An instance
240admin reads them all in one place:
241
242#+begin_src sh
243gitbay dashboard --json | jq .queues   # webhooks, mail, mirrors, builds, deps
244#+end_src
245
246Per worker: pending, retrying (pending with a failed attempt) and
247dead-lettered counts with the oldest pending age, and the retrying or
248failed rows themselves, capped at twenty each. Builds list what is
249running and then what is pending, each since when, so a build no runner
250is scoped to claim is visible here rather than only in its repository;
251mirrors list the ones whose last sync failed;
252dependency checks list the ones whose last check errored. Non-admins get
253no =queues= key at all.
254
255In accounts mode the same read renders at =/admin=, linked from the rail
256for admins. Anyone else gets a 404 there.
257
258A dead-lettered mail is logged as =notification dead-lettered mail=<id>=,
259with the address redacted out of the relay's error. The id is the queue
260row: find it in the Mail table on =/admin=, or in =dashboard --json=,
261where the recipient and the unredacted error are. That is deliberate —
262see the Threat-Model page.
263
264* Maintenance
265
266#+begin_src sh
267gitbayd admin stats [--json]         # counts, database size, per-repo disk
268ssh git@<host> admin stats [--json]  # the same, from an admin session
269gitbayd admin gc [--repo owner/name] # git gc: repack and prune; per-repo sizes
270gitbayd admin gc --aggressive        # thorough repack; slow, rarely needed
271gitbayd admin gc --lfs               # also drop LFS objects no pointer names (older than a day)
272#+end_src
273
274=deploy/cloud-init.yaml= ships a =gitbay-gc.timer= that runs =admin gc=
275weekly (Sunday 07:00 UTC). Imported repositories keep whatever pack
276layout the source sent, so a first manual =admin gc= after a bulk
277import is worthwhile.
278
279* Backup and restore
280
281#+begin_src sh
282gitbayd admin backup --out /var/backups/gitbay/backup.tar.gz
283gitbayd admin backup --verify /var/backups/gitbay/backup.tar.gz   # read it back
284#+end_src
285
286One archive: a consistent SQLite snapshot (taken *before* the
287repositories are read, so the database never references objects the
288archive missed), every repository, and the SSH host keys. Excluded:
289hook socket, regenerated hook scripts, WAL files. Safe to run against a
290live daemon.
291
292=--verify= reads an archive back: the snapshot must pass SQLite's
293integrity check, and every repository the snapshot names must be in the
294archive. A database-only archive is checked for integrity and says so.
295Exit is non-zero on damage or a missing repository.
296
297Restore: extract into an empty directory, point =server.root= at it,
298start gitbayd. Host keys are preserved, so clients keep their
299known_hosts entries; hooks regenerate at startup.
300
301** Schedule and recovery point
302
303Two timers, because the two halves of the data have different exposure.
304
305- =gitbay-backup.timer=, nightly. The full archive above, last 7 kept.
306- =gitbay-db-backup.timer=, hourly. =admin backup --db-only=, which
307  writes the SQLite snapshot alone, last 48 kept. A few MB against the
308  full archive's hundreds, which is what makes the frequency affordable.
309
310The split follows what a loss would actually cost. Repositories are git,
311so a mirror or any clone is a second copy; the database is the only copy
312of issues, merge requests, comments and review state. So the recovery
313point is about an hour for the data that exists nowhere else, and a day
314for the data that does.
315
316Continuous replication (litestream and similar) was considered and not
317adopted. It would take the database's recovery point to seconds, but the
318repositories would still be on the nightly archive, so a restore could
319produce a database referencing commits the repository backup does not
320have. Consistency between the two halves is worth more here than latency
321on one of them. Revisit if repository replication becomes continuous
322too.
323
324* Upgrades
325
326Replace the binary, restart the unit. Migrations apply automatically and
327are transactional; hook scripts under =<root>/hooks= are rewritten at
328startup to point at the current binary path.
329
330* CI runner
331
332=gitbay-runner= executes builds queued by pushes and merge requests. It
333polls over SSH with a key of scope =runner=, which reaches only the
334runner protocol and read-only git (a runner executes arbitrary
335repository code, so the key it holds must not do more). A runner key
336claims builds only for the repositories it is attached to, by =repo
337runner add= from a repository admin or an instance admin; an admin key
338claims any. Users attach their own runners: see the Users page. For an
339instance runner, run it as a dedicated unprivileged user on a non-admin
340account. =admin user create --key= registers a full-scope key, so the
341runner key is added afterwards through a bootstrap key that is then
342removed, and attached to each repository it should build:
343
344#+begin_src sh
345useradd --system --create-home --home-dir /var/lib/gitbay-runner ci-runner
346sudo -u ci-runner ssh-keygen -t ed25519 -N "" -f /var/lib/gitbay-runner/.ssh/id_ed25519
347ssh-keygen -t ed25519 -N "" -f /tmp/ci-bootstrap
348gitbayd --config /etc/gitbay/config.toml admin user create ci --key /tmp/ci-bootstrap.pub
349ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys add --scope runner < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
350ssh -i /tmp/ci-bootstrap git@127.0.0.1 keys remove "$(ssh-keygen -lf /tmp/ci-bootstrap.pub | awk '{print $2}')"
351rm /tmp/ci-bootstrap /tmp/ci-bootstrap.pub
352gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work
353#+end_src
354
355#+begin_src sh
356gitbay repo runner add krz/site < /var/lib/gitbay-runner/.ssh/id_ed25519.pub
357#+end_src
358
359=-jobs N= runs N builds at once. Claiming is one transaction that
360selects and updates, and each build works in its own =build-<id>=
361directory, so workers do not collide; idle polls are staggered across
362the interval so N of them do not wake together. The drop-in's weights
363below are per service, not per build, so raising =-jobs= divides them
364rather than multiplying the host's load.
365
366=admin runners= shows which account each runner polls as, and what each
367may claim. A runner key claims builds only for the repositories it is
368attached to: with none attached it claims nothing, and =-repos= may only
369narrow within them. An admin's full-scope key claims any repository —
370that is what =-repos= was for — and still works for the protocol during
371a rotation. A merge request head from a fork is built in the target
372repository as untrusted: the claim carries no secrets, and only a runner
373started with =-untrusted= takes it. Same-repository heads were built by
374their branch push and are not built again.
375
376=make deploy-runner= also installs
377=deploy/gitbay-runner.override.conf= as a systemd drop-in: =Nice=10=,
378=CPUWeight=30=, =IOWeight=30=, so a build never starves the host's sshd,
379the daemon or the backup timers, and =NoNewPrivileges=,
380=ProtectSystem=full=, =ProtectKernelTunables=, =ProtectControlGroups=
381and =RestrictSUIDSGID=, so a step cannot reach outside its workspace
382and the runner's home. The e2e suite alone starts sixty
383daemon instances; without the drop-in a deploy's copy over the admin
384sshd stalled. Both deploy targets copy with =rsync --partial=, which
385resumes a stalled transfer.
386
387Under =-isolation podman=, the default and what bay1 runs, each build
388is confined to a container (see Container isolation below). Under
389=-isolation none= steps run directly on the host as the runner's user,
390so treat that machine as executing whatever your users push, and
391install the toolchains your builds need on it.
392
393A runner claims the oldest pending build among the repositories its key
394is attached to — for an admin key, the oldest in the instance. =-repos=
395narrows within that set, which is what makes a runner outside the server
396practical: one on a machine that should build a single project, or that
397holds credentials for one deployment, stays on it.
398
399Oldest-first is across everything the key may claim, so a repository
400with a deep queue holds every other repository the same runner serves;
401bay1 measured a 15-minute average wait on a day of merge request
402stacks from one repository. A runner attached to one repository cannot
403be starved. That is the rule, decided in krz/gitbay#207: a runner
404serving several repositories takes them oldest-first, and an operator
405who wants one repository never to wait on another runs a second
406runner attached to it alone. Nothing caps what an account queues,
407and nothing needs to (krz/gitbay#206): a schedule tick queues nothing
408while the job's last build is pending or running, so a repository
409with no runner holds one row per scheduled job rather than one per
410tick, and a build runs only on a runner its owner attaches, so a busy
411schedule spends the owner's compute. Pushes are bounded by what an
412account can push.
413
414#+begin_src sh
415gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs \
416  -workdir /var/lib/gitbay-runner/work
417#+end_src
418
419Add =-untrusted= only with =-isolation podman=.
420
421gitbay.org's runner is attached to the forge's own repositories and
422the isolation canary, nothing else, because it shares the host with
423the forge; its unit names no =-repos=, the attachments are the
424boundary. Any other repository builds on a runner its owner attaches.
425
426=-repos= narrows an admin runner; for a runner key the attachments are
427the boundary, held by the server, and =-repos= may only name
428repositories among them. =-untrusted= makes a runner claim merge
429request heads from forks; the bay1 unit sets it because it isolates in
430podman. A runner without it builds trusted commits only.
431
432=gitbay dashboard= and =ssh git@<host> admin runners= list every key
433that has polled as a runner: the account, the key's fingerprint, when it
434last polled, the repositories it may claim — its attachments for a runner
435key, the =-repos= it asked for or =any= for an admin key — and the build
436it holds; =admin runners forget <fingerprint>= drops the row for a key
437that polled by mistake, the key itself untouched. =admin runners= also
438heads the list with the queue: builds
439pending now, and over the last day how many were claimed, how long they
440waited to be claimed (average and worst), and
441how many the reaper ended instead of a runner reporting them. A build a runner claimed and never
442reported is failed by the scheduler's minute tick, whether or not any
443runner is still alive: within about two minutes of its log stream ending
444with no outcome reported — the runner reports right after closing the
445stream, retrying for half a minute if gitbayd is unreachable — or, if no
446stream was ever seen, at the deadline.
447
448Instance admin on the runner account only authorizes the claim/report
449protocol; it grants no repo access. A build that pushes back — a pages
450deploy, an archive publish, an automated MR branch — needs an explicit
451grant on that repo: =repo access grant <owner/name> ci write=. Private
452repos likewise need at least read for the clone.
453
454** Container isolation
455
456Builds run in a rootless podman container, one per job, with the
457workspace bind mounted and nothing else: the clone happens outside with
458the runner's key, so a step cannot read it. =-isolation none= keeps the
459old behaviour — steps on the host as the runner's user — for an instance
460where every repository is trusted. There is no automatic fallback: a
461runner started with =-isolation podman= that cannot find a working
462podman exits rather than running a build unsandboxed.
463
464gitbay's own jobs name =localhost/gitbay-ci:2=, built from
465=deploy/Containerfile.ci= on the runner host. A job's image must carry
466what its steps need: the suite drives real git, git-lfs, gpg and sshd and
467asserts they exist before running, so the stock runner default would fail
468it immediately. Build or rebuild it with:
469
470#+begin_src sh
471ssh -p 2222 root@<host> 'cat > /tmp/Containerfile.ci' < deploy/Containerfile.ci
472ssh -p 2222 root@<host> 'su - ci-runner -s /bin/sh -c \
473  "podman build -t localhost/gitbay-ci:2 -f /tmp/Containerfile.ci /tmp"'
474#+end_src
475
476The tag is deliberate rather than =:latest=: changing the file means
477bumping the tag in =.gitbay/ci.yml=, so a running branch's image does not
478change under it.
479
480=-image= names the image a job runs in when it declares none, and is
481required under =-isolation podman=: there is no built-in default,
482because an image this host does not have would fail every build. A job
483overrides it with =image:= in =.gitbay/ci.yml=, validated as a reference
484so a config file cannot turn it into podman arguments.
485
486=-cpus= and =-memory= cap one build (podman's units, e.g. =-cpus 2
487-memory 4g=); unset means uncapped. The runner applies them itself: it
488creates a cgroup per build under its own delegated service cgroup,
489writes the limits there, and starts every podman process for the build
490inside it, with podman's cgroup handling off. Podman's own =--memory=
491and =--cpus= never applied under rootless cgroupfs, which is what a
492system service gets (krz/gitbay#188). The unit therefore needs
493=Delegate=yes=, which the drop-in sets; without it the runner refuses
494to start when a limit is set, and logs that builds run unconfined when
495none is. bay1 runs =-cpus 3 -memory 6g= per build inside =MemoryMax=6G=
496and =CPUQuota=300%= on the unit, on a 7.7GB four-core host with no
497swap: the memory cap is what keeps the forge alive when a build
498allocates without bound, and it sits above the e2e suite's 5GB peak
499rather than at a fair share. =OOMPolicy=continue= keeps systemd from
500stopping the runner when a build is OOM-killed.
501
502Each repository gets its own build home under the runner's workdir,
503mounted into its containers as =HOME=. Caches persist between builds of
504one repository and are never read by another's.
505
506*Images are provisioned, never pulled by a build.* The runner passes
507=--pull=never=. Two reasons, and the second is the better one: the
508service runs with =RestrictSUIDSGID=yes= so podman cannot unpack a layer
509holding a setuid file, which is nearly every distribution image; and on
510an instance where anyone can push a =ci.yml=, =image:= would otherwise
511mean "fetch and run anything from the internet". An operator pulls or
512builds what is allowed and a build picks among those. A job naming an
513image the host does not have fails with a message saying so.
514
515#+begin_src sh
516su - ci-runner -s /bin/sh -c "podman pull docker.io/library/alpine:3.20"
517su - ci-runner -s /bin/sh -c "podman images"
518#+end_src
519
520Prepare a host before pointing an isolating runner at it:
521
522#+begin_src sh
523ssh -p 2222 root@<host> 'sh -s' < deploy/runner-podman-setup.sh
524make deploy-runner
525#+end_src
526
527The script installs podman, delegates a subuid/subgid range to
528=ci-runner=, checks that user namespaces are enabled rather than
529assuming, enables lingering, and verifies rootless podman actually runs
530as that user. It is idempotent.
531
532The drop-in sets =NoNewPrivileges=no=, without which rootless podman
533cannot call =newuidmap= and the runner refuses to start. That is a
534considered trade, explained in the file and in the Threat-Model; if you
535run with =-isolation none=, set it back to =yes=.
536
537*Restarting the runner is safe.* On SIGTERM it stops claiming, finishes
538the build in flight, reports it, and exits; the drop-in's
539=TimeoutStopSec=50min= covers the longest build, and its =KillMode=mixed=
540is what makes the signal reach the runner alone — under systemd's default
541the build's container and the log session are signalled with it, and the
542runner drains a build that is already dead. So =make deploy-runner=
543waits for a running build rather than orphaning it, and a build's result
544is retried for half a minute if gitbayd is restarting at that moment. A
545second SIGTERM ends the runner at once, abandoning the build to the
546reaper. The suite checks all three: =TestRunnerDrainsOnSIGTERM= signals
547the process, =TestRunnerDropInLetsTheDrainHappen= reads the drop-in's
548=KillMode= and =TimeoutStopSec=, and =TestRunnerDrainUnderSystemd= runs
549the runner as a transient user unit under =systemd-run= and stops it
550under both kill modes. That last one needs a systemd user manager, so it
551skips in the container CI runs in; run it on a Linux host with
552=go test ./e2e -run TestRunnerDrainUnderSystemd -v=.
553
554*Validate podman mode on a scratch repository before pointing the runner
555at real ones.* Every deploy that switched the whole instance to
556containers and failed took CI down with it. Instead: create a throwaway
557repository the runner account can read (public, or granted read — a
558private one is "not found" to the runner and the build stays pending),
559give it one job that names the CI image, and deploy the runner with
560=-repos= naming only that repository. The production unit, with its real
561hardening, then claims nothing else; other repositories' builds queue
562until =-repos= is switched back, which is a pause, not an outage.
563
564#+begin_src sh
565gitbay repo create cmc/ci-smoke          # then push a .gitbay/ci.yml naming the image
566sed -i 's#-repos krz/gitbay #-repos cmc/ci-smoke #' /etc/systemd/system/gitbay-runner.service.d/override.conf
567systemctl daemon-reload && systemctl restart gitbay-runner
568gitbay build log cmc/ci-smoke 1         # green: switch -repos back, redeploy
569#+end_src
570
571*Do not deploy an isolating runner to a host that has not been
572prepared.* The runner is specified to refuse to start without a working
573podman rather than fall back to running builds unsandboxed — a fallback
574that silently drops isolation is worse than a stopped runner, because
575nothing surfaces it. On an unprepared host that refusal stops every
576build on the instance.
577
578The service drop-in carries =Delegate=yes= for rootless cgroup
579management and =ReadWritePaths= for podman's store under
580=/var/lib/gitbay-runner=, which =ProtectSystem=full= would otherwise
581make read-only. Those paths are prefixed =-= so they are ignored when
582absent: the drop-in installs on unprepared hosts too, and a unit that
583refused to start would stop every build.
584The nightly canary on =cmc/ci-smoke= only runs if the runner's =-repos=
585names that repository too; a scoped runner claims nothing else.
586=gitbay-runner-prune.timer= prunes unused images weekly, as the runner's
587user: rootless storage belongs to that user, and root's prune would not
588see it. An unpruned image store on a 40GB host is a slow outage.
589
590* LFS storage
591
592Objects live content-addressed under =[lfs] root= (default
593=<server.root>/lfs=); =[lfs] max_object_bytes= caps a single object
594(512MB default). Storage sits behind a small interface — an
595S3-compatible backend is a drop-in with the server proxying, and
596presigned URLs a later optimization. LFS objects do not travel with
597push mirrors (mirrors move git refs only), and gc does not yet collect
598orphaned objects.
599
600* Pages
601
602=[pages] domain = "example.site"= serves public repos' =pages= branches
603on =<owner>.<domain>=. DNS needs a wildcard record =*.<domain>= to the
604server; ACME issues per-subdomain certificates on demand (only for
605owners that exist). The domain must not be the site host or a parent of
606it — pages content runs its own scripts and must stay off the forge's
607origin.
608
609Users with repo admin claim custom domains with =repo domain add=.
610Claims activate only after a DNS TXT challenge proves control of the
611domain (=repo domain verify=, audit-logged); pending claims serve
612nothing, get no certificates, and expire after 7 days. ACME issues
613certificates only for verified hosts, so stray DNS pointed at the
614server gets nothing.
615
616* Security
617
618The [[Threat-Model]] file is the reference for what the forge
619trusts and refuses to do. Operational checklist:
620
621- *Software checks.* =deploy/audit.sh= runs =go vet=, =govulncheck=
622  (the module list is deliberately short — review it on each release),
623  and a short fuzz pass over every attacker-facing parser (pkt-line,
624  commit, SSHSIG armor, OpenPGP key, SSH tokenizer). Run it before
625  tagging a release. CI's own =vuln= job runs =govulncheck= nightly
626  against main rather than per push, because =@latest= scans today's
627  advisory database and an advisory lands without anyone pushing;
628  =build trigger krz/gitbay vuln= runs it on demand.
629- *Web responses* carry a scripts-forbidden CSP, =X-Frame-Options:
630  DENY=, =nosniff=, =no-referrer=, and HSTS when TLS is on — no
631  configuration needed.
632- *Host sandboxing.* The systemd unit in =deploy/cloud-init.yaml= runs
633  gitbayd unprivileged with =ProtectSystem=strict=, =PrivateDevices=,
634  =LockPersonality=, =MemoryDenyWriteExecute=,
635  =SystemCallFilter=@system-service=, and =RestrictAddressFamilies= to
636  INET/INET6/UNIX. It keeps =CAP_NET_BIND_SERVICE= only, to bind 22/80/443.
637- *OS patches* apply via =unattended-upgrades= (security origins,
638  auto-reboot 04:30 if required).
639- *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and
640  watched by =fail2ban=; gitbayd's own port 22 is throttled by
641  =limits.ssh_auth_rate= (auth failures per IP per minute), and every
642  account's writes by =limits.write_rate=.
643- *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to
644journald and, when =/etc/gitbay/monitor.url= exists, posts it to that
645webhook: disk, service, the daemon's own =/healthz= answer, certificate
646expiry, and the age of the newest full backup and database snapshot.
647It exits non-zero on an alert so the unit shows in =systemctl
648--failed=: a stopped service, =/healthz= not answering =ok=, disk ≥ 85%,
649a certificate under 21 days, a full backup over 25 hours old, or a
650database snapshot over 2 hours old.
651
652=GET /healthz= is unauthenticated and cache-free: whether the database
653answers and which commit serves, 503 when it does not.
654- *Database.* =gitbay.db= and its WAL live under =/var/lib/gitbay= (mode
655  0750, owned by =gitbay=). The nightly archive plus provider snapshots
656  are the recovery path; for tighter RPO, add continuous replication
657  (litestream) against the same file — it coexists with the WAL.
658
659* Odds and ends
660
661- deleting a fork marks MRs sourced from it =source_gone=; their diffs
662  remain viewable and mergeable because the target repo owns the
663  objects.
664- =refs/merge-requests/*= is server-owned and unpushable by clients.
665- audit-relevant activity (issue/MR lifecycle, imports, pushes) lands in
666  the =events= table, which also feeds webhooks.
667- the daemon idles under 10MB RSS; the smallest VPS tier is adequate.