Compare commits

..

2 commits

Author SHA1 Message Date
Deivid Soto
0f8e0fec53 docs(roadmap): design hueco #2 (debrid in the streaming path) 2026-05-31 01:22:35 +02:00
Deivid Soto
444d7e63fd feat(stream): authenticate /stream and /hls with signed tokens
/stream and /hls were served with no auth (only CORS + rate limit), so a
funnel- or UPnP-exposed daemon leaked active downloads to anyone with the URL.

Bind a short-lived HMAC token (scope + 6h expiry) to every stream URL the
daemon hands out and verify it on each request:
- /stream + VLC playlist: ?t= query, agent-minted, scope "stream"
- /hls: path segment /hls/<session>/<token>/<resource>, web-minted with the
  agent's reported secret, scope "hls:<session>" — relative playlist URIs
  inherit it with no rewriting
- NO loopback exemption: cloudflared relays public funnel traffic over
  localhost, so a loopback source address is not a trust signal
- the agent reports its per-run signing key on register only when enforcing
- require_stream_token config (default true); secret fails hard if rand fails
- /playlist.m3u no longer self-mints a token (was an open token oracle)

Roadmap: Docs/plans/unarr-agent-roadmap.md (hueco #1).
Deploy the web HLS-minting change BEFORE shipping this agent release.
2026-05-31 01:19:14 +02:00
8 changed files with 684 additions and 36 deletions

View file

@ -0,0 +1,196 @@
# 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 🔵 DISEÑADO (ver estado abajo)
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**.
Diseño por fases (2a direct-play / 2b HLS-desde-URL / 2c fallback) en el estado abajo.
### 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: `<expUnix>.<hexHMAC(secret, scope:exp)>`, TTL 6h.
- `/stream` + VLC: token en query `?t=`; scope `"stream"`.
- `/hls`: token en **path** `/hls/<sessionID>/<token>/<resource>`; scope `"hls:<sessionID>"`.
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.
---
### Hueco #2 — Debrid en el path de streaming
**Estado:** 🔵 DISEÑADO (2026-05-31), listo para implementar en sesión fresca.
**Problema (confirmado en el análisis):** hoy `debrid` es **solo descarga**
(`engine/debrid.go` baja la `DirectURL` HTTPS resuelta server-side). El
streaming es **100% torrent**: `daemon.OnStreamSession` arma el provider desde
`sess.FilePath`/`sess.InfoHash`/`sess.TaskID` y `StreamSession` **no lleva
DirectURL**. La promesa "play instantáneo cache-fast por debrid" no ocurre.
**Arquitectura de providers (lo que ya hay):** `FileProvider{ NewFileReader(ctx)
io.ReadSeekCloser; FileName(); FileSize() }`. Implementaciones: `torrentFileProvider`,
`diskFileProvider`, `StreamEngine`. El /stream sirve un `FileProvider` via
`http.ServeContent` (range/seek). El HLS arranca una `HLSSession` desde una ruta
de fichero (ffmpeg `-i <path>`).
**Diseño por fases (de menos a más riesgo):**
- **Fase 2a — debrid como fuente de /stream (direct-play).** *Slice completo y
acotado.*
1. Añadir `DirectURL string` a `StreamSession` (web→agente) y a su validación.
2. Nuevo `debridFileProvider` (`FileProvider`): `NewFileReader` devuelve un
`io.ReadSeekCloser` que hace **GET con Range** contra la `DirectURL` (debrid
ya soporta Range, ver `debrid.go`); `FileSize` via HEAD o `sess.FileSize`;
`Seek` traducido a `Range:`. Reutilizar la lógica de `debrid.go` (416,
Content-Range, reintentos).
3. En `OnStreamSession`: si `sess.DirectURL` presente → `debridFileProvider`
`SetFile`. (Direct-play; el navegador hace range sobre el provider.)
4. Web: al crear la sesión de stream, si el contenido está **cacheado en
debrid**, resolver la `DirectURL` server-side (como en descargas) e incluirla
en el `StreamSession`. Señal de cache: `debridCacheStatus` fresh (ya existe).
5. Tests: `debridFileProvider` con un httptest server que sirve Range; round-trip
/stream con provider debrid.
- **Fase 2b — HLS desde URL (transcode de fuentes debrid no-compatibles).**
ffmpeg lee HTTP directo (`-i https://…`), así que `HLSSession` puede aceptar
una URL como source en vez de una ruta. Mayor cambio en el pipeline HLS
(timeouts, reintentos de red, headers). Permite transcodear contenido debrid.
- **Fase 2c — selección cache-fast + fallback mid-stream ("sin callejones").**
- Conciencia de cache en el agente o señal del web para **preferir debrid
cacheado sobre torrent** cuando aplique (hoy `resolve.go:22` pone torrent
primero).
- **Fallback mid-stream**: si la fuente activa muere (peers a 0 / 5xx debrid),
cambiar a la otra sin cortar la reproducción. Complejo (estado de sesión,
re-seek). Es lo que de verdad cierra "play-anything sin callejones".
**Ficheros a tocar:** CLI `internal/engine/{stream_server.go (provider), debrid.go,
hls.go (2b)}`, `internal/agent/types.go` (+DirectURL), `internal/cmd/daemon.go`
(wiring). WEB `src/app/api/internal/stream/session/route.ts` (resolver DirectURL +
cache), `src/lib/services/agent.ts`.
**Partes difíciles / riesgos:** ranged reader robusto sobre HTTP (reconexión,
timeouts), HLS-desde-URL (red dentro de ffmpeg), y el fallback mid-stream (estado).
Empezar por 2a (valor inmediato, riesgo bajo), 2b y 2c como iteraciones.
**Mejora detectada:** `resolve.go:22` ordena `torrent > debrid > usenet`; para el
diferenciador cache-fast convendría que, **cuando hay cache debrid confirmada**,
el orden de STREAMING (no el de descarga) prefiera debrid.

View file

@ -22,6 +22,7 @@ type DaemonConfig struct {
Version string Version string
DownloadDir string DownloadDir string
StreamPort int // port for the HTTP stream server 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) LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale 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 CanDelete bool // library.allow_delete is enabled
@ -109,6 +110,13 @@ func (d *Daemon) SetFunnelURL(url string) {
WriteState(&d.State) 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. // UpdateStreamPort updates the stream port reported in sync requests.
func (d *Daemon) UpdateStreamPort(port int) { func (d *Daemon) UpdateStreamPort(port int) {
d.cfg.StreamPort = port d.cfg.StreamPort = port
@ -126,6 +134,7 @@ func (d *Daemon) Register(ctx context.Context) error {
Version: d.cfg.Version, Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir, DownloadDir: d.cfg.DownloadDir,
StreamPort: d.cfg.StreamPort, StreamPort: d.cfg.StreamPort,
StreamSecret: d.cfg.StreamSecret,
LanIP: d.cfg.LanIP, LanIP: d.cfg.LanIP,
TailscaleIP: d.cfg.TailscaleIP, TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel, HWAccel: d.cfg.HWAccel,

View file

@ -18,6 +18,11 @@ type RegisterRequest struct {
StreamPort int `json:"streamPort,omitempty"` StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"` LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,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 // Transcode capabilities — let the web side suggest a smarter quality
// before the player even starts. HWAccel is the picked backend // before the player even starts. HWAccel is the picked backend
// ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is // ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is

View file

@ -310,6 +310,14 @@ func runDaemonStart() error {
// Create persistent stream server // Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort) streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP) 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. // CORS extras = operator config + dynamic mirror list from /api/mirrors.
// Without the mirror merge, a user playing from `torrentclaw.to` (or any // Without the mirror merge, a user playing from `torrentclaw.to` (or any
// future mirror) hits the daemon, gets 200 + body, but no // future mirror) hits the daemon, gets 200 + body, but no

View file

@ -39,22 +39,27 @@ type AgentConfig struct {
} }
type DownloadConfig struct { type DownloadConfig struct {
Dir string `toml:"dir"` Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"` PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"` MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "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") 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") 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) 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) 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) EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030) // RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a
Transcode TranscodeConfig `toml:"transcode"` // signed, short-lived token embedded in the URLs the agent reports. Default
HLSCache HLSCacheConfig `toml:"hls_cache"` // true (secure by default); loopback callers (local mpv/vlc) are always exempt.
VPN VPNConfig `toml:"vpn"` // Set false only to debug a player that can't carry the token.
Funnel FunnelConfig `toml:"funnel"` 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 // 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 // size budget. Enabled by default — disable to save disk space at the cost of
// re-encoding every play. // re-encoding every play.
type HLSCacheConfig struct { type HLSCacheConfig struct {
Enabled bool `toml:"enabled"` // default: true Enabled bool `toml:"enabled"` // default: true
SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1 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 Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache
} }
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the // FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
@ -188,12 +193,13 @@ func Default() Config {
}, },
}, },
Download: DownloadConfig{ Download: DownloadConfig{
PreferredMethod: "auto", PreferredMethod: "auto",
MaxConcurrent: 3, MaxConcurrent: 3,
StreamPort: 11818, StreamPort: 11818,
RequireStreamToken: true, // secure by default; loopback exempt
Transcode: TranscodeConfig{ Transcode: TranscodeConfig{
Enabled: true, Enabled: true,
HWAccel: "auto", HWAccel: "auto",
// Empty preset → engine.ResolveEncoderProfile picks the // Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override // latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters. // in config.toml when quality > first-start latency matters.

View file

@ -2,6 +2,7 @@ package engine
import ( import (
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -65,6 +66,12 @@ type StreamServer struct {
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/... hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
// 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 lastActivity atomic.Int64
maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
totalFileSize atomic.Int64 totalFileSize atomic.Int64
@ -83,7 +90,37 @@ type StreamServer struct {
// have no auth, so exposing them to the public internet is something the // have no auth, so exposing them to the public internet is something the
// operator must explicitly request. // operator must explicitly request.
func NewStreamServer(port int) *StreamServer { 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 // 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). // 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=<token>` (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 { func (ss *StreamServer) URLsJSON() string {
b, _ := json.Marshal(ss.urls) b, _ := json.Marshal(ss.tokenizedStreamURLs())
return string(b) return string(b)
} }
// tokenizedStreamURLs appends a `?t=<token>` (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. // Port returns the bound port.
func (ss *StreamServer) Port() int { return ss.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 web client picks the first reachable one — same fallback strategy as
// the legacy /stream URLs. // the legacy /stream URLs.
func (ss *StreamServer) hlsBaseURLs(sessionID string) StreamURLs { 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 var out StreamURLs
if ss.urls.LAN != "" { 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 != "" { 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 != "" { 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 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) http.Error(w, "hls session not found", http.StatusNotFound)
return return
} }
remainder := ""
if len(parts) > 1 {
remainder = parts[1]
}
// Auth: when enforcement is on, the URL is /hls/<sessionID>/<token>/<resource>.
// 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) session := ss.hls.Get(sessionID)
if session == nil { if session == nil {
http.Error(w, "hls session not found", http.StatusNotFound) http.Error(w, "hls session not found", http.StatusNotFound)
return return
} }
if len(parts) == 1 { if remainder == "" {
http.Error(w, "missing resource", http.StatusNotFound) http.Error(w, "missing resource", http.StatusNotFound)
return return
} }
resource := parts[1] resource := remainder
switch { switch {
case resource == "master.m3u8": case resource == "master.m3u8":
@ -539,9 +635,11 @@ func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request)
streamURL = "" streamURL = ""
} }
if streamURL == "" { if streamURL == "" {
streamURL = ss.url // No self-minting fallback: returning a freshly-tokenised URL for a
} // param-less request would make /playlist.m3u an open token oracle
if streamURL == "" { // (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) http.Error(w, "no active stream", http.StatusNotFound)
return return
} }
@ -591,6 +689,14 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
return 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()) rawReader := provider.NewFileReader(r.Context())
if rawReader == nil { if rawReader == nil {
http.Error(w, "file not found", http.StatusNotFound) http.Error(w, "file not found", http.StatusNotFound)

View file

@ -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 <video src> cannot attach an
// Authorization header, and media-tag/segment requests are issued by the
// browser, not our JS. So we bind a short-lived, unforgeable token to each
// stream URL the daemon hands out and verify it on every request.
//
// The token is HMAC-signed by the daemon's own in-memory secret — there is no
// server-side token store and no DB column. The web is a pure pass-through: it
// stores and serves whatever tokenised URL the agent reports.
//
// - /stream (+ VLC playlist): token rides as a `?t=` query parameter.
// - /hls: token rides as a PATH segment — /hls/<sessionID>/<token>/<resource>
// — so the relative child URIs inside the playlists (video/index.m3u8,
// seg-N.m4s, subs/…) resolve under the same prefix and carry the token
// automatically, with zero playlist rewriting.
//
// There is NO loopback exemption: the Cloudflare funnel proxies public traffic
// to the daemon over localhost (cloudflared --url http://localhost:<port>), so
// a loopback source address is NOT a trust signal — exempting it would leave the
// funnel (the headline public path) wide open. Every URL the agent/web hands a
// player is already tokenised (URL(), URLsJSON, buildHlsUrls), so enforcing the
// token unconditionally breaks no legitimate client. /health stays ungated (a
// reachability probe that leaks nothing sensitive).
const (
// streamTokenTTL is how long a minted token stays valid. Long enough for a
// movie plus pauses; short enough that a leaked URL stops working same-day.
streamTokenTTL = 6 * time.Hour
// streamScopeStream is the token scope for the single-file /stream endpoint.
streamScopeStream = "stream"
)
// streamScopeHLS is the token scope for an HLS session. Binding to the session
// id means a token minted for one session never validates another.
func streamScopeHLS(sessionID string) string { return "hls:" + sessionID }
// newStreamSecret returns 32 cryptographically-random bytes used to sign stream
// tokens for the lifetime of the daemon. Regenerated each start, so tokens from
// a previous run stop validating (the web re-resolves the URL on demand).
func newStreamSecret() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand.Read does not fail on supported platforms. If it ever
// does, fail hard rather than fall back to a predictable key while still
// claiming to enforce auth — a guessable key is worse than no streaming.
panic("unarr: crypto/rand unavailable, cannot generate stream secret: " + err.Error())
}
return b
}
// mintStreamToken issues `<expUnix>.<hexHMAC>` binding scope to an expiry.
// Verification needs only the same secret + scope.
func mintStreamToken(secret []byte, scope string, now time.Time) string {
expStr := strconv.FormatInt(now.Add(streamTokenTTL).Unix(), 10)
return expStr + "." + streamTokenMAC(secret, scope, expStr)
}
func streamTokenMAC(secret []byte, scope, expStr string) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte(scope + ":" + expStr))
return hex.EncodeToString(m.Sum(nil))
}
// verifyStreamToken reports whether token is a valid, unexpired signature for
// scope under secret. Cheap rejects (format, expiry) happen before the
// constant-time MAC compare since they don't depend on the secret.
func verifyStreamToken(secret []byte, scope, token string, now time.Time) bool {
dot := strings.IndexByte(token, '.')
if dot <= 0 || dot >= len(token)-1 {
return false
}
expStr, gotMAC := token[:dot], token[dot+1:]
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil || now.Unix() > exp {
return false
}
wantMAC := streamTokenMAC(secret, scope, expStr)
return subtle.ConstantTimeCompare([]byte(gotMAC), []byte(wantMAC)) == 1
}

View file

@ -0,0 +1,224 @@
package engine
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestStreamToken_RoundTrip(t *testing.T) {
secret := newStreamSecret()
now := time.Now()
tok := mintStreamToken(secret, streamScopeStream, now)
if !verifyStreamToken(secret, streamScopeStream, tok, now) {
t.Fatalf("freshly minted token failed to verify: %q", tok)
}
// Still valid just before expiry.
if !verifyStreamToken(secret, streamScopeStream, tok, now.Add(streamTokenTTL-time.Minute)) {
t.Error("token rejected before its TTL elapsed")
}
}
func TestStreamToken_Expired(t *testing.T) {
secret := newStreamSecret()
now := time.Now()
tok := mintStreamToken(secret, streamScopeStream, now)
if verifyStreamToken(secret, streamScopeStream, tok, now.Add(streamTokenTTL+time.Second)) {
t.Error("expired token verified as valid")
}
}
func TestStreamToken_WrongScope(t *testing.T) {
secret := newStreamSecret()
now := time.Now()
tok := mintStreamToken(secret, streamScopeHLS("abc"), now)
if verifyStreamToken(secret, streamScopeStream, tok, now) {
t.Error("token for one scope verified under another")
}
if verifyStreamToken(secret, streamScopeHLS("xyz"), tok, now) {
t.Error("hls token verified for a different session id")
}
if !verifyStreamToken(secret, streamScopeHLS("abc"), tok, now) {
t.Error("hls token failed to verify under its own session id")
}
}
func TestStreamToken_WrongSecret(t *testing.T) {
now := time.Now()
tok := mintStreamToken(newStreamSecret(), streamScopeStream, now)
if verifyStreamToken(newStreamSecret(), streamScopeStream, tok, now) {
t.Error("token verified under a different secret")
}
}
func TestStreamToken_Tampered(t *testing.T) {
secret := newStreamSecret()
now := time.Now()
tok := mintStreamToken(secret, streamScopeStream, now)
// Flip the last hex char of the MAC.
last := tok[len(tok)-1]
flip := byte('0')
if last == '0' {
flip = '1'
}
tampered := tok[:len(tok)-1] + string(flip)
if verifyStreamToken(secret, streamScopeStream, tampered, now) {
t.Error("tampered MAC verified as valid")
}
}
func TestStreamToken_Malformed(t *testing.T) {
secret := newStreamSecret()
now := time.Now()
for _, bad := range []string{
"",
"nodot",
"123.", // empty MAC
".deadbeef", // empty exp
"notanint.abc", // non-numeric exp
".",
} {
if verifyStreamToken(secret, streamScopeStream, bad, now) {
t.Errorf("malformed token %q verified as valid", bad)
}
}
}
// TestVerifyStreamToken_CrossLangVector pins the wire format against the web's
// TypeScript minter (tests/unit/stream-token.test.ts asserts the same vector).
// A token the web mints MUST verify here or remote HLS playback 404s.
func TestVerifyStreamToken_CrossLangVector(t *testing.T) {
secret := make([]byte, 32)
for i := range secret {
secret[i] = 0xab // matches secretHex "ab"*32 on the web side
}
const (
sessionID = "sess-1"
token = "1900000000.3ee840ccf2c2a42b784d7cef68458db1d3cea5ecdcab41061504de32eb52fbc2"
)
before := time.Unix(1899978400, 0) // before exp 1900000000
if !verifyStreamToken(secret, streamScopeHLS(sessionID), token, before) {
t.Fatal("web-minted parity token failed to verify in the daemon")
}
after := time.Unix(1900000001, 0) // past exp
if verifyStreamToken(secret, streamScopeHLS(sessionID), token, after) {
t.Error("parity token verified past its expiry")
}
}
func TestNewStreamSecret_LengthAndUniqueness(t *testing.T) {
a, b := newStreamSecret(), newStreamSecret()
if len(a) != 32 {
t.Errorf("secret length = %d, want 32", len(a))
}
if string(a) == string(b) {
t.Error("two secrets were identical — not random")
}
}
// --- /stream handler enforcement ---------------------------------------------
func streamReq(remoteAddr, query string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://stream.test/stream"+query, nil)
r.RemoteAddr = remoteAddr
return r
}
func newServedServer(t *testing.T) *StreamServer {
t.Helper()
srv := NewStreamServer(0)
srv.SetFile(newFakeProvider("movie.mkv", []byte("fake video bytes")), "task-1")
return srv
}
func TestStreamHandler_RemoteWithoutToken_404(t *testing.T) {
srv := newServedServer(t)
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("198.51.100.7:40000", ""))
if rec.Code != http.StatusNotFound {
t.Errorf("remote request without token: status = %d, want 404", rec.Code)
}
}
func TestStreamHandler_RemoteValidToken_200(t *testing.T) {
srv := newServedServer(t)
tok := mintStreamToken(srv.streamSecret, streamScopeStream, time.Now())
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("198.51.100.7:40000", "?t="+tok))
if rec.Code != http.StatusOK {
t.Errorf("remote request with valid token: status = %d, want 200", rec.Code)
}
}
func TestStreamHandler_RemoteBadToken_404(t *testing.T) {
srv := newServedServer(t)
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("198.51.100.7:40000", "?t=deadbeef.0000"))
if rec.Code != http.StatusNotFound {
t.Errorf("remote request with bad token: status = %d, want 404", rec.Code)
}
}
func TestStreamHandler_LoopbackWithoutToken_404(t *testing.T) {
// No loopback exemption: cloudflared relays public funnel traffic over
// localhost, so loopback must still present a valid token.
srv := newServedServer(t)
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("127.0.0.1:55555", ""))
if rec.Code != http.StatusNotFound {
t.Errorf("loopback request without token: status = %d, want 404 (no exemption)", rec.Code)
}
}
func TestStreamHandler_LoopbackWithValidToken_200(t *testing.T) {
srv := newServedServer(t)
tok := mintStreamToken(srv.streamSecret, streamScopeStream, time.Now())
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("127.0.0.1:55555", "?t="+tok))
if rec.Code != http.StatusOK {
t.Errorf("loopback request with valid token: status = %d, want 200", rec.Code)
}
}
func TestStreamHandler_EnforcementOff_NoToken_200(t *testing.T) {
srv := newServedServer(t)
srv.SetRequireStreamToken(false)
rec := httptest.NewRecorder()
srv.handler(rec, streamReq("198.51.100.7:40000", ""))
if rec.Code != http.StatusOK {
t.Errorf("enforcement off: status = %d, want 200", rec.Code)
}
}
// --- /hls handler enforcement ------------------------------------------------
func TestHLSHandler_RemoteBadToken_404(t *testing.T) {
srv := NewStreamServer(0)
// A syntactically valid session id (UUID-ish) with a bogus token segment.
const sess = "11111111-1111-4111-8111-111111111111"
r := httptest.NewRequest(http.MethodGet, "http://stream.test/hls/"+sess+"/badtoken/master.m3u8", nil)
r.RemoteAddr = "198.51.100.7:40000"
rec := httptest.NewRecorder()
srv.hlsHandler(rec, r)
if rec.Code != http.StatusNotFound {
t.Errorf("remote hls with bad token: status = %d, want 404", rec.Code)
}
}
func TestHLSBaseURLs_CarryTokenSegment(t *testing.T) {
srv := NewStreamServer(0)
srv.urls.LAN = "http://192.168.1.2:11818/stream"
const sess = "22222222-2222-4222-8222-222222222222"
urls := srv.hlsBaseURLs(sess)
prefix := "http://192.168.1.2:11818/hls/" + sess + "/"
if !strings.HasPrefix(urls.LAN, prefix) || len(urls.LAN) <= len(prefix) {
t.Errorf("hls LAN url = %q, want token segment after %q", urls.LAN, prefix)
}
// The trailing segment must be a verifiable hls-scoped token.
tok := strings.TrimPrefix(urls.LAN, prefix)
if !verifyStreamToken(srv.streamSecret, streamScopeHLS(sess), tok, time.Now()) {
t.Errorf("token segment %q does not verify for session %s", tok, sess)
}
}