Commit 568b879143
Verified · cmc
Layout: unified · split
cmd/gitbayd/main.go +15 −1
| @@ -262,12 +262,20 @@ func serveCmd() *cobra.Command { | ||
| 262 | 262 | if packMax > 1 { |
| 263 | 263 | packs.CapClass("ip:", packMax-1) |
| 264 | 264 | } |
| 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)) | |
| 265 | 273 | |
| 266 | 274 | errCh := make(chan error, 3) |
| 267 | 275 | var sshSrv *sshd.Server |
| 268 | 276 | var sshLn, gitLn net.Listener |
| 269 | 277 | if cfg.SSH.Mode == "embedded" { |
| 270 | srv, err := sshd.New(cfg, st, packs) | |
| 278 | srv, err := sshd.New(cfg, st, packs, pushes) | |
| 271 | 279 | if err != nil { |
| 272 | 280 | return err |
| 273 | 281 | } |
| @@ -645,3 +653,9 @@ func reapPending(ctx context.Context, st *store.Store, maxAge time.Duration) { | ||
| 645 | 653 | } |
| 646 | 654 | } |
| 647 | 655 | } |
| 656 | ||
| 657 | // pushPerQueue is how many pushes one principal may have waiting: half | |
| 658 | // the queue, at least one. | |
| 659 | func 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 { | ||
| 100 | 100 | os.Exit(protocol.ExitUsage) |
| 101 | 101 | } |
| 102 | 102 | // 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) | |
| 105 | 105 | st.Close() |
| 106 | 106 | os.Exit(code) |
| 107 | 107 | return nil |
internal/packlimit/packlimit.go +13 −2
| @@ -24,6 +24,7 @@ var ( | ||
| 24 | 24 | |
| 25 | 25 | type Limiter struct { |
| 26 | 26 | max, per, queue int |
| 27 | perQueue int // waiting, per principal; per unless set | |
| 27 | 28 | wait time.Duration |
| 28 | 29 | name string // what is limited, for the refusal log |
| 29 | 30 | |
| @@ -47,7 +48,7 @@ func New(max, per, queue int, wait time.Duration) *Limiter { | ||
| 47 | 48 | if max <= 0 { |
| 48 | 49 | return nil |
| 49 | 50 | } |
| 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", | |
| 51 | 52 | held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}), |
| 52 | 53 | warned: map[string]time.Time{}} |
| 53 | 54 | } |
| @@ -86,6 +87,16 @@ func (l *Limiter) Name(name string) { | ||
| 86 | 87 | l.name = name |
| 87 | 88 | } |
| 88 | 89 | |
| 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. | |
| 93 | func (l *Limiter) CapQueue(n int) { | |
| 94 | if l == nil { | |
| 95 | return | |
| 96 | } | |
| 97 | l.perQueue = n | |
| 98 | } | |
| 99 | ||
| 89 | 100 | // CapClass caps the slots that principals starting with prefix may hold |
| 90 | 101 | // between them. Call it before the limiter is in use. |
| 91 | 102 | func (l *Limiter) CapClass(prefix string, n int) { |
| @@ -127,7 +138,7 @@ func (l *Limiter) Acquire(done <-chan struct{}, principal string) (release func( | ||
| 127 | 138 | l.mu.Unlock() |
| 128 | 139 | return l.releaser(principal), nil |
| 129 | 140 | } |
| 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) { | |
| 131 | 142 | l.mu.Unlock() |
| 132 | 143 | return nil, ErrBusy |
| 133 | 144 | } |
internal/packlimit/packlimit_test.go +28
| @@ -337,3 +337,31 @@ func TestRefusedNamesTheLimit(t *testing.T) { | ||
| 337 | 337 | var none *Limiter |
| 338 | 338 | none.Name("push") |
| 339 | 339 | } |
| 340 | ||
| 341 | // With a waiting cap above per, one principal runs per and queues up to | |
| 342 | // perQueue, and is refused past that. | |
| 343 | func 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 | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "errors" | |
| 5 | 6 | "io" |
| 6 | 7 | "os" |
| 7 | 8 | "path/filepath" |
| 9 | "strconv" | |
| 8 | 10 | "strings" |
| 9 | 11 | "testing" |
| 10 | 12 | "time" |
| @@ -56,7 +58,7 @@ func TestRefusedPushIsAudited(t *testing.T) { | ||
| 56 | 58 | bob.Pending = pending |
| 57 | 59 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 58 | 60 | 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", | |
| 60 | 62 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 61 | 63 | if code != protocol.ExitDenied { |
| 62 | 64 | t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String()) |
| @@ -83,7 +85,7 @@ func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) { | ||
| 83 | 85 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 84 | 86 | for _, service := range []string{"git-upload-pack", "git-upload-archive"} { |
| 85 | 87 | 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", | |
| 87 | 89 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 88 | 90 | if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") { |
| 89 | 91 | t.Fatalf("%s: exit %d: %q", service, code, errOut.String()) |
| @@ -106,7 +108,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) { | ||
| 106 | 108 | packs := packlimit.New(1, 0, 0, time.Second) |
| 107 | 109 | |
| 108 | 110 | 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", | |
| 110 | 112 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { |
| 111 | 113 | t.Fatalf("clone: exit %d: %s", code, errOut.String()) |
| 112 | 114 | } |
| @@ -118,7 +120,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) { | ||
| 118 | 120 | |
| 119 | 121 | out.Reset() |
| 120 | 122 | 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", | |
| 122 | 124 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { |
| 123 | 125 | t.Fatalf("push with slots full: exit %d: %s", code, errOut.String()) |
| 124 | 126 | } |
| @@ -159,7 +161,7 @@ func killedClone(t *testing.T, stdout io.Writer, done, stopping, revoked <-chan | ||
| 159 | 161 | packs := packlimit.New(1, 0, 0, time.Second) |
| 160 | 162 | codec := make(chan int, 1) |
| 161 | 163 | 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", | |
| 163 | 165 | silentStdin(t), stdout, io.Discard, done, stopping, revoked) |
| 164 | 166 | }() |
| 165 | 167 | select { |
| @@ -208,7 +210,7 @@ func TestCloneRunsOnDuringRestart(t *testing.T) { | ||
| 208 | 210 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 209 | 211 | packs := packlimit.New(1, 0, 0, time.Second) |
| 210 | 212 | 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", | |
| 212 | 214 | strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK { |
| 213 | 215 | t.Fatalf("exit %d: %s", code, errOut.String()) |
| 214 | 216 | } |
| @@ -233,9 +235,156 @@ func TestRefusedCloneStaysOffLimiter(t *testing.T) { | ||
| 233 | 235 | defer hold() |
| 234 | 236 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 235 | 237 | 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", | |
| 237 | 239 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 238 | 240 | if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") { |
| 239 | 241 | t.Fatalf("exit %d: %q", code, errOut.String()) |
| 240 | 242 | } |
| 241 | 243 | } |
| 244 | ||
| 245 | // hungUpStdin is a client that sends nothing until hangUp, which ends | |
| 246 | // its stdin the way a closed channel does. | |
| 247 | func 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. | |
| 259 | func 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. | |
| 274 | func 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. | |
| 350 | func 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. | |
| 375 | func 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 { | ||
| 39 | 39 | cfg config.Config |
| 40 | 40 | st *store.Store |
| 41 | 41 | packs *packlimit.Limiter |
| 42 | pushes *packlimit.Limiter | |
| 42 | 43 | sshCfg *ssh.ServerConfig |
| 43 | 44 | authLimiter *rateLimiter |
| 44 | 45 | sessions sync.WaitGroup // accepted connections still being served |
| @@ -70,8 +71,8 @@ func (c *conn) cut() { | ||
| 70 | 71 | c.net.Close() |
| 71 | 72 | } |
| 72 | 73 | |
| 73 | func 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{})} | |
| 74 | func 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{})} | |
| 75 | 76 | |
| 76 | 77 | sc := &ssh.ServerConfig{ |
| 77 | 78 | PublicKeyCallback: s.authenticate, |
| @@ -425,7 +426,7 @@ func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term co | ||
| 425 | 426 | return protocol.ExitDenied |
| 426 | 427 | } |
| 427 | 428 | _ = 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) | |
| 429 | 430 | } |
| 430 | 431 | |
| 431 | 432 | // 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 { | ||
| 461 | 462 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 462 | 463 | // single dispatch path shared by the embedded listener and the system-sshd |
| 463 | 464 | // forced command (gitbayd shell). Closing revoked kills a git transport. |
| 464 | func 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. | |
| 467 | func Exec(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, term control.Term, cmdline string, | |
| 465 | 468 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { |
| 466 | 469 | if user.Disabled { |
| 467 | 470 | 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 | ||
| 479 | 482 | if user.Pending { |
| 480 | 483 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 481 | 484 | } 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) | |
| 483 | 486 | } |
| 484 | 487 | // A refused push is a refused write, audited like one. runGit |
| 485 | 488 | // 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 | ||
| 517 | 520 | } |
| 518 | 521 | |
| 519 | 522 | // runGit streams a git transport service after access checks. |
| 520 | func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user store.User, scope string, argv []string, | |
| 523 | func runGit(cfg config.Config, st *store.Store, packs, pushes *packlimit.Limiter, user store.User, key store.SSHKey, argv []string, | |
| 521 | 524 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { |
| 522 | service := argv[0] | |
| 525 | service, scope := argv[0], key.Scope | |
| 523 | 526 | if len(argv) != 2 { |
| 524 | 527 | fmt.Fprintf(stderr, "usage: %s <path>\n", service) |
| 525 | 528 | return protocol.ExitUsage |
| @@ -594,6 +597,24 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s | ||
| 594 | 597 | } |
| 595 | 598 | } |
| 596 | 599 | 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() | |
| 597 | 618 | // hookd answers only a hook that names this receive-pack. |
| 598 | 619 | token, err := st.CreatePushToken(repo.ID, user.ID, scope) |
| 599 | 620 | if err != nil { |
| @@ -606,21 +627,10 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s | ||
| 606 | 627 | cancel := revoked |
| 607 | 628 | if !write { |
| 608 | 629 | // 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 | |
| 624 | 634 | } |
| 625 | 635 | // Deferred before Transport runs, so it fires after git has |
| 626 | 636 | // exited and been waited for. |
| @@ -673,3 +683,21 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s | ||
| 673 | 683 | } |
| 674 | 684 | return protocol.ExitOK |
| 675 | 685 | } |
| 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. | |
| 690 | func 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 { | ||
| 64 | 64 | |
| 65 | 65 | cfg := config.Default() |
| 66 | 66 | cfg.Server.Root = root |
| 67 | srv, err := New(cfg, st, nil) | |
| 67 | srv, err := New(cfg, st, nil, nil) | |
| 68 | 68 | if err != nil { |
| 69 | 69 | t.Fatal(err) |
| 70 | 70 | } |
| @@ -245,7 +245,7 @@ func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) { | ||
| 245 | 245 | // The settings link keeps the site URL's scheme and port. |
| 246 | 246 | cfg.Server.SiteURL = "http://forge.test:8080/" |
| 247 | 247 | cfg.Registration.Mode = "open" |
| 248 | srv, err := New(cfg, st, nil) | |
| 248 | srv, err := New(cfg, st, nil, nil) | |
| 249 | 249 | if err != nil { |
| 250 | 250 | t.Fatal(err) |
| 251 | 251 | } |