Files
silo-server/internal/proxy/egress.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

110 lines
3.4 KiB
Go

package proxy
import (
"io"
"net/http"
"sync"
"time"
)
// meterWindowSeconds is the averaging window for the egress rate. HLS clients
// fetch segments in bursts (especially when buffering ahead), so a window of
// this size smooths the spikes into something close to the steady-state
// stream rate. The planner's bandwidth reservation bridge matches this value.
const meterWindowSeconds = 60
// egressMeter measures outbound stream bytes as a rolling per-second ring,
// reporting the average rate over the window. Safe for concurrent use.
type egressMeter struct {
mu sync.Mutex
// One extra bucket so the current (partial) second never collides with
// the oldest second still inside the window.
buckets [meterWindowSeconds + 1]int64
stamps [meterWindowSeconds + 1]int64 // unix second each bucket holds
now func() time.Time
}
func newEgressMeter() *egressMeter {
return &egressMeter{now: time.Now}
}
// Add records n egressed bytes against the current second.
func (m *egressMeter) Add(n int64) {
if n <= 0 {
return
}
sec := m.now().Unix()
i := int(sec % int64(len(m.buckets)))
m.mu.Lock()
if m.stamps[i] != sec {
m.stamps[i] = sec
m.buckets[i] = 0
}
m.buckets[i] += n
m.mu.Unlock()
}
// RateKbps returns the average egress over the window in kilobits/s.
func (m *egressMeter) RateKbps() int {
sec := m.now().Unix()
var total int64
m.mu.Lock()
for i := range m.buckets {
if sec-m.stamps[i] < meterWindowSeconds {
total += m.buckets[i]
}
}
m.mu.Unlock()
return int(total * 8 / 1000 / meterWindowSeconds)
}
// meteredResponseWriter counts every byte written to the client. Embedding the
// interface hides optimizations like io.ReaderFrom, so ReadFrom is forwarded
// explicitly below — every stream handler runs inside meterEgress, so without
// that forwarding NO proxied pour could ever reach the kernel sendfile path,
// regardless of what the inner writers (sessionByteWriter) forward.
type meteredResponseWriter struct {
http.ResponseWriter
meter *egressMeter
}
func (w *meteredResponseWriter) Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.meter.Add(int64(n))
return n, err
}
// ReadFrom preserves the underlying ResponseWriter's sendfile fast path
// (disk→socket inside the kernel) while still metering the bytes: the count
// only needs the total, which sendfile reports on return. Without a ReaderFrom
// underneath it falls back to a plain copy through Write (which meters), via
// writeOnly so io.Copy cannot re-enter this method and recurse.
func (w *meteredResponseWriter) ReadFrom(src io.Reader) (int64, error) {
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
n, err := rf.ReadFrom(src)
w.meter.Add(n)
return n, err
}
return io.Copy(writeOnly{w}, src)
}
func (w *meteredResponseWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap lets http.NewResponseController reach the underlying ResponseWriter so
// SetWriteDeadline (used by the revocation connection-cut) can find the socket.
func (w *meteredResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
// meterEgress wraps stream handlers so their responses count toward the
// node's measured egress bandwidth.
func (s *Server) meterEgress(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&meteredResponseWriter{ResponseWriter: w, meter: s.egress}, r)
})
}