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:
parent
ea00130d08
commit
444d7e63fd
8 changed files with 622 additions and 36 deletions
|
|
@ -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)
|
||||
|
|
|
|||
94
internal/engine/stream_token.go
Normal file
94
internal/engine/stream_token.go
Normal 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
|
||||
}
|
||||
224
internal/engine/stream_token_test.go
Normal file
224
internal/engine/stream_token_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue