Files
silo-server/internal/streamenforcer/enforcer.go
T
CoffeeKnyte 0217cce2df feat(playback): stream kill switch + async over-cap enforcer
Add the enforcement layer on top of server-observed monitoring: a revocation
kill switch that stops any stream within ~120s and keeps it dead, plus an async
over-cap enforcer that drives kills off the live monitoring picture — entirely
off the per-segment hot path and with no client-protocol change.

- internal/streamrevoke: the central kill list. IsRevoked is a pure in-memory
  lookup safe on the request hot path; a Redis pub/sub + poll mirror keeps edge
  caches current, and a Postgres durable mirror lets kills survive a server
  restart AND a Redis flush so a restart-resilient stream cannot be reconstructed
  and re-served after being killed. A user revocation is a cutoff (kills tokens
  minted before it, spares post-reauth tokens), not a 24h ban.
- internal/streamenforcer: async over-cap brain — reads the monitoring snapshot
  and per-user limits, selects victims, and collapses every reason (exceeded
  limit, admin terminate, abuse) to the same action: write a revocation.
- Edge + native + jellycompat enforcement: proxy refuses revoked sessions on
  every request and cuts long direct-play/remux pours mid-stream; the transcode
  node guards both serve and the reconstruct path so a killed session is never
  re-spawned after a node restart; jellycompat serve surfaces close their
  kill-switch coverage holes.
- streamtoken.IssuedTime exposes the token iat the user-kill cutoff compares
  against; token IssuedTime + revocation guards wire through router, downloads,
  and admin terminate-by-id (with admin-list dedupe).
- Restore sendfile zero-copy on direct-play/remux byte counting so the monitor's
  served-byte accounting does not cost the sendfile fast path.
- migrations/sql: stream_revocations durable table.

Part of the stream monitoring & kill-switch epic.
2026-07-29 12:26:03 +00:00

159 lines
5.8 KiB
Go

// Package streamenforcer is the async decision loop ("the brain") of the
// monitor-and-kill design. It runs on central only, off the hot path: it reads
// the authoritative live-streams snapshot, compares each user's live count to
// that user's limit, and issues revocations for the over-cap sessions. The
// revocation kill switch (internal/streamrevoke) then stops them at the edge
// within one propagation/poll interval.
//
// Every enforcement reason (over-cap here, admin terminate and account
// revocation elsewhere) collapses to the same action: write a revocation. This
// package owns only the over-cap rule; other reasons call the revoker directly.
package streamenforcer
import (
"context"
"log/slog"
"sort"
"time"
"github.com/Silo-Server/silo-server/internal/streammonitor"
)
// DefaultInterval is how often the enforcer evaluates the live picture. The
// ~120s enforcement budget = this interval + the revocation propagation/poll.
const DefaultInterval = 30 * time.Second
// revocationTTL is how long an over-cap revocation lasts. It is deliberately
// short: the enforcer re-evaluates every DefaultInterval, so a persistent abuser
// is re-revoked long before this lapses (staying dead), while a transient
// over-count — e.g. a ghost session lingering in the monitor next to a fresh
// reconnect — self-heals within this window instead of banning the reconnect for
// 24h. Must comfortably exceed DefaultInterval so re-revocation has slack.
const revocationTTL = 5 * time.Minute
// Revoker is the subset of *streamrevoke.Store the enforcer needs. It uses the
// TTL-scoped variant so an over-cap kill self-heals (see revocationTTL).
type Revoker interface {
RevokeSessionFor(ctx context.Context, sessionID, reason string, ttl time.Duration) error
}
// LimitFunc returns the maximum concurrent streams allowed for a user. A return
// of <= 0 means "unlimited" (no enforcement for that user), matching the
// SessionManager's convention. An error means the limit is currently unknown;
// the enforcer fails OPEN (does not kill) so a limit-lookup blip never
// terminates legitimate playback.
type LimitFunc func(ctx context.Context, userID int) (maxStreams int, err error)
// Enforcer periodically trims over-cap streams.
type Enforcer struct {
source streammonitor.Source
limits LimitFunc
revoker Revoker
interval time.Duration
now func() time.Time
}
// New builds an enforcer. interval <= 0 uses DefaultInterval.
func New(source streammonitor.Source, limits LimitFunc, revoker Revoker, interval time.Duration) *Enforcer {
if interval <= 0 {
interval = DefaultInterval
}
return &Enforcer{
source: source,
limits: limits,
revoker: revoker,
interval: interval,
now: time.Now,
}
}
// Start runs the evaluation loop until ctx is cancelled. Non-blocking.
func (e *Enforcer) Start(ctx context.Context) {
if e == nil || e.source == nil || e.limits == nil || e.revoker == nil {
return
}
go func() {
ticker := time.NewTicker(e.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.evaluate(ctx)
}
}
}()
}
// evaluate runs one pass: snapshot → per-user over-cap check → revoke victims.
// Exported behavior is covered by EvaluateOnce for tests.
func (e *Enforcer) evaluate(ctx context.Context) {
if err := e.EvaluateOnce(ctx); err != nil {
slog.Debug("stream enforcer evaluate failed", "error", err)
}
}
// EvaluateOnce performs a single enforcement pass and returns the number of
// sessions revoked. Deterministic and side-effect-scoped for testing.
func (e *Enforcer) EvaluateOnce(ctx context.Context) error {
snap, err := e.source.Snapshot(ctx)
if err != nil {
return err
}
for userID, streams := range snap.ByUser() {
if userID <= 0 || len(streams) == 0 {
// user 0 == records with no resolved owner; never enforce against it.
continue
}
limit, err := e.limits(ctx, userID)
if err != nil {
// Fail open: a limit-lookup error must never kill legitimate streams.
slog.Debug("stream enforcer: limit lookup failed; skipping user",
"user_id", userID, "error", err)
continue
}
if limit <= 0 || len(streams) <= limit {
continue
}
for _, victim := range e.selectVictims(streams, limit) {
if err := e.revoker.RevokeSessionFor(ctx, victim.SessionID, "over_concurrent_stream_limit", revocationTTL); err != nil {
slog.Warn("stream enforcer: revoke failed",
"user_id", userID, "session_id", victim.SessionID, "error", err)
continue
}
slog.Info("stream enforcer: revoked over-cap session",
"user_id", userID, "session_id", victim.SessionID,
"limit", limit, "live", len(streams))
}
}
return nil
}
// selectVictims returns the streams beyond the limit, keeping the `limit`
// MOST-RECENTLY-SERVED sessions and trimming the rest. Ordering by real serve
// activity (LastServedAt), not StartedAt, matters: after a network blip a client
// reconnects with a new session while the old ghost lingers in the monitor for
// up to its TTL. Keeping the freshly-served sessions means the live reconnect
// survives and the stale ghost is the one trimmed (and it would have aged out
// anyway). Falls back to StartedAt, then session id, for deterministic ties.
func (e *Enforcer) selectVictims(streams []streammonitor.LiveStream, limit int) []streammonitor.LiveStream {
ordered := make([]streammonitor.LiveStream, len(streams))
copy(ordered, streams)
// Most-recently-served first.
sort.SliceStable(ordered, func(i, j int) bool {
if !ordered[i].LastServedAt.Equal(ordered[j].LastServedAt) {
return ordered[i].LastServedAt.After(ordered[j].LastServedAt)
}
if !ordered[i].StartedAt.Equal(ordered[j].StartedAt) {
return ordered[i].StartedAt.After(ordered[j].StartedAt)
}
return ordered[i].SessionID < ordered[j].SessionID
})
if limit >= len(ordered) {
return nil
}
// Keep the first `limit` (freshest); revoke the rest (stalest).
return ordered[limit:]
}