fix(stream): self-heal host→container path skew in HLS + sidecar handlers

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.
This commit is contained in:
Deivid Soto 2026-06-04 22:38:12 +02:00
parent d44f16cae2
commit d97ca11fa5
3 changed files with 112 additions and 7 deletions

View file

@ -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,12 +808,28 @@ func runDaemonStart() error {
return
}
filePath = filepath.Clean(filePath)
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
// 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() {
found := engine.FindVideoFile(filePath)
@ -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 {

View file

@ -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

View file

@ -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) {