mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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`"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user