Files
silo-server/internal/proxy/server.go
T
CoffeeKnyte ecb4555eec fix(playback): make edge tracking lifecycle-safe and session identity canonical
Five defects that all produced a WRONG over-cap count, which is why they land
before the revocation batch: decision A1 raises the over-cap revocation TTL from
5m to ~24h, removing the self-healing that currently limits the damage of a
miscount. A false positive after A1 blocks a legitimate stream for a day, so the
count has to be trustworthy first.

#1 -- overlapping edge requests deleted a live stream. Tracker.sessions was a
set and Remove tore down all state plus the Redis key, while both proxy pour
handlers deferred removal unconditionally. Two overlapping Range GETs on one
session id -- ordinary seek behaviour -- meant the first to finish deleted the
record while the second was still pouring, and later AddBytes calls were then
dropped because AddBytes ignores bytes for a session with no live record. The
stream went invisible to authoritative monitoring while still serving.

Track now returns a Lease that the request-scoped caller releases exactly once;
teardown happens when the last live lease is released. A plain refcount would
have been wrong: Track(A) -> Remove -> Track(B) -> Release(A) decrements B, and
clamping at zero does not help because the count legitimately belongs to B. That
is not hypothetical -- the transcode node deliberately replaces sessions under
the same id so a quality switch does not orphan ffmpeg, and it calls
unconditional Remove from its reaper and stop paths. So each generation carries
an epoch, Remove and Cleanup bump it, and a release from a superseded generation
is a logged no-op. Lease identity is a set rather than a counter, which makes a
duplicate release detectable instead of silently destructive.

The transcode node keeps using Remove: its Track calls are not request-scoped and
are correctly owned by session lifecycle. "Every Track needs a paired Release" is
true only of the request-scoped callers.

#8 -- async transcode tracking could leave a permanent ghost. The tracking write
ran as a bare goroutine with a WithoutCancel context, so if stop won the race the
delayed Track recreated the record after cleanup -- and because it landed in
sessions, Snapshot treated it as live until Remove and it NEVER idle-expired. A
permanent phantom inflating its owner's count, able to trigger false over-cap
kills of that user's real streams. The write now takes the per-session lifecycle
lock that stop and reap already hold, and re-checks session pointer identity
before writing, so a stopped or replaced generation cannot resurrect a record.
Pointer identity rather than id equality is what makes same-id replacement safe.
The write stays off the request path -- the API server and the playback client
are blocked on the 202.

#9 + M3 -- protocol-v3 counted one stream twice. The stream token carries a
transport id distinct from the logical session id, and the node tracked under the
transport id while the API/proxy record used the logical one, so mergeStreams saw
two streams. M3 was the reason this had not yet bitten: the v3 fresh-start caller
sent no owner attribution at all, so the transport record landed under user 0,
which the enforcer skips -- silently exempting the stream from the cap entirely.
Fresh v3 starts now carry the logical session id and full owner attribution
(both were already in scope at the call site), and merging is keyed on logical
identity where present via one shared helper used by both merge functions, which
had already drifted apart once.

The enforcer view resolves SessionID to the logical id so a kill targets the real
session rather than a replaceable transport generation. The raw admin view keeps
the transport id and exposes logical_session_id as an additive omitempty field,
advertised on the node-sessions capability endpoint, so the v1 response shape is
unchanged.

GAP-15 -- edge transcode liveness was request-observed. touchTranscodeSession
fired before proxying, so hammering dead segment URLs advanced LastServedAt with
zero bytes served. Visibility and liveness are now separate operations:
EnsureEphemeral makes a session visible without claiming bytes were served, and
served-byte liveness advances only from a 2xx/206 upstream response. Previously
the proxy metered every upstream body regardless of status, so a node 404's error
body counted as served bytes -- moving the touch later would not have fixed it.

S4 -- LiveLocalSessions moved from the HTTP handlers package to streammonitor,
which owns monitoring. A background enforcer importing api/handlers was
backwards. Pure move; its existing mapping assertions moved with it. The
LastActivityAt fallback inside it is left as-is -- decision A5 removes it in the
liveness batch.

Verified with go test -race across nodesessions, proxy and transcodenode; the
overlap regression test was confirmed to fail under the old unconditional
teardown.

Part of #305.
2026-07-30 09:09:35 +00:00

577 lines
22 KiB
Go

package proxy
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
// revocationStore is the subset of *streamrevoke.Store the edge consults to
// enforce kills. IsRevoked is a pure in-memory lookup (no I/O) so it is safe on
// the per-request hot path. Nil disables enforcement.
type revocationStore interface {
IsRevoked(sessionID string, userID int, startedAt time.Time) bool
Refuse(w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) bool
WatchAndCutContext(ctx context.Context, w http.ResponseWriter, sessionID string, userID int, startedAt time.Time) func()
}
// Server is the HTTP handler for proxy mode.
type Server struct {
watcher *nodeconfig.Watcher
tracker *nodesessions.Tracker
httpClient *http.Client
egress *egressMeter
// subCache stores full-track PGS (.sup) extracts under the transcode dir
// so repeat selections skip the whole-file ffmpeg demux.
subCache *playback.SubtitleCache
revocation revocationStore
}
// SetRevocationStore wires the kill-switch the edge consults per request. The
// store's cache is kept current out-of-band (pub/sub + poll), so the hot-path
// check is a local map read.
func (s *Server) SetRevocationStore(store revocationStore) {
s.revocation = store
}
// NewServer creates a new proxy server backed by a config watcher and session
// tracker.
func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Server {
return &Server{
watcher: watcher,
tracker: tracker,
// No overall timeout — stream bodies are long-lived. Hung nodes are
// bounded by the transport's response-header timeout instead.
httpClient: &http.Client{Transport: newStreamTransport()},
egress: newEgressMeter(),
subCache: playback.NewSubtitleCache(func() string {
return watcher.Config().Playback.TranscodeDir
}),
}
}
// newStreamTransport tunes the proxy→transcode-node connection pool. Many
// concurrent viewers fan their segment fetches through one proxy→node pair,
// and Go's default of 2 idle connections per host causes constant connection
// churn (and TLS re-handshakes) under load. The response-header timeout
// bounds requests to a hung node; the longest legitimate server-side wait is
// the 30s manifest-readiness poll on the transcode node.
func newStreamTransport() *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConns = 128
t.MaxIdleConnsPerHost = 32
t.ResponseHeaderTimeout = 60 * time.Second
return t
}
// Handler returns the chi.Router with all proxy routes mounted.
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
// hls.js uses XHR for manifest/segment fetches which are subject to
// CORS when the proxy runs on a different origin than the web app.
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Range"},
MaxAge: 86400,
}))
r.Get("/api/v1/health", s.handleHealth)
r.Group(func(r chi.Router) {
// Everything under /stream counts toward the node's measured
// egress bandwidth.
r.Use(s.meterEgress)
r.Head("/stream/direct/{token}", s.handleDirectPlay)
r.Get("/stream/direct/{token}", s.handleDirectPlay)
r.Head("/stream/remux/{token}", s.handleRemux)
r.Get("/stream/remux/{token}", s.handleRemux)
r.Head("/stream/transcode/{token}/master.m3u8", s.handleTranscodeManifest)
r.Get("/stream/transcode/{token}/master.m3u8", s.handleTranscodeManifest)
r.Get("/stream/transcode/{token}/segment/{name}", s.handleTranscodeSegment)
r.Get("/stream/subtitles/{token}/{track}/fonts", s.handleSubtitleFonts)
r.Get("/stream/subtitles/{token}/{track}", s.handleSubtitle)
})
// Admin routes — bearer-auth protected.
r.Group(func(r chi.Router) {
r.Use(s.requireBearer)
r.Post("/admin/force-reload", s.handleForceReload)
r.Get("/status", s.handleStatus)
})
return r
}
type healthResponse struct {
Status string `json:"status"`
ActiveJobs int `json:"active_jobs"`
EgressKbps int `json:"egress_kbps"`
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
activeJobs := 0
if s.tracker != nil {
activeJobs = s.tracker.ActiveCount()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(healthResponse{
Status: "ok",
ActiveJobs: activeJobs,
EgressKbps: s.egress.RateKbps(),
})
}
// requireBearer checks Authorization: Bearer {secret} for admin endpoints.
func (s *Server) requireBearer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.watcher.Config()
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != cfg.Auth.JWTSecret {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// verifyToken extracts and validates the stream token from the URL.
func (s *Server) verifyToken(w http.ResponseWriter, r *http.Request) *streamtoken.Claims {
cfg := s.watcher.Config()
tokenStr := chi.URLParam(r, "token")
claims, err := streamtoken.Verify(tokenStr, cfg.Auth.JWTSecret)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return nil
}
// Kill switch: a revoked session/user is refused here, on every request. For
// chunked (HLS) playback this stops the stream on its next segment fetch and
// refuses reconnects; long direct-play/remux pours are additionally cut mid-
// stream by cutOnRevocation.
// The token's iat is the credential-issue time the user-kill cutoff compares
// against: edge requests carry no fresh auth, only this token, so a user
// revocation kills exactly the tokens minted before it.
if s.revocation != nil && s.revocation.Refuse(w, claims.SessionID, claims.UserID, claims.IssuedTime()) {
return nil
}
return claims
}
// cutOnRevocation watches a long-lived pour (direct play / remux) and hangs up
// the socket the moment the session/user is revoked, via the shared store helper.
// Returns a stop func to cancel the watcher when the request finishes normally.
func (s *Server) cutOnRevocation(ctx context.Context, w http.ResponseWriter, claims *streamtoken.Claims) func() {
if s.revocation == nil {
return func() {}
}
return s.revocation.WatchAndCutContext(ctx, w, claims.SessionID, claims.UserID, claims.IssuedTime())
}
// releaseTracked releases one request lease with a bounded background context. The
// request context is already canceled by the time this defer runs on a client
// disconnect, which would skip the Redis DEL and leave a phantom session until
// TTL — a false over-cap window.
func (s *Server) releaseTracked(lease nodesessions.Lease) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
s.tracker.Release(ctx, lease)
}
func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
}
info := sessionInfo(s.tracker, claims, "direct_play", edgeClientIP(r))
lease := s.tracker.Track(r.Context(), info)
defer s.releaseTracked(lease)
sw := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer sw.flush()
stop := s.cutOnRevocation(r.Context(), sw, claims)
defer stop()
http.ServeFile(sw, r, claims.MediaPath)
}
// sessionByteWriter attributes served bytes to a session so LastServedAt advances
// during long direct-play/remux pours (authoritative liveness). Bytes are
// flushed to the tracker in coarse chunks to avoid per-write lock churn. Unwrap
// lets the revocation connection-cut reach the socket through this layer.
type sessionByteWriter struct {
http.ResponseWriter
tracker *nodesessions.Tracker
sessionID string
acc int64
}
func (w *sessionByteWriter) Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.account(int64(n))
return n, err
}
// account tallies served bytes, flushing to the tracker in coarse ~1 MiB chunks
// to avoid per-write lock churn. Shared by Write and the ReadFrom fast path.
func (w *sessionByteWriter) account(n int64) {
if n <= 0 {
return
}
w.acc += n
if w.acc >= 1<<20 { // flush every ~1 MiB
w.tracker.AddBytes(w.sessionID, w.acc)
w.acc = 0
}
}
// ReadFrom preserves the underlying writer's sendfile fast path while still
// attributing served bytes. http.ServeFile pours an *os.File through the
// ResponseWriter's io.ReaderFrom (sendfile: disk->socket inside the kernel, no
// userspace copy) — but only if it can see that method. Wrapping the writer for
// byte counting would hide it and force every direct-play/remux byte through
// userspace; forwarding here keeps zero-copy AND the count. Liveness does not
// depend on this coarse count: the session stays live from Track to Remove
// (tracker re-SETs it, never idle-prunes an open pour), so a single sendfile
// call that only tallies on return still stays visible for the whole pour.
func (w *sessionByteWriter) ReadFrom(src io.Reader) (int64, error) {
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
n, err := rf.ReadFrom(src)
w.account(n)
return n, err
}
// No sendfile fast path underneath. Copy manually — but NOT via io.Copy(w,
// src), which would re-detect this very ReadFrom and recurse forever.
// writeOnly exposes only Write, so io.Copy falls back to the Write loop
// (which accounts bytes).
return io.Copy(writeOnly{w}, src)
}
// writeOnly wraps an io.Writer to expose ONLY Write, hiding any ReadFrom so
// io.Copy cannot re-enter sessionByteWriter.ReadFrom.
type writeOnly struct{ io.Writer }
func (w *sessionByteWriter) flush() {
if w.acc > 0 {
w.tracker.AddBytes(w.sessionID, w.acc)
w.acc = 0
}
}
func (w *sessionByteWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *sessionByteWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
}
info := sessionInfo(s.tracker, claims, "remux", edgeClientIP(r))
lease := s.tracker.Track(r.Context(), info)
defer s.releaseTracked(lease)
sw := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer sw.flush()
stop := s.cutOnRevocation(r.Context(), sw, claims)
defer stop()
seekSeconds := 0.0
if seekStr := r.URL.Query().Get("seek"); seekStr != "" {
if v, err := strconv.ParseFloat(seekStr, 64); err == nil {
seekSeconds = v
}
}
// Honor the Dolby Vision mode frozen in the token (empty decodes as the
// legacy auto behavior for old tokens), mirroring how the integrated
// server's stream handler serves the same claims. Serve through sw so the
// bytes are metered and the revocation cut can reach this response.
_ = playback.ServeRemuxWithDVMode(sw, r, claims.MediaPath, "mp4", seekSeconds, claims.TranscodeAudio, claims.AudioTrackIndex, claims.DVProfile, playback.RemuxDVMode(claims.RemuxDVMode), s.watcher.Config().Playback.FFmpegPath)
}
func (s *Server) handleTranscodeManifest(w http.ResponseWriter, r *http.Request) {
claims := s.verifyToken(w, r)
if claims == nil {
return
}
s.ensureTranscodeSession(r, claims)
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+transcodeTransportIDFromClaims(claims)+"/master.m3u8")
}
func (s *Server) handleTranscodeSegment(w http.ResponseWriter, r *http.Request) {
claims := s.verifyToken(w, r)
if claims == nil {
return
}
s.ensureTranscodeSession(r, claims)
name := chi.URLParam(r, "name")
s.proxyToTranscodeNode(w, r, claims, "/transcode/"+transcodeTransportIDFromClaims(claims)+"/segment/"+name)
}
func transcodeTransportIDFromClaims(claims *streamtoken.Claims) string {
if claims != nil && claims.TranscodeTransportID != "" {
return claims.TranscodeTransportID
}
if claims == nil {
return ""
}
return claims.SessionID
}
// ensureTranscodeSession keeps HLS sessions visible in the active stream count.
// Unlike direct play and remux, transcode playback reaches the proxy as many
// short manifest/segment requests, so the session is tracked by recent
// requests instead of request lifetime. Visibility is separate from served
// liveness: only a successful upstream media response advances LastServedAt.
func (s *Server) ensureTranscodeSession(r *http.Request, claims *streamtoken.Claims) {
s.tracker.EnsureEphemeral(r.Context(), sessionInfo(s.tracker, claims, "transcode", edgeClientIP(r)))
}
// sessionInfo builds the node-session tracker record for a verified token,
// copying the numeric ownership keys plus the monitoring attribution (route +
// client identity) the first-class monitor view needs. clientIP is the connecting
// address observed at the edge (best-effort; the client reaches the proxy
// directly). Route/ClientName come from the token so the edge — which never sees
// the originating API path — can still tag native vs jellycompat.
func sessionInfo(tr *nodesessions.Tracker, claims *streamtoken.Claims, kind, clientIP string) nodesessions.SessionInfo {
return nodesessions.SessionInfo{
SessionID: claims.SessionID,
NodeURL: tr.NodeURL(),
NodeName: tr.NodeName(),
Type: kind,
Route: claims.Origin,
ClientIP: clientIP,
ClientName: claims.ClientName,
StartedAt: time.Now().UTC().Format(time.RFC3339),
AuthUserID: claims.UserID,
ProfileID: claims.ProfileID,
MediaFileID: claims.MediaFileID,
}
}
// edgeClientIP extracts the connecting client's IP from the request, best-effort.
func edgeClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(httpstream.WithCutLatch(r.Context(), &httpstream.CutLatch{}))
claims := s.verifyToken(w, r)
if claims == nil {
return
}
// Attribute subtitle bytes only when the session already has a tracker
// record. Do not take lifecycle ownership here: Track/Remove per subtitle
// request can delete a concurrently active media request until Batch 2 adds
// reference-counted edge tracking.
metered := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer metered.flush()
stop := s.cutOnRevocation(r.Context(), metered, claims)
defer stop()
w = metered
cfg := s.watcher.Config()
trackParam := chi.URLParam(r, "track")
trackIndex, requestedFormat, err := playback.ParseSubtitleTrackParam(trackParam)
if err != nil {
http.Error(w, "invalid subtitle index", http.StatusBadRequest)
return
}
// When the URL requests SUP format (e.g. /subtitles/{token}/2.sup),
// serve the PGS track as a raw .sup elementary stream for client-side
// bitmap rendering (libpgs). Unlike the buffered text paths below, this
// serves the cached full-track extract when present, and otherwise
// streams ffmpeg output directly (the client renders progressively as
// data arrives) while teeing it into the cache for the next request.
// Clients that manage their own sliding window opt in with ?windowed=1
// (+ ?position=/?duration=), mirroring the API stream handler; windowed
// requests extract only the requested slice — from the cached full
// track when one exists (warming it in the background when not).
if requestedFormat == "sup" {
allowWindow, seek, duration := playback.PGSWindowRequest(r.URL.Query())
err := s.subCache.ServeSUPExtract(w, r, playback.StreamExtractOpts{
InputPath: claims.MediaPath,
TrackIndex: trackIndex,
SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks
SeekSeconds: seek,
DurationSeconds: duration,
AllowWindow: allowWindow,
FFmpegPath: cfg.Playback.FFmpegPath,
}, playback.StreamExtractSubtitle)
if err != nil && r.Context().Err() == nil {
// Headers already committed — log and let the client see a
// truncated response.
slog.ErrorContext(r.Context(), "stream subtitle (sup)", "component", "proxy", "error", err, "track", trackIndex,
"path", claims.MediaPath, "playback_session_id", claims.SessionID)
}
return
}
// When the URL requests ASS format (e.g. /subtitles/{token}/2.ass),
// extract as raw ASS to preserve styling for client-side rendering.
if requestedFormat == "ass" {
data, err := playback.ExtractSubtitleWithFormat(r.Context(), claims.MediaPath, trackIndex, "ass", cfg.Playback.FFmpegPath)
if err != nil {
slog.ErrorContext(r.Context(), "extract subtitle (ass)", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
http.Error(w, "subtitle extraction failed", http.StatusInternalServerError)
return
}
playback.ServeSubtitle(w, data, "ass")
return
}
data, format, err := playback.ExtractSubtitle(r.Context(), claims.MediaPath, trackIndex, cfg.Playback.FFmpegPath)
if err != nil {
slog.ErrorContext(r.Context(), "extract subtitle", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
http.Error(w, "subtitle extraction failed", http.StatusInternalServerError)
return
}
vtt, err := playback.ConvertToVTT(data, format)
if err != nil {
slog.ErrorContext(r.Context(), "convert to vtt", "component", "proxy", "error", err, "playback_session_id", claims.SessionID)
http.Error(w, "subtitle conversion failed", http.StatusInternalServerError)
return
}
playback.ServeSubtitle(w, vtt, "vtt")
}
func (s *Server) handleSubtitleFonts(w http.ResponseWriter, r *http.Request) {
claims := s.verifyToken(w, r)
if claims == nil {
return
}
cfg := s.watcher.Config()
trackParam := chi.URLParam(r, "track")
trackIndex, _, err := playback.ParseSubtitleTrackParam(trackParam)
if err != nil {
http.Error(w, "invalid subtitle index", http.StatusBadRequest)
return
}
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), claims.MediaPath, cfg.Playback.FFmpegPath)
if err != nil {
slog.ErrorContext(r.Context(), "extract subtitle fonts", "component", "proxy", "error", err, "track", trackIndex, "path", claims.MediaPath, "playback_session_id", claims.SessionID)
http.Error(w, "subtitle font extraction failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(playback.EncodeSubtitleFontBundle(fonts)); err != nil {
slog.WarnContext(r.Context(), "subtitle font response encode failed", "component", "proxy", "error", err, "playback_session_id", claims.SessionID)
}
}
// proxyToTranscodeNode forwards the request to the transcode node specified in the claims.
func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, claims *streamtoken.Claims, path string) {
cfg := s.watcher.Config()
if claims.TranscodeNode == "" {
http.Error(w, "no transcode node in token", http.StatusBadRequest)
return
}
targetURL := claims.TranscodeNode + path
if rawQuery := r.URL.RawQuery; rawQuery != "" {
targetURL = fmt.Sprintf("%s?%s", targetURL, rawQuery)
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, nil)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret)
// Forward the verified stream token so the transcode node can self-reconstruct
// a lost session after its OWN restart: the token carries the full byte-affecting
// recipe, so the node can re-spawn ffmpeg seeked to the requested segment instead
// of 404ing (the integrated server already does this from the same token). The
// node re-verifies the token independently before trusting it.
if token := chi.URLParam(r, "token"); token != "" {
req.Header.Set("X-Silo-Stream-Token", token)
}
resp, err := s.httpClient.Do(req)
if err != nil {
slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "proxy", "error", err, "url", targetURL, "playback_session_id", claims.SessionID)
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
// Copy response headers
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
_, _ = io.Copy(w, resp.Body)
return
}
// Attribute served bytes to the session for authoritative monitoring —
// incrementally (~1 MiB granularity), not in one post-copy tally: a slow
// segment drain that outlives the 60s record TTL would otherwise go
// invisible mid-pour and then post bytes to a record that no longer exists.
// Manifest bytes are tiny; segment bytes are the real signal. Best-effort,
// never gates. writeOnly forces the per-chunk Write path: sw.ReadFrom would
// tally only once on return, which is the single-post-copy behavior this
// replaces (there is no file to sendfile here — the source is the node's
// HTTP response body).
sw := &sessionByteWriter{ResponseWriter: w, tracker: s.tracker, sessionID: claims.SessionID}
defer sw.flush()
_, _ = io.Copy(writeOnly{sw}, resp.Body)
}
func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
if err := s.watcher.ForceReload(r.Context()); err != nil {
http.Error(w, "reload failed: "+err.Error(), http.StatusInternalServerError)
return
}
slog.InfoContext(r.Context(), "proxy force reload completed", slog.String("component", "proxy"))
w.WriteHeader(http.StatusNoContent)
}
type statusResponse struct {
ActiveSessions int `json:"active_sessions"`
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statusResponse{
ActiveSessions: s.tracker.ActiveCount(),
})
}