Commit 6c6c6248c1
Verified · cmc
Layout: unified · split
.gitbay/wiki/Admin.org +4 −1
| @@ -212,7 +212,10 @@ push=. | ||
| 212 | 212 | =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, |
| 213 | 213 | =git archive --remote=) over SSH, smart HTTP and git:// shares one |
| 214 | 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. | |
| 215 | address when anonymous: an IPv4 address, or an IPv6 /64), and this | |
| 216 | many waiting for at most the wait. Anonymous clients together hold | |
| 217 | at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in | |
| 218 | client (SSH key, bearer token or web session) can always get the last. | |
| 216 | 219 | Past that an SSH client gets "the server is busy…" and exit 1, HTTP |
| 217 | 220 | gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued |
| 218 | 221 | client that disconnects leaves the queue; a running clone whose |
CHANGELOG.org +3
| @@ -227,6 +227,9 @@ missing, =gitbayd admin backup --verify <archive>= names it, and | ||
| 227 | 227 | to turn the limit off. Ref listings, pushes and web archives are |
| 228 | 228 | unaffected. Under =ssh.mode = "system"= SSH clones are not counted, |
| 229 | 229 | since each session is its own process (#262). |
| 230 | - Anonymous clones are counted per IPv4 address or IPv6 /64, and | |
| 231 | together hold at most =pack_concurrency= − 1 slots, so a signed-in | |
| 232 | client can always get the last one (#262). | |
| 230 | 233 | |
| 231 | 234 | * v1.36.0 — 2026-09-23 |
| 232 | 235 | |
cmd/gitbayd/main.go +7 −1
| @@ -230,7 +230,13 @@ func serveCmd() *cobra.Command { | ||
| 230 | 230 | }, buildinfo.String()).Run(whCtx) |
| 231 | 231 | |
| 232 | 232 | // One pack-generation budget for SSH, smart HTTP and git://. |
| 233 | packs := packlimit.New(cfg.Limits.PackLimits()) | |
| 233 | // Anonymous clients ("ip:" principals) share all but one | |
| 234 | // slot, so an account can always get the last. | |
| 235 | packMax, packPer, packQueue, packWait := cfg.Limits.PackLimits() | |
| 236 | packs := packlimit.New(packMax, packPer, packQueue, packWait) | |
| 237 | if packMax > 1 { | |
| 238 | packs.CapClass("ip:", packMax-1) | |
| 239 | } | |
| 234 | 240 | |
| 235 | 241 | errCh := make(chan error, 3) |
| 236 | 242 | var sshSrv *sshd.Server |
internal/gitd/gitd.go +7 −2
| @@ -70,10 +70,9 @@ func (s *Server) handle(conn net.Conn) { | ||
| 70 | 70 | return |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) | |
| 74 | 73 | // A nil done: a queued client that leaves, or a restart, does not |
| 75 | 74 | // end the wait; only the limiter's wait does. |
| 76 | release, err := s.packs.Acquire(nil, "ip:"+host) | |
| 75 | release, err := s.packs.Acquire(nil, principal(conn.RemoteAddr())) | |
| 77 | 76 | if err != nil { |
| 78 | 77 | writeErr(conn, err.Error()) |
| 79 | 78 | return |
| @@ -101,6 +100,12 @@ func (s *Server) handle(conn net.Conn) { | ||
| 101 | 100 | gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill) |
| 102 | 101 | } |
| 103 | 102 | |
| 103 | // principal is the pack-limit principal for a client at addr. | |
| 104 | func principal(addr net.Addr) string { | |
| 105 | host, _, _ := net.SplitHostPort(addr.String()) | |
| 106 | return packlimit.AddrPrincipal(host) | |
| 107 | } | |
| 108 | ||
| 104 | 109 | func readPktLine(r io.Reader) (string, error) { |
| 105 | 110 | var lenHex [4]byte |
| 106 | 111 | if _, err := io.ReadFull(r, lenHex[:]); err != nil { |
internal/gitd/gitd_test.go +11
| @@ -49,3 +49,14 @@ func TestBusyAnswersERR(t *testing.T) { | ||
| 49 | 49 | t.Fatalf("got %q, %v", line, err) |
| 50 | 50 | } |
| 51 | 51 | } |
| 52 | ||
| 53 | func TestPrincipal(t *testing.T) { | |
| 54 | for addr, want := range map[net.Addr]string{ | |
| 55 | &net.TCPAddr{IP: net.ParseIP("192.0.2.7"), Port: 9418}: "ip:192.0.2.7", | |
| 56 | &net.TCPAddr{IP: net.ParseIP("2001:db8:1:2:3:4:5:6"), Port: 9418}: "ip:2001:db8:1:2::/64", | |
| 57 | } { | |
| 58 | if got := principal(addr); got != want { | |
| 59 | t.Errorf("principal(%v) = %q, want %q", addr, got, want) | |
| 60 | } | |
| 61 | } | |
| 62 | } | |
internal/httpd/packlimit_test.go +5
| @@ -95,6 +95,11 @@ func TestPackPrincipal(t *testing.T) { | ||
| 95 | 95 | if got := s.packPrincipal(r); got != "ip:192.0.2.7" { |
| 96 | 96 | t.Fatalf("bad token: %q", got) |
| 97 | 97 | } |
| 98 | r6 := httptest.NewRequest("POST", "/alice/app/git-upload-pack", nil) | |
| 99 | r6.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4000" | |
| 100 | if got := s.packPrincipal(r6); got != "ip:2001:db8:1:2::/64" { | |
| 101 | t.Fatalf("anonymous IPv6: %q", got) | |
| 102 | } | |
| 98 | 103 | want := "user:" + strconv.FormatInt(alice.ID, 10) |
| 99 | 104 | r.Header.Set("Authorization", "Bearer secret") |
| 100 | 105 | if got := s.packPrincipal(r); got != want { |
internal/httpd/smart.go +2 −2
| @@ -214,7 +214,7 @@ func lsRefs(br *bufio.Reader) bool { | ||
| 214 | 214 | // packPrincipal is who a fetch is counted against: the account when the |
| 215 | 215 | // request carries a valid bearer token or web session, the same key SSH |
| 216 | 216 | // uses, so switching transport buys no extra slots; otherwise the |
| 217 | // client address. | |
| 217 | // client address, an IPv6 one by its /64. | |
| 218 | 218 | func (s *Server) packPrincipal(r *http.Request) string { |
| 219 | 219 | if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.TrimSpace(tok) != "" { |
| 220 | 220 | if u, _, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(tok))); err == nil { |
| @@ -224,7 +224,7 @@ func (s *Server) packPrincipal(r *http.Request) string { | ||
| 224 | 224 | if u := s.viewer(r); u.ID != 0 { |
| 225 | 225 | return "user:" + strconv.FormatInt(u.ID, 10) |
| 226 | 226 | } |
| 227 | return "ip:" + s.clientIP(r) | |
| 227 | return packlimit.AddrPrincipal(s.clientIP(r)) | |
| 228 | 228 | } |
| 229 | 229 | |
| 230 | 230 | // gitProtocolEnv forwards the client's protocol negotiation header so |
internal/packlimit/packlimit.go +52 −6
| @@ -9,6 +9,8 @@ package packlimit | ||
| 9 | 9 | |
| 10 | 10 | import ( |
| 11 | 11 | "errors" |
| 12 | "net/netip" | |
| 13 | "strings" | |
| 12 | 14 | "sync" |
| 13 | 15 | "time" |
| 14 | 16 | ) |
| @@ -22,12 +24,18 @@ type Limiter struct { | ||
| 22 | 24 | max, per, queue int |
| 23 | 25 | wait time.Duration |
| 24 | 26 | |
| 25 | mu sync.Mutex | |
| 26 | running int | |
| 27 | queued int | |
| 28 | held map[string]int // running, per principal | |
| 29 | waiting map[string]int // queued, per principal | |
| 30 | changed chan struct{} // closed and replaced on every release | |
| 27 | // Principals starting with class may hold at most classCap slots | |
| 28 | // between them; classCap 0 is no class cap. | |
| 29 | class string | |
| 30 | classCap int | |
| 31 | ||
| 32 | mu sync.Mutex | |
| 33 | running int | |
| 34 | classHeld int | |
| 35 | queued int | |
| 36 | held map[string]int // running, per principal | |
| 37 | waiting map[string]int // queued, per principal | |
| 38 | changed chan struct{} // closed and replaced on every release | |
| 31 | 39 | } |
| 32 | 40 | |
| 33 | 41 | // New returns a limiter, or nil — no limit — when max is not positive. |
| @@ -39,6 +47,31 @@ func New(max, per, queue int, wait time.Duration) *Limiter { | ||
| 39 | 47 | held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})} |
| 40 | 48 | } |
| 41 | 49 | |
| 50 | // CapClass caps the slots that principals starting with prefix may hold | |
| 51 | // between them. Call it before the limiter is in use. | |
| 52 | func (l *Limiter) CapClass(prefix string, n int) { | |
| 53 | if l == nil { | |
| 54 | return | |
| 55 | } | |
| 56 | l.class, l.classCap = prefix, n | |
| 57 | } | |
| 58 | ||
| 59 | // AddrPrincipal is the principal for an unauthenticated client at addr: | |
| 60 | // an IPv4 address as is, an IPv6 address by its /64, since one host | |
| 61 | // commonly holds a whole /64. An address that does not parse is used | |
| 62 | // as given. | |
| 63 | func AddrPrincipal(addr string) string { | |
| 64 | a, err := netip.ParseAddr(addr) | |
| 65 | if err != nil { | |
| 66 | return "ip:" + addr | |
| 67 | } | |
| 68 | a = a.WithZone("").Unmap() | |
| 69 | if a.Is4() { | |
| 70 | return "ip:" + a.String() | |
| 71 | } | |
| 72 | return "ip:" + netip.PrefixFrom(a, 64).Masked().String() | |
| 73 | } | |
| 74 | ||
| 42 | 75 | // Acquire takes a slot for principal, queueing when none is free. |
| 43 | 76 | // done, when it closes, ends the wait. Once Acquire returns a nil |
| 44 | 77 | // error, the caller holds the slot and must call release — once git |
| @@ -106,12 +139,22 @@ func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func( | ||
| 106 | 139 | } |
| 107 | 140 | |
| 108 | 141 | func (l *Limiter) fits(principal string) bool { |
| 142 | if l.inClass(principal) && l.classHeld >= l.classCap { | |
| 143 | return false | |
| 144 | } | |
| 109 | 145 | return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per) |
| 110 | 146 | } |
| 111 | 147 | |
| 148 | func (l *Limiter) inClass(principal string) bool { | |
| 149 | return l.classCap > 0 && strings.HasPrefix(principal, l.class) | |
| 150 | } | |
| 151 | ||
| 112 | 152 | func (l *Limiter) take(principal string) { |
| 113 | 153 | l.running++ |
| 114 | 154 | l.held[principal]++ |
| 155 | if l.inClass(principal) { | |
| 156 | l.classHeld++ | |
| 157 | } | |
| 115 | 158 | } |
| 116 | 159 | |
| 117 | 160 | func (l *Limiter) releaser(principal string) func() { |
| @@ -121,6 +164,9 @@ func (l *Limiter) releaser(principal string) func() { | ||
| 121 | 164 | l.mu.Lock() |
| 122 | 165 | defer l.mu.Unlock() |
| 123 | 166 | l.running-- |
| 167 | if l.inClass(principal) { | |
| 168 | l.classHeld-- | |
| 169 | } | |
| 124 | 170 | if l.held[principal]--; l.held[principal] == 0 { |
| 125 | 171 | delete(l.held, principal) |
| 126 | 172 | } |
internal/packlimit/packlimit_test.go +60
| @@ -222,3 +222,63 @@ func TestUnboundedQueue(t *testing.T) { | ||
| 222 | 222 | waitQueued(t, l, 64) |
| 223 | 223 | r1() |
| 224 | 224 | } |
| 225 | ||
| 226 | // Anonymous clients cannot hold every slot: with max 3 and "ip:" capped | |
| 227 | // at 2, a third anonymous request queues and an account still gets in. | |
| 228 | func TestClassCap(t *testing.T) { | |
| 229 | l := New(3, 0, 4, 5*time.Second) | |
| 230 | l.CapClass("ip:", 2) | |
| 231 | r1, err1 := l.Acquire(nil, "ip:192.0.2.1") | |
| 232 | r2, err2 := l.Acquire(nil, "ip:192.0.2.2") | |
| 233 | if err1 != nil || err2 != nil { | |
| 234 | t.Fatal(err1, err2) | |
| 235 | } | |
| 236 | got := make(chan error, 1) | |
| 237 | go func() { | |
| 238 | r, err := l.Acquire(nil, "ip:192.0.2.3") | |
| 239 | if err == nil { | |
| 240 | r() | |
| 241 | } | |
| 242 | got <- err | |
| 243 | }() | |
| 244 | waitQueued(t, l, 1) | |
| 245 | ru, err := l.Acquire(nil, "user:1") | |
| 246 | if err != nil { | |
| 247 | t.Fatalf("account refused the free slot: %v", err) | |
| 248 | } | |
| 249 | select { | |
| 250 | case err := <-got: | |
| 251 | t.Fatalf("third anonymous request did not queue: %v", err) | |
| 252 | default: | |
| 253 | } | |
| 254 | r1() | |
| 255 | select { | |
| 256 | case err := <-got: | |
| 257 | if err != nil { | |
| 258 | t.Fatal(err) | |
| 259 | } | |
| 260 | case <-time.After(2 * time.Second): | |
| 261 | t.Fatal("queued anonymous request never got the freed slot") | |
| 262 | } | |
| 263 | r2() | |
| 264 | ru() | |
| 265 | assertHeldEmpty(t, l) | |
| 266 | if l.classHeld != 0 { | |
| 267 | t.Fatalf("classHeld = %d after every release", l.classHeld) | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | func TestAddrPrincipal(t *testing.T) { | |
| 272 | for in, want := range map[string]string{ | |
| 273 | "192.0.2.7": "ip:192.0.2.7", | |
| 274 | "::ffff:192.0.2.7": "ip:192.0.2.7", | |
| 275 | "2001:db8:1:2:3:4:5:6": "ip:2001:db8:1:2::/64", | |
| 276 | "2001:db8:1:2:ffff::1": "ip:2001:db8:1:2::/64", | |
| 277 | "fe80::1%en0": "ip:fe80::/64", | |
| 278 | "not-an-address": "ip:not-an-address", | |
| 279 | } { | |
| 280 | if got := AddrPrincipal(in); got != want { | |
| 281 | t.Errorf("AddrPrincipal(%q) = %q, want %q", in, got, want) | |
| 282 | } | |
| 283 | } | |
| 284 | } | |