The edge recorded the connecting peer's address (`RemoteAddr`) for every monitoring record, ignoring forwarding headers. Behind an ingress, reverse proxy or load balancer that is the *same address for every viewer*, so the admin session list showed one indistinguishable client per node and any per-viewer analysis built on those records was reading the ingress address. The native API surface has resolved this correctly for a while via `internal/clientip`, which walks `X-Forwarded-For` right-to-left and returns the first untrusted hop. The edge simply never used it. This wires the same resolver into proxy mode so both surfaces share one trust model. The trust boundary is what makes this safe to believe: forwarding headers are consulted only when the connecting peer is itself in `clientip.trusted_proxies`, so an ordinary client cannot choose the address it is recorded under. If the trust list cannot be loaded the resolver keeps an empty list, which ignores forwarding headers entirely and falls back to the peer — failing closed on trust rather than failing startup. A directly-exposed edge with no resolver wired keeps the previous behavior. The list is reloaded on the node config watcher's change hook, so the edge follows a hot-reloaded setting without a restart, matching central. Only proxy mode is affected; `internal/transcodenode` records no client address. This was found while scoping re-stream detection (now issue #522) — an IP-based signal is meaningless if every viewer presents as the ingress — but the defect is independent of that feature and is worth fixing on its own. Part of #305
605 lines
23 KiB
Go
605 lines
23 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/clientip"
|
|
"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
|
|
ipResolver *clientip.Resolver
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetClientIPResolver wires the trusted-proxy-aware client address resolver so
|
|
// edge monitoring records the real viewer rather than the ingress that fronts
|
|
// this node. Without one the edge falls back to the connecting address, which
|
|
// behind a reverse proxy or load balancer is the same value for every viewer.
|
|
// The resolver's trusted list is updated in place on setting changes, so this
|
|
// is wired once at startup.
|
|
func (s *Server) SetClientIPResolver(resolver *clientip.Resolver) {
|
|
s.ipResolver = resolver
|
|
}
|
|
|
|
// 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", s.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", s.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", s.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 resolves the viewer's address for monitoring attribution.
|
|
//
|
|
// When a trusted-proxy resolver is wired it walks the forwarding headers the
|
|
// same way the native API surface does, honouring the operator's
|
|
// `clientip.trusted_proxies` boundary — headers are consulted only when the
|
|
// connecting peer is itself trusted, so an untrusted client cannot forge its
|
|
// own address. Without a resolver (or when the peer is untrusted) this is the
|
|
// connecting address, which is correct for a directly-exposed edge but
|
|
// collapses every viewer to one value behind an ingress or load balancer.
|
|
func (s *Server) edgeClientIP(r *http.Request) string {
|
|
if s != nil && s.ipResolver != nil {
|
|
return s.ipResolver.ClientIP(r)
|
|
}
|
|
return remoteAddrIP(r)
|
|
}
|
|
|
|
// remoteAddrIP extracts the connecting peer's IP, best-effort.
|
|
func remoteAddrIP(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(),
|
|
})
|
|
}
|