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

View file

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

View file

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

View file

@ -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/<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
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=<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 {
b, _ := json.Marshal(ss.urls)
b, _ := json.Marshal(ss.tokenizedStreamURLs())
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.
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/<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)
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)

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)
}
}