diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 179e81e9..46144c44 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -143,6 +143,16 @@ def serve( if remote_only: api_only = True + global_services = config.serve.get("services") + if global_services: + log.info(f"Global service allowlist: {', '.join(global_services)}") + users = config.serve.get("users", {}) + for user_key, user_cfg in (users.items() if isinstance(users, dict) else []): + user_services = user_cfg.get("services") if isinstance(user_cfg, dict) else None + if user_services: + username = user_cfg.get("username", user_key[:8] + "...") + log.info(f"User '{username}' restricted to services: {', '.join(user_services)}") + if api_only: log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)") if no_key: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 0fadd23e..b32768dc 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -7,6 +7,7 @@ from aiohttp import web from unshackle.core.api.errors import APIError, APIErrorCode, handle_api_exception from unshackle.core.api.input_bridge import AuthStatus, InputBridge +from unshackle.core.config import config from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy from unshackle.core.services import Services @@ -64,13 +65,48 @@ DEFAULT_DOWNLOAD_PARAMS = { } -def validate_service(service_tag: str) -> Optional[str]: - """Validate and normalize service tag.""" +def get_allowed_services(request: Optional[web.Request] = None) -> Optional[List[str]]: + """Get effective service allowlist considering global + per-key config. + + Returns None if all services are allowed. + """ + global_allowed = config.serve.get("services") + global_set: Optional[set[str]] = None + if global_allowed: + global_set = {Services.get_tag(s) for s in global_allowed} + + key_set: Optional[set[str]] = None + if request: + secret_key = request.headers.get("X-Secret-Key") + if secret_key: + users = config.serve.get("users", {}) + user_config = users.get(secret_key, {}) + user_services = user_config.get("services") + if user_services: + key_set = {Services.get_tag(s) for s in user_services} + + if global_set and key_set: + result = global_set & key_set + elif global_set: + result = global_set + elif key_set: + result = key_set + else: + return None + + return list(result) + + +def validate_service(service_tag: str, request: Optional[web.Request] = None) -> Optional[str]: + """Validate, normalize, and check allowlist for service tag.""" try: normalized = Services.get_tag(service_tag) service_path = Services.get_path(normalized) if not service_path.exists(): return None + allowed = get_allowed_services(request) + if allowed is not None and normalized not in allowed: + return None return normalized except Exception: return None @@ -341,6 +377,14 @@ async def search_handler(data: Dict[str, Any], request: Optional[web.Request] = details={"service": service_tag}, ) + allowed = get_allowed_services(request) + if allowed is not None and normalized_service not in allowed: + raise APIError( + APIErrorCode.INVALID_SERVICE, + f"Service '{service_tag}' not found", + details={"service": service_tag}, + ) + profile = data.get("profile") proxy_param = data.get("proxy") no_proxy = data.get("no_proxy", False) @@ -465,7 +509,7 @@ async def list_titles_handler(data: Dict[str, Any], request: Optional[web.Reques details={"missing_parameter": "title_id"}, ) - normalized_service = validate_service(service_tag) + normalized_service = validate_service(service_tag, request) if not normalized_service: raise APIError( APIErrorCode.INVALID_SERVICE, @@ -617,7 +661,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques details={"missing_parameter": "title_id"}, ) - normalized_service = validate_service(service_tag) + normalized_service = validate_service(service_tag, request) if not normalized_service: raise APIError( APIErrorCode.INVALID_SERVICE, @@ -993,7 +1037,7 @@ async def download_handler(data: Dict[str, Any], request: Optional[web.Request] details={"missing_parameter": "title_id"}, ) - normalized_service = validate_service(service_tag) + normalized_service = validate_service(service_tag, request) if not normalized_service: raise APIError( APIErrorCode.INVALID_SERVICE, @@ -1377,7 +1421,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req if not title_id: raise APIError(APIErrorCode.INVALID_INPUT, "Missing required parameter: title_id") - normalized_service = validate_service(service_tag) + normalized_service = validate_service(service_tag, request) if not normalized_service: raise APIError( APIErrorCode.INVALID_SERVICE, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index c4ee10e1..16b422d9 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -7,12 +7,12 @@ from aiohttp_swagger3 import SwaggerDocs, SwaggerInfo, SwaggerUiSettings from unshackle.core import __version__ from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception -from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler, - list_download_jobs_handler, list_titles_handler, list_tracks_handler, - search_handler, session_create_handler, session_delete_handler, - session_info_handler, session_license_handler, session_prompt_get_handler, - session_prompt_post_handler, session_segments_handler, - session_titles_handler, session_tracks_handler) +from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_allowed_services, + get_download_job_handler, list_download_jobs_handler, list_titles_handler, + list_tracks_handler, search_handler, session_create_handler, + session_delete_handler, session_info_handler, session_license_handler, + session_prompt_get_handler, session_prompt_post_handler, + session_segments_handler, session_titles_handler, session_tracks_handler) from unshackle.core.services import Services from unshackle.core.update_checker import UpdateChecker @@ -155,6 +155,9 @@ async def services(request: web.Request) -> web.Response: """ try: service_tags = Services.get_tags() + allowed = get_allowed_services(request) + if allowed is not None: + service_tags = [t for t in service_tags if t in allowed] services_info = [] for tag in service_tags: diff --git a/unshackle/core/manifests/hls.py b/unshackle/core/manifests/hls.py index 4cc2f1f1..ddab100f 100644 --- a/unshackle/core/manifests/hls.py +++ b/unshackle/core/manifests/hls.py @@ -259,7 +259,7 @@ class HLS: @staticmethod def _probe_ts_info( - variant_url: str, session: Optional[Union[Session, CurlSession]] = None + variant_url: str, session: Optional[Union[Session, RnetSession]] = None ) -> Optional[tuple[int, int, Video.Codec]]: """Probe the first TS segment of a variant playlist to extract resolution and codec.""" if not session: diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 5e65121a..f2d54658 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -469,6 +469,12 @@ serve: # Maximum concurrent sessions before oldest is evicted (default: 100) max_sessions: 100 + # Global service allowlist (optional) + # Only these services will be exposed on the API. If omitted, all services are available. + # services: + # - SERVICE_TAG_1 + # - SERVICE_TAG_2 + users: secret_key_for_user: devices: # Widevine devices (WVDs) this user can access @@ -476,6 +482,12 @@ serve: playready_devices: # PlayReady devices (PRDs) this user can access - playready_device_sl3000 username: user + # Per-user service allowlist (optional) + # Restricts this user to only the listed services. If omitted, user can access + # all globally-allowed services. Effective access is the intersection of global + # and per-user allowlists. + # services: + # - SERVICE_TAG_1 # devices: # Widevine device paths (auto-populated from directories.wvds) # - '/path/to/device.wvd' # playready_devices: # PlayReady device paths (auto-populated from directories.prds)