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.
118 lines
4.9 KiB
Go
118 lines
4.9 KiB
Go
package streamtoken
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Claims holds everything a stateless proxy or transcode node needs
|
|
// to serve a streaming session without database access.
|
|
//
|
|
// Under token-carried reconstruction (TR-lease) the token is also the durable
|
|
// reconstruction descriptor: its claims carry the full set of byte-affecting
|
|
// encode parameters (the former Postgres "recipe card"), so a front-end that has
|
|
// lost its in-memory session can rebuild ffmpeg from the token the client
|
|
// re-presents — no shared per-session store. The ownership claims (uid/pid/mfid)
|
|
// are lookup keys re-resolved against the authority on reconstruct; they are
|
|
// never trusted on their own.
|
|
type Claims struct {
|
|
SessionID string `json:"sid"`
|
|
MediaPath string `json:"path"`
|
|
PlayMethod string `json:"method"`
|
|
TranscodeAudio bool `json:"ta,omitempty"`
|
|
TranscodeNode string `json:"tnode,omitempty"`
|
|
TranscodeTransportID string `json:"tid,omitempty"`
|
|
TargetCodec string `json:"tc,omitempty"`
|
|
TargetRes string `json:"tres,omitempty"`
|
|
AudioCodec string `json:"ac,omitempty"`
|
|
AudioChannels int `json:"ach,omitempty"`
|
|
AudioTrackIndex int `json:"ati,omitempty"`
|
|
// DVProfile is the file's Dolby Vision profile (0 = none); remux nodes
|
|
// use it to strip dangling profile 7 RPUs. Absent in older tokens, which
|
|
// decodes as 0 (no strip — the pre-existing behavior).
|
|
DVProfile int `json:"dvp,omitempty"`
|
|
// RemuxDVMode freezes whether a Profile 7 remux preserves or strips DV
|
|
// metadata. Empty is the legacy auto behavior for old tokens.
|
|
RemuxDVMode string `json:"dvm,omitempty"`
|
|
|
|
// Ownership / authorization lookup keys (re-resolved at reconstruct).
|
|
// Not trust assertions.
|
|
UserID int `json:"uid,omitempty"`
|
|
ProfileID string `json:"pid,omitempty"`
|
|
MediaFileID int `json:"mfid,omitempty"`
|
|
|
|
// Monitoring attribution (not byte-affecting, not a trust assertion): the
|
|
// origin protocol ("native" | "jellycompat") and the reported client/app
|
|
// name, carried so an edge node — which never sees the originating API path —
|
|
// can stamp them onto its live-session record for the first-class monitor view.
|
|
Origin string `json:"org,omitempty"`
|
|
ClientName string `json:"cn,omitempty"`
|
|
|
|
// Reconstruction recipe — the byte-affecting encode parameters, mirroring the
|
|
// former playback.RecipeCard. Zero for direct/remux tokens, which reconstruct
|
|
// from identity alone plus the client-supplied position.
|
|
SourceVideoCodec string `json:"svc,omitempty"`
|
|
VideoBitstreamFilter string `json:"vbsf,omitempty"`
|
|
OutputSubdir string `json:"osd,omitempty"`
|
|
SeekSeconds float64 `json:"seek,omitempty"`
|
|
StreamOriginSeconds float64 `json:"origin,omitempty"`
|
|
CopySeekAnchorResolved bool `json:"origin_ok,omitempty"`
|
|
SegmentDuration int `json:"segd,omitempty"`
|
|
StartSegmentNumber int `json:"ssn,omitempty"`
|
|
SubtitleTrackIndex int `json:"sti,omitempty"`
|
|
SubtitleBurnIn bool `json:"sbi,omitempty"`
|
|
SubtitleCodec string `json:"sbc,omitempty"`
|
|
TargetBitrateKbps int `json:"tbr,omitempty"`
|
|
TotalDuration float64 `json:"dur,omitempty"`
|
|
FastStart bool `json:"fs,omitempty"`
|
|
TargetCodecAudio string `json:"tca,omitempty"`
|
|
|
|
// Recipe staleness hint, bumped on each re-mint after a recipe mutation
|
|
// (audio/quality/seek switch). An optional client-side hint only.
|
|
Version int `json:"ver,omitempty"`
|
|
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// IssuedTime returns the token's iat as a time.Time, zero when absent. It is
|
|
// the credential-issue instant the revocation store's user-kill cutoff compares
|
|
// against (streamrevoke.Store.IsRevoked): a user revocation kills streams whose
|
|
// token predates it and spares ones minted after re-authentication.
|
|
func (c *Claims) IssuedTime() time.Time {
|
|
if c == nil || c.IssuedAt == nil {
|
|
return time.Time{}
|
|
}
|
|
return c.IssuedAt.Time
|
|
}
|
|
|
|
// Sign creates a signed JWT string from the given claims.
|
|
func Sign(c Claims, secret string, ttl time.Duration) (string, error) {
|
|
now := time.Now()
|
|
c.RegisteredClaims = jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
}
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
|
|
return token.SignedString([]byte(secret))
|
|
}
|
|
|
|
// Verify parses and validates a stream token JWT string.
|
|
func Verify(tokenString, secret string) (*Claims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return []byte(secret), nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid stream token: %w", err)
|
|
}
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok || !token.Valid {
|
|
return nil, fmt.Errorf("invalid stream token claims")
|
|
}
|
|
return claims, nil
|
|
}
|