Commit 3035725993

3035725993b817e53e256ff0d07edfd7f46b48e6

parent: b94ae2638b

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 22:35 UTC

gitbayd: one pack-generation limit for SSH, HTTP and git://

Closes #262

Layout: unified · split

.gitbay/wiki/Admin.org +14
@@ -208,6 +208,20 @@ push=.
208 Organizations are not capped. 208 Organizations are not capped.
209- =max_bytes_per_user= (0, unlimited) — disk the account's own 209- =max_bytes_per_user= (0, unlimited) — disk the account's own
210 repositories may take; a push may be no larger than what is left. 210 repositories may take; a push may be no larger than what is left.
211- =pack_concurrency= (3), =pack_per_principal= (2), =pack_queue= (32),
212 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
213 =git archive --remote=) over SSH, smart HTTP and git:// shares one
214 budget: this many at once, this many per account (per client
215 address when anonymous), and this many waiting for at most the wait.
216 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
217 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued
218 client that disconnects leaves the queue; a running clone whose
219 client disconnects is killed. Ref listings (info/refs, protocol v2
220 =ls-refs=), pushes and web archives are outside the budget. For the
221 three counts 0 means the default and a negative value turns that
222 bound off. The defaults suit a four-core host; see [[Performance]].
223 With =ssh.mode = "system"= each SSH session is its own process and
224 SSH clones are not counted.
211- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced. 225- =max_pack_bytes=, =ssh_auth_rate= — reserved, not yet enforced.
212 226
213** [git_daemon] 227** [git_daemon]
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -96,7 +96,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
96| Control | Status | Evidence | 96| Control | Status | Evidence |
97|---------------------------------------------+----------+------------------------------------------------------------------| 97|---------------------------------------------+----------+------------------------------------------------------------------|
98| Rate limits on API and writes | in place | [[file:05-Identity-and-Access.org][5. Rate limits]] | 98| Rate limits on API and writes | in place | [[file:05-Identity-and-Access.org][5. Rate limits]] |
99| Concurrency limit on git pack generation | gap | #262 | 99| Concurrency limit on git pack generation | in place | global, per-principal, bounded queue across SSH, HTTP and git:// (=internal/packlimit=); not in system SSH mode |
100| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) | 100| Service hardening | in place | systemd sandboxing ([[file:03-Deployment.org][3]]) |
101| Backups offsite and append-only | in place | restic with append-only credentials (documented) | 101| Backups offsite and append-only | in place | restic with append-only credentials (documented) |
102| Restore tested | gap | #259 | 102| Restore tested | gap | #259 |
.gitbay/wiki/Architecture/10-Known-Gaps.org +1 −2
@@ -13,8 +13,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
13| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | 13| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high |
14| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #262 | Availability | No limit on concurrent git pack generation | high |
17| #298 | SSRF | =repo import --from= fetches without an address check | medium |
18| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 16| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
19 17
20* Not filed 18* Not filed
@@ -22,6 +20,7 @@ what the 2026-09-27 review found; remove a row when its issue closes.
22| Area | Gap | Severity | 20| Area | Gap | Severity |
23|-------+-------------------------------------------------------------------------------------------------------------+----------| 21|-------+-------------------------------------------------------------------------------------------------------------+----------|
24| Audit | Removing the newest audit rows, or writing new rows under their freed ids, is not detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low | 22| Audit | Removing the newest audit rows, or writing new rows under their freed ids, is not detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low |
23| Availability | Under =ssh.mode = "system"= each SSH session is a separate =gitbayd shell= process, so the pack-generation limit (=internal/packlimit=, #262) cannot count SSH clones across sessions; only HTTP and git:// share a budget there | low |
25 24
26* Questions an auditor will ask that have no answer yet 25* Questions an auditor will ask that have no answer yet
27 26
.gitbay/wiki/Performance.org +12 −2
@@ -45,5 +45,15 @@ this scale never appears in a profile.
45 45
46The practical ceiling on this hardware is concurrent pack generation: 46The practical ceiling on this hardware is concurrent pack generation:
47full clones of large repositories are CPU-bound in git itself (the 17s 47full clones of large repositories are CPU-bound in git itself (the 17s
48clone ran git at ~156% CPU). A busier instance would scale that with 48clone ran git at ~156% CPU). =limits.pack_concurrency= bounds how many
49cores, not with changes to gitbay. 49run at once across SSH, HTTP and git://, with a queue behind it (see
50[[Admin]], =[limits]=); the measurements below set its default.
51
52* Concurrent clones
53
54Measured with =deploy/clonebench.sh https://gitbay.org/krz/gitbay.git <n>=
55from a machine outside bay1 (four cores), before and after the pack
56limit was deployed with its defaults (=pack_concurrency= 3,
57=pack_per_principal= 2, =pack_queue= 32, =pack_queue_wait= 60s). All
58clones in one run come from one address, so the per-principal cap
59applies to them; the "limit off" run sets the counts to -1.
CHANGELOG.org +10
@@ -217,6 +217,16 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
217 repository on the host. (#259) 217 repository on the host. (#259)
218- =gitbayd admin secrets init= and =rotate= hold an flock on =<key 218- =gitbayd admin secrets init= and =rotate= hold an flock on =<key
219 file>.lock=, so two runs at once serialize. (#273) 219 file>.lock=, so two runs at once serialize. (#273)
220- Git pack generation (clones, fetches, =git archive --remote=) over
221 SSH, smart HTTP and git:// now shares one concurrency budget:
222 =limits.pack_concurrency= (3), =pack_per_principal= (2), =pack_queue=
223 (32) and =pack_queue_wait= (60s). Past the queue an SSH client sees
224 "the server is busy…" and exits 1, HTTP gets 503 with
225 =Retry-After: 30=, and git:// gets an =ERR= line. *Operators:* the
226 defaults are tuned for a four-core host; set the three counts to -1
227 to turn the limit off. Ref listings, pushes and web archives are
228 unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
229 since each session is its own process (#262).
220 230
221* v1.36.0 — 2026-09-23 231* v1.36.0 — 2026-09-23
222 232
cmd/gitbayd/main.go +7 −3
@@ -32,6 +32,7 @@ import (
32 "gitbay.org/gitbay/internal/httpd" 32 "gitbay.org/gitbay/internal/httpd"
33 "gitbay.org/gitbay/internal/mirror" 33 "gitbay.org/gitbay/internal/mirror"
34 "gitbay.org/gitbay/internal/notify" 34 "gitbay.org/gitbay/internal/notify"
35 "gitbay.org/gitbay/internal/packlimit"
35 "gitbay.org/gitbay/internal/push" 36 "gitbay.org/gitbay/internal/push"
36 "gitbay.org/gitbay/internal/seal" 37 "gitbay.org/gitbay/internal/seal"
37 "gitbay.org/gitbay/internal/sshd" 38 "gitbay.org/gitbay/internal/sshd"
@@ -228,11 +229,14 @@ func serveCmd() *cobra.Command {
228 return control.RepoDir(cfg.Server.Root, owner, name) 229 return control.RepoDir(cfg.Server.Root, owner, name)
229 }, buildinfo.String()).Run(whCtx) 230 }, buildinfo.String()).Run(whCtx)
230 231
232 // One pack-generation budget for SSH, smart HTTP and git://.
233 packs := packlimit.New(cfg.Limits.PackLimits())
234
231 errCh := make(chan error, 3) 235 errCh := make(chan error, 3)
232 var sshSrv *sshd.Server 236 var sshSrv *sshd.Server
233 var sshLn, gitLn net.Listener 237 var sshLn, gitLn net.Listener
234 if cfg.SSH.Mode == "embedded" { 238 if cfg.SSH.Mode == "embedded" {
235 srv, err := sshd.New(cfg, st, nil) 239 srv, err := sshd.New(cfg, st, packs)
236 if err != nil { 240 if err != nil {
237 return err 241 return err
238 } 242 }
@@ -249,7 +253,7 @@ func serveCmd() *cobra.Command {
249 slog.Info("ssh handled by host sshd (ssh.mode = system)") 253 slog.Info("ssh handled by host sshd (ssh.mode = system)")
250 } 254 }
251 255
252 web := httpd.New(cfg, st, nil) 256 web := httpd.New(cfg, st, packs)
253 // Header and idle timeouts bound what an idle or slow client can 257 // Header and idle timeouts bound what an idle or slow client can
254 // hold open. No write timeout: archives and upload-pack stream 258 // hold open. No write timeout: archives and upload-pack stream
255 // for as long as they take (#104). 259 // for as long as they take (#104).
@@ -338,7 +342,7 @@ func serveCmd() *cobra.Command {
338 } 342 }
339 slog.Info("git-daemon listening", "addr", gln.Addr()) 343 slog.Info("git-daemon listening", "addr", gln.Addr())
340 gitLn = gln 344 gitLn = gln
341 go func() { errCh <- gitd.New(cfg, st, nil).Serve(gln) }() 345 go func() { errCh <- gitd.New(cfg, st, packs).Serve(gln) }()
342 } 346 }
343 347
344 select { 348 select {
deploy/clonebench.sh added +24
@@ -0,0 +1,24 @@
1#!/bin/sh
2# clonebench.sh <clone-url> <n>: start n full bare clones of <clone-url>
3# at once and print each one's wall time and outcome, then the total.
4# Run from a machine other than the server, against a public repository.
5set -eu
6url=$1
7n=$2
8dir=$(mktemp -d)
9trap 'rm -rf "$dir"' EXIT
10start=$(date +%s)
11i=1
12while [ "$i" -le "$n" ]; do
13 (
14 s=$(date +%s)
15 if git clone --quiet --bare "$url" "$dir/$i.git" 2>"$dir/$i.err"; then
16 echo "$i ok $(( $(date +%s) - s ))s"
17 else
18 echo "$i failed $(( $(date +%s) - s ))s: $(head -n 1 "$dir/$i.err")"
19 fi
20 ) &
21 i=$((i + 1))
22done
23wait
24echo "total $(( $(date +%s) - start ))s for $n clones"