Commit 568b879143
Verified · cmc
Layout: unified · split
cmd/gitbayd/main.go +15 −1
| @@ -262,12 +262,20 @@ func serveCmd() *cobra.Command { | |||
| 262 | if packMax > 1 { | 262 | if packMax > 1 { |
| 263 | packs.CapClass("ip:", packMax-1) | 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 | errCh := make(chan error, 3) | 274 | errCh := make(chan error, 3) |
| 267 | var sshSrv *sshd.Server | 275 | var sshSrv *sshd.Server |
| 268 | var sshLn, gitLn net.Listener | 276 | var sshLn, gitLn net.Listener |
| 269 | if cfg.SSH.Mode == "embedded" { | 277 | if cfg.SSH.Mode == "embedded" { |
| 270 | srv, err := sshd.New(cfg, st, packs) | 278 | srv, err := sshd.New(cfg, st, packs, pushes) |
| 271 | if err != nil { | 279 | if err != nil { |
| 272 | return err | 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 | os.Exit(protocol.ExitUsage) | 100 | os.Exit(protocol.ExitUsage) |
| 101 | } | 101 | } |
| 102 | // Each forced command is its own process, so there is no | 102 | // Each forced command is its own process, so there is no |
| 103 | // shared pack budget in system mode. | 103 | // shared pack or push 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) | 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 | st.Close() | 105 | st.Close() |
| 106 | os.Exit(code) | 106 | os.Exit(code) |
| 107 | return nil | 107 | return nil |
internal/packlimit/packlimit.go +13 −2
| @@ -24,6 +24,7 @@ var ( | |||
| 24 | 24 | ||
| 25 | type Limiter struct { | 25 | type Limiter struct { |
| 26 | max, per, queue int | 26 | max, per, queue int |
| 27 | perQueue int // waiting, per principal; per unless set | ||
| 27 | wait time.Duration | 28 | wait time.Duration |
| 28 | name string // what is limited, for the refusal log | 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 | if max <= 0 { | 48 | if max <= 0 { |
| 48 | return nil | 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 | held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}), | 52 | held: map[string]int{}, waiting: map[string]int{}, changed: make(chan struct{}), |
| 52 | warned: map[string]time.Time{}} | 53 | warned: map[string]time.Time{}} |
| 53 | } | 54 | } |
| @@ -86,6 +87,16 @@ func (l *Limiter) Name(name string) { | |||
| 86 | l.name = name | 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 | // CapClass caps the slots that principals starting with prefix may hold | 100 | // CapClass caps the slots that principals starting with prefix may hold |
| 90 | // between them. Call it before the limiter is in use. | 101 | // between them. Call it before the limiter is in use. |
| 91 | func (l *Limiter) CapClass(prefix string, n int) { | 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 | l.mu.Unlock() | 138 | l.mu.Unlock() |
| 128 | return l.releaser(principal), nil | 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 | l.mu.Unlock() | 142 | l.mu.Unlock() |
| 132 | return nil, ErrBusy | 143 | return nil, ErrBusy |
| 133 | } | 144 | } |
internal/packlimit/packlimit_test.go +28
| @@ -337,3 +337,31 @@ func TestRefusedNamesTheLimit(t *testing.T) { | |||
| 337 | var none *Limiter | 337 | var none *Limiter |
| 338 | none.Name("push") | 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 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | "errors" | ||
| 5 | "io" | 6 | "io" |
| 6 | "os" | 7 | "os" |
| 7 | "path/filepath" | 8 | "path/filepath" |
| 9 | "strconv" | ||
| 8 | "strings" | 10 | "strings" |
| 9 | "testing" | 11 | "testing" |
| 10 | "time" | 12 | "time" |
| @@ -56,7 +58,7 @@ func TestRefusedPushIsAudited(t *testing.T) { | |||
| 56 | bob.Pending = pending | 58 | bob.Pending = pending |
| 57 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} | 59 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 58 | var out, errOut bytes.Buffer | 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 | strings.NewReader(""), &out, &errOut, nil, nil, nil) | 62 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 61 | if code != protocol.ExitDenied { | 63 | if code != protocol.ExitDenied { |
| 62 | t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String()) | 64 | t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String()) |
| @@ -83,7 +85,7 @@ func TestCloneRefusedWhenPackSlotsAreFull(t *testing.T) { | |||
| 83 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} | 85 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 84 | for _, service := range []string{"git-upload-pack", "git-upload-archive"} { | 86 | for _, service := range []string{"git-upload-pack", "git-upload-archive"} { |
| 85 | var out, errOut bytes.Buffer | 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 | strings.NewReader(""), &out, &errOut, nil, nil, nil) | 89 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 88 | if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") { | 90 | if code != protocol.ExitFailure || !strings.Contains(errOut.String(), "busy") { |
| 89 | t.Fatalf("%s: exit %d: %q", service, code, errOut.String()) | 91 | t.Fatalf("%s: exit %d: %q", service, code, errOut.String()) |
| @@ -106,7 +108,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) { | |||
| 106 | packs := packlimit.New(1, 0, 0, time.Second) | 108 | packs := packlimit.New(1, 0, 0, time.Second) |
| 107 | 109 | ||
| 108 | var out, errOut bytes.Buffer | 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 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { | 112 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { |
| 111 | t.Fatalf("clone: exit %d: %s", code, errOut.String()) | 113 | t.Fatalf("clone: exit %d: %s", code, errOut.String()) |
| 112 | } | 114 | } |
| @@ -118,7 +120,7 @@ func TestPushBypassesPackLimitAndCloneReleasesSlot(t *testing.T) { | |||
| 118 | 120 | ||
| 119 | out.Reset() | 121 | out.Reset() |
| 120 | errOut.Reset() | 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 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { | 124 | strings.NewReader("0000"), &out, &errOut, nil, nil, nil); code != protocol.ExitOK { |
| 123 | t.Fatalf("push with slots full: exit %d: %s", code, errOut.String()) | 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 | packs := packlimit.New(1, 0, 0, time.Second) | 161 | packs := packlimit.New(1, 0, 0, time.Second) |
| 160 | codec := make(chan int, 1) | 162 | codec := make(chan int, 1) |
| 161 | go func() { | 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 | silentStdin(t), stdout, io.Discard, done, stopping, revoked) | 165 | silentStdin(t), stdout, io.Discard, done, stopping, revoked) |
| 164 | }() | 166 | }() |
| 165 | select { | 167 | select { |
| @@ -208,7 +210,7 @@ func TestCloneRunsOnDuringRestart(t *testing.T) { | |||
| 208 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} | 210 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 209 | packs := packlimit.New(1, 0, 0, time.Second) | 211 | packs := packlimit.New(1, 0, 0, time.Second) |
| 210 | var errOut bytes.Buffer | 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 | strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK { | 214 | strings.NewReader("0000"), io.Discard, &errOut, closed(), closed(), nil); code != protocol.ExitOK { |
| 213 | t.Fatalf("exit %d: %s", code, errOut.String()) | 215 | t.Fatalf("exit %d: %s", code, errOut.String()) |
| 214 | } | 216 | } |
| @@ -233,9 +235,156 @@ func TestRefusedCloneStaysOffLimiter(t *testing.T) { | |||
| 233 | defer hold() | 235 | defer hold() |
| 234 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} | 236 | key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"} |
| 235 | var out, errOut bytes.Buffer | 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 | strings.NewReader(""), &out, &errOut, nil, nil, nil) | 239 | strings.NewReader(""), &out, &errOut, nil, nil, nil) |
| 238 | if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") { | 240 | if code != protocol.ExitNotFound || strings.Contains(errOut.String(), "busy") { |
| 239 | t.Fatalf("exit %d: %q", code, errOut.String()) | 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 | cfg config.Config | 39 | cfg config.Config |
| 40 | st *store.Store | 40 | st *store.Store |
| 41 | packs *packlimit.Limiter | 41 | packs *packlimit.Limiter |
| 42 | pushes *packlimit.Limiter | ||
| 42 | sshCfg *ssh.ServerConfig | 43 | sshCfg *ssh.ServerConfig |
| 43 | authLimiter *rateLimiter | 44 | authLimiter *rateLimiter |
| 44 | sessions sync.WaitGroup // accepted connections still being served | 45 | sessions sync.WaitGroup // accepted connections still being served |
| @@ -70,8 +71,8 @@ func (c *conn) cut() { | |||
| 70 | c.net.Close() | 71 | c.net.Close() |
| 71 | } | 72 | } |
| 72 | 73 | ||
| 73 | func New(cfg config.Config, st *store.Store, packs *packlimit.Limiter) (*Server, error) { | 74 | func New(cfg config.Config, st *store.Store, packs, pushes *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{})} | 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 | sc := &ssh.ServerConfig{ | 77 | sc := &ssh.ServerConfig{ |
| 77 | PublicKeyCallback: s.authenticate, | 78 | PublicKeyCallback: s.authenticate, |
| @@ -425,7 +426,7 @@ func (s *Server) runExec(c *conn, sconn *ssh.ServerConn, ch ssh.Channel, term co | |||
| 425 | return protocol.ExitDenied | 426 | return protocol.ExitDenied |
| 426 | } | 427 | } |
| 427 | _ = s.st.TouchSSHKey(keyID) | 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 | // runAnonymous handles a session from an unregistered key: the register | 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 | // Exec runs one SSH exec command line for an authenticated key. It is the | 462 | // Exec runs one SSH exec command line for an authenticated key. It is the |
| 462 | // single dispatch path shared by the embedded listener and the system-sshd | 463 | // single dispatch path shared by the embedded listener and the system-sshd |
| 463 | // forced command (gitbayd shell). Closing revoked kills a git transport. | 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 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { | 468 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { |
| 466 | if user.Disabled { | 469 | if user.Disabled { |
| 467 | fmt.Fprintln(stderr, "this account is disabled; contact the instance admin") | 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 | if user.Pending { | 482 | if user.Pending { |
| 480 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") | 483 | fmt.Fprintln(stderr, "your account is not active yet: verify your email first") |
| 481 | } else { | 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 | // A refused push is a refused write, audited like one. runGit | 487 | // A refused push is a refused write, audited like one. runGit |
| 485 | // refuses only with the path as the one argument, so argv[1:] | 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 | // runGit streams a git transport service after access checks. | 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 | stdin io.Reader, stdout, stderr io.Writer, done, stopping, revoked <-chan struct{}) int { | 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 | if len(argv) != 2 { | 526 | if len(argv) != 2 { |
| 524 | fmt.Fprintf(stderr, "usage: %s <path>\n", service) | 527 | fmt.Fprintf(stderr, "usage: %s <path>\n", service) |
| 525 | return protocol.ExitUsage | 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 | if write { | 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 | // hookd answers only a hook that names this receive-pack. | 618 | // hookd answers only a hook that names this receive-pack. |
| 598 | token, err := st.CreatePushToken(repo.ID, user.ID, scope) | 619 | token, err := st.CreatePushToken(repo.ID, user.ID, scope) |
| 599 | if err != nil { | 620 | if err != nil { |
| @@ -606,21 +627,10 @@ func runGit(cfg config.Config, st *store.Store, packs *packlimit.Limiter, user s | |||
| 606 | cancel := revoked | 627 | cancel := revoked |
| 607 | if !write { | 628 | if !write { |
| 608 | // Pack generation shares one budget with smart HTTP and git://. | 629 | // Pack generation shares one budget with smart HTTP and git://. |
| 609 | // receive-pack stays outside it: its post-receive runs after the | 630 | release, code := takeSlot(packs, "user:"+strconv.FormatInt(user.ID, 10), done, stderr, |
| 610 | // client has its report, and must not be queued or killed. | 631 | "the server is busy: it is at its limit of concurrent clones and fetches; try again in a minute") |
| 611 | principal := "user:" + strconv.FormatInt(user.ID, 10) | 632 | if code != protocol.ExitOK { |
| 612 | release, err := packs.Acquire(done, principal) | 633 | return code |
| 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 | ||
| 624 | } | 634 | } |
| 625 | // Deferred before Transport runs, so it fires after git has | 635 | // Deferred before Transport runs, so it fires after git has |
| 626 | // exited and been waited for. | 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 | return protocol.ExitOK | 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 | cfg := config.Default() | 65 | cfg := config.Default() |
| 66 | cfg.Server.Root = root | 66 | cfg.Server.Root = root |
| 67 | srv, err := New(cfg, st, nil) | 67 | srv, err := New(cfg, st, nil, nil) |
| 68 | if err != nil { | 68 | if err != nil { |
| 69 | t.Fatal(err) | 69 | t.Fatal(err) |
| 70 | } | 70 | } |
| @@ -245,7 +245,7 @@ func TestUnregisteredKeyMessageNamesFingerprintAndHost(t *testing.T) { | |||
| 245 | // The settings link keeps the site URL's scheme and port. | 245 | // The settings link keeps the site URL's scheme and port. |
| 246 | cfg.Server.SiteURL = "http://forge.test:8080/" | 246 | cfg.Server.SiteURL = "http://forge.test:8080/" |
| 247 | cfg.Registration.Mode = "open" | 247 | cfg.Registration.Mode = "open" |
| 248 | srv, err := New(cfg, st, nil) | 248 | srv, err := New(cfg, st, nil, nil) |
| 249 | if err != nil { | 249 | if err != nil { |
| 250 | t.Fatal(err) | 250 | t.Fatal(err) |
| 251 | } | 251 | } |