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=.
215215 budget: this many at once, this many per account (per client
216216 address when anonymous: an IPv4 address, or an IPv6 /64), and this
217217 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
219 client (SSH key, bearer token or web session) can always get the last.
218 at most =pack_concurrency= − 1 slots when it is above 1: an account
219 (SSH key, bearer token or web session) can take the last slot when it
220 is free, and anonymous clients cannot hold it.
220221 Past that an SSH client gets "the server is busy…" and exit 1, HTTP
221222 gets 503 with =Retry-After: 30=, git:// an =ERR= line; the daemon
222223 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,
5555=pack_queue= 32, =pack_queue_wait= 60s) are set for a four-core host
5656from the single-clone figure above; no concurrent-clone measurement
5757backs them yet. =deploy/clonebench.sh <clone-url> <n>= starts n full
58clones at once from a machine other than the server. Clones from one
59address count against one principal, so run it from two addresses, or
60against an instance with =pack_per_principal = -1=, or it measures the
61per-principal cap instead of the global one.
58clones at once from a machine other than the server. Anonymous clones
59together hold at most =pack_concurrency= − 1 slots, and clones from one
60address or account count against one principal, so an anonymous run
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
238238 download= are unaffected. Under =ssh.mode = "system"= SSH clones are not counted,
239239 since each session is its own process (#262).
240240- Anonymous clones are counted per IPv4 address or IPv6 /64, and
241 together hold at most =pack_concurrency= − 1 slots, so a signed-in
242 client can always get the last one (#262).
241 together hold at most =pack_concurrency= − 1 slots: an account can
242 take the last slot when it is free, and anonymous clients cannot hold
243 it (#262).
243244- A request turned away by the pack limit logs a warning naming the
244245 transport and whether the client was signed in, never its address,
245246 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) {
172172// peerCheck is checkPeer; tests replace it.
173173var peerCheck = checkPeer
174174
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
175179// refusePush answers a pre-receive refusal and audits it.
176180func (s *Server) refusePush(enc *json.Encoder, req Request, repo store.Repo, msg string) {
177 refs := make([]string, len(req.Updates))
178 for i, u := range req.Updates {
181 n := min(len(req.Updates), auditedRefs)
182 refs := make([]string, n)
183 for i, u := range req.Updates[:n] {
179184 refs[i] = u.Ref
180185 }
181 control.AuditRefused(s.st, req.UserID, "refused push",
182 map[string]any{"repo": repo.Path(), "refs": refs, "reason": msg})
186 data := 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)
183191 enc.Encode(Response{Allow: false, Message: msg})
184192}
185193
internal/hookd/socket_test.go +34
@@ -1,7 +1,9 @@
11package hookd
22
33import (
4 "encoding/json"
45 "errors"
6 "fmt"
57 "net"
68 "os"
79 "path/filepath"
@@ -161,3 +163,35 @@ func TestPeerRefusalIsAudited(t *testing.T) {
161163 t.Fatalf("refused hook rows: %+v", rows)
162164 }
163165}
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}