diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md new file mode 100644 index 0000000..9709b99 --- /dev/null +++ b/Docs/plans/unarr-agent-roadmap.md @@ -0,0 +1,134 @@ +# unarr CLI agent — roadmap del diferenciador + +> Estado de partida: **v0.9.19 beta** (~26k LOC fuente / ~18k test). +> Objetivo estratégico: el agente CLI es el **soporte real y diferenciador** de +> unarr — un *servidor de streaming personal* que la web sola no puede ser. +> Compite en **profundidad**, no en anchura (no apps nativas por dispositivo: +> el agente sirve a un único web-player responsive vía navegador). + +## La visión en 6 puntos + +1. **Hospeda localmente** toda la biblioteca. +2. **Debrid** para reproducir cualquier cosa cache-fast. +3. **Play-anything sin callejones** (local | debrid | descarga-y-reproduce, con + fallback mid-stream). +4. **Transcodifica según el dispositivo** (direct-play cuando ya es compatible). +5. **Sirve a un web-player universal** en cualquier dispositivo vía navegador. +6. **Acceso remoto seguro** al agente. + +## Mapa de partida (qué TIENE el agente hoy) + +Sólido salvo nota: + +- **Descarga torrent** (anacrolix): mmap, DHT warm-start, 30 trackers, pause/cancel, + selección vídeo+subs `[engine/torrent.go]`. **Stream-while-download** con reader + responsive + `PrioritizeTail` `[engine/stream.go]`. +- **Usenet** completo: NNTP pool, yEnc, ensamblado `WriteAt`, resume por segmento, + par2 repair, unrar/7z `[usenet/*]`. +- **Debrid downloader**: GET con Range/resume `[engine/debrid.go]` — pero solo + DESCARGA (no streaming). Resolución server-side. +- **HLS transcode** fMP4 + seek real + supervisor `[engine/hls.go]`, **caché HLS LRU** + `[engine/hls_cache.go]`, **HW accel** NVENC/QSV/VAAPI/VideoToolbox `[engine/hwaccel.go]`. +- **Servidor HTTP** persistente: range/seek, rate-limit 2×bitrate, CORS `[engine/stream_server.go]`. +- **Library scan + ffprobe** (codec/HDR/tracks), parse título/temporada `[library/, mediainfo/]`. +- **Red**: CloudFlare Quick Tunnel `[funnel/]`, WireGuard userspace split-tunnel `[vpn/]`, + NAT-PMP + UPnP `[engine/upnp.go]`. Web hace de broker de URLs (LAN/Tailscale/Public/Funnel). +- **Agente**: daemon cobra, sync HTTP long-poll + `/wake`, auto-upgrade opt-in, + config.toml exhaustivo. + +## Huecos (de más crítico a más bajo) + +### Hueco #1 — Auth de stream ✅ CERRADO (2026-05-31) / ver estado abajo +`/stream` y `/hls` se sirven **sin autenticación** (solo CORS+rate-limit). Con +funnel/UPnP el stream queda público en internet. Plan previo +`Docs/plans/security-stream-token.md` (deferido, sin código). + +### Hueco #2 — Debrid en el path de streaming ⬜ +Hoy debrid es **solo descarga**, resuelto server-side; el streaming es 100% +torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debrid +en el path de streaming + cache-availability + **fallback torrent↔debrid mid-stream**. + +### Hueco #3 — Device-profile + direct-play + ABR ⬜ +El path HLS **siempre re-encoda** (incluso mp4 h264/aac ya compatible). `DecideAction` +(passthrough/remux) existe pero muerto en el path browser. Sin negociación por +capacidades del dispositivo. Sin ABR multi-bitrate. + +### Huecos medios ⬜ +- Sin gestión de espacio en disco (`Statfs`) → disco lleno revienta a mitad. +- Resume de torrent NO persiste reinicio del daemon (usenet sí). +- Sin seeding/ratio lifecycle (flags existen, nadie los aplica). +- Reproducir-mientras-baja: readahead estático 5MB, sin playhead→prioridad dinámica. +- HDR→SDR sin tonemap (zscale/zimg) → HDR desaturado. +- Sin thumbnails/sprites/trickplay. +- Subtítulos bitmap (PGS/DVB) sin burn-in. +- Audio siempre downmix estéreo AAC (sin passthrough 5.1). +- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia. +- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS). +- Funnel = SPOF CloudFlare (rota ~6h), sin relay propio. +- "Tailscale Funnel" mal nombrado (no usa tsnet/Funnel real). +- Dos clientes HTTP divergentes (go-client vs agent client). +- Long-poll en vez de WS/SSE. + +### Deuda puntual +`makeReadable` parchea mmap 0000 (frágil NFS) · par2/unrar degradan en silencio si +falta binario · VAAPI workarounds por host · cloudflared sin verificación de firma · +WireGuard endpoint sin pin · sesión única (1 viewer). + +## Mejoras detectadas durante el trabajo (backlog) + +> Se rellena a medida que se trabaja cada hueco. Cada entrada: qué, por qué, prioridad. + +- **Clock-skew en verificación de token** (baja): `verifyStreamToken` no tolera skew; con TTL 6h y NTP es irrelevante, pero el HLS lo mintea el web y lo verifica el agente (relojes distintos). Considerar ~60s de gracia si aparecen 404 espurios. +- **Secreto de stream en claro en DB** (baja): `agent_registration.stream_secret` es una clave HMAC viva (por arranque) en la DB central; quien lea la DB puede mintear tokens HLS de cualquier agente. Inherente al diseño (el web debe mintear HLS). Mitigado por regeneración por arranque. Excluir esta columna de cualquier JSON admin/usuario. +- **Refrescar/limpiar streamUrl al re-registrar** (baja): tras reinicio del daemon el secreto cambia; URLs `?t=` ya guardadas en `download_task.streamUrl` quedan stale hasta re-stream. Es auto-curativo, pero el web podría limpiar streamUrl en el re-register del agente. +- **gofmt preexistente** en `internal/agent/types.go` (StreamSession) y `hls.go`/`torrent.go`/`stream_source.go` (no introducido por este trabajo) — chore aparte. +- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente. + +--- + +## ESTADO POR HUECO + +### Hueco #1 — Auth de stream +**Estado:** 🟡 en progreso (iniciado 2026-05-31). + +**Enfoque elegido** (mejora sobre el plan previo, menor blast radius — sin migración DB): +token **HMAC stateless minteado por el propio agente**. El agente ya construye las +stream URLs que reporta a la web (`daemon.go` → `streamSrv.URLsJSON()`), así que +puede firmar el token, embeberlo en la URL, y verificarlo en cada request — la web +es passthrough (cambio web ~nulo). + +- Secreto: 32 bytes random en memoria del daemon (rota al reiniciar). +- Token: `.`, TTL 6h. +- `/stream` + VLC: token en query `?t=`; scope `"stream"`. +- `/hls`: token en **path** `/hls///`; scope `"hls:"`. + Los URIs hijos de los playlists son **relativos** → el token se propaga solo a + segmentos/subs sin reescribir playlists. +- **Loopback exento** (mpv/vlc local + health-probe siguen funcionando; el token solo + gatea acceso remoto LAN/Tailscale/Public/funnel). +- Config `require_stream_token` (default **true**, seguro por defecto). + +**Hecho (CERRADO 2026-05-31):** + +CLI (`torrentclaw-cli`): +- `internal/engine/stream_token.go` (nuevo): `mintStreamToken`/`verifyStreamToken` (HMAC-SHA256, constant-time), `newStreamSecret` (32 bytes; **fail-hard** si crypto/rand falla, sin fallback débil). +- `internal/engine/stream_server.go`: secreto + `requireToken` en StreamServer; `/stream` y `/hls` verifican el token; `URLsJSON`/`hlsBaseURLs`/`URL()` tokenizan; `StreamSecretHex()`; **sin exención de loopback**; `/playlist.m3u` ya no auto-mintea (cerrado el oracle). +- `internal/config/config.go`: `require_stream_token` (default true). +- `internal/agent/{types,daemon}.go` + `internal/cmd/daemon.go`: el agente reporta el secreto en register **solo si enforcing**. +- Tests: `stream_token_test.go` (mint/verify/expiry/tamper/scope/secret, handler /stream + /hls, **vector de paridad cross-lenguaje**). + +WEB (`torrentclaw-web`): +- `src/lib/stream-token.ts` (nuevo): minter HMAC en TS (paridad byte a byte con Go, guard de clave 64-hex). +- `src/app/api/internal/stream/session/route.ts`: `buildHlsUrls` inyecta el token de path usando el secreto del agente. +- `src/lib/db/schema.ts` + migración `0134_grey_chat.sql`: columna `agent_registration.stream_secret` (ADD COLUMN nullable, segura). +- `src/app/api/internal/agent/register/route.ts` + `src/lib/services/agent.ts`: valida (64-hex) + persiste + expone en `getAgentHealth`. +- Tests: `tests/unit/stream-token.test.ts` (paridad + guard). + +**Revisión adversarial** (workflow 4 dimensiones) → 1 crítico + 3 high corregidos antes de cerrar: +- **CRÍTICO**: la exención de loopback dejaba el **funnel CloudFlare** sin protección (cloudflared proxya tráfico público vía `localhost` → todo el funnel llegaba como loopback). **Fix: eliminada la exención.** Toda URL entregada ya va tokenizada, así que ningún cliente legítimo se rompe; el funnel ahora lleva el token en la URL y verifica. +- **HIGH** `/playlist.m3u` era oracle de tokens (fallback self-minting) → **fix: 404 sin streamUrl**. +- **HIGH** gate de version-skew mal señalizado (el agente reportaba el secreto aunque enforcement=off) → **fix: reportar solo si enforcing**. +- **HIGH** new-agent+old-web rompe HLS remoto → **mitigación por orden de deploy (ver abajo)**, sin tolerar tokenless (no reabrir el agujero). + +**Verificación:** CLI `go build/vet/test ./...` ✓; WEB typecheck+lint+2325 unit ✓; paridad cross-lenguaje verificada en ambos sentidos. + +> ⚠️ **ORDEN DE DEPLOY (obligatorio):** desplegar **primero el WEB** (columna `stream_secret` + minteo HLS), **luego** publicar el binario del agente. Un agente nuevo (enforce por defecto) contra un web viejo (sin minteo HLS) rompería el HLS remoto. El web es retrocompatible (agente viejo sin secreto → URLs sin token). Smoke real de extremo a extremo (daemon + funnel + navegador) **pendiente de hacer con un agente desplegado** — los tests cubren mint/verify/handlers y la paridad, no el round-trip cloudflared en vivo. diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index f7994fb..1f22171 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -22,6 +22,7 @@ type DaemonConfig struct { Version string DownloadDir string StreamPort int // port for the HTTP stream server + StreamSecret string // hex HMAC key for stream tokens (reported so the web can mint HLS tokens) LanIP string // LAN IP (reported in sync for stream URL resolution) TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution) CanDelete bool // library.allow_delete is enabled @@ -109,6 +110,13 @@ func (d *Daemon) SetFunnelURL(url string) { WriteState(&d.State) } +// UpdateStreamSecret sets the hex HMAC key reported on register so the web can +// mint HLS stream tokens the agent will accept. +func (d *Daemon) UpdateStreamSecret(secretHex string) { + d.cfg.StreamSecret = secretHex + d.sync.cfg.StreamSecret = secretHex +} + // UpdateStreamPort updates the stream port reported in sync requests. func (d *Daemon) UpdateStreamPort(port int) { d.cfg.StreamPort = port @@ -126,6 +134,7 @@ func (d *Daemon) Register(ctx context.Context) error { Version: d.cfg.Version, DownloadDir: d.cfg.DownloadDir, StreamPort: d.cfg.StreamPort, + StreamSecret: d.cfg.StreamSecret, LanIP: d.cfg.LanIP, TailscaleIP: d.cfg.TailscaleIP, HWAccel: d.cfg.HWAccel, diff --git a/internal/agent/types.go b/internal/agent/types.go index ae87bb6..d45b1b3 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -18,6 +18,11 @@ type RegisterRequest struct { StreamPort int `json:"streamPort,omitempty"` LanIP string `json:"lanIp,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"` + // StreamSecret is the daemon's per-run HMAC key (hex) for stream tokens. The + // web mints the HLS path token with it (the agent mints /stream tokens on its + // own URLs); the agent verifies both. In memory, regenerated each start, so a + // fresh register after restart re-syncs it. + StreamSecret string `json:"streamSecret,omitempty"` // Transcode capabilities — let the web side suggest a smarter quality // before the player even starts. HWAccel is the picked backend // ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 425cee0..a373d7b 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -310,6 +310,14 @@ func runDaemonStart() error { // Create persistent stream server streamSrv := engine.NewStreamServer(cfg.Download.StreamPort) streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP) + 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 + // verifies tokens". Reporting it with enforcement off would make the web + // mint HLS path tokens the agent never peels → 404. Set before Register(). + if cfg.Download.RequireStreamToken { + d.UpdateStreamSecret(streamSrv.StreamSecretHex()) + } // CORS extras = operator config + dynamic mirror list from /api/mirrors. // Without the mirror merge, a user playing from `torrentclaw.to` (or any // future mirror) hits the daemon, gets 200 + body, but no diff --git a/internal/config/config.go b/internal/config/config.go index dd406a6..0aec611 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,22 +39,27 @@ type AgentConfig struct { } type DownloadConfig struct { - Dir string `toml:"dir"` - PreferredMethod string `toml:"preferred_method"` - PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection - MaxConcurrent int `toml:"max_concurrent"` - MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited - MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited - MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") - StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") - ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) - StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) - EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet) - CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030) - Transcode TranscodeConfig `toml:"transcode"` - HLSCache HLSCacheConfig `toml:"hls_cache"` - VPN VPNConfig `toml:"vpn"` - Funnel FunnelConfig `toml:"funnel"` + Dir string `toml:"dir"` + PreferredMethod string `toml:"preferred_method"` + PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection + MaxConcurrent int `toml:"max_concurrent"` + MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited + MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited + MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") + StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") + ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) + StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) + EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in) + // RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a + // signed, short-lived token embedded in the URLs the agent reports. Default + // true (secure by default); loopback callers (local mpv/vlc) are always exempt. + // Set false only to debug a player that can't carry the token. + RequireStreamToken bool `toml:"require_stream_token"` + CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030) + Transcode TranscodeConfig `toml:"transcode"` + HLSCache HLSCacheConfig `toml:"hls_cache"` + VPN VPNConfig `toml:"vpn"` + Funnel FunnelConfig `toml:"funnel"` } // HLSCacheConfig controls the persistent HLS segment cache. A completed encode @@ -63,9 +68,9 @@ type DownloadConfig struct { // size budget. Enabled by default — disable to save disk space at the cost of // re-encoding every play. type HLSCacheConfig struct { - Enabled bool `toml:"enabled"` // default: true - SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1 - Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache + Enabled bool `toml:"enabled"` // default: true + SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1 + Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache } // FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the @@ -188,12 +193,13 @@ func Default() Config { }, }, Download: DownloadConfig{ - PreferredMethod: "auto", - MaxConcurrent: 3, - StreamPort: 11818, + PreferredMethod: "auto", + MaxConcurrent: 3, + StreamPort: 11818, + RequireStreamToken: true, // secure by default; loopback exempt Transcode: TranscodeConfig{ - Enabled: true, - HWAccel: "auto", + Enabled: true, + HWAccel: "auto", // Empty preset → engine.ResolveEncoderProfile picks the // latency-biased default ("superfast" on libx264). Override // in config.toml when quality > first-start latency matters. diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 2e182fd..a5440e6 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -2,6 +2,7 @@ package engine import ( "context" + "encoding/hex" "encoding/json" "fmt" "io" @@ -65,6 +66,12 @@ type StreamServer struct { hls *HLSSessionRegistry // HLS sessions served on /hls//... + // streamSecret signs the per-URL stream tokens (see stream_token.go). In + // memory only; regenerated each daemon start. requireToken gates whether + // remote (non-loopback) /stream and /hls requests must carry a valid token. + streamSecret []byte + requireToken bool + lastActivity atomic.Int64 maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) totalFileSize atomic.Int64 @@ -83,7 +90,37 @@ type StreamServer struct { // have no auth, so exposing them to the public internet is something the // operator must explicitly request. func NewStreamServer(port int) *StreamServer { - return &StreamServer{port: port, hls: NewHLSSessionRegistry()} + return &StreamServer{ + port: port, + hls: NewHLSSessionRegistry(), + streamSecret: newStreamSecret(), + requireToken: true, // secure by default; the agent self-mints tokens + } +} + +// StreamSecretHex returns the daemon's stream-token signing key as hex, so it +// can be reported to the web (which mints the HLS path token the agent then +// verifies). Treat as a secret — it lets the holder mint valid stream tokens. +func (ss *StreamServer) StreamSecretHex() string { + return hex.EncodeToString(ss.streamSecret) +} + +// SetRequireStreamToken toggles remote stream-token enforcement. Loopback +// callers are always exempt. Call before Listen() / before reporting URLs. +// Default is true; an operator can disable it via config for debugging. +func (ss *StreamServer) SetRequireStreamToken(require bool) { + ss.requireToken = require +} + +// checkStreamToken reports whether a request may proceed: always true when +// enforcement is off; otherwise the token must be a valid signature for scope. +// No loopback exemption — cloudflared relays public funnel traffic over +// localhost, so loopback is not a trust signal. +func (ss *StreamServer) checkStreamToken(scope, token string) bool { + if !ss.requireToken { + return true + } + return verifyStreamToken(ss.streamSecret, scope, token, time.Now()) } // SetUPnPEnabled toggles WAN publishing of the stream port. Call before @@ -286,14 +323,47 @@ func (ss *StreamServer) HasFile() bool { } // URL returns the best single stream URL (backward compat). -func (ss *StreamServer) URL() string { return ss.url } +// URL returns the best single /stream URL, carrying a `?t=` token when +// enforcement is on. This is what the one-shot `unarr stream` hands to the +// player — and since the best URL is the Tailscale/LAN address (not loopback), +// it must be tokenised or a remote-addressed player would be rejected. +func (ss *StreamServer) URL() string { return ss.tokenizeStreamURL(ss.url) } -// URLsJSON returns all available stream URLs as a JSON string. +// tokenizeStreamURL appends a freshly-minted `?t=` (scope "stream") to a +// /stream URL. No-op when the URL is empty or enforcement is off. +func (ss *StreamServer) tokenizeStreamURL(u string) string { + if u == "" || !ss.requireToken { + return u + } + sep := "?" + if strings.Contains(u, "?") { + sep = "&" + } + return u + sep + "t=" + mintStreamToken(ss.streamSecret, streamScopeStream, time.Now()) +} + +// URLsJSON returns all available stream URLs as a JSON string, each carrying a +// freshly-minted `?t=` stream token when enforcement is on. The web reports +// these verbatim to the browser (pass-through), so the token reaches the +// player without any web-side minting. func (ss *StreamServer) URLsJSON() string { - b, _ := json.Marshal(ss.urls) + b, _ := json.Marshal(ss.tokenizedStreamURLs()) return string(b) } +// tokenizedStreamURLs appends a `?t=` (scope "stream") to each non-empty +// /stream URL. No-op when enforcement is off. +func (ss *StreamServer) tokenizedStreamURLs() StreamURLs { + if !ss.requireToken { + return ss.urls + } + return StreamURLs{ + LAN: ss.tokenizeStreamURL(ss.urls.LAN), + Tailscale: ss.tokenizeStreamURL(ss.urls.Tailscale), + Public: ss.tokenizeStreamURL(ss.urls.Public), + } +} + // Port returns the bound port. func (ss *StreamServer) Port() int { return ss.port } @@ -323,15 +393,21 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error { // The web client picks the first reachable one — same fallback strategy as // the legacy /stream URLs. func (ss *StreamServer) hlsBaseURLs(sessionID string) StreamURLs { + // Token rides as a path segment so the playlists' relative child URIs + // (video/index.m3u8, seg-N.m4s, subs/…) inherit it via relative resolution. + base := "/hls/" + sessionID + if ss.requireToken { + base += "/" + mintStreamToken(ss.streamSecret, streamScopeHLS(sessionID), time.Now()) + } var out StreamURLs if ss.urls.LAN != "" { - out.LAN = strings.Replace(ss.urls.LAN, "/stream", "/hls/"+sessionID, 1) + out.LAN = strings.Replace(ss.urls.LAN, "/stream", base, 1) } if ss.urls.Tailscale != "" { - out.Tailscale = strings.Replace(ss.urls.Tailscale, "/stream", "/hls/"+sessionID, 1) + out.Tailscale = strings.Replace(ss.urls.Tailscale, "/stream", base, 1) } if ss.urls.Public != "" { - out.Public = strings.Replace(ss.urls.Public, "/stream", "/hls/"+sessionID, 1) + out.Public = strings.Replace(ss.urls.Public, "/stream", base, 1) } return out } @@ -374,16 +450,36 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "hls session not found", http.StatusNotFound) return } + remainder := "" + if len(parts) > 1 { + remainder = parts[1] + } + // Auth: when enforcement is on, the URL is /hls///. + // Peel the token segment and verify it (no loopback exemption — funnel + // traffic arrives over localhost). 404 on mismatch — same response as an + // unknown session, no oracle. + if ss.requireToken { + sub := strings.SplitN(remainder, "/", 2) + if !verifyStreamToken(ss.streamSecret, streamScopeHLS(sessionID), sub[0], time.Now()) { + http.Error(w, "hls session not found", http.StatusNotFound) + return + } + if len(sub) < 2 { + http.Error(w, "missing resource", http.StatusNotFound) + return + } + remainder = sub[1] + } session := ss.hls.Get(sessionID) if session == nil { http.Error(w, "hls session not found", http.StatusNotFound) return } - if len(parts) == 1 { + if remainder == "" { http.Error(w, "missing resource", http.StatusNotFound) return } - resource := parts[1] + resource := remainder switch { case resource == "master.m3u8": @@ -539,9 +635,11 @@ func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) streamURL = "" } if streamURL == "" { - streamURL = ss.url - } - if streamURL == "" { + // No self-minting fallback: returning a freshly-tokenised URL for a + // param-less request would make /playlist.m3u an open token oracle + // (any caller could fetch a valid /stream?t=… here). The web always + // passes an already-tokenised streamUrl param; the playlist just echoes + // it — the real auth gate is /stream itself. http.Error(w, "no active stream", http.StatusNotFound) return } @@ -591,6 +689,14 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { return } + // Auth: every caller must carry a valid stream token. 404 (not 401/403) so + // an unauthorised caller gets no oracle that a stream is active here. + if !ss.checkStreamToken(streamScopeStream, r.URL.Query().Get("t")) { + log.Printf("[stream] rejected %s — bad/absent token", clientIP) + http.Error(w, "no active stream", http.StatusNotFound) + return + } + rawReader := provider.NewFileReader(r.Context()) if rawReader == nil { http.Error(w, "file not found", http.StatusNotFound) diff --git a/internal/engine/stream_token.go b/internal/engine/stream_token.go new file mode 100644 index 0000000..e40f9b6 --- /dev/null +++ b/internal/engine/stream_token.go @@ -0,0 +1,94 @@ +package engine + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "strconv" + "strings" + "time" +) + +// Stream authentication. +// +// /stream and /hls have no header-based auth: a