From 2718ae9baf97c97394463a795a5838f4e4034f05 Mon Sep 17 00:00:00 2001 From: ntthuan060102github Date: Tue, 16 Jun 2026 16:59:21 +0700 Subject: [PATCH 1/2] feat(publisher): enforce playback token on the RTMP play path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RTMP play handler hardcoded an empty token into the media-auth chain, so a stream bound to a token-required policy was denied for every RTMP client with no way to authorize — token policies effectively excluded RTMP while working on HLS/DASH/SRT/RTSP. Carry the token on the play-URL query (rtmp://host/live/?token=...), the same transport the SRT and RTSP play paths already use. The RTMP server forwards the session's raw query via PlayInfo.RawQuery (staying media-auth-agnostic) and the publisher extracts the token with the shared sessions.TokenFromQuery helper. User-Agent and Referer remain unset for RTMP, so those rules stay inert; IP / country / token now apply uniformly. Add table-driven auth tests for the RTMP play path and a reference doc for the playback-policy model with a per-protocol enforcement matrix. --- docs/media-auth-policies.md | 329 +++++++++++++++++++++ internal/ingestor/push/rtmp_server.go | 12 +- internal/publisher/serve_rtmp.go | 13 +- internal/publisher/serve_rtmp_auth_test.go | 89 ++++++ 4 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 docs/media-auth-policies.md create mode 100644 internal/publisher/serve_rtmp_auth_test.go diff --git a/docs/media-auth-policies.md b/docs/media-auth-policies.md new file mode 100644 index 0000000..03174cb --- /dev/null +++ b/docs/media-auth-policies.md @@ -0,0 +1,329 @@ +# Media-Plane Auth — Playback Policies + +Playback authorization decides **who may watch a stream** over HLS, DASH, +HTTP-MPEGTS, RTMP, SRT, and RTSP. It is the media-plane counterpart to the +control-plane (admin API) auth, and it is **policy-based**: every rule lives on a +first-class `Policy` entity in the store. There is **no global media-auth +config**. + +- Source of truth: [internal/domain/policy.go](../internal/domain/policy.go) +- Decision engine: [internal/mediaauth/mediaauth.go](../internal/mediaauth/mediaauth.go) +- HTTP gate: [internal/api/dispatch.go](../internal/api/dispatch.go) +- RTMP/SRT/RTSP gate: [internal/publisher/playauth.go](../internal/publisher/playauth.go) +- REST CRUD: [internal/api/handler/policy.go](../internal/api/handler/policy.go) + +--- + +## 1. The `Policy` entity + +A policy is a reusable, named bundle of rules. It is fully self-contained — it +carries its **own** token secret and its **own** allow/deny chain, so revoking +one policy's key never affects another. + +| Field (JSON) | Type | Meaning | Format | +|---|---|---|---| +| `code` | string | Unique key (primary key) | `A-Z a-z 0-9 _ -`, ≤ 128 chars | +| `name` | string | Operator-facing label | free-form | +| `description` | string | Operator-facing notes | free-form | +| `require_token` | bool | Require a valid signed token on every request | — | +| `token_secret` | string | HMAC-SHA256 key for **this** policy's tokens | required when `require_token=true` | +| `allow_ips` | []string | If non-empty, client IP **must** match | exact IP or CIDR | +| `deny_ips` | []string | Client IP on this list is rejected | exact IP or CIDR | +| `allow_countries` | []string | If non-empty, client country **must** appear | ISO 3166-1 alpha-2 (needs GeoIP DB) | +| `deny_countries` | []string | Client country on this list is rejected | ISO 3166-1 alpha-2 | +| `allow_user_agents` | []string | If non-empty, UA **must** contain one entry | case-insensitive substring | +| `deny_user_agents` | []string | UA containing any entry is rejected | case-insensitive substring | +| `allowed_domains` | []string | If non-empty, HTTP `Referer` host **must** match | exact host or parent domain (`example.com` covers `play.example.com`) | + +`Policy.Validate()` runs at save time: it rejects a bad `code`, a missing +`token_secret` when `require_token=true`, and any malformed IP/CIDR or country +code — so a silently-dropped CIDR can never weaken a deny list. + +--- + +## 2. Binding a policy to a stream + +A stream binds **at most one** policy via `Stream.PlaybackPolicy` (JSON +`playback_policy`). Templates carry the same field and `ResolveStream` inherits +it with **zero-value = inherit** semantics (a non-empty value on the stream +overrides the template). + +| Binding state | Result | +|---|---| +| No policy (empty) | **Public** — allow-all, returns `allow` immediately | +| Valid policy code | Rules evaluated (see chain below) | +| Unknown policy code | **Fail-closed** — `deny "unknown policy"` | + +An unknown code only arises from a direct store edit: the delete handler refuses +to remove a referenced policy (see §8). + +### Policy resolution (which policy applies) + +`PolicyResolver` ([cmd/server/main.go](../cmd/server/main.go), `wireMediaAuth`) +resolves a stream code to its policy code in two tiers: + +1. **Live (hot path):** `publisher.Service.PlaybackPolicy(code)` — O(1) + in-memory lookup, no store read per segment. +2. **Stopped / DVR fallback:** `streamRepo.FindByCode` → if the stream has a + template, `domain.ResolveStream` merges the template's `playback_policy` + before returning the effective code. Store error → `""` (treated as public). + +--- + +## 3. Evaluation chain + +For one playback request the engine runs **deny → allow → token** +([mediaauth.go](../internal/mediaauth/mediaauth.go), `evaluate`): + +``` +0. No policy bound .......................... ALLOW (public) +1. Policy code unknown ...................... DENY "unknown policy" (fail-closed) +2. DENY lists (deny wins) .................. ip / country / user-agent on a deny list → DENY +3. ALLOW gates (every configured list must pass) + allow_ips non-empty & no match → DENY + allow_countries non-empty & no match → DENY + allow_user_agents non-empty & no match → DENY + allowed_domains non-empty & no match → DENY +4. TOKEN gate (only when require_token=true) + no token_secret .................... → DENY "token required but policy has no secret" + verify() fails ..................... → DENY "missing or invalid token" +5. ............................................ ALLOW +``` + +The compiled policy set lives in an `atomic.Pointer` and is hot-swapped by +`SetPolicies` (see §8); `Authorize` itself does zero allocations. + +--- + +## 4. Playback tokens + +Tokens are **client-signed, server-verify-only**. The server never mints tokens +on the hot path; `mediaauth.SignToken` is the reference implementation for +clients. + +### Wire format + +``` +token = "." + + exp = Unix expiry (seconds, decimal) + sig = HMAC-SHA256( token_secret, "|" ) // raw 32 bytes + encoded with base64 URL-safe, no padding +``` + +The MAC binds to **both** the stream code and the expiry, so a token minted for +one stream never authorizes another — even under the same policy secret. + +- **Expiry / clock skew:** a token is valid while `now − 60s ≤ exp` (60 s skew + tolerance). +- **Verification:** constant-time compare (`subtle.ConstantTimeCompare`). + +### Minting a token (example, shell) + +```bash +CODE="mychannel" +SECRET="s3cr3t" +EXP=$(( $(date +%s) + 3600 )) # valid 1 hour +SIG=$(printf '%s|%s' "$CODE" "$EXP" \ + | openssl dgst -sha256 -hmac "$SECRET" -binary \ + | basenc --base64url | tr -d '=') +TOKEN="$EXP.$SIG" +``` + +### Token transport per protocol + +| Protocol | How the token is carried | +|---|---| +| HLS / DASH / HTTP-MPEGTS | URL query `?token=` (on **every** request, incl. each segment) | +| RTSP | URL query `rtsp://host:554/live/?token=` | +| SRT | inside the streamid: `srt://host:9999?streamid=live/?token=` | +| RTMP | URL query on the play URL: `rtmp://host:1935/live/?token=` | + +--- + +## 5. Enforcement matrix (protocol × rule) + +Which policy rule actually takes effect depends on whether the delivery protocol +can carry that signal. This is the core of the design — **not every rule applies +to every protocol.** + +| Policy rule | HLS / DASH / HTTP-MPEGTS | RTMP play | SRT play | RTSP play | +|---|:---:|:---:|:---:|:---:| +| **Token** (`require_token`) | ✅ `?token=` | ✅ play-URL query | ✅ via `streamid` | ✅ `?token=` | +| **IP** (`allow_ips` / `deny_ips`) | ✅ | ✅ | ✅ | ✅ | +| **Country** (`allow_countries` / `deny_countries`) | ✅ | ✅ | ✅ | ✅ | +| **User-Agent** (`allow_user_agents` / `deny_user_agents`) | ✅ | ❌ | ❌ | ⚠️ | +| **Referer domain** (`allowed_domains`) | ✅ | ❌ | ❌ | ❌ | + +**Legend** + +- ✅ **Enforced** — the signal is captured; the rule works as intended. +- ⚠️ **Conditional** — captured only if the client sends it (RTSP `User-Agent`); + an **allow-list** may block legitimate clients that omit the header. +- ❌ **Not available** — the signal is never captured (the gate passes an empty + string). Effect depends on list polarity: + - **deny-list** → inert (empty never matches; harmless no-op); + - **allow-list** → **denies every client of that protocol** (empty never + matches a required list). See §7. + +Country is derived from the client IP via the GeoIP DB, so it is enforceable +exactly when IP is — on all protocols. + +### Where each gate lives + +| Protocol | Gate call (file:line) | +|---|---| +| HLS / DASH / HTTP-MPEGTS | `mediaAllowed(r, code)` — [dispatch.go:164](../internal/api/dispatch.go#L164) (one gate for all three, before file routing) | +| RTMP | `playAllowed(code, "rtmp", addr, token, "", "")` — token from the play-URL query — [serve_rtmp.go](../internal/publisher/serve_rtmp.go) | +| SRT | `playAllowed(code, "srt", addr, srtTok, "", "")` — [serve_srt.go:129](../internal/publisher/serve_srt.go#L129) | +| RTSP | `playAllowed(code, "rtsp", remote, TokenFromQuery, ua, "")` — [serve_rtsp.go:253](../internal/publisher/serve_rtsp.go#L253) | + +--- + +## 6. Per-protocol behaviour + +### HLS / DASH / HTTP-MPEGTS + +All three are gated **identically** in the API dispatcher, once at the top of +`dispatchMedia` before any per-file branching — so the manifest, **every +segment** (`.ts` / `.m4s` / `.mp4`), the `//mpegts` endpoint, and DVR blob +paths are all protected. A token must be present on **every** request, not just +the manifest. The ABR rendition slug (`//track_N/…`) is normalised to +`/` before auth, so a token minted for the parent code authorizes all +renditions. Full signal set available: IP, country, token, User-Agent, Referer. + +### RTMP play + +Carries the token on the play-URL query +(`rtmp://host:1935/live/?token=…`): the push server forwards the session's +raw query and the publisher extracts the token with the same helper as SRT/RTSP. +**IP / country / token** are effective. **User-Agent** and **Referer** are always +empty (the RTMP handshake carries neither), so those rules are inert. The query +rides as a genuine query string, so it never pollutes the stream code. + +### SRT play + +Carries a token inside the `streamid` (`…?token=…`); the code lookup strips +everything after the first `?` so the token does not bleed into the stream code. +IP / country / token are effective; User-Agent and Referer are always empty. The +gate fires at the **subscribe** phase (`srtHandleSubscribe`), not at connect — a +denied client is dropped before any buffer subscriber is allocated. + +### RTSP play + +Carries a token in the URL query (`?token=`). IP / country / token are +effective. **User-Agent** is read from the request header **if the client sends +it** (VLC/ffmpeg do; headless/embedded clients may not). **Referer** is never +available. Denial returns `401 Unauthorized` before the connection cap check. + +--- + +## 7. Footguns & caveats + +1. **RTMP token rides the play URL.** The token must be appended to the RTMP + play URL as a query (`rtmp://host:1935/live/?token=…`). Players differ + in where they place the query (on the app/tcURL vs. the play stream name) — + test your encoder/player; the server reads it from the session's raw query. + Like every URL-borne token it appears in logs/proxies, so keep `exp` short. + +2. **Allow-lists on an unavailable signal block everyone.** Setting a *positive* + `allow_user_agents` on a policy used by RTMP/SRT, or `allowed_domains` on + RTMP/SRT/RTSP, denies **every** client of that protocol (the captured value + is empty and can never satisfy a required list). Deny-lists on the same + signal are inert (harmless). Rule of thumb: only put `allow_user_agents` / + `allowed_domains` on policies whose streams are served over **HTTP** + (HLS/DASH/MPEGTS), where the header is always present. + +3. **RTSP `User-Agent` allow-list may block valid clients.** Because the header + is optional, an `allow_user_agents` list will reject any RTSP client that + omits it. + +4. **IP / country trust the forwarded headers.** The HTTP path derives the + client IP from chi's `RealIP` middleware (`True-Client-IP` → `X-Real-IP` → + left-most `X-Forwarded-For`, then TCP peer). Without a trusted reverse proxy + stripping/setting these, a client can spoof its IP — and therefore bypass + IP/country rules — by sending a forged header. Terminate at a trusted proxy + that overwrites these headers before relying on IP/country gates. + +--- + +## 8. Managing policies + +### REST API (`/policies`, admin-authenticated) + +| Method | Path | Action | +|---|---|---| +| `GET` | `/policies/` | List all policies → `{data: [...], total: N}` | +| `GET` | `/policies/{code}` | Get one → `{data: policy}` or 404 | +| `POST` | `/policies/{code}` | Create (201) or replace (200); URL `{code}` wins over body | +| `DELETE` | `/policies/{code}` | Delete → 204, or 409 if referenced | + +### Hot reload + +Every `POST` / `DELETE` calls `reloadAuthorizer`, which re-lists all policies and +calls `Authorizer.SetPolicies` — the compiled set is swapped atomically with no +restart and no in-flight disruption. At boot, `wireMediaAuth` loads the set once; +a load failure logs a WARN and leaves the set empty (all playback public until +the first reload). + +### Delete guard (`POLICY_IN_USE`) + +`DELETE` scans all streams and templates for references. If any depend on the +policy it returns **409** with the dependents listed: + +```json +{ + "error": "POLICY_IN_USE", + "message": "policy is referenced by 2 stream(s) / 1 template(s)", + "streams": ["..."], + "templates": ["..."] +} +``` + +Detach each dependent (set `playback_policy` to null/empty) before retrying. + +--- + +## 9. Worked examples + +### Token-gated browser playback (HLS), domain-locked + +```json +POST /policies/web-embed +{ + "code": "web-embed", + "name": "Website embeds only, token required", + "require_token": true, + "token_secret": "s3cr3t", + "allowed_domains": ["example.com"] +} +``` +Bind to a stream (`"playback_policy": "web-embed"`). Players load +`https://host//index.m3u8?token=` from a page on `example.com`. +RTMP clients of the same stream pass the token on the play URL +(`rtmp://host:1935/live/?token=`); note `allowed_domains` does not +apply to RTMP (no Referer), so the domain lock is browser-only. + +### Geo-restricted, RTMP-friendly (no token) + +```json +POST /policies/vn-only +{ + "code": "vn-only", + "name": "Vietnam only", + "allow_countries": ["VN"] +} +``` +Works uniformly on HLS/DASH/MPEGTS/RTMP/SRT/RTSP — IP/country apply to every +protocol. + +### Block a set of abusive networks (deny-list) + +```json +POST /policies/block-bad-nets +{ + "code": "block-bad-nets", + "deny_ips": ["203.0.113.0/24", "198.51.100.7"] +} +``` +Deny-lists are safe on every protocol (an absent signal simply never matches). diff --git a/internal/ingestor/push/rtmp_server.go b/internal/ingestor/push/rtmp_server.go index 15cc258..e31b0d5 100644 --- a/internal/ingestor/push/rtmp_server.go +++ b/internal/ingestor/push/rtmp_server.go @@ -102,6 +102,13 @@ type PlayInfo struct { // RemoteAddr is the peer's "ip:port" string from the underlying TCP // connection. Useful for the play-sessions tracker / abuse mitigation. RemoteAddr string + + // RawQuery is the query string of the play URL (the part after '?' in the + // app or stream name), already stripped of the leading '?'. The push server + // stays media-auth-agnostic and only forwards it; the publisher's RTMP play + // handler extracts the playback token from it (?token=…), mirroring how the + // SRT/RTSP play paths carry the token. Empty when the client sent no query. + RawQuery string } // StreamCallbacks receives per-publish-session events for a single stream. @@ -517,7 +524,10 @@ func (s *RTMPServer) OnNewRtmpSubSession(session *rtmp.ServerSession) error { return errors.New("rtmp server: play handler not configured") } - info := PlayInfo{RemoteAddr: session.GetStat().RemoteAddr} + info := PlayInfo{ + RemoteAddr: session.GetStat().RemoteAddr, + RawQuery: session.RawQuery(), + } ctx, cancel := context.WithCancel(context.Background()) s.mu.Lock() diff --git a/internal/publisher/serve_rtmp.go b/internal/publisher/serve_rtmp.go index bbc587f..9f3ccae 100644 --- a/internal/publisher/serve_rtmp.go +++ b/internal/publisher/serve_rtmp.go @@ -29,6 +29,7 @@ import ( "github.com/ntt0601zcoder/open-streamer/internal/buffer" "github.com/ntt0601zcoder/open-streamer/internal/domain" "github.com/ntt0601zcoder/open-streamer/internal/ingestor/push" + "github.com/ntt0601zcoder/open-streamer/internal/sessions" "github.com/ntt0601zcoder/open-streamer/internal/tsdemux" "github.com/ntt0601zcoder/open-streamer/internal/tsmux" ) @@ -54,10 +55,14 @@ func (s *Service) HandleRTMPPlay( return fmt.Errorf("stream %q not active", key) } - // Media-plane authorization (token / IP / country) before any state. RTMP - // play carries no query/UA in the handshake, so token/UA rules don't apply - // here — IP/country/policy still do (B / S-13). - if !s.playAllowed(code, "rtmp", info.RemoteAddr, "", "", "") { + // Media-plane authorization (token / IP / country) before any state. The + // playback token rides the play-URL query (rtmp://host/live/?token=…), + // extracted the same way as the SRT/RTSP paths; a token-required policy is + // therefore enforceable over RTMP. RTMP has no User-Agent / Referer in the + // handshake, so those rules stay inert — IP/country/token/policy apply + // (B / S-13). + token := sessions.TokenFromQuery(info.RawQuery) + if !s.playAllowed(code, "rtmp", info.RemoteAddr, token, "", "") { return fmt.Errorf("stream %q: playback not authorized", key) } diff --git a/internal/publisher/serve_rtmp_auth_test.go b/internal/publisher/serve_rtmp_auth_test.go new file mode 100644 index 0000000..b21c511 --- /dev/null +++ b/internal/publisher/serve_rtmp_auth_test.go @@ -0,0 +1,89 @@ +package publisher + +// serve_rtmp_auth_test.go — media-plane authorization for the RTMP play path. +// The cases mirror exactly what HandleRTMPPlay does: take the play-URL raw +// query, extract the token via sessions.TokenFromQuery, and run playAllowed. + +import ( + "testing" + "time" + + "github.com/ntt0601zcoder/open-streamer/internal/domain" + "github.com/ntt0601zcoder/open-streamer/internal/mediaauth" + "github.com/ntt0601zcoder/open-streamer/internal/sessions" +) + +func TestRTMPPlay_TokenPolicy(t *testing.T) { + const ( + code = domain.StreamCode("mychannel") + polCfg = domain.PolicyCode("tok") + secret = "s3cr3t" + ) + + authz := mediaauth.New(nil, func(c domain.StreamCode) domain.PolicyCode { + if c == code { + return polCfg + } + return "" + }) + authz.SetPolicies([]*domain.Policy{{ + Code: polCfg, + RequireToken: true, + TokenSecret: secret, + }}) + + svc := &Service{} + svc.SetMediaAuthorizer(authz) + + now := time.Now() + valid := mediaauth.SignToken([]byte(secret), code, now.Add(time.Hour).Unix()) + expired := mediaauth.SignToken([]byte(secret), code, now.Add(-time.Hour).Unix()) + otherStream := mediaauth.SignToken([]byte(secret), domain.StreamCode("other"), now.Add(time.Hour).Unix()) + wrongSecret := mediaauth.SignToken([]byte("nope"), code, now.Add(time.Hour).Unix()) + + // play mirrors HandleRTMPPlay's auth step: rawQuery -> token -> playAllowed. + play := func(rawQuery string) bool { + tok := sessions.TokenFromQuery(rawQuery) + return svc.playAllowed(code, "rtmp", "203.0.113.5:5555", tok, "", "") + } + + cases := []struct { + name string + rawQuery string + want bool + }{ + {"valid token", "token=" + valid, true}, + {"valid token with extra params", "token=" + valid + "&foo=bar", true}, + {"missing query", "", false}, + {"empty token param", "token=", false}, + {"garbage token", "token=not-a-token", false}, + {"expired token", "token=" + expired, false}, + {"token bound to another stream", "token=" + otherStream, false}, + {"token signed with wrong secret", "token=" + wrongSecret, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := play(tc.rawQuery); got != tc.want { + t.Fatalf("play(%q) = %v, want %v", tc.rawQuery, got, tc.want) + } + }) + } +} + +func TestRTMPPlay_NoPolicyIsPublic(t *testing.T) { + const code = domain.StreamCode("public") + authz := mediaauth.New(nil, func(domain.StreamCode) domain.PolicyCode { return "" }) + svc := &Service{} + svc.SetMediaAuthorizer(authz) + + if !svc.playAllowed(code, "rtmp", "203.0.113.9:1", "", "", "") { + t.Fatal("stream with no policy must be public over RTMP") + } +} + +func TestRTMPPlay_NilAuthorizerAllows(t *testing.T) { + svc := &Service{} // no authorizer wired → media auth disabled + if !svc.playAllowed("any", "rtmp", "203.0.113.9:1", "", "", "") { + t.Fatal("nil authorizer must allow playback") + } +} From dd9911765a07d3bd27862f4e99b8ccc963107d62 Mon Sep 17 00:00:00 2001 From: ntthuan060102github Date: Tue, 16 Jun 2026 17:19:39 +0700 Subject: [PATCH 2/2] fix(publisher): drop dead referer param from playAllowed (lint) unparam flagged playAllowed's referer argument: RTMP / SRT / RTSP all pass "" because none of those protocols carry a Referer (the AllowedDomains gate runs only on the HTTP path). Remove the parameter and update the call sites and tests; no behaviour change. --- api/docs/docs.go | 16 ++++++++-------- api/docs/swagger.json | 16 ++++++++-------- api/docs/swagger.yaml | 12 ++++++------ docs/media-auth-policies.md | 6 +++--- internal/publisher/playauth.go | 7 ++++--- internal/publisher/serve_rtmp.go | 2 +- internal/publisher/serve_rtmp_auth_test.go | 6 +++--- internal/publisher/serve_rtsp.go | 2 +- internal/publisher/serve_srt.go | 2 +- 9 files changed, 35 insertions(+), 34 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index 927f37d..e327a96 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -2646,6 +2646,8 @@ const docTemplate = `{ "domain.EventType": { "type": "string", "enum": [ + "session.opened", + "session.closed", "stream.created", "stream.updated", "stream.started", @@ -2682,9 +2684,7 @@ const docTemplate = `{ "policy.updated", "policy.deleted", "stream.runtime_created", - "stream.runtime_expired", - "session.opened", - "session.closed" + "stream.runtime_expired" ], "x-enum-comments": { "EventDVRSegmentPruned": "retention loop deleted an aged-out segment", @@ -2701,6 +2701,8 @@ const docTemplate = `{ "EventStreamUpdated": "PUT /streams/{code} on existing record" }, "x-enum-descriptions": [ + "", + "", "", "PUT /streams/{code} on existing record", "", @@ -2737,11 +2739,11 @@ const docTemplate = `{ "", "", "", - "", - "", "" ], "x-enum-varnames": [ + "EventSessionOpened", + "EventSessionClosed", "EventStreamCreated", "EventStreamUpdated", "EventStreamStarted", @@ -2778,9 +2780,7 @@ const docTemplate = `{ "EventPolicyUpdated", "EventPolicyDeleted", "EventStreamRuntimeCreated", - "EventStreamRuntimeExpired", - "EventSessionOpened", - "EventSessionClosed" + "EventStreamRuntimeExpired" ] }, "domain.GlobalConfig": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index d3d56d9..93900d5 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -2639,6 +2639,8 @@ "domain.EventType": { "type": "string", "enum": [ + "session.opened", + "session.closed", "stream.created", "stream.updated", "stream.started", @@ -2675,9 +2677,7 @@ "policy.updated", "policy.deleted", "stream.runtime_created", - "stream.runtime_expired", - "session.opened", - "session.closed" + "stream.runtime_expired" ], "x-enum-comments": { "EventDVRSegmentPruned": "retention loop deleted an aged-out segment", @@ -2694,6 +2694,8 @@ "EventStreamUpdated": "PUT /streams/{code} on existing record" }, "x-enum-descriptions": [ + "", + "", "", "PUT /streams/{code} on existing record", "", @@ -2730,11 +2732,11 @@ "", "", "", - "", - "", "" ], "x-enum-varnames": [ + "EventSessionOpened", + "EventSessionClosed", "EventStreamCreated", "EventStreamUpdated", "EventStreamStarted", @@ -2771,9 +2773,7 @@ "EventPolicyUpdated", "EventPolicyDeleted", "EventStreamRuntimeCreated", - "EventStreamRuntimeExpired", - "EventSessionOpened", - "EventSessionClosed" + "EventStreamRuntimeExpired" ] }, "domain.GlobalConfig": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index 278bf87..740549a 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -678,6 +678,8 @@ definitions: type: object domain.EventType: enum: + - session.opened + - session.closed - stream.created - stream.updated - stream.started @@ -715,8 +717,6 @@ definitions: - policy.deleted - stream.runtime_created - stream.runtime_expired - - session.opened - - session.closed type: string x-enum-comments: EventDVRSegmentPruned: retention loop deleted an aged-out segment @@ -734,6 +734,8 @@ definitions: EventStreamUpdated: PUT /streams/{code} on existing record x-enum-descriptions: - "" + - "" + - "" - PUT /streams/{code} on existing record - "" - "" @@ -770,9 +772,9 @@ definitions: - "" - "" - "" - - "" - - "" x-enum-varnames: + - EventSessionOpened + - EventSessionClosed - EventStreamCreated - EventStreamUpdated - EventStreamStarted @@ -810,8 +812,6 @@ definitions: - EventPolicyDeleted - EventStreamRuntimeCreated - EventStreamRuntimeExpired - - EventSessionOpened - - EventSessionClosed domain.GlobalConfig: properties: auth: diff --git a/docs/media-auth-policies.md b/docs/media-auth-policies.md index 03174cb..1fbb476 100644 --- a/docs/media-auth-policies.md +++ b/docs/media-auth-policies.md @@ -174,9 +174,9 @@ exactly when IP is — on all protocols. | Protocol | Gate call (file:line) | |---|---| | HLS / DASH / HTTP-MPEGTS | `mediaAllowed(r, code)` — [dispatch.go:164](../internal/api/dispatch.go#L164) (one gate for all three, before file routing) | -| RTMP | `playAllowed(code, "rtmp", addr, token, "", "")` — token from the play-URL query — [serve_rtmp.go](../internal/publisher/serve_rtmp.go) | -| SRT | `playAllowed(code, "srt", addr, srtTok, "", "")` — [serve_srt.go:129](../internal/publisher/serve_srt.go#L129) | -| RTSP | `playAllowed(code, "rtsp", remote, TokenFromQuery, ua, "")` — [serve_rtsp.go:253](../internal/publisher/serve_rtsp.go#L253) | +| RTMP | `playAllowed(code, "rtmp", addr, token, "")` — token from the play-URL query — [serve_rtmp.go](../internal/publisher/serve_rtmp.go) | +| SRT | `playAllowed(code, "srt", addr, srtTok, "")` — [serve_srt.go:129](../internal/publisher/serve_srt.go#L129) | +| RTSP | `playAllowed(code, "rtsp", remote, TokenFromQuery, ua)` — [serve_rtsp.go:253](../internal/publisher/serve_rtsp.go#L253) | --- diff --git a/internal/publisher/playauth.go b/internal/publisher/playauth.go index 4dbf932..44994aa 100644 --- a/internal/publisher/playauth.go +++ b/internal/publisher/playauth.go @@ -49,8 +49,10 @@ func (s *Service) PushStreamKey(code domain.StreamCode) (streamKey string, runni // playAllowed runs the media-auth chain for one playback request. Returns true // (allow) when no authorizer is wired or media auth is disabled. Denials are -// logged with the (non-leaky) reason. -func (s *Service) playAllowed(code domain.StreamCode, proto, addr, token, ua, referer string) bool { +// logged with the (non-leaky) reason. Referer is left unset: the non-HTTP play +// protocols (RTMP / SRT / RTSP) carry no Referer, so AllowedDomains rules never +// apply to them — that gate is enforced only on the HTTP path (see dispatch.go). +func (s *Service) playAllowed(code domain.StreamCode, proto, addr, token, ua string) bool { if s.mediaAuth == nil { return true } @@ -59,7 +61,6 @@ func (s *Service) playAllowed(code domain.StreamCode, proto, addr, token, ua, re ClientIP: ipFromAddr(addr), Token: token, UserAgent: ua, - Referer: referer, }) if !d.Allow { slog.Info("publisher: playback denied", "stream_code", code, "proto", proto, "reason", d.Reason, "remote", addr) diff --git a/internal/publisher/serve_rtmp.go b/internal/publisher/serve_rtmp.go index 9f3ccae..b235234 100644 --- a/internal/publisher/serve_rtmp.go +++ b/internal/publisher/serve_rtmp.go @@ -62,7 +62,7 @@ func (s *Service) HandleRTMPPlay( // handshake, so those rules stay inert — IP/country/token/policy apply // (B / S-13). token := sessions.TokenFromQuery(info.RawQuery) - if !s.playAllowed(code, "rtmp", info.RemoteAddr, token, "", "") { + if !s.playAllowed(code, "rtmp", info.RemoteAddr, token, "") { return fmt.Errorf("stream %q: playback not authorized", key) } diff --git a/internal/publisher/serve_rtmp_auth_test.go b/internal/publisher/serve_rtmp_auth_test.go index b21c511..f61ac72 100644 --- a/internal/publisher/serve_rtmp_auth_test.go +++ b/internal/publisher/serve_rtmp_auth_test.go @@ -44,7 +44,7 @@ func TestRTMPPlay_TokenPolicy(t *testing.T) { // play mirrors HandleRTMPPlay's auth step: rawQuery -> token -> playAllowed. play := func(rawQuery string) bool { tok := sessions.TokenFromQuery(rawQuery) - return svc.playAllowed(code, "rtmp", "203.0.113.5:5555", tok, "", "") + return svc.playAllowed(code, "rtmp", "203.0.113.5:5555", tok, "") } cases := []struct { @@ -76,14 +76,14 @@ func TestRTMPPlay_NoPolicyIsPublic(t *testing.T) { svc := &Service{} svc.SetMediaAuthorizer(authz) - if !svc.playAllowed(code, "rtmp", "203.0.113.9:1", "", "", "") { + if !svc.playAllowed(code, "rtmp", "203.0.113.9:1", "", "") { t.Fatal("stream with no policy must be public over RTMP") } } func TestRTMPPlay_NilAuthorizerAllows(t *testing.T) { svc := &Service{} // no authorizer wired → media auth disabled - if !svc.playAllowed("any", "rtmp", "203.0.113.9:1", "", "", "") { + if !svc.playAllowed("any", "rtmp", "203.0.113.9:1", "", "") { t.Fatal("nil authorizer must allow playback") } } diff --git a/internal/publisher/serve_rtsp.go b/internal/publisher/serve_rtsp.go index 869e569..17a566a 100644 --- a/internal/publisher/serve_rtsp.go +++ b/internal/publisher/serve_rtsp.go @@ -250,7 +250,7 @@ func (h *rtspHandler) OnPlay(ctx *gortsplib.ServerHandlerOnPlayCtx) (*base.Respo if c := ctx.Conn.NetConn().RemoteAddr(); c != nil { remote = c.String() } - if !h.svc.playAllowed(code, "rtsp", remote, sessions.TokenFromQuery(ctx.Query), ua, "") { + if !h.svc.playAllowed(code, "rtsp", remote, sessions.TokenFromQuery(ctx.Query), ua) { return &base.Response{StatusCode: base.StatusUnauthorized}, nil } diff --git a/internal/publisher/serve_srt.go b/internal/publisher/serve_srt.go index fd89e69..6c8d494 100644 --- a/internal/publisher/serve_srt.go +++ b/internal/publisher/serve_srt.go @@ -126,7 +126,7 @@ func (s *Service) srtHandleSubscribe(ctx context.Context, conn srt.Conn) { srtTok = sessions.TokenFromQuery(sid[i+1:]) } } - if !s.playAllowed(streamCode, "srt", conn.RemoteAddr().String(), srtTok, "", "") { + if !s.playAllowed(streamCode, "srt", conn.RemoteAddr().String(), srtTok, "") { return }