mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 03:55:05 +00:00
feat(serve): gate server CDM licensing behind a per-key server_cdm opt-in
Keys configured under serve.users default to off, so they license with their own local CDM; session licensing and download jobs both refuse otherwise.
This commit is contained in:
@@ -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`"
|
||||
|
||||
@@ -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({})
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user