/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.
9.3 KiB
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
- Hospeda localmente toda la biblioteca.
- Debrid para reproducir cualquier cosa cache-fast.
- Play-anything sin callejones (local | debrid | descarga-y-reproduce, con fallback mid-stream).
- Transcodifica según el dispositivo (direct-play cuando ya es compatible).
- Sirve a un web-player universal en cualquier dispositivo vía navegador.
- 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):
verifyStreamTokenno 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_secretes 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 endownload_task.streamUrlquedan 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) yhls.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 +requireTokenen StreamServer;/streamy/hlsverifican el token;URLsJSON/hlsBaseURLs/URL()tokenizan;StreamSecretHex(); sin exención de loopback;/playlist.m3uya 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:buildHlsUrlsinyecta el token de path usando el secreto del agente.src/lib/db/schema.ts+ migración0134_grey_chat.sql: columnaagent_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 engetAgentHealth.- 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.m3uera 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.