Commit 568b879143

568b87914365b4278f02383e9da7756ed7f243ce

parent: 973797757f

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 16:31 UTC

sshd: receive-pack takes a slot from its own push limit

A deploy key is its own principal. The slot covers both hooks and is
released once receive-pack has exited.

Ref #308

Layout: unified · split

cmd/gitbayd/main.go +15 −1
@@ -262,12 +262,20 @@ func serveCmd() *cobra.Command {
262262 if packMax > 1 {
263263 packs.CapClass("ip:", packMax-1)
264264 }
265 // receive-pack has a budget of its own. Parallel pushes
266 // from one account (scripts, bots, several terminals)
267 // queue, up to half the queue, rather than being refused
268 // once one is waiting.
269 pushMax, pushPer, pushQueue, pushWait := cfg.Limits.PushLimits()
270 pushes := packlimit.New(pushMax, pushPer, pushQueue, pushWait)
271 pushes.Name("push")
272 pushes.CapQueue(pushPerQueue(pushQueue))
265273
266274 errCh := make(chan error, 3)
267275 var sshSrv *sshd.Server
268276 var sshLn, gitLn net.Listener
269277 if cfg.SSH.Mode == "embedded" {
270 srv, err := sshd.New(cfg, st, packs)
278 srv, err := sshd.New(cfg, st, packs, pushes)
271279 if err != nil {
272280 return err
273281 }
@@ -645,3 +653,9 @@ func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) {
645653 }
646654 }
647655}
656
657// pushPerQueue is how many pushes one principal may have waiting: half
658// the queue, at least one.
659func pushPerQueue(queue int) int {
660 return max(1, queue/2)
661}
cmd/gitbayd/system.go +2 −2
@@ -100,8 +100,8 @@ func shellCmd() *cobra.Command {
100100 os.Exit(protocol.ExitUsage)
101101 }
102102 // Each forced command is its own process, so there is no
103 // shared pack budget in system mode.
104 code := sshd.Exec(cfg, st, nil, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
103 // shared pack or push budget in system mode.
104 code := sshd.Exec(cfg, st, nil, nil, user, key, control.ParseTerm(os.Getenv("GITBAY_TERM")), cmdline, os.Stdin, os.Stdout, os.Stderr, nil, nil, nil)
105105 st.Close()
106106 os.Exit(code)
107107 return nil
internal/packlimit/packlimit.go +13 −2
@@ -24,6 +24,7 @@ var (
2424
2525type Limiter struct {
2626 max, per, queue int
27 perQueue int // waiting, per principal; per unless set
2728 wait time.Duration
2829 name string // what is limited, for the refusal log
2930
@@ -47,7 +48,7 @@ func New(max, per, queue int, wait time.Duration) *Limiter {
4748 if max <= 0 {
4849 return nil
4950 }
50 return &Limiter{max: max, per: per, queue: queue, wait: wait, name: "pack",
51 return &Limiter{max: max, per: per, perQueue: per, queue: queue, wait: wait, name: "pack",
5152 held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}),
5253 warned: map[string]time.Time{}}
5354}
@@ -86,6 +87,16 @@ func (l *Limiter) Name(name string) {
8687 l.name = name
8788}
8889
90// CapQueue lets one principal have up to n requests waiting, where by
91// default it may have as many as it may run. It applies only while a
92// per-principal cap is set. Call it before the limiter is in use.
93func (l *Limiter) CapQueue(n int) {
94 if l == nil {
95 return
96 }
97 l.perQueue = n
98}
99
89100// CapClass caps the slots that principals starting with prefix may hold
90101// between them. Call it before the limiter is in use.
91102func (l *Limiter) CapClass(prefix string, n int) {
@@ -127,7 +138,7 @@ func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func(
127138 l.mu.Unlock()
128139 return l.releaser(principal), nil
129140 }
130 if l.queued >= l.queue || (l.per > 0 && l.waiting[principal] >= l.per) {
141 if l.queued >= l.queue || (l.per > 0 && l.waiting[principal] >= l.perQueue) {
131142 l.mu.Unlock()
132143 return nil, ErrBusy
133144 }
internal/packlimit/packlimit_test.go +28
@@ -337,3 +337,31 @@ func TestRefusedNamesTheLimit(t *testing.T) {
337337 var none *Limiter
338338 none.Name("push")
339339}
340
341// With a waiting cap above per, one principal runs per and queues up to
342// perQueue, and is refused past that.
343func TestPerPrincipalQueueCap(t *testing.T) {
344 l := New(4, 1, 16, 5*time.Second)
345 l.CapQueue(4)
346 r, err := l.Acquire(nil, "a")
347 if err != nil {
348 t.Fatal(err)
349 }
350 for i := 1; i <= 4; i++ {
351 go func() {
352 if r, err := l.Acquire(nil, "a"); err == nil {
353 r()
354 }
355 }()
356 waitQueued(t, l, i)
357 }
358 if _, err := l.Acquire(nil, "a"); !errors.Is(err, ErrBusy) {
359 t.Fatalf("sixth for a: %v", err)
360 }
361 rb, err := l.Acquire(nil, "b")
362 if err != nil {
363 t.Fatalf("b blocked by a's queue: %v", err)
364 }
365 rb()
366 r()
367}
internal/sshd/refusal_test.go +156 −7
@@ -2,9 +2,11 @@ package sshd
22
33import (
44 "bytes"
5 "errors"
56 "io"
67 "os"
78 "path/filepath"
9 "strconv"
810 "strings"
911 "testing"
1012 "time"
@@ -56,7 +58,7 @@ func TestRefusedPushIsAudited(t *testing.T) {
5658 bob.Pending = pending
5759 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
5860 var out, errOut bytes.Buffer
59 code := Exec(cfg, st, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
61 code := Exec(cfg, st, nil, nil, bob, key, control.Term{}, "git-receive-pack alice/app",
6062 strings.NewReader(""), &out, &errOut, nil, nil, nil)
6163 if code != protocol.ExitDenied {
6264 t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
@@ -83,7 +85,7 @@ func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) {
8385 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
8486 for _, service := range []string{"git-upload-pack", "git-upload-archive"} {
8587 var out, errOut bytes.Buffer
86 code := Exec(cfg, st, packs, bob, key, control.Term{}, service+" alice/app",
88 code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, service+" alice/app",
8789 strings.NewReader(""), &out, &errOut, nil, nil, nil)
8890 if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") {
8991 t.Fatalf("%s: exit %d: %q", service, code, errOut.String())
@@ -106,7 +108,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
106108 packs := packlimit.New(1, 0, 0, time.Second)
107109
108110 var out, errOut bytes.Buffer
109 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
111 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
110112 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
111113 t.Fatalf("clone: exit %d: %s", code, errOut.String())
112114 }
@@ -118,7 +120,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) {
118120
119121 out.Reset()
120122 errOut.Reset()
121 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-receive-pack alice/app",
123 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-receive-pack alice/app",
122124 strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK {
123125 t.Fatalf("push with slots full: exit %d: %s", code, errOut.String())
124126 }
@@ -159,7 +161,7 @@ func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan
159161 packs := packlimit.New(1, 0, 0, time.Second)
160162 codec := make(chan int, 1)
161163 go func() {
162 codec <- Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
164 codec <- Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
163165 silentStdin(t), stdout, io.Discard, done, stopping, revoked)
164166 }()
165167 select {
@@ -208,7 +210,7 @@ func TestCloneRunsOnDuringRestart(t *testing.T) {
208210 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
209211 packs := packlimit.New(1, 0, 0, time.Second)
210212 var errOut bytes.Buffer
211 if code := Exec(cfg, st, packs, alice, key, control.Term{}, "git-upload-pack alice/app",
213 if code := Exec(cfg, st, packs, nil, alice, key, control.Term{}, "git-upload-pack alice/app",
212214 strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK {
213215 t.Fatalf("exit %d: %s", code, errOut.String())
214216 }
@@ -233,9 +235,156 @@ func TestRefusedCloneStaysOffLimiter(t *testing.T) {
233235 defer hold()
234236 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
235237 var out, errOut bytes.Buffer
236 code := Exec(cfg, st, packs, bob, key, control.Term{}, "git-upload-pack alice/secret",
238 code := Exec(cfg, st, packs, nil, bob, key, control.Term{}, "git-upload-pack alice/secret",
237239 strings.NewReader(""), &out, &errOut, nil, nil, nil)
238240 if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") {
239241 t.Fatalf("exit %d: %q", code, errOut.String())
240242 }
241243}
244
245// hungUpStdin is a client that sends nothing until hangUp, which ends
246// its stdin the way a closed channel does.
247func hungUpStdin(t *testing.T) (r *os.File, hangUp func()) {
248 t.Helper()
249 r, w, err := os.Pipe()
250 if err != nil {
251 t.Fatal(err)
252 }
253 t.Cleanup(func() { r.Close(); w.Close() })
254 return r, func() { w.Close() }
255}
256
257// queuedFor waits until principal has a push waiting on l: a probe that
258// gives up at once is then refused busy rather than queued.
259func queuedFor(t *testing.T, l *packlimit.Limiter, principal string) {
260 t.Helper()
261 deadline := time.Now().Add(5 * time.Second)
262 for time.Now().Before(deadline) {
263 if _, err := l.Acquire(closed(), principal); errors.Is(err, packlimit.ErrBusy) {
264 return
265 }
266 time.Sleep(10 * time.Millisecond)
267 }
268 t.Fatalf("no push queued for %s", principal)
269}
270
271// With push_per_principal 1 one account runs one push, queues a second
272// and is refused a third, while another principal still gets in. A
273// client hanging up frees its slot for the one queued behind it.
274func TestPushPerPrincipalCap(t *testing.T) {
275 cfg, st, alice := cloneFixture(t)
276 repo, err := st.RepoByPath("alice/app")
277 if err != nil {
278 t.Fatal(err)
279 }
280 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
281 pushes := packlimit.New(2, 1, 16, 10*time.Second)
282 push := func(k store.SSHKey, stdin io.Reader, errOut io.Writer) <-chan int {
283 codec := make(chan int, 1)
284 go func() {
285 codec <- Exec(cfg, st, nil, pushes, alice, k, control.Term{}, "git-receive-pack alice/app",
286 stdin, io.Discard, errOut, nil, nil, nil)
287 }()
288 return codec
289 }
290 exited := func(codec <-chan int, want int, what string) {
291 t.Helper()
292 select {
293 case code := <-codec:
294 if code != want {
295 t.Fatalf("%s: exit %d", what, code)
296 }
297 case <-time.After(5 * time.Second):
298 t.Fatalf("%s still running", what)
299 }
300 }
301 principal := "user:" + strconv.FormatInt(alice.ID, 10)
302
303 in1, hangUp1 := hungUpStdin(t)
304 first := push(key, in1, io.Discard)
305 // The first holds alice's one slot once a probe cannot take it.
306 deadline := time.Now().Add(5 * time.Second)
307 for {
308 r, err := pushes.Acquire(closed(), principal)
309 if err != nil {
310 break
311 }
312 r()
313 if time.Now().After(deadline) {
314 t.Fatal("first push never took a slot")
315 }
316 time.Sleep(10 * time.Millisecond)
317 }
318 in2, hangUp2 := hungUpStdin(t)
319 second := push(key, in2, io.Discard)
320 queuedFor(t, pushes, principal)
321
322 var errOut bytes.Buffer
323 if code := Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
324 strings.NewReader(""), io.Discard, &errOut, nil, nil, nil); code != protocol.ExitFailure ||
325 !strings.Contains(errOut.String(), "limit of concurrent pushes") {
326 t.Fatalf("third push: exit %d: %q", code, errOut.String())
327 }
328
329 // A deploy key on the same account is its own principal: it takes
330 // the second global slot while alice's push waits.
331 deploy := store.SSHKey{ID: 2, Scope: "deploy:" + strconv.FormatInt(repo.ID, 10) + ":rw", Fingerprint: "SHA256:deploy"}
332 exited(push(deploy, strings.NewReader("0000"), io.Discard), protocol.ExitOK, "deploy key push")
333
334 // receive-pack fails a client that hangs up before sending anything.
335 hangUp1()
336 exited(first, protocol.ExitFailure, "first push")
337 hangUp2()
338 exited(second, protocol.ExitFailure, "second push")
339 for _, p := range []string{"a", "b"} {
340 r, err := pushes.Acquire(nil, p)
341 if err != nil {
342 t.Fatalf("slot not released: %v", err)
343 }
344 defer r()
345 }
346}
347
348// A revoked key kills a push waiting on its client, and the slot comes
349// back.
350func TestPushKilledWhenKeyRevokedReleasesSlot(t *testing.T) {
351 cfg, st, alice := cloneFixture(t)
352 key := store.SSHKey{ID: 1, Scope: "full", Fingerprint: "SHA256:test"}
353 pushes := packlimit.New(1, 1, 0, time.Second)
354 codec := make(chan int, 1)
355 go func() {
356 codec <- Exec(cfg, st, nil, pushes, alice, key, control.Term{}, "git-receive-pack alice/app",
357 silentStdin(t), io.Discard, io.Discard, nil, nil, closed())
358 }()
359 select {
360 case code := <-codec:
361 if code != protocol.ExitFailure {
362 t.Fatalf("exit %d, want the push killed", code)
363 }
364 case <-time.After(5 * time.Second):
365 t.Fatal("push still running")
366 }
367 r, err := pushes.Acquire(nil, "elsewhere")
368 if err != nil {
369 t.Fatalf("slot not released after the kill: %v", err)
370 }
371 r()
372}
373
374// A push the key may not make is refused before it reaches the limiter.
375func TestRefusedPushStaysOffLimiter(t *testing.T) {
376 cfg, st, bob := execFixture(t)
377 pushes := packlimit.New(1, 0, 0, time.Second)
378 hold, err := pushes.Acquire(nil, "elsewhere")
379 if err != nil {
380 t.Fatal(err)
381 }
382 defer hold()
383 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
384 var errOut bytes.Buffer
385 code := Exec(cfg, st, nil, pushes, bob, key, control.Term{}, "git-receive-pack alice/app",
386 strings.NewReader(""), io.Discard, &errOut, nil, nil, nil)
387 if code != protocol.ExitDenied || strings.Contains(errOut.String(), "busy") {
388 t.Fatalf("exit %d: %q", code, errOut.String())
389 }
390}
internal/sshd/sshd.go +50 −22
@@ -39,6 +39,7 @@ type Server struct {
3939 cfg config.Config
4040 st *store.Store
4141 packs *packlimit.Limiter
42 pushes *packlimit.Limiter
4243 sshCfg *ssh.ServerConfig
4344 authLimiter *rateLimiter
4445 sessions sync.WaitGroup // accepted connections still being served
@@ -70,8 +71,8 @@ func (c *conn) cut() {
7071 c.net.Close()
7172}
7273
73func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error) {
74 s := &Server{cfg: cfg, st: st, packs: packs, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
74func New(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter) (*Server, error) {
75 s := &Server{cfg: cfg, st: st, packs: packs, pushes: pushes, authLimiter: newRateLimiter(cfg.Limits.SSHAuthRate, time.Minute), conns: map[*conn]struct{}{}, stopping: make(chan struct{})}
7576
7677 sc := &ssh.ServerConfig{
7778 PublicKeyCallback: s.authenticate,
@@ -425,7 +426,7 @@ func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term co
425426 return protocol.ExitDenied
426427 }
427428 _ = s.st.TouchSSHKey(keyID)
428 return Exec(s.cfg, s.st, s.packs, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
429 return Exec(s.cfg, s.st, s.packs, s.pushes, user, key, term, cmdline, ch, ch, ch.Stderr(), done, s.stopping, c.revoked)
429430}
430431
431432// runAnonymous handles a session from an unregistered key: the register
@@ -461,7 +462,9 @@ func (s *Server) runAnonymous(ch ssh.Channel, keyB64, cmdline string) int {
461462// Exec runs one SSH exec command line for an authenticated key. It is the
462463// single dispatch path shared by the embedded listener and the system-sshd
463464// forced command (gitbayd shell). Closing revoked kills a git transport.
464func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string,
465// packs bounds clones and fetches, and repo download; pushes bounds
466// receive-pack. A nil limiter is no limit.
467func Exec(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string,
465468 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
466469 if user.Disabled {
467470 fmt.Fprintln(stderr, "this account is disabled; contact the instance admin")
@@ -479,7 +482,7 @@ func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user sto
479482 if user.Pending {
480483 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
481484 } else {
482 code = runGit(cfg, st, packs, user, key.Scope, argv, stdin, stdout, stderr, done, stopping, revoked)
485 code = runGit(cfg, st, packs, pushes, user, key, argv, stdin, stdout, stderr, done, stopping, revoked)
483486 }
484487 // A refused push is a refused write, audited like one. runGit
485488 // refuses only with the path as the one argument, so argv[1:]
@@ -517,9 +520,9 @@ func Exec(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user sto
517520}
518521
519522// runGit streams a git transport service after access checks.
520func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope string, argv []string,
523func runGit(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, argv []string,
521524 stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int {
522 service := argv[0]
525 service, scope := argv[0], key.Scope
523526 if len(argv) != 2 {
524527 fmt.Fprintf(stderr, "usage: %s <path>\n", service)
525528 return protocol.ExitUsage
@@ -594,6 +597,24 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s
594597 }
595598 }
596599 if write {
600 // Pushes have their own budget, so a clone storm cannot starve
601 // them or the reverse. The slot covers receive-pack and both
602 // hooks: git waits for post-receive (RefsUpdated) before it
603 // exits, and that work is the push's cost. A deploy key is its
604 // own principal, not the account that registered it.
605 principal := "user:" + strconv.FormatInt(user.ID, 10)
606 if policy.IsDeployScope(scope) {
607 principal = "key:" + strconv.FormatInt(key.ID, 10)
608 }
609 release, code := takeSlot(pushes, principal, done, stderr,
610 "the server is busy: it is at its limit of concurrent pushes; try again in a minute")
611 if code != protocol.ExitOK {
612 return code
613 }
614 // Deferred before Transport runs, so it fires after receive-pack
615 // has exited, on every path: the client hanging up ends its
616 // stdin and receive-pack with it, and a revoked key kills it.
617 defer release()
597618 // hookd answers only a hook that names this receive-pack.
598619 token, err := st.CreatePushToken(repo.ID, user.ID, scope)
599620 if err != nil {
@@ -606,21 +627,10 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s
606627 cancel := revoked
607628 if !write {
608629 // Pack generation shares one budget with smart HTTP and git://.
609 // receive-pack stays outside it: its post-receive runs after the
610 // client has its report, and must not be queued or killed.
611 principal := "user:" + strconv.FormatInt(user.ID, 10)
612 release, err := packs.Acquire(done, principal)
613 if err != nil {
614 packs.Refused("ssh", principal, err)
615 }
616 if errors.Is(err, packlimit.ErrBusy) {
617 fmt.Fprintln(stderr, "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute")
618 return protocol.ExitFailure
619 }
620 if err != nil {
621 // ErrGone: the client left, or the server is restarting.
622 fmt.Fprintln(stderr, "the server is restarting; try again in a minute")
623 return protocol.ExitFailure
630 release, code := takeSlot(packs, "user:"+strconv.FormatInt(user.ID, 10), done, stderr,
631 "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute")
632 if code != protocol.ExitOK {
633 return code
624634 }
625635 // Deferred before Transport runs, so it fires after git has
626636 // exited and been waited for.
@@ -673,3 +683,21 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s
673683 }
674684 return protocol.ExitOK
675685}
686
687// takeSlot takes a slot from l for principal, waiting until done closes
688// at most. On a refusal it prints busy, or that the server is going
689// away, and returns a nonzero exit.
690func takeSlot(l *packlimit.Limiter, principal string, done <-chan struct{}, stderr io.Writer, busy string) (release func(), code int) {
691 release, err := l.Acquire(done, principal)
692 if err == nil {
693 return release, protocol.ExitOK
694 }
695 l.Refused("ssh", principal, err)
696 if errors.Is(err, packlimit.ErrBusy) {
697 fmt.Fprintln(stderr, busy)
698 } else {
699 // ErrGone: the client left, or the server is restarting.
700 fmt.Fprintln(stderr, "the server is restarting; try again in a minute")
701 }
702 return nil, protocol.ExitFailure
703}
internal/sshd/sshd_test.go +2 −2
@@ -64,7 +64,7 @@ func newTestServer(t *testing.T) testServer {
6464
6565 cfg := config.Default()
6666 cfg.Server.Root = root
67 srv, err := New(cfg, st, nil)
67 srv, err := New(cfg, st, nil, nil)
6868 if err != nil {
6969 t.Fatal(err)
7070 }
@@ -245,7 +245,7 @@ func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) {
245245 // The settings link keeps the site URL's scheme and port.
246246 cfg.Server.SiteURL = "http://forge.test:8080/"
247247 cfg.Registration.Mode = "open"
248 srv, err := New(cfg, st, nil)
248 srv, err := New(cfg, st, nil, nil)
249249 if err != nil {
250250 t.Fatal(err)
251251 }