Skip to content

Commit 3b53aca

Browse files
committed
feat(mediaauth): playback authorization plane (token + IP/country/UA/referer chain)
Open-Streamer had no media-plane access control: anyone who could reach a stream URL could watch it, the ?token= param was recorded but never enforced, and a tokened SRT streamid failed to resolve (audit S-13). Add a playback authorizer enforced across every delivery protocol. internal/mediaauth: a Flussonic-style chain — deny IP/country/UA wins, then allow-list gates (IP/country/UA + Referer allowed-domains), then a per-stream token-policy gate. Tokens are HMAC-SHA256, client-signed and server-verified (token = "<exp>.<base64url(HMAC(secret, code|exp))>"): constant-time compare, expiry- and stream-code-bound. The server only verifies — clients mint tokens with the shared secret via the documented SignToken format (no mint endpoint). Wiring (authorize before any bytes/state): - HTTP (HLS/DASH/MPEGTS/DVR) in dispatchMedia.mediaAllowed. - RTMP/SRT/RTSP play sites via publisher.playAllowed. Effective policy = per-stream Stream.PlaybackAuth (public/token, template- inherited) else global auth.media.default_policy; static rules are global. Country uses the existing sessions GeoIP. Disabled by default (no behaviour change); fail-closed when token policy has no secret. Config auth.media.*, hot-reloaded via the runtime diff (atomic snapshot swap). Per-stream policy is resolved from the publisher's in-memory table for live streams (O(1), no store read on the hot path) and from the store for stopped-stream DVR archives so a token stream isn't downgraded. Also fixes: SRT srtStreamCode strips the ?token= query so tokened streamids resolve; ABR renditions key auth on the parent stream code so a token for <code> covers /<code>/track_N/… (adversarial review found both — without the strip, token playback of transcoded/ABR streams was broken). Closes the media-plane half of S-13. Caveats (separate findings): IP/country rules trust RealIP/X-Forwarded-For, so they need a trusted proxy (S-15/S-17); RTMP play carries no token (IP/country still apply); the dynamic HTTP-callback backend and per-stream rule overrides are planned follow-ups. Tests: internal/mediaauth chain+token suite (deny/allow per dimension, token sign/verify/expiry/tamper/cross-stream, per-stream override, fail-closed, hot-reload), TestStripABRTrackSlug. Adversarially reviewed.
1 parent 82c47cb commit 3b53aca

19 files changed

Lines changed: 1127 additions & 9 deletions

File tree

api/docs/docs.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,6 +1941,10 @@ const docTemplate = `{
19411941
"name": {
19421942
"type": "string"
19431943
},
1944+
"playback_auth": {
1945+
"description": "PlaybackAuth overrides the global media-auth default policy for this\nstream: \"public\" (no token) or \"token\" (signed token required). Empty\ninherits auth.media.default_policy. Static rules (IP/country/UA/domains)\nremain global. See internal/mediaauth.",
1946+
"type": "string"
1947+
},
19441948
"protocols": {
19451949
"description": "Protocols defines which delivery protocols are opened for this stream.\nnil means the field is unset and ResolveStream inherits the template's\nProtocols (or leaves the resolved value nil when no template applies —\npublisher treats nil as \"no protocols enabled\"). An explicit non-nil\npointer — including the zero value \u0026OutputProtocols{} — is an\noperator-asserted override and beats template inheritance.",
19461950
"allOf": [
@@ -2104,6 +2108,9 @@ const docTemplate = `{
21042108
"properties": {
21052109
"api": {
21062110
"$ref": "#/definitions/config.APIAuthConfig"
2111+
},
2112+
"media": {
2113+
"$ref": "#/definitions/config.MediaAuthConfig"
21072114
}
21082115
}
21092116
},
@@ -2227,6 +2234,65 @@ const docTemplate = `{
22272234
}
22282235
}
22292236
},
2237+
"config.MediaAuthConfig": {
2238+
"type": "object",
2239+
"properties": {
2240+
"allow_countries": {
2241+
"type": "array",
2242+
"items": {
2243+
"type": "string"
2244+
}
2245+
},
2246+
"allow_ips": {
2247+
"description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).",
2248+
"type": "array",
2249+
"items": {
2250+
"type": "string"
2251+
}
2252+
},
2253+
"allow_user_agents": {
2254+
"type": "array",
2255+
"items": {
2256+
"type": "string"
2257+
}
2258+
},
2259+
"allowed_domains": {
2260+
"type": "array",
2261+
"items": {
2262+
"type": "string"
2263+
}
2264+
},
2265+
"default_policy": {
2266+
"description": "DefaultPolicy is \"public\" (no token needed) or \"token\" (signed token\nrequired) for streams that don't set their own. Empty = public.",
2267+
"type": "string"
2268+
},
2269+
"deny_countries": {
2270+
"type": "array",
2271+
"items": {
2272+
"type": "string"
2273+
}
2274+
},
2275+
"deny_ips": {
2276+
"type": "array",
2277+
"items": {
2278+
"type": "string"
2279+
}
2280+
},
2281+
"deny_user_agents": {
2282+
"type": "array",
2283+
"items": {
2284+
"type": "string"
2285+
}
2286+
},
2287+
"enabled": {
2288+
"type": "boolean"
2289+
},
2290+
"token_secret": {
2291+
"description": "TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback\ntokens. Clients (your app) mint tokens with the same secret — the server\nnever issues them. Required when any stream's effective policy is \"token\".\nSee internal/mediaauth.SignToken for the canonical token format.",
2292+
"type": "string"
2293+
}
2294+
}
2295+
},
22302296
"config.PublisherConfig": {
22312297
"type": "object",
22322298
"properties": {
@@ -3242,6 +3308,10 @@ const docTemplate = `{
32423308
"description": "Name and Description are template-level metadata. Name surfaces in\nthe API for human-readable lists; Description carries the rationale\nbehind the template's settings. Streams inheriting this template\nkeep their own Name / Description fields — the template metadata is\nfor operator-facing tooling, not for downstream consumers.",
32433309
"type": "string"
32443310
},
3311+
"playback_auth": {
3312+
"description": "PlaybackAuth is the media-auth policy (\"public\"/\"token\") inherited by\nstreams referencing this template. Empty = inherit global default.",
3313+
"type": "string"
3314+
},
32453315
"prefixes": {
32463316
"description": "Prefixes is the list of URL-path prefixes that trigger auto-publish.\nWhen an encoder pushes to a path whose first segment(s) match any\nprefix here AND this template has at least one publish:// input, a\nruntime stream is created on the fly. Prefixes must not overlap any\nother template's prefix (validated at save time).",
32473317
"type": "array",

api/docs/swagger.json

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,6 +1934,10 @@
19341934
"name": {
19351935
"type": "string"
19361936
},
1937+
"playback_auth": {
1938+
"description": "PlaybackAuth overrides the global media-auth default policy for this\nstream: \"public\" (no token) or \"token\" (signed token required). Empty\ninherits auth.media.default_policy. Static rules (IP/country/UA/domains)\nremain global. See internal/mediaauth.",
1939+
"type": "string"
1940+
},
19371941
"protocols": {
19381942
"description": "Protocols defines which delivery protocols are opened for this stream.\nnil means the field is unset and ResolveStream inherits the template's\nProtocols (or leaves the resolved value nil when no template applies —\npublisher treats nil as \"no protocols enabled\"). An explicit non-nil\npointer — including the zero value \u0026OutputProtocols{} — is an\noperator-asserted override and beats template inheritance.",
19391943
"allOf": [
@@ -2097,6 +2101,9 @@
20972101
"properties": {
20982102
"api": {
20992103
"$ref": "#/definitions/config.APIAuthConfig"
2104+
},
2105+
"media": {
2106+
"$ref": "#/definitions/config.MediaAuthConfig"
21002107
}
21012108
}
21022109
},
@@ -2220,6 +2227,65 @@
22202227
}
22212228
}
22222229
},
2230+
"config.MediaAuthConfig": {
2231+
"type": "object",
2232+
"properties": {
2233+
"allow_countries": {
2234+
"type": "array",
2235+
"items": {
2236+
"type": "string"
2237+
}
2238+
},
2239+
"allow_ips": {
2240+
"description": "Static allow/deny chain. IPs accept exact addresses or CIDR ranges;\nCountries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see\nSessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;\nAllowedDomains match the Referer host (exact or parent domain).",
2241+
"type": "array",
2242+
"items": {
2243+
"type": "string"
2244+
}
2245+
},
2246+
"allow_user_agents": {
2247+
"type": "array",
2248+
"items": {
2249+
"type": "string"
2250+
}
2251+
},
2252+
"allowed_domains": {
2253+
"type": "array",
2254+
"items": {
2255+
"type": "string"
2256+
}
2257+
},
2258+
"default_policy": {
2259+
"description": "DefaultPolicy is \"public\" (no token needed) or \"token\" (signed token\nrequired) for streams that don't set their own. Empty = public.",
2260+
"type": "string"
2261+
},
2262+
"deny_countries": {
2263+
"type": "array",
2264+
"items": {
2265+
"type": "string"
2266+
}
2267+
},
2268+
"deny_ips": {
2269+
"type": "array",
2270+
"items": {
2271+
"type": "string"
2272+
}
2273+
},
2274+
"deny_user_agents": {
2275+
"type": "array",
2276+
"items": {
2277+
"type": "string"
2278+
}
2279+
},
2280+
"enabled": {
2281+
"type": "boolean"
2282+
},
2283+
"token_secret": {
2284+
"description": "TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback\ntokens. Clients (your app) mint tokens with the same secret — the server\nnever issues them. Required when any stream's effective policy is \"token\".\nSee internal/mediaauth.SignToken for the canonical token format.",
2285+
"type": "string"
2286+
}
2287+
}
2288+
},
22232289
"config.PublisherConfig": {
22242290
"type": "object",
22252291
"properties": {
@@ -3235,6 +3301,10 @@
32353301
"description": "Name and Description are template-level metadata. Name surfaces in\nthe API for human-readable lists; Description carries the rationale\nbehind the template's settings. Streams inheriting this template\nkeep their own Name / Description fields — the template metadata is\nfor operator-facing tooling, not for downstream consumers.",
32363302
"type": "string"
32373303
},
3304+
"playback_auth": {
3305+
"description": "PlaybackAuth is the media-auth policy (\"public\"/\"token\") inherited by\nstreams referencing this template. Empty = inherit global default.",
3306+
"type": "string"
3307+
},
32383308
"prefixes": {
32393309
"description": "Prefixes is the list of URL-path prefixes that trigger auto-publish.\nWhen an encoder pushes to a path whose first segment(s) match any\nprefix here AND this template has at least one publish:// input, a\nruntime stream is created on the fly. Prefixes must not overlap any\nother template's prefix (validated at save time).",
32403310
"type": "array",

api/docs/swagger.yaml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ definitions:
213213
type: array
214214
name:
215215
type: string
216+
playback_auth:
217+
description: |-
218+
PlaybackAuth overrides the global media-auth default policy for this
219+
stream: "public" (no token) or "token" (signed token required). Empty
220+
inherits auth.media.default_policy. Static rules (IP/country/UA/domains)
221+
remain global. See internal/mediaauth.
222+
type: string
216223
protocols:
217224
allOf:
218225
- $ref: '#/definitions/domain.OutputProtocols'
@@ -331,6 +338,8 @@ definitions:
331338
properties:
332339
api:
333340
$ref: '#/definitions/config.APIAuthConfig'
341+
media:
342+
$ref: '#/definitions/config.MediaAuthConfig'
334343
type: object
335344
config.BufferConfig:
336345
properties:
@@ -442,6 +451,56 @@ definitions:
442451
healthy primary will be falsely failed over to a lower priority.
443452
type: integer
444453
type: object
454+
config.MediaAuthConfig:
455+
properties:
456+
allow_countries:
457+
items:
458+
type: string
459+
type: array
460+
allow_ips:
461+
description: |-
462+
Static allow/deny chain. IPs accept exact addresses or CIDR ranges;
463+
Countries are ISO 3166-1 alpha-2 codes (need a GeoIP DB — see
464+
SessionsConfig.GeoIPDBPath); UserAgents match case-insensitive substring;
465+
AllowedDomains match the Referer host (exact or parent domain).
466+
items:
467+
type: string
468+
type: array
469+
allow_user_agents:
470+
items:
471+
type: string
472+
type: array
473+
allowed_domains:
474+
items:
475+
type: string
476+
type: array
477+
default_policy:
478+
description: |-
479+
DefaultPolicy is "public" (no token needed) or "token" (signed token
480+
required) for streams that don't set their own. Empty = public.
481+
type: string
482+
deny_countries:
483+
items:
484+
type: string
485+
type: array
486+
deny_ips:
487+
items:
488+
type: string
489+
type: array
490+
deny_user_agents:
491+
items:
492+
type: string
493+
type: array
494+
enabled:
495+
type: boolean
496+
token_secret:
497+
description: |-
498+
TokenSecret is the HMAC-SHA256 key the server uses to VERIFY playback
499+
tokens. Clients (your app) mint tokens with the same secret — the server
500+
never issues them. Required when any stream's effective policy is "token".
501+
See internal/mediaauth.SignToken for the canonical token format.
502+
type: string
503+
type: object
445504
config.PublisherConfig:
446505
properties:
447506
dash:
@@ -1405,6 +1464,11 @@ definitions:
14051464
keep their own Name / Description fields — the template metadata is
14061465
for operator-facing tooling, not for downstream consumers.
14071466
type: string
1467+
playback_auth:
1468+
description: |-
1469+
PlaybackAuth is the media-auth policy ("public"/"token") inherited by
1470+
streams referencing this template. Empty = inherit global default.
1471+
type: string
14081472
prefixes:
14091473
description: |-
14101474
Prefixes is the list of URL-path prefixes that trigger auto-publish.

cmd/server/main.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/ntt0601zcoder/open-streamer/internal/hooks"
3131
"github.com/ntt0601zcoder/open-streamer/internal/ingestor"
3232
"github.com/ntt0601zcoder/open-streamer/internal/manager"
33+
"github.com/ntt0601zcoder/open-streamer/internal/mediaauth"
3334
"github.com/ntt0601zcoder/open-streamer/internal/metrics"
3435
"github.com/ntt0601zcoder/open-streamer/internal/publisher"
3536
"github.com/ntt0601zcoder/open-streamer/internal/runtime"
@@ -133,6 +134,11 @@ func run() error {
133134
// 5. Wire all services.
134135
wireServices(injector)
135136

137+
// 5b. Wire the media-plane playback authorizer (token / IP / country / UA /
138+
// referer) into the publisher + API server. Returns the shared authorizer
139+
// so the runtime config-reload path can hot-swap its rules.
140+
mediaAuthz := wireMediaAuth(injector, gcfg)
141+
136142
// 6. Assemble RuntimeManager deps from DI.
137143
rtm := runtime.New(ctx, runtime.Deps{
138144
Ingestor: do.MustInvoke[*ingestor.Service](injector),
@@ -144,6 +150,7 @@ func run() error {
144150
SessionsSvc: do.MustInvoke[*sessions.Service](injector),
145151
AutoPublish: do.MustInvoke[*autopublish.Service](injector),
146152
APISrv: do.MustInvoke[*api.Server](injector),
153+
MediaAuth: mediaAuthz,
147154
Bus: do.MustInvoke[events.Bus](injector),
148155
StreamRepo: do.MustInvoke[store.StreamRepository](injector),
149156
GlobalConfigRepo: gcRepo,
@@ -330,6 +337,48 @@ func wireServices(i *do.RootScope) {
330337
// happens at packet-read time, long after the original request that created
331338
// the worker has been served. The repo's FindByCode is fast (in-memory or
332339
// indexed), so blocking briefly here is acceptable.
340+
// wireMediaAuth builds the shared media-plane playback authorizer and injects
341+
// it into the publisher (RTMP/SRT/RTSP play) and API server (HLS/DASH/MPEGTS).
342+
// The country backend reuses the sessions GeoIP resolver; the per-stream policy
343+
// is resolved from the publisher's in-memory stream table (O(1), no store hit).
344+
func wireMediaAuth(i do.Injector, gcfg *domain.GlobalConfig) *mediaauth.Authorizer {
345+
pub := do.MustInvoke[*publisher.Service](i)
346+
apiSrv := do.MustInvoke[*api.Server](i)
347+
geoIP := do.MustInvoke[*sessions.SwappableGeoIP](i)
348+
streamRepo := do.MustInvoke[store.StreamRepository](i)
349+
templateRepo := do.MustInvoke[store.TemplateRepository](i)
350+
351+
// policyFor resolves a stream's effective playback policy. Live streams hit
352+
// the publisher's in-memory table (O(1), no store read on the hot path). A
353+
// STOPPED stream — whose DVR archive is still served — falls back to the
354+
// store + template so its per-stream `token` policy isn't silently
355+
// downgraded to the global default.
356+
policyFor := func(code domain.StreamCode) string {
357+
if policy, running := pub.PlaybackPolicy(code); running {
358+
return policy
359+
}
360+
s, err := streamRepo.FindByCode(context.Background(), code)
361+
if err != nil {
362+
return ""
363+
}
364+
if s.Template != nil {
365+
if tpl, terr := templateRepo.FindByCode(context.Background(), *s.Template); terr == nil {
366+
s = domain.ResolveStream(s, tpl)
367+
}
368+
}
369+
return s.PlaybackAuth
370+
}
371+
372+
cfg := config.AuthConfig{}
373+
if gcfg.Auth != nil {
374+
cfg = *gcfg.Auth
375+
}
376+
authz := mediaauth.New(cfg.Media, geoIP.Country, policyFor)
377+
pub.SetMediaAuthorizer(authz)
378+
apiSrv.SetMediaAuthorizer(authz)
379+
return authz
380+
}
381+
333382
func wireCopyLookup(i do.Injector) {
334383
ing := do.MustInvoke[*ingestor.Service](i)
335384
repo := do.MustInvoke[store.StreamRepository](i)

0 commit comments

Comments
 (0)