Commit 7f2fd45218
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
Layout: unified · split
.gitbay/wiki/Admin.org +14 −1
| @@ -221,6 +221,18 @@ per minute — successful auths never count and clear the slate. | |||
| 221 | =limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every | 221 | =limits.max_pack_bytes= is enforced as =receive.maxInputSize= on every |
| 222 | push. | 222 | push. |
| 223 | 223 | ||
| 224 | =limits.write_rate= (60) bounds *mutating commands per account per | ||
| 225 | minute*. It is counted in the dispatcher, so SSH, the JSON API and the | ||
| 226 | web spend one budget and a caller cannot refresh it by changing surface; | ||
| 227 | =limits.api_rate= stays in front of it, bounding a network source rather | ||
| 228 | than an account. A command is one token whatever it writes, so a bundle | ||
| 229 | import costs one and only a loop of separate commands spends the budget. | ||
| 230 | Read-only commands, the runner protocol (a build streams its log in many | ||
| 231 | small writes) and the host CLI are exempt. Refusals exit 4 and say when | ||
| 232 | to retry. A negative value turns the limit off; it matters most with | ||
| 233 | =registration = "open"=, where every write also queues notification mail | ||
| 234 | and webhook deliveries. | ||
| 235 | |||
| 224 | * Queues | 236 | * Queues |
| 225 | 237 | ||
| 226 | Every background worker keeps a backlog and a failure state. An instance | 238 | Every background worker keeps a backlog and a failure state. An instance |
| @@ -436,7 +448,8 @@ trusts and refuses to do. Operational checklist: | |||
| 436 | auto-reboot 04:30 if required). | 448 | auto-reboot 04:30 if required). |
| 437 | - *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and | 449 | - *Admin sshd (2222)* is throttled by =MaxStartups=/=MaxAuthTries= and |
| 438 | watched by =fail2ban=; gitbayd's own port 22 is throttled by | 450 | watched by =fail2ban=; gitbayd's own port 22 is throttled by |
| 439 | =limits.ssh_auth_rate= (auth failures per IP per minute). | 451 | =limits.ssh_auth_rate= (auth failures per IP per minute), and every |
| 452 | account's writes by =limits.write_rate=. | ||
| 440 | - *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to | 453 | - *Monitoring.* =gitbay-monitor.timer= writes a reading hourly to |
| 441 | journald and, when =/etc/gitbay/monitor.url= exists, posts it to that | 454 | journald and, when =/etc/gitbay/monitor.url= exists, posts it to that |
| 442 | webhook: disk, service, the daemon's own =/healthz= answer, certificate | 455 | webhook: disk, service, the daemon's own =/healthz= answer, certificate |
e2e/writerate_test.go added +78
| @@ -0,0 +1,78 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "path/filepath" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // Mutating commands are bounded per account in the dispatcher, so the | ||
| 11 | // budget is the same one whichever surface spends it. Reads are not | ||
| 12 | // charged (#148). | ||
| 13 | func TestWriteRateLimit(t *testing.T) { | ||
| 14 | inst := startInstanceWith(t, "[limits]\nwrite_rate = 4\n") | ||
| 15 | aliceKey := inst.newKey(t, "alice") | ||
| 16 | bobKey := inst.newKey(t, "bob") | ||
| 17 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 18 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | ||
| 19 | |||
| 20 | // repo create plus three issues is the whole budget. | ||
| 21 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 22 | t.Fatalf("repo create: %s", errOut) | ||
| 23 | } | ||
| 24 | for i := 0; i < 3; i++ { | ||
| 25 | if _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "t"); code != 0 { | ||
| 26 | t.Fatalf("issue %d within the budget was refused: %s", i+1, errOut) | ||
| 27 | } | ||
| 28 | } | ||
| 29 | _, errOut, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "over") | ||
| 30 | if code != 4 { | ||
| 31 | t.Fatalf("write past the budget exited %d, want 4:\n%s", code, errOut) | ||
| 32 | } | ||
| 33 | if !strings.Contains(errOut, "too many writes") || !strings.Contains(errOut, "try again in") { | ||
| 34 | t.Errorf("refusal does not say what happened or when to retry: %s", errOut) | ||
| 35 | } | ||
| 36 | |||
| 37 | // Reads are not charged, so a throttled account can still look. | ||
| 38 | if out, _, code := inst.ssh(t, aliceKey, "", "issue", "list", "alice/app"); code != 0 { | ||
| 39 | t.Fatalf("a read was refused while throttled: %s", out) | ||
| 40 | } | ||
| 41 | |||
| 42 | // The budget is per account, not per instance. | ||
| 43 | if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "bob/app"); code != 0 { | ||
| 44 | t.Fatalf("bob was charged for alice's writes: %s", errOut) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | // The runner protocol is exempt: a build streams its log in many small | ||
| 49 | // writes, and throttling those would throttle CI itself. | ||
| 50 | func TestWriteRateLimitSparesTheRunner(t *testing.T) { | ||
| 51 | inst := startInstanceWith(t, "[limits]\nwrite_rate = 2\n") | ||
| 52 | inst.runner = buildRunner(t) | ||
| 53 | aliceKey := inst.newKey(t, "alice") | ||
| 54 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") | ||
| 55 | runnerKey := inst.newKey(t, "ci") | ||
| 56 | inst.admin(t, "admin", "user", "create", "ci", "--key", runnerKey+".pub", "--admin") | ||
| 57 | |||
| 58 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | ||
| 59 | t.Fatalf("repo create: %s", errOut) | ||
| 60 | } | ||
| 61 | env := inst.gitEnv(aliceKey) | ||
| 62 | work := t.TempDir() | ||
| 63 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 64 | dir := filepath.Join(work, "w") | ||
| 65 | os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755) | ||
| 66 | os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), | ||
| 67 | []byte("jobs:\n smoke:\n steps:\n - echo ok\n"), 0o644) | ||
| 68 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 69 | mustGit(t, dir, env, "add", ".") | ||
| 70 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 71 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 72 | |||
| 73 | inst.runnerOnce(t, runnerKey) | ||
| 74 | out, _, _ := inst.ssh(t, aliceKey, "", "build", "list", "alice/app") | ||
| 75 | if !strings.Contains(out, "success") { | ||
| 76 | t.Fatalf("the build did not run under a tight write budget:\n%s", out) | ||
| 77 | } | ||
| 78 | } | ||
internal/config/config.go +10
| @@ -13,6 +13,12 @@ import ( | |||
| 13 | "github.com/BurntSushi/toml" | 13 | "github.com/BurntSushi/toml" |
| 14 | ) | 14 | ) |
| 15 | 15 | ||
| 16 | // DefaultWriteRate is the per-account write budget when the config leaves | ||
| 17 | // write_rate at zero: generous for a person at a terminal, and a bound on | ||
| 18 | // what one account can enqueue — every write also queues notification mail | ||
| 19 | // and webhook deliveries. | ||
| 20 | const DefaultWriteRate = 60 | ||
| 21 | |||
| 16 | type Config struct { | 22 | type Config struct { |
| 17 | Server Server `toml:"server"` | 23 | Server Server `toml:"server"` |
| 18 | SSH SSH `toml:"ssh"` | 24 | SSH SSH `toml:"ssh"` |
| @@ -175,6 +181,10 @@ type Limits struct { | |||
| 175 | // APIRate is sustained JSON-API requests per minute per caller; writes | 181 | // APIRate is sustained JSON-API requests per minute per caller; writes |
| 176 | // draw on a tenth of it. 0 uses the default. | 182 | // draw on a tenth of it. 0 uses the default. |
| 177 | APIRate int `toml:"api_rate"` | 183 | APIRate int `toml:"api_rate"` |
| 184 | // WriteRate is sustained mutating commands per minute per account, | ||
| 185 | // counted in the dispatcher so every surface shares one budget. 0 uses | ||
| 186 | // the default; a negative value turns the limit off. | ||
| 187 | WriteRate int `toml:"write_rate"` | ||
| 178 | // Per-account quotas on what a user owns directly (organizations are | 188 | // Per-account quotas on what a user owns directly (organizations are |
| 179 | // not capped). 0 means unlimited; admin user limits overrides per | 189 | // not capped). 0 means unlimited; admin user limits overrides per |
| 180 | // account. | 190 | // account. |
internal/control/control.go +30
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | "reflect" | 11 | "reflect" |
| 12 | "slices" | 12 | "slices" |
| 13 | "strings" | 13 | "strings" |
| 14 | "time" | ||
| 14 | 15 | ||
| 15 | "gitbay.org/gitbay/internal/config" | 16 | "gitbay.org/gitbay/internal/config" |
| 16 | "gitbay.org/gitbay/internal/protocol" | 17 | "gitbay.org/gitbay/internal/protocol" |
| @@ -120,6 +121,9 @@ func Dispatch(c *Ctx, argv []string) int { | |||
| 120 | return c.fail(protocol.ExitDenied, | 121 | return c.fail(protocol.ExitDenied, |
| 121 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") | 122 | "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") |
| 122 | } | 123 | } |
| 124 | if code := limitWrites(c, cmd); code >= 0 { | ||
| 125 | return code | ||
| 126 | } | ||
| 123 | if !cmd.ReadsStdin { | 127 | if !cmd.ReadsStdin { |
| 124 | c.Stdin = emptyReader{} | 128 | c.Stdin = emptyReader{} |
| 125 | } | 129 | } |
| @@ -165,6 +169,32 @@ func auditArgs(args []string) []string { | |||
| 165 | } | 169 | } |
| 166 | 170 | ||
| 167 | // pendingAllowed lists what an unverified self-registered account may do. | 171 | // pendingAllowed lists what an unverified self-registered account may do. |
| 172 | // limitWrites spends one token of the account's write budget, and refuses | ||
| 173 | // with the wait when it is empty. Returns -1 when the command may run. | ||
| 174 | // | ||
| 175 | // Exempt: read-only commands, which cost the instance nothing to serve | ||
| 176 | // twice; the runner protocol, which streams a build's log in many small | ||
| 177 | // writes and would throttle CI; and the host CLI on the server, which has | ||
| 178 | // no account to key on and is already root-equivalent. | ||
| 179 | func limitWrites(c *Ctx, cmd Command) int { | ||
| 180 | if cmd.ReadOnly || cmd.Path[0] == "runner" || c.Source == "host" || c.User.ID == 0 { | ||
| 181 | return -1 | ||
| 182 | } | ||
| 183 | perMinute := c.Cfg.Limits.WriteRate | ||
| 184 | if perMinute == 0 { | ||
| 185 | perMinute = config.DefaultWriteRate | ||
| 186 | } | ||
| 187 | if perMinute < 0 { | ||
| 188 | return -1 | ||
| 189 | } | ||
| 190 | if ok, wait := writes.allow(c.User.ID, perMinute); !ok { | ||
| 191 | return c.fail(protocol.ExitDenied, | ||
| 192 | "too many writes: %d a minute per account; try again in %s", | ||
| 193 | perMinute, wait.Round(time.Second)) | ||
| 194 | } | ||
| 195 | return -1 | ||
| 196 | } | ||
| 197 | |||
| 168 | func pendingAllowed(path []string) bool { | 198 | func pendingAllowed(path []string) bool { |
| 169 | key := joinPath(path) | 199 | key := joinPath(path) |
| 170 | return key == "email verify" || key == "email add" || key == "whoami" || key == "help" | 200 | return key == "email verify" || key == "email add" || key == "whoami" || key == "help" |
internal/control/ratelimit.go added +66
| @@ -0,0 +1,66 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "sync" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // writeLimiter bounds mutating commands per account. It lives in the | ||
| 9 | // dispatcher rather than in a surface because every surface reaches the | ||
| 10 | // same registry: SSH, the JSON API and the web draw on one budget, so a | ||
| 11 | // caller cannot get a fresh allowance by changing how it connects (#148). | ||
| 12 | // | ||
| 13 | // The API's own limiter stays in front of it, keyed by token or IP: that | ||
| 14 | // one bounds a network source, this one bounds an account. | ||
| 15 | // | ||
| 16 | // A command is one token whatever it writes. `account import-bundle` | ||
| 17 | // replays a whole bundle in a single dispatch, so bulk work costs one | ||
| 18 | // write and only a loop of separate commands spends the budget. | ||
| 19 | type writeLimiter struct { | ||
| 20 | mu sync.Mutex | ||
| 21 | buckets map[int64]*writeBucket | ||
| 22 | } | ||
| 23 | |||
| 24 | type writeBucket struct { | ||
| 25 | tokens float64 | ||
| 26 | last time.Time | ||
| 27 | } | ||
| 28 | |||
| 29 | var writes = &writeLimiter{buckets: map[int64]*writeBucket{}} | ||
| 30 | |||
| 31 | // allow reports whether this account may write now, and how long until it | ||
| 32 | // can if not. perMinute is both the sustained rate and the burst, so an | ||
| 33 | // idle account gets a full minute's worth at once. | ||
| 34 | func (l *writeLimiter) allow(user int64, perMinute int) (bool, time.Duration) { | ||
| 35 | l.mu.Lock() | ||
| 36 | defer l.mu.Unlock() | ||
| 37 | now := time.Now() | ||
| 38 | |||
| 39 | // Bounded by the number of accounts that wrote in the last ten | ||
| 40 | // minutes; a busy instance never grows this without also using it. | ||
| 41 | if len(l.buckets) > 4096 { | ||
| 42 | for k, b := range l.buckets { | ||
| 43 | if now.Sub(b.last) > 10*time.Minute { | ||
| 44 | delete(l.buckets, k) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | burst := float64(perMinute) | ||
| 50 | rate := burst / 60 | ||
| 51 | b := l.buckets[user] | ||
| 52 | if b == nil { | ||
| 53 | b = &writeBucket{tokens: burst, last: now} | ||
| 54 | l.buckets[user] = b | ||
| 55 | } | ||
| 56 | elapsed := now.Sub(b.last).Seconds() | ||
| 57 | b.last = now | ||
| 58 | if b.tokens += elapsed * rate; b.tokens > burst { | ||
| 59 | b.tokens = burst | ||
| 60 | } | ||
| 61 | if b.tokens < 1 { | ||
| 62 | return false, time.Duration((1-b.tokens)/rate*float64(time.Second)) + time.Second | ||
| 63 | } | ||
| 64 | b.tokens-- | ||
| 65 | return true, 0 | ||
| 66 | } | ||
internal/control/ratelimit_test.go added +55
| @@ -0,0 +1,55 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "testing" | ||
| 5 | "time" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // The budget is per account: spending one account's does not touch | ||
| 9 | // another's, which is the property that makes the limit meaningful when | ||
| 10 | // registration is open (#148). | ||
| 11 | func TestWriteLimiterIsPerAccount(t *testing.T) { | ||
| 12 | l := &writeLimiter{buckets: map[int64]*writeBucket{}} | ||
| 13 | for i := 0; i < 3; i++ { | ||
| 14 | if ok, _ := l.allow(1, 3); !ok { | ||
| 15 | t.Fatalf("write %d of the burst refused", i+1) | ||
| 16 | } | ||
| 17 | } | ||
| 18 | if ok, wait := l.allow(1, 3); ok || wait <= 0 { | ||
| 19 | t.Errorf("the fourth write was allowed, or gave no wait: %v", wait) | ||
| 20 | } | ||
| 21 | if ok, _ := l.allow(2, 3); !ok { | ||
| 22 | t.Error("a second account was charged for the first's writes") | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | // An idle account refills at the sustained rate, so the limit throttles | ||
| 27 | // rather than locks out. | ||
| 28 | func TestWriteLimiterRefills(t *testing.T) { | ||
| 29 | l := &writeLimiter{buckets: map[int64]*writeBucket{}} | ||
| 30 | if ok, _ := l.allow(1, 60); !ok { | ||
| 31 | t.Fatal("first write refused") | ||
| 32 | } | ||
| 33 | // 60 a minute is one a second: rewind the clock a second and the | ||
| 34 | // spent token is back. | ||
| 35 | l.buckets[1].tokens = 0 | ||
| 36 | l.buckets[1].last = time.Now().Add(-time.Second) | ||
| 37 | if ok, wait := l.allow(1, 60); !ok { | ||
| 38 | t.Errorf("no refill after a second: wait %v", wait) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | // The wait a refusal reports is the time until a token exists, so a | ||
| 43 | // caller that honours it is not refused again immediately. | ||
| 44 | func TestWriteLimiterReportsAUsableWait(t *testing.T) { | ||
| 45 | l := &writeLimiter{buckets: map[int64]*writeBucket{}} | ||
| 46 | l.allow(1, 60) | ||
| 47 | l.buckets[1].tokens = 0 | ||
| 48 | ok, wait := l.allow(1, 60) | ||
| 49 | if ok { | ||
| 50 | t.Fatal("allowed with an empty bucket") | ||
| 51 | } | ||
| 52 | if wait < time.Second || wait > 3*time.Second { | ||
| 53 | t.Errorf("wait %v is not the ~1s a 60/min bucket needs", wait) | ||
| 54 | } | ||
| 55 | } | ||