diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index 95ee8ff8..405b4236 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -126,6 +126,13 @@ Each entry under `users` is keyed by that user's API key and may set its own `se `username` used as the log label for that key (defaults to a truncated form of the key). A user with no `playready_devices` key gets no PlayReady access at all, not the global list. +`server_cdm` decides whether the server runs the CDM licensing for that 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, a 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. + ```yaml serve: api_secret: change-me @@ -137,6 +144,8 @@ serve: users: a1b2c3d4: # this user's API key services: [EXAMPLE1] # may only use EXAMPLE1 + e5f6a7b8: + server_cdm: true # this key may have the server do the licensing ``` !!! note "`dl` keys inside `serve`" diff --git a/tests/remote/unit/test_server_cdm_gate.py b/tests/remote/unit/test_server_cdm_gate.py new file mode 100644 index 00000000..85f799b4 --- /dev/null +++ b/tests/remote/unit/test_server_cdm_gate.py @@ -0,0 +1,56 @@ +"""Only keys that opt in may have the server run the CDM licensing.""" + +from types import SimpleNamespace + +import pytest + +from unshackle.core.api import handlers +from unshackle.core.api.errors import APIError, APIErrorCode + +pytestmark = pytest.mark.unit + +USERS = { + "tier1key": {"username": "tier1"}, + "tier3key": {"username": "tier3", "server_cdm": True}, +} + +ENTITLEMENTS = [ + ("tier3key", True), + ("tier1key", False), + ("adminsecret", True), # no users entry, e.g. api_secret + (None, True), +] + + +@pytest.fixture +def serve_users(monkeypatch): + monkeypatch.setattr(handlers.config, "serve", {"users": USERS}, raising=False) + monkeypatch.setattr(handlers, "request_secret_key", lambda request: request.headers.get("X-Secret-Key")) + + +def _request(key): + return SimpleNamespace(headers={"X-Secret-Key": key} if key else {}) + + +@pytest.mark.parametrize(("key", "allowed"), ENTITLEMENTS) +def test_server_cdm_allowed(serve_users, key, allowed): + assert handlers.server_cdm_allowed(_request(key)) is allowed + + +def test_no_request_allows(serve_users): + assert handlers.server_cdm_allowed(None) is True + + +@pytest.mark.parametrize(("key", "allowed"), ENTITLEMENTS) +def test_download_gate_follows_entitlement(serve_users, key, allowed): + # A download job licenses with the server CDM, so submission obeys the same gate. + if allowed: + handlers.enforce_download_gates({}, _request(key)) + else: + with pytest.raises(APIError) as ei: + handlers.enforce_download_gates({}, _request(key)) + assert ei.value.error_code == APIErrorCode.FORBIDDEN + + +def test_download_gate_without_request(serve_users): + handlers.enforce_download_gates({}) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 237777f6..7ecc6f76 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1404,7 +1404,7 @@ class dl: self.service_anime = bool(getattr(service, "ANIME", False)) self.service_daily = bool(getattr(service, "DAILY", False)) self.server_cdm = getattr(service, "_server_cdm", False) - self._remote_service = service if self.server_cdm else None + self._remote_service = service if hasattr(service, "_server_cdm") else None start_time = time.time() lang, lang_excl = partition_exclusions(lang) @@ -4256,7 +4256,8 @@ class dl: if not drm: return - server_cdm = getattr(self, "server_cdm", False) + svc_for_cdm = getattr(self, "_remote_service", None) + server_cdm = getattr(svc_for_cdm, "_server_cdm", getattr(self, "server_cdm", False)) if server_cdm: if not drm.content_keys: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index d0def6a6..292bce0d 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -283,6 +283,21 @@ 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 key may have the server run the CDM licensing. + + Configured keys opt in with ``server_cdm: true``; keys absent from + ``serve.users`` (the admin secret) keep full access. + """ + if not request: + return True + secret_key = request_secret_key(request) + user_config = config.serve.get("users", {}).get(secret_key) + if user_config is None: + return True + return bool(user_config.get("server_cdm", False)) + + JOB_EVENTS_ROUTE = "/api/download/jobs/{job_id}/events" CORS_HEADERS = { @@ -1034,7 +1049,7 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: return None -def enforce_download_gates(params: Dict[str, Any]) -> None: +def enforce_download_gates(params: Dict[str, Any], request: Optional[web.Request] = None) -> None: """Enforce serve-config gates on per-job cdm overrides and client-supplied credentials. A per-request `cdm` selects a server-side device, so it is gated here rather than honoured @@ -1043,7 +1058,15 @@ def enforce_download_gates(params: Dict[str, Any]) -> None: A per-request `credential` (or `credentials` map) authenticates the job with client-supplied secrets instead of the server-side credentials. Gate it behind `serve.allow_job_credentials` (default off) so a default deployment stays locked to its own credentials; mirrors the CDM gate. + A download job licenses DRM in-process with the server's own CDM, so a key without + ``server_cdm`` cannot submit or retry jobs. """ + if not server_cdm_allowed(request): + raise APIError( + APIErrorCode.FORBIDDEN, + "Download jobs license with the server CDM, which is not enabled for this key.", + ) + requested_cdm = params.get("cdm") if requested_cdm: allowed = (config.serve or {}).get("cdm_overrides") @@ -1087,7 +1110,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] details={"service": normalized_service, "title_id": title_id}, ) - enforce_download_gates(data) + enforce_download_gates(data, request) try: # Load service module to extract service-specific parameter defaults @@ -1396,7 +1419,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) + enforce_download_gates(job.parameters, request) await manager.start_workers() @@ -2122,6 +2145,7 @@ async def session_tracks_handler( "manifests": manifests, "session_headers": session_headers, "session_cookies": session_cookies, + "server_cdm": server_cdm_allowed(request), "server_cdm_type": server_cdm_type, } ) @@ -2742,6 +2766,12 @@ 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): + raise APIError( + APIErrorCode.FORBIDDEN, + "Server CDM licensing is not enabled for this key. Use a local CDM (proxy mode).", + ) + if mode == "server_cdm" and track_ids: from unshackle.core.config import config as app_config diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 53ab60c1..dd7bfd4f 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -662,6 +662,9 @@ class RemoteService: _resolve_manifest_data(tracks, result.get("manifests", [])) + if self._server_cdm and not result.get("server_cdm", True): + self._server_cdm = False + self.log.warning("Server CDM licensing is not enabled for this key, using the local CDM") self._server_cdm_type = result.get("server_cdm_type", "widevine") self._tracks_by_title[title_id] = tracks