Commit 6ee49bebaa

6ee49bebaa449abadc9a5baa0ca0d38d2136acbe

parent: 9c31ca2462

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 23:04 UTC

hookd: cap refs in a refused-push row; pack-limit docs name the class cap

Ref #262

Layout: unified · split

.gitbay/wiki/Admin.org +3 −2
@@ -215,8 +215,9 @@ push=.
215 budget: this many at once, this many per account (per client 215 budget: this many at once, this many per account (per client
216 address when anonymous: an IPv4 address, or an IPv6 /64), and this 216 address when anonymous: an IPv4 address, or an IPv6 /64), and this
217 many waiting for at most the wait. Anonymous clients together hold 217 many waiting for at most the wait. Anonymous clients together hold
218 at most =pack_concurrency= − 1 slots when it is above 1, so a signed-in 218 at most =pack_concurrency= − 1 slots when it is above 1: an account
219 client (SSH key, bearer token or web session) can always get the last. 219 (SSH key, bearer token or web session) can take the last slot when it
220 is free, and anonymous clients cannot hold it.
220 Past that an SSH client gets "the server is busy…" and exit 1, HTTP 221 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
221 gets 503 with =Retry-After: 30=, git:// an =ERR= line; the daemon 222 gets 503 with =Retry-After: 30=, git:// an =ERR= line; the daemon
222 logs a =pack limit= warning naming the transport and whether the 223 logs a =pack limit= warning naming the transport and whether the
.gitbay/wiki/Performance.org +7 −4
@@ -55,7 +55,10 @@ The defaults (=pack_concurrency= 3, =pack_per_principal= 2,
55=pack_queue= 32, =pack_queue_wait= 60s) are set for a four-core host 55=pack_queue= 32, =pack_queue_wait= 60s) are set for a four-core host
56from the single-clone figure above; no concurrent-clone measurement 56from the single-clone figure above; no concurrent-clone measurement
57backs them yet. =deploy/clonebench.sh <clone-url> <n>= starts n full 57backs them yet. =deploy/clonebench.sh <clone-url> <n>= starts n full
58clones at once from a machine other than the server. Clones from one 58clones at once from a machine other than the server. Anonymous clones
59address count against one principal, so run it from two addresses, or 59together hold at most =pack_concurrency= − 1 slots, and clones from one
60against an instance with =pack_per_principal = -1=, or it measures the 60address or account count against one principal, so an anonymous run
61per-principal cap instead of the global one. 61measures those caps rather than the global one. Run it as signed-in
62clients (SSH keys, or HTTP with bearer tokens) from more than one
63account, or against an instance with =pack_per_principal = -1= and
64read the result as the anonymous class cap.
CHANGELOG.org +3 −2
@@ -238,8 +238,9 @@ missing, =gitbayd admin backup --verify <archive>= names it, and
238 download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted, 238 download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
239 since each session is its own process (#262). 239 since each session is its own process (#262).
240- Anonymous clones are counted per IPv4 address or IPv6 /64, and 240- Anonymous clones are counted per IPv4 address or IPv6 /64, and
241 together hold at most =pack_concurrency= − 1 slots, so a signed-in 241 together hold at most =pack_concurrency= − 1 slots: an account can
242 client can always get the last one (#262). 242 take the last slot when it is free, and anonymous clients cannot hold
243 it (#262).
243- A request turned away by the pack limit logs a warning naming the 244- A request turned away by the pack limit logs a warning naming the
244 transport and whether the client was signed in, never its address, 245 transport and whether the client was signed in, never its address,
245 at most once a minute per transport (#262). 246 at most once a minute per transport (#262).
internal/hookd/hookd.go +12 −4
@@ -172,14 +172,22 @@ func (s *Server) authorize(req Request) (actor int64, msg string) {
172// peerCheck is checkPeer; tests replace it. 172// peerCheck is checkPeer; tests replace it.
173var peerCheck = checkPeer 173var peerCheck = checkPeer
174 174
175// auditedRefs is how many ref names a refused-push row keeps; the rest
176// are counted, so one push of many refs cannot write an unbounded row.
177const auditedRefs = 20
178
175// refusePush answers a pre-receive refusal and audits it. 179// refusePush answers a pre-receive refusal and audits it.
176func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) { 180func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
177 refs := make([]string, len(req.Updates)) 181 n := min(len(req.Updates), auditedRefs)
178 for i, u := range req.Updates { 182 refs := make([]string, n)
183 for i, u := range req.Updates[:n] {
179 refs[i] = u.Ref 184 refs[i] = u.Ref
180 } 185 }
181 control.AuditRefused(s.st, req.UserID, "refused push", 186 data := map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg}
182 map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg}) 187 if more := len(req.Updates) - n; more > 0 {
188 data["more_refs"] = more
189 }
190 control.AuditRefused(s.st, req.UserID, "refused push", data)
183 enc.Encode(Response{Allow: false, Message: msg}) 191 enc.Encode(Response{Allow: false, Message: msg})
184} 192}
185 193
internal/hookd/socket_test.go +34
@@ -1,7 +1,9 @@
1package hookd 1package hookd
2 2
3import ( 3import (
4 "encoding/json"
4 "errors" 5 "errors"
6 "fmt"
5 "net" 7 "net"
6 "os" 8 "os"
7 "path/filepath" 9 "path/filepath"
@@ -161,3 +163,35 @@ func TestPeerRefusalIsAudited(t *testing.T) {
161 t.Fatalf("refused hook rows: %+v", rows) 163 t.Fatalf("refused hook rows: %+v", rows)
162 } 164 }
163} 165}
166
167// A refused push of many refs records the first auditedRefs names and
168// a count of the rest.
169func TestRefusedPushCapsRefs(t *testing.T) {
170 sock, st, repoID, uid := serveSocket(t)
171 token, err := st.CreatePushToken(repoID, uid, "full")
172 if err != nil {
173 t.Fatal(err)
174 }
175 req := Request{Hook: "pre-receive", RepoID: repoID, UserID: uid, Scope: "full", Token: token}
176 for i := range 500 {
177 req.Updates = append(req.Updates, policy.RefUpdate{Ref: fmt.Sprintf("refs/merge-requests/%d/head", i),
178 Old: zeroSHA40, New: strings.Repeat("a", 40)})
179 }
180 if resp, err := Ask(sock, req, nil); err != nil || resp.Allow {
181 t.Fatalf("push: %+v, %v", resp, err)
182 }
183 rows := refusedRows(t, st, "refused push")
184 if len(rows) != 1 {
185 t.Fatalf("rows: %+v", rows)
186 }
187 var data struct {
188 Refs []string `json:"refs"`
189 MoreRefs int `json:"more_refs"`
190 }
191 if err := json.Unmarshal([]byte(rows[0].Data), &data); err != nil {
192 t.Fatal(err)
193 }
194 if len(data.Refs) != auditedRefs || data.MoreRefs != 500-auditedRefs || data.Refs[0] != "refs/merge-requests/0/head" {
195 t.Fatalf("refs %d, more %d", len(data.Refs), data.MoreRefs)
196 }
197}