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=.
212212 =pack_queue_wait= (="60s"=) — git pack generation (clones, fetches,
213213 =git archive --remote=) over SSH, smart HTTP and git:// shares one
214214 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.
216219 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
217220 gets 503 with =Retry-After: 30=, git:// an =ERR= line. A queued
218221 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
227227 to turn the limit off. Ref listings, pushes and web archives are
228228 unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
229229 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).
230233
231234* v1.36.0 — 2026-09-23
232235
cmd/gitbayd/main.go +7 −1
@@ -230,7 +230,13 @@ func serveCmd() *cobra.Command {
230230 }, buildinfo.String()).Run(whCtx)
231231
232232 // 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 }
234240
235241 errCh := make(chan error, 3)
236242 var sshSrv *sshd.Server
internal/gitd/gitd.go +7 −2
@@ -70,10 +70,9 @@ func (s *Server) handle(conn net.Conn) {
7070 return
7171 }
7272
73 host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
7473 // A nil done: a queued client that leaves, or a restart, does not
7574 // 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()))
7776 if err != nil {
7877 writeErr(conn, err.Error())
7978 return
@@ -101,6 +100,12 @@ func (s *Server) handle(conn net.Conn) {
101100 gitutil.Transport("git-upload-pack", dir, conn, out, io.Discard, protoEnv, 0, kill)
102101}
103102
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
104109func readPktLine(r io.Reader) (string, error) {
105110 var lenHex [4]byte
106111 if _, err := io.ReadFull(r, lenHex[:]); err != nil {
internal/gitd/gitd_test.go +11
@@ -49,3 +49,14 @@ func TestBusyAnswersERR(t *testing.T) {
4949 t.Fatalf("got %q, %v", line, err)
5050 }
5151}
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) {
9595 if got := s.packPrincipal(r); got != "ip:192.0.2.7" {
9696 t.Fatalf("bad token: %q", got)
9797 }
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 }
98103 want := "user:" + strconv.FormatInt(alice.ID, 10)
99104 r.Header.Set("Authorization", "Bearer secret")
100105 if got := s.packPrincipal(r); got != want {
internal/httpd/smart.go +2 −2
@@ -214,7 +214,7 @@ func lsRefs(br *bufio.Reader) bool {
214214// packPrincipal is who a fetch is counted against: the account when the
215215// request carries a valid bearer token or web session, the same key SSH
216216// uses, so switching transport buys no extra slots; otherwise the
217// client address.
217// client address, an IPv6 one by its /64.
218218func (s *Server) packPrincipal(r *http.Request) string {
219219 if tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && strings.TrimSpace(tok) != "" {
220220 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 {
224224 if u := s.viewer(r); u.ID != 0 {
225225 return "user:" + strconv.FormatInt(u.ID, 10)
226226 }
227 return "ip:" + s.clientIP(r)
227 return packlimit.AddrPrincipal(s.clientIP(r))
228228}
229229
230230// gitProtocolEnv forwards the client's protocol negotiation header so
internal/packlimit/packlimit.go +52 −6
@@ -9,6 +9,8 @@ package packlimit
99
1010import (
1111 "errors"
12 "net/netip"
13 "strings"
1214 "sync"
1315 "time"
1416)
@@ -22,12 +24,18 @@ type Limiter struct {
2224 max, per, queue int
2325 wait time.Duration
2426
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
3139}
3240
3341// 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 {
3947 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{})}
4048}
4149
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
4275// Acquire takes a slot for principal, queueing when none is free.
4376// done, when it closes, ends the wait. Once Acquire returns a nil
4477// 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(
106139}
107140
108141func (l *Limiter) fits(principal string) bool {
142 if l.inClass(principal) && l.classHeld >= l.classCap {
143 return false
144 }
109145 return l.running < l.max && (l.per <= 0 || l.held[principal] < l.per)
110146}
111147
148func (l *Limiter) inClass(principal string) bool {
149 return l.classCap > 0 && strings.HasPrefix(principal, l.class)
150}
151
112152func (l *Limiter) take(principal string) {
113153 l.running++
114154 l.held[principal]++
155 if l.inClass(principal) {
156 l.classHeld++
157 }
115158}
116159
117160func (l *Limiter) releaser(principal string) func() {
@@ -121,6 +164,9 @@ func (l *Limiter) releaser(principal string) func() {
121164 l.mu.Lock()
122165 defer l.mu.Unlock()
123166 l.running--
167 if l.inClass(principal) {
168 l.classHeld--
169 }
124170 if l.held[principal]--; l.held[principal] == 0 {
125171 delete(l.held, principal)
126172 }
internal/packlimit/packlimit_test.go +60
@@ -222,3 +222,63 @@ func TestUnboundedQueue(t *testing.T) {
222222 waitQueued(t, l, 64)
223223 r1()
224224}
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}