feat(serve): gate server_cdm per service and let clients follow it

serve.users.<key>.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.
This commit is contained in:
imSp4rky
2026-08-27 10:10:46 -06:00
parent 5128fef473
commit 3aa275035b
5 changed files with 94 additions and 23 deletions
+14 -6
View File
@@ -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`"
+1 -1
View File
@@ -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:
+49
View File
@@ -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
+19 -11
View File
@@ -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:
+11 -5
View File
@@ -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