From d97ca11fa5dd0bc72709be7c45cb558e414256c1 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 4 Jun 2026 22:38:12 +0200 Subject: [PATCH] =?UTF-8?q?fix(stream):=20self-heal=20host=E2=86=92contain?= =?UTF-8?q?er=20path=20skew=20in=20HLS=20+=20sidecar=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a docker agent the web DB holds host paths (e.g. /mnt/nas/peliculas/…) while the container mounts that media at /downloads, so the runtime allowed root (cfg.Download.Dir=/downloads) rejects the host path. The raw /stream handler already self-heals via relocateUnreachable, but the HLS/remux session handler did not — it logged "path outside allowed dirs" and returned, so the web silently fell back to the raw /stream path (no transcode, slow funnel start) and HLS/remux never ran. The path-scoped sidecar handlers (/thumbnail, /trickplay, /sub) had the same skew → 404 for every scrubber frame, trickplay sprite and external subtitle. - HLS handler (OnStreamSession): apply the same relocateUnreachable remap as the raw handler before the dir-resolve. - StreamServer: add SetPathResolver/healMediaPath, applied in /thumbnail, /trickplay, /sub AFTER token verification (the token still binds the original web path; the resolver is a pure function of that path and re-validates containment, so it can't be abused to serve a different file). - Hoist the allowed-roots list into streamAllowedRoots(cfg) so the raw, HLS and sidecar handlers can't drift apart. Note: relocateUnreachable needs a ≥3-segment path tail, so flat media layouts are not self-healed (same limitation as /stream; a re-scan rewrites the DB path). The HLS handler replicates only the lexical remap, not the raw handler's transient-NFS os.Stat retry. --- internal/cmd/daemon.go | 56 +++++++++++++++++++++++---- internal/engine/stream_server.go | 36 +++++++++++++++++ internal/engine/stream_server_test.go | 27 +++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index a1b0a8a..5818640 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -380,6 +380,23 @@ func runDaemonStart() error { } } streamSrv.SetTrickplayWidth(trickW) + // Self-heal a host→container base-path skew for the path-scoped handlers + // (/thumbnail, /trickplay, /sub), mirroring the /stream + /hls remap. Without + // it, a docker agent whose web DB holds host paths (/mnt/nas/peliculas/…) but + // mounts that media at /downloads returns 404 for every scrubber frame / + // trickplay sprite / external subtitle. Same allowed roots + relocate logic. + // NOTE: relocateUnreachable needs a ≥3-segment path tail, so a FLAT media + // layout (file directly under the root) is not self-healed here — those + // sidecars 404 on a docker agent with a host→container skew until a re-scan + // rewrites the DB path. Same limitation as the /stream self-heal. + streamSrv.SetPathResolver(func(p string) string { + p = filepath.Clean(p) + roots := streamAllowedRoots(cfg) + if isAllowedStreamPath(p, roots...) { + return p + } + return relocateUnreachable(p, roots) // "" when not locatable → caller 404s + }) streamSrv.SetRequireStreamToken(cfg.Download.RequireStreamToken) // Report the stream-token signing key ONLY when enforcing, so the web's // "secret present → mint HLS token" signal accurately means "this agent @@ -607,8 +624,7 @@ func runDaemonStart() error { }() } - allowedRoots := []string{cfg.Download.Dir, cfg.Library.ScanPath, - cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir} + allowedRoots := streamAllowedRoots(cfg) filePath := filepath.Clean(sr.FilePath) // Self-heal a base-path mismatch: the web may hand us a path under an old @@ -792,11 +808,27 @@ func runDaemonStart() error { return } filePath = filepath.Clean(filePath) - if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath, - cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) { - log.Printf("[hls %s] rejected: path outside allowed dirs: %s", - agent.ShortID(sess.SessionID), filePath) - return + // Apply the SAME base-path self-heal remap as the raw /stream handler + // (OnStreamRequest above). Without it, a path under an old/host base + // (e.g. /mnt/nas/peliculas/… handed by the web while this docker agent + // mounts that media at /downloads) is rejected here even though the raw + // path self-heals it — so the web silently falls back to the raw stream + // and HLS/remux never runs (no transcode, slow funnel start). NOTE: this + // replicates only the lexical-remap; the raw handler additionally retries + // os.Stat for transient NFS errors. The HLS dir-check below proceeds (not + // rejects) on a stat error, so it tolerates an NFS blip differently. + // See docs/plans/unarr-path-resilience.md. + hlsAllowedRoots := streamAllowedRoots(cfg) + if !isAllowedStreamPath(filePath, hlsAllowedRoots...) { + if remapped := relocateUnreachable(filePath, hlsAllowedRoots); remapped != "" { + log.Printf("[hls %s] self-heal: remapped %s → %s", + agent.ShortID(sess.SessionID), filePath, remapped) + filePath = remapped + } else { + log.Printf("[hls %s] rejected: path outside allowed dirs: %s", + agent.ShortID(sess.SessionID), filePath) + return + } } // Resolve directory → first video file (matches StreamRequest behavior). if info, err := os.Stat(filePath); err == nil && info.IsDir() { @@ -993,6 +1025,16 @@ func runDaemonStart() error { // the daemon is configured to manage. This defends against a compromised API // server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest. // isAllowedStreamPath reports whether filePath is contained within one of the +// streamAllowedRoots returns the directory roots a stream / sidecar path is +// permitted under. Single source of truth so the raw /stream, HLS, and +// path-scoped (/thumbnail, /trickplay, /sub) handlers never disagree about what +// is reachable — a root added to one place but not the others would otherwise +// produce confusing partial failures (stream plays, scrubber frames 404). +func streamAllowedRoots(cfg config.Config) []string { + return []string{cfg.Download.Dir, cfg.Library.ScanPath, + cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir} +} + // allowedDirs. filePath must already be cleaned (filepath.Clean) by the caller. // This defends against a compromised API server sending a path traversal payload. func isAllowedStreamPath(filePath string, allowedDirs ...string) bool { diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 9b38c3a..6740246 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -120,6 +120,15 @@ type StreamServer struct { // on-demand /thumbnail). Set once before Listen() via SetTrickplayWidth. trickplayWidth int + // resolveMediaPath remaps a web-supplied media path that's unreachable as-is + // (e.g. a host path /mnt/nas/peliculas/… while this docker agent mounts that + // media at /downloads) onto the real on-disk path, mirroring the /stream and + // /hls self-heal. Set by the daemon (which owns the allowed roots + relocate + // logic); nil = identity. Applied AFTER token verification in the path-scoped + // handlers (/thumbnail, /trickplay, /sub) so the token still binds the + // original web path. Read-only after Listen(). + resolveMediaPath func(string) string + lastActivity atomic.Int64 maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) totalFileSize atomic.Int64 @@ -240,6 +249,27 @@ func (ss *StreamServer) SetTrickplayWidth(width int) { ss.trickplayWidth = width } +// SetPathResolver installs the media-path self-heal used by the path-scoped +// handlers (/thumbnail, /trickplay, /sub). fn receives the web-supplied path +// and returns the real on-disk path, or "" when it can't be located under an +// allowed root. nil leaves paths untouched. Call before Listen(). +func (ss *StreamServer) SetPathResolver(fn func(string) string) { + ss.resolveMediaPath = fn +} + +// healMediaPath applies the resolver (if set) to a web-supplied path. Returns +// the path unchanged when no resolver is installed or it found no better path — +// the caller's os.Stat then fails as before, preserving the 404 behaviour. +func (ss *StreamServer) healMediaPath(rawPath string) string { + if ss.resolveMediaPath == nil { + return rawPath + } + if healed := ss.resolveMediaPath(rawPath); healed != "" { + return healed + } + return rawPath +} + // SetCORSAllowedOrigins replaces the operator-supplied extra origins. The // default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev // ports) is always merged in. Call before Listen(). @@ -1032,6 +1062,10 @@ func (ss *StreamServer) thumbnailHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "not found", http.StatusNotFound) return } + // Self-heal a host→container base-path skew AFTER the token bound the original + // web path (mirrors /stream + /hls). On a docker agent the prewarm wrote its + // sidecars next to the real file, so cache lookups must key on the healed path. + rawPath = ss.healMediaPath(rawPath) if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() { http.Error(w, "not found", http.StatusNotFound) return @@ -1133,6 +1167,7 @@ func (ss *StreamServer) trickplayHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "trickplay disabled", http.StatusNotFound) return } + rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail) if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() { http.Error(w, "not found", http.StatusNotFound) return @@ -1200,6 +1235,7 @@ func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request) http.Error(w, "not found", http.StatusNotFound) return } + rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail) if fi, statErr := os.Stat(rawPath); statErr != nil || !fi.Mode().IsRegular() { http.Error(w, "not found", http.StatusNotFound) return diff --git a/internal/engine/stream_server_test.go b/internal/engine/stream_server_test.go index 46f3e8c..1c49487 100644 --- a/internal/engine/stream_server_test.go +++ b/internal/engine/stream_server_test.go @@ -35,6 +35,33 @@ func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekC return &readSeekNopCloser{strings.NewReader(string(f.content))} } +// TestStreamServer_healMediaPath covers the host→container base-path self-heal +// used by the path-scoped handlers (/thumbnail, /trickplay, /sub). +func TestStreamServer_healMediaPath(t *testing.T) { + srv := NewStreamServer(0) + + // No resolver installed → identity (preserves the pre-fix 404 behaviour). + if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/mnt/nas/peliculas/a/b/c.mkv" { + t.Errorf("nil resolver should be identity, got %q", got) + } + + // Resolver locates the file under a current root → use the healed path. + srv.SetPathResolver(func(p string) string { + if p == "/mnt/nas/peliculas/a/b/c.mkv" { + return "/downloads/a/b/c.mkv" + } + return "" + }) + if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/downloads/a/b/c.mkv" { + t.Errorf("resolver remap: got %q want /downloads/a/b/c.mkv", got) + } + + // Resolver can't locate it ("") → keep the original so os.Stat 404s as before. + if got := srv.healMediaPath("/elsewhere/x.mkv"); got != "/elsewhere/x.mkv" { + t.Errorf("unlocatable path should stay unchanged, got %q", got) + } +} + // TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto // y URL() devuelve una URL accesible. func TestStreamServer_Listen_BindsPort(t *testing.T) {