From 3aa275035b96b6961ee54bf19a783aedbd4e3095 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 27 Aug 2026 10:10:46 -0600 Subject: [PATCH] feat(serve): gate server_cdm per service and let clients follow it serve.users..server_cdm accepts a list of service tags; retry re-attaches the job service and a bare string no longer fails open. An unset client server_cdm now follows the server's per-service /tracks flag. --- docs/reference/configuration/services.md | 20 ++++++--- tests/remote/unit/test_remote_resolve.py | 2 +- tests/remote/unit/test_server_cdm_gate.py | 49 +++++++++++++++++++++++ unshackle/core/api/handlers.py | 30 +++++++++----- unshackle/core/remote_service.py | 16 +++++--- 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index 21fb0f5e..be7f14d8 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -86,6 +86,10 @@ the server's `url` (required), an optional `api_key`, an optional `auth_headers` optional `server_cdm` boolean, and an optional `services` sub-dict of per-service local overrides such as `title_map`. +Leave `server_cdm` unset and the client follows the server: it uses the server CDM for each +service the API key has it on, and the local CDM for the others. `true` asks for the server CDM +and falls back to the local CDM when the server refuses; `false` always uses the local CDM. + `auth_headers` lists extra header names to send the API key in, tried before the defaults `X-Secret-Key` and `X-Api-Key`, which are always appended as fallbacks. unshackle sends the first name. If the server answers `401`, it retries the same request with the next name, and @@ -130,12 +134,13 @@ Each entry under `users` uses that user's API key as its name, and can set its o A user with no `playready_devices` config key gets no PlayReady access at all, not the global list. -`server_cdm` decides whether the server runs the CDM licensing for that API key. It is `false` -unless the entry sets it, so a remote client configured with `server_cdm: true` is told to -license with its own local CDM instead, and a client that asks anyway gets a `FORBIDDEN` -error. Because a download job always licenses with the server's CDM, an API key without -`server_cdm` also cannot submit or retry `/api/download` jobs. Keys that have no `users` -entry, such as `api_secret`, keep server CDM access. +`server_cdm` decides whether the server runs the CDM licensing for that API key. Set it to +`true` to enable every service, or to a list of service tags to enable only those. It is `false` +unless the entry sets it. For a service the API key does not cover, the server tells a remote +client configured with `server_cdm: true` to license with its own local CDM instead, and a +client that asks anyway gets a `FORBIDDEN` error. Because a download job always licenses with the server's CDM, +an API key without `server_cdm` for that service also cannot submit or retry `/api/download` +jobs. Keys that have no `users` entry, such as `api_secret`, keep server CDM access. ```yaml serve: @@ -151,6 +156,9 @@ serve: services: [EXAMPLE1] # may only use EXAMPLE1 e5f6a7b8: server_cdm: true # this key may have the server do the licensing + c9d0e1f2: + services: [EXAMPLE1, EXAMPLE2] + server_cdm: [EXAMPLE1] # the server licenses only EXAMPLE1; EXAMPLE2 needs a local CDM ``` !!! note "`dl` keys inside `serve`" diff --git a/tests/remote/unit/test_remote_resolve.py b/tests/remote/unit/test_remote_resolve.py index 9c48cc98..7ae0718e 100644 --- a/tests/remote/unit/test_remote_resolve.py +++ b/tests/remote/unit/test_remote_resolve.py @@ -122,7 +122,7 @@ def test_resolve_server_multi_with_name(multi_remote_services) -> None: url, key, services = resolve_server("b") assert url == "https://b:8080" assert key == "kb" - assert services["_server_cdm"] is False + assert services["_server_cdm"] is None def test_resolve_proxy_none_returns_none() -> None: diff --git a/tests/remote/unit/test_server_cdm_gate.py b/tests/remote/unit/test_server_cdm_gate.py index b2864297..78e82df1 100644 --- a/tests/remote/unit/test_server_cdm_gate.py +++ b/tests/remote/unit/test_server_cdm_gate.py @@ -1,5 +1,6 @@ """Only keys that opt in may have the server run the CDM licensing.""" +from datetime import datetime from types import SimpleNamespace import pytest @@ -12,6 +13,8 @@ pytestmark = pytest.mark.unit USERS = { "tier1key": {"username": "tier1"}, "tier3key": {"username": "tier3", "server_cdm": True}, + "tier2key": {"username": "tier2", "server_cdm": ["example1"]}, + "tier2strkey": {"username": "tier2str", "server_cdm": "example1"}, } ENTITLEMENTS = [ @@ -37,6 +40,52 @@ def test_server_cdm_allowed(serve_users, key, allowed): assert handlers.server_cdm_allowed(request(key)) is allowed +@pytest.mark.parametrize(("service", "allowed"), [("EXAMPLE1", True), ("EXAMPLE2", False), (None, False)]) +def test_server_cdm_service_list(serve_users, service, allowed): + assert handlers.server_cdm_allowed(request("tier2key"), service) is allowed + + +@pytest.mark.parametrize(("service", "allowed"), [("EXAMPLE1", True), ("EXAMPLE2", False)]) +def test_server_cdm_bare_tag(serve_users, service, allowed): + # A bare tag gates one service. It must not read as a truthy value that opens every service. + assert handlers.server_cdm_allowed(request("tier2strkey"), service) is allowed + + +def test_download_gate_uses_job_service(serve_users): + handlers.enforce_download_gates({"service": "EXAMPLE1"}, request("tier2key")) + with pytest.raises(APIError): + handlers.enforce_download_gates({"service": "EXAMPLE2"}, request("tier2key")) + + +@pytest.mark.parametrize(("service", "allowed"), [("EXAMPLE1", True), ("EXAMPLE2", False)]) +async def test_retry_gate_uses_job_service(serve_users, monkeypatch, service, allowed): + # The job keeps its tag outside `parameters`, so the retry gate must add it back. + from unshackle.core.api import download_manager as dm + + job = SimpleNamespace(service=service, title_id="t1", parameters={}, status=dm.JobStatus.FAILED) + new_job = SimpleNamespace(job_id="new", status=dm.JobStatus.QUEUED, created_time=datetime.now()) + + async def start_workers(): + return None + + manager = SimpleNamespace( + get_job=lambda job_id: job, + start_workers=start_workers, + create_job=lambda *args, **kwargs: new_job, + ) + monkeypatch.setattr(dm, "get_download_manager", lambda: manager) + monkeypatch.setattr(handlers, "owns_job", lambda job, request: True) + monkeypatch.setattr(handlers, "validate_service", lambda tag, request=None: tag) + + if allowed: + response = await handlers.retry_download_job_handler("job1", request("tier2key")) + assert response.status == 202 + else: + with pytest.raises(APIError) as ei: + await handlers.retry_download_job_handler("job1", request("tier2key")) + assert ei.value.error_code == APIErrorCode.FORBIDDEN + + def test_no_request_allows(serve_users): assert handlers.server_cdm_allowed(None) is True diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 828353f7..5b5cd777 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -291,11 +291,12 @@ def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List return list(result) -def server_cdm_allowed(request: Optional[web.Request] = None) -> bool: - """Whether the calling API key may have the server operate the CDM licensing. +def server_cdm_allowed(request: Optional[web.Request] = None, service: Optional[str] = None) -> bool: + """Whether the calling API key may have the server operate the CDM licensing for ``service``. - Configured API keys opt in with ``server_cdm: true``. An API key absent from - ``serve.users`` (the admin secret) keeps full access. + Configured API keys opt in with ``server_cdm: true`` for every service, or with a list of + service tags for only those. An API key absent from ``serve.users`` (the admin secret) keeps + full access. """ if not request: return True @@ -303,7 +304,14 @@ def server_cdm_allowed(request: Optional[web.Request] = None) -> bool: user_config = config.serve.get("users", {}).get(secret_key) if user_config is None: return True - return bool(user_config.get("server_cdm", False)) + allowed = user_config.get("server_cdm", False) + if isinstance(allowed, str): + allowed = [allowed] + if isinstance(allowed, (list, tuple, set)): + return service is not None and Services.get_tag(service).upper() in { + Services.get_tag(s).upper() for s in allowed + } + return bool(allowed) JOB_EVENTS_ROUTE = "/api/download/jobs/{job_id}/events" @@ -1192,10 +1200,10 @@ def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request CDM gate. A download job licenses DRM in-process with the server's own CDM, so an API key without ``server_cdm`` cannot submit or retry jobs. """ - if not server_cdm_allowed(request): + if not server_cdm_allowed(request, params.get("service")): raise APIError( APIErrorCode.FORBIDDEN, - "Download jobs license with the server CDM, which is not enabled for this key.", + "Download jobs license with the server CDM, which is not enabled for this key on this service.", ) requested_cdm = params.get("cdm") @@ -1545,7 +1553,7 @@ async def retry_download_job_handler(job_id: str, request: Optional[web.Request] f"Invalid or unavailable service: {job.service}", details={"service": job.service}, ) - enforce_download_gates(job.parameters, request) + enforce_download_gates({**job.parameters, "service": job.service}, request) await manager.start_workers() @@ -2249,7 +2257,7 @@ async def session_tracks_handler( "manifests": manifests, "session_headers": session_headers, "session_cookies": session_cookies, - "server_cdm": server_cdm_allowed(request), + "server_cdm": server_cdm_allowed(request, service_tag), "server_cdm_type": server_cdm_type, } ) @@ -2870,10 +2878,10 @@ async def session_license_handler( drm_type = data.get("drm_type", "widevine") mode = data.get("mode", "proxy") - if mode == "server_cdm" and not server_cdm_allowed(request): + if mode == "server_cdm" and not server_cdm_allowed(request, session.service_tag): raise APIError( APIErrorCode.FORBIDDEN, - "Server CDM licensing is not enabled for this key. Use a local CDM (proxy mode).", + "Server CDM licensing is not enabled for this key on this service. Use a local CDM (proxy mode).", ) if mode == "server_cdm" and track_ids: diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 0c60155d..2fe10186 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -541,7 +541,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: available = ", ".join(remote_services.keys()) raise click.ClickException(f"Remote service '{server_name}' not found. Available: {available}") services = svc.get("services", {}) - services["_server_cdm"] = svc.get("server_cdm", False) + services["_server_cdm"] = svc.get("server_cdm") services["_auth_headers"] = resolve_auth_headers(svc, server_name) return svc["url"], svc.get("api_key", ""), services @@ -549,7 +549,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: name, svc = next(iter(remote_services.items())) log.info(f"Using remote service: {name}") services = svc.get("services", {}) - services["_server_cdm"] = svc.get("server_cdm", False) + services["_server_cdm"] = svc.get("server_cdm") services["_auth_headers"] = resolve_auth_headers(svc, name) return svc["url"], svc.get("api_key", ""), services @@ -647,7 +647,7 @@ class RemoteService: self._session.mount("http://", self._session.adapters["https://"]) svc_config = services_config.get(service_tag, {}) - self._server_cdm = services_config.get("_server_cdm", False) + self._server_cdm: bool | None = services_config.get("_server_cdm") self.apply_service_config(svc_config) def apply_service_config(self, svc_config: dict) -> None: @@ -837,9 +837,15 @@ class RemoteService: resolve_manifest_data(tracks, result.get("manifests", [])) apply_service_track_data(tracks, result) - if self._server_cdm and not result.get("server_cdm", True): + server_cdm = bool(result.get("server_cdm", False)) + if self._server_cdm is None: + self._server_cdm = server_cdm + elif self._server_cdm and not server_cdm: self._server_cdm = False - self.log.warning("Server CDM licensing is not enabled for this key, using the local CDM") + self.log.warning( + f"{self.service_tag} is not available for server CDM licensing with this API key, " + "falling back to the local CDM" + ) self._server_cdm_type = result.get("server_cdm_type", "widevine") self._tracks_by_title[title_id] = tracks