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.
This commit is contained in:
Deivid Soto 2026-05-31 01:19:14 +02:00
parent ea00130d08
commit 444d7e63fd
8 changed files with 622 additions and 36 deletions

View file

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

View file

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