Commit 6c6c6248c1

6c6c6248c1c2e64af01c002df586692fc8a4985b

parent: 8b4a553eee

Verified · cmc

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

packlimit: key IPv6 clients on their /64; anonymous clients leave a slot for accounts

Ref #262

Layout: unified · split

.gitbay/wiki/Admin.org +4 −1
@@ -212,7 +212,10 @@ push=.
212 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches, 212 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
213 =git archive --remote=) over SSH, smart HTTP and git:// shares one 213 =git archive --remote=) over SSH, smart HTTP and git:// shares one
214 budget: this many at once, this many per account (per client 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 Past that an SSH client gets "the server is busy…" and exit 1, HTTP 219 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 220 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued
218 client that disconnects leaves the queue; a running clone whose 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 to turn the limit off. Ref listings, pushes and web archives are 227 to turn the limit off. Ref listings, pushes and web archives are
228 unaffected. Under =ssh.mode = "system"= SSH clones are not counted, 228 unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
229 since each session is its own process (#262). 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* v1.36.0 — 2026-09-23 234* v1.36.0 — 2026-09-23
232 235
cmd/gitbayd/main.go +7 −1
@@ -230,7 +230,13 @@ func serveCmd() *cobra.Command {
230 }, buildinfo.String()).Run(whCtx) 230 }, buildinfo.String()).Run(whCtx)
231 231
232 // One pack-generation budget for SSH, smart HTTP and git://. 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 errCh := make(chan error, 3) 241 errCh := make(chan error, 3)
236 var sshSrv *sshd.Server 242 var sshSrv *sshd.Server
internal/gitd/gitd.go +7 −2
@@ -70,10 +70,9 @@ func (s *Server) handle(conn net.Conn) {
70 return 70 return
71 } 71 }
72 72
73 host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
74 // A nil done: a queued client that leaves, or a restart, does not 73 // A nil done: a queued client that leaves, or a restart, does not
75 // end the wait; only the limiter's wait does. 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 if err != nil { 76 if err != nil {
78 writeErr(conn, err.Error()) 77 writeErr(conn, err.Error())
79 return 78 return
@@ -101,6 +100,12 @@ func (s *Server) handle(conn net.Conn) {
101 gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill) 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.
104func principal(addr net.Addr) string {
105 host, _, _ := net.SplitHostPort(addr.String())
106 return packlimit.AddrPrincipal(host)
107}
108
104func readPktLine(r io.Reader) (string, error) { 109func readPktLine(r io.Reader) (string, error) {
105 var lenHex [4]byte 110 var lenHex [4]byte
106 if _, err := io.ReadFull(r, lenHex[:]); err != nil { 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 t.Fatalf("got %q, %v", line, err) 49 t.Fatalf("got %q, %v", line, err)
50 } 50 }
51} 51}
52
53func 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 if got := s.packPrincipal(r); got != "ip:192.0.2.7" { 95 if got := s.packPrincipal(r); got != "ip:192.0.2.7" {
96 t.Fatalf("bad token: %q", got) 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 want := "user:" + strconv.FormatInt(alice.ID, 10) 103 want := "user:" + strconv.FormatInt(alice.ID, 10)
99 r.Header.Set("Authorization", "Bearer secret") 104 r.Header.Set("Authorization", "Bearer secret")
100 if got := s.packPrincipal(r); got != want { 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// packPrincipal is who a fetch is counted against: the account when the 214// packPrincipal is who a fetch is counted against: the account when the
215// request carries a valid bearer token or web session, the same key SSH 215// request carries a valid bearer token or web session, the same key SSH
216// uses, so switching transport buys no extra slots; otherwise the 216// uses, so switching transport buys no extra slots; otherwise the
217// client address. 217// client address, an IPv6 one by its /64.
218func (s *Server) packPrincipal(r *http.Request) string { 218func (s *Server) packPrincipal(r *http.Request) string {
219 if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.TrimSpace(tok) != "" { 219 if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.TrimSpace(tok) != "" {
220 if u, _, err := s.st.APITokenUser(store.HashToken(strings.TrimSpace(tok))); err == nil { 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 if u := s.viewer(r); u.ID != 0 { 224 if u := s.viewer(r); u.ID != 0 {
225 return "user:" + strconv.FormatInt(u.ID, 10) 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// gitProtocolEnv forwards the client's protocol negotiation header so 230// gitProtocolEnv forwards the client's protocol negotiation header so
internal/packlimit/packlimit.go +52 −6
@@ -9,6 +9,8 @@ package packlimit
9 9
10import ( 10import (
11 "errors" 11 "errors"
12 "net/netip"
13 "strings"
12 "sync" 14 "sync"
13 "time" 15 "time"
14) 16)
@@ -22,12 +24,18 @@ type Limiter struct {
22 max, per, queue int 24 max, per, queue int
23 wait time.Duration 25 wait time.Duration
24 26
25 mu sync.Mutex 27 // Principals starting with class may hold at most classCap slots
26 running int 28 // between them; classCap 0 is no class cap.
27 queued int 29 class string
28 held map[string]int // running, per principal 30 classCap int
29 waiting map[string]int // queued, per principal 31
30 changed chan struct{} // closed and replaced on every release 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// New returns a limiter, or nil — no limit — when max is not positive. 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 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})} 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.
52func (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.
63func 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// Acquire takes a slot for principal, queueing when none is free. 75// Acquire takes a slot for principal, queueing when none is free.
43// done, when it closes, ends the wait. Once Acquire returns a nil 76// done, when it closes, ends the wait. Once Acquire returns a nil
44// error, the caller holds the slot and must call release — once git 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
108func (l *Limiter) fits(principal string) bool { 141func (l *Limiter) fits(principal string) bool {
142 if l.inClass(principal) && l.classHeld >= l.classCap {
143 return false
144 }
109 return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per) 145 return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per)
110} 146}
111 147
148func (l *Limiter) inClass(principal string) bool {
149 return l.classCap > 0 && strings.HasPrefix(principal, l.class)
150}
151
112func (l *Limiter) take(principal string) { 152func (l *Limiter) take(principal string) {
113 l.running++ 153 l.running++
114 l.held[principal]++ 154 l.held[principal]++
155 if l.inClass(principal) {
156 l.classHeld++
157 }
115} 158}
116 159
117func (l *Limiter) releaser(principal string) func() { 160func (l *Limiter) releaser(principal string) func() {
@@ -121,6 +164,9 @@ func (l *Limiter) releaser(principal string) func() {
121 l.mu.Lock() 164 l.mu.Lock()
122 defer l.mu.Unlock() 165 defer l.mu.Unlock()
123 l.running-- 166 l.running--
167 if l.inClass(principal) {
168 l.classHeld--
169 }
124 if l.held[principal]--; l.held[principal] == 0 { 170 if l.held[principal]--; l.held[principal] == 0 {
125 delete(l.held, principal) 171 delete(l.held, principal)
126 } 172 }
internal/packlimit/packlimit_test.go +60
@@ -222,3 +222,63 @@ func TestUnboundedQueue(t *testing.T) {
222 waitQueued(t, l, 64) 222 waitQueued(t, l, 64)
223 r1() 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.
228func 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
271func 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}