fix(api): cap concurrent SSE streams per principal

Console and build-log relays hold a Server-Sent Event connection open for the
life of a client's attachment; a stalled reader pins the relay goroutine plus
its upstream kube-apiserver follow. Without a bound, one authenticated
principal could open these repeatedly and accumulate leaked control-plane
connections.

Add a per-principal stream cap (streamLimiter) enforced before either relay
opens its follow stream, returning 429 too_many_streams past the limit.
cmd/felis wires it to 16; zero disables it, matching the "zero disables"
idiom of the other levers.

This bounds the blast radius of the stalled-stream leak; it does not close the
leak itself -- the per-write deadline that severs a stalled stream is a
separate change.
This commit is contained in:
flyemoji committed 2026-07-01 22:04:19 +09:00
1 parent c6c0772a7a
commit 3c1d64749f
5 files changed
+175

No files matched your search

+13
View File
@@ -118,6 +118,19 @@ func (a *API) handleBuildLogs(w http.ResponseWriter, r *http.Request) {
return
}
p := principalFromContext(r.Context())
// Bound concurrent SSE streams per principal (shared with the console relay): a
// stalled reader pins this relay and its upstream build-pod follow, so cap how many
// one principal may hold at once. Acquired before opening the stream and released on
// every return path. See streamGate — this bounds blast radius, not the leak itself.
release, ok := a.streamGate().acquire(streamKey(p))
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "too_many_streams",
"too many open build-log streams; close one and retry"))
return
}
defer release()
src, err := a.BuildLogs.StreamLogs(r.Context(), id)
switch {
case errors.Is(err, ErrNotFound):