diff --git a/docs/dev/rest-api/index.md b/docs/dev/rest-api/index.md index e0a74f30..47058ccc 100644 --- a/docs/dev/rest-api/index.md +++ b/docs/dev/rest-api/index.md @@ -63,6 +63,7 @@ The `serve` command accepts the following flags: | `-h`, `--host` | `127.0.0.1` | Address to bind to. Use `0.0.0.0` to accept connections from other machines. | | `-p`, `--port` | `8786` | Port to bind to. | | `--api-only` | off | Serve only the REST API, without the Widevine/PlayReady CDM HTTP endpoints. | +| `--cdm-only` | off | Serve only the Widevine/PlayReady CDM HTTP endpoints, plus `/api/health` and the dashboard. No REST API, no remote sessions, no Swagger UI. Cannot be combined with `--api-only` or `--remote-only`. | | `--no-widevine` | off | Disable the Widevine CDM endpoints. Cannot be combined with `--api-only`. | | `--no-playready` | off | Disable the PlayReady CDM endpoints. Cannot be combined with `--api-only`. | | `--no-key` | off | Disable API-key authentication entirely; every request is allowed. | @@ -101,6 +102,13 @@ The routes the server mounts depend on how you start the server: and everything under `/api/session/*`). This mode implies `--api-only`, and the interactive Swagger UI is **not** mounted. +=== "`--cdm-only`" + + The server mounts only the pywidevine and pyplayready CDM endpoints. Under `/api/` it + answers `/api/health` and, when `serve.dashboard.key` is set, the dashboard. It does not + start the remote session store, the service refresh loop or the Swagger UI. Use it for a + CDM backend that a proxy reaches on its own hostname. + ## Base URL All REST endpoints live under the `/api` prefix. With the default bind address the diff --git a/tests/remote/unit/test_routes.py b/tests/remote/unit/test_routes.py index da3f38b2..573a380b 100644 --- a/tests/remote/unit/test_routes.py +++ b/tests/remote/unit/test_routes.py @@ -286,3 +286,13 @@ async def test_auth_middleware_accepts_known_key(make_app, aiohttp_client) -> No resp = await client.get("/api/session/nonexistent", headers={"X-Secret-Key": "good-key"}) # Auth passed; handler then 404s the session; anything other than 401 is fine here. assert resp.status != 401 + + +def test_setup_routes_cdm_only_keeps_health_and_dashboard_only() -> None: + """A CDM-only server answers health and the dashboard under /api/, nothing else.""" + app = web.Application() + setup_routes(app, dashboard=True, cdm_only=True) + paths = {path for _, path in collect_paths(app)} + assert "/api/health" in paths + assert any(path.startswith("/api/dashboard/") for path in paths) + assert all(path == "/api/health" or path.startswith("/api/dashboard/") for path in paths) diff --git a/tests/remote/unit/test_serve_cli.py b/tests/remote/unit/test_serve_cli.py index ad287404..5b1b0a72 100644 --- a/tests/remote/unit/test_serve_cli.py +++ b/tests/remote/unit/test_serve_cli.py @@ -181,3 +181,11 @@ def test_good_rate_limit_config_starts(runner: CliRunner, monkeypatch: pytest.Mo {"tiers": {"bot": {"rate_limit": 600}}, "users": {"k": {"username": "bot", "tier": "bot"}}}, ) assert started, output + + +def test_serve_cdm_only_with_remote_only_rejected(runner: CliRunner) -> None: + """`--cdm-only` is mutually exclusive with `--api-only` and `--remote-only`; the check runs before config.""" + result = runner.invoke(serve, ["--cdm-only", "--remote-only"]) + assert result.exit_code != 0 + output = re.sub(r"\x1b\[[0-9;]*m", "", result.output or str(result.exception)) + assert "Cannot use --cdm-only" in output diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 64b4b216..ed05f9f3 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -54,6 +54,23 @@ class _MaskAccessKey(logging.Filter): return True +def _install_session_store(app: web.Application) -> None: + """Start the remote session store's idle sweep with the app and stop it, bridges included, on cleanup.""" + from unshackle.core.api.session_store import get_session_store + + session_store = get_session_store() + + async def start(_app: web.Application) -> None: + await session_store.start_cleanup_loop() + + async def stop(_app: web.Application) -> None: + await session_store.cancel_all_bridges() + await session_store.stop_cleanup_loop() + + app.on_startup.append(start) + app.on_cleanup.append(stop) + + def _install_service_refresh(app: web.Application) -> None: """Report service load issues, then periodically pull the service repos and hot-reload changed services.""" from unshackle.core import services @@ -298,6 +315,12 @@ def _install_config_reload(app: web.Application, *, no_key: bool, serve_widevine @click.option( "--api-only", is_flag=True, default=False, help="Serve only the REST API, not pywidevine/pyplayready CDM." ) +@click.option( + "--cdm-only", + is_flag=True, + default=False, + help="Serve only the pywidevine/pyplayready CDM endpoints (plus health and the dashboard), not the REST API.", +) @click.option("--no-widevine", is_flag=True, default=False, help="Disable Widevine CDM endpoints.") @click.option("--no-playready", is_flag=True, default=False, help="Disable PlayReady CDM endpoints.") @click.option("--no-key", is_flag=True, default=False, help="Disable API key authentication (allows all requests).") @@ -331,6 +354,7 @@ def serve( port: int, caddy: bool, api_only: bool, + cdm_only: bool, no_widevine: bool, no_playready: bool, no_key: bool, @@ -408,6 +432,8 @@ def serve( if api_only and (no_widevine or no_playready): raise click.ClickException("Cannot use --api-only with --no-widevine or --no-playready.") + if cdm_only and (api_only or remote_only): + raise click.ClickException("Cannot use --cdm-only with --api-only or --remote-only.") if caddy: if not binaries.Caddy: @@ -428,31 +454,34 @@ def serve( if remote_only: api_only = True stats.host, stats.port = host, port - stats.mode = "remote_only" if remote_only else "api_only" if api_only else "full" + stats.mode = "remote_only" if remote_only else "api_only" if api_only else "cdm_only" if cdm_only else "full" dashboard = bool(dashboard_key()) if dashboard: log.info(f"Developer dashboard endpoints available at http://{host}:{port}/api/dashboard/") elif quiet: log.info("Developer dashboard disabled: set serve.dashboard.key in unshackle.yaml to enable it") - global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit")) - if global_speed_limit: - set_speed_limit(global_speed_limit, lock=True) - log.info(f"Global speed limit: {format_speed(global_speed_limit)} (shared by all jobs)") + if not cdm_only: + global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit")) + if global_speed_limit: + set_speed_limit(global_speed_limit, lock=True) + log.info(f"Global speed limit: {format_speed(global_speed_limit)} (shared by all jobs)") - global_services = config.serve.get("services") - if global_services: - log.info(f"Global service allowlist: {', '.join(global_services)}") - try: - server_accounts = validate_server_accounts() - except ValueError as e: - raise click.ClickException(str(e)) - for tag in server_accounts: - regions = server_account_regions(tag) or {} - covered = list(regions.get("regions") or []) + (["global"] if regions.get("global") else []) - log.info(f"Server accounts for {tag}: {', '.join(covered)}") - if config.key_vaults and not any(v.get("type") == "SQLite" for v in config.key_vaults): - log.warning("No SQLite key vault configured: a content key a remote client proves wrong cannot be flagged") + global_services = config.serve.get("services") + if global_services: + log.info(f"Global service allowlist: {', '.join(global_services)}") + try: + server_accounts = validate_server_accounts() + except ValueError as e: + raise click.ClickException(str(e)) + for tag in server_accounts: + regions = server_account_regions(tag) or {} + covered = list(regions.get("regions") or []) + (["global"] if regions.get("global") else []) + log.info(f"Server accounts for {tag}: {', '.join(covered)}") + if config.key_vaults and not any(v.get("type") == "SQLite" for v in config.key_vaults): + log.warning( + "No SQLite key vault configured: a content key a remote client proves wrong cannot be flagged" + ) serve_widevine = not api_only and not no_widevine serve_playready = not api_only and not no_playready @@ -478,19 +507,7 @@ def serve( app["config"] = serve_config app["debug_api"] = debug_api - from unshackle.core.api.session_store import get_session_store - - session_store = get_session_store() - - async def start_session_cleanup(_app: web.Application) -> None: - await session_store.start_cleanup_loop() - - async def stop_session_cleanup(_app: web.Application) -> None: - await session_store.cancel_all_bridges() - await session_store.stop_cleanup_loop() - - app.on_startup.append(start_session_cleanup) - app.on_cleanup.append(stop_session_cleanup) + _install_session_store(app) _install_service_refresh(app) _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) @@ -571,20 +588,9 @@ def serve( app["config"] = serve_config app["debug_api"] = debug_api - from unshackle.core.api.session_store import get_session_store - - session_store = get_session_store() - - async def start_session_cleanup(_app: web.Application) -> None: - await session_store.start_cleanup_loop() - - async def stop_session_cleanup(_app: web.Application) -> None: - await session_store.cancel_all_bridges() - await session_store.stop_cleanup_loop() - - app.on_startup.append(start_session_cleanup) - app.on_cleanup.append(stop_session_cleanup) - _install_service_refresh(app) + if not cdm_only: + _install_session_store(app) + _install_service_refresh(app) _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) if serve_widevine: @@ -617,11 +623,13 @@ def serve( elif serve_playready: log.info("No PlayReady devices found, skipping PlayReady CDM endpoints") - setup_routes(app, remote_only=remote_only, dashboard=dashboard) + setup_routes(app, remote_only=remote_only, dashboard=dashboard, cdm_only=cdm_only) if serve_widevine: log.info(f"Widevine CDM endpoints available at http://{host}:{port}/{{device}}/open") - if remote_only: + if cdm_only: + log.info("REST API disabled (--cdm-only): only /api/health and the dashboard answer under /api/") + elif remote_only: log.info(f"Remote service endpoints available at http://{host}:{port}/api/session/") else: setup_swagger(app, dashboard=dashboard) diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index afdcf0a6..7f7de9a4 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -3081,15 +3081,19 @@ DASHBOARD_ROUTES: list[tuple[str, str, Handler, bool]] = [ ] -def setup_routes(app: web.Application, remote_only: bool = False, dashboard: bool = False) -> None: +def setup_routes( + app: web.Application, remote_only: bool = False, dashboard: bool = False, cdm_only: bool = False +) -> None: """Setup API routes. When remote_only=True, only the remote session endpoints operate. - When dashboard=True, this also registers the /api/dashboard/ routes.""" + When cdm_only=True, only /api/health operates. When dashboard=True, this also registers + the /api/dashboard/ routes.""" add: dict[str, Callable[..., Any]] = { "GET": app.router.add_get, "POST": app.router.add_post, "DELETE": app.router.add_delete, } - for method, path, handler, remote in ROUTES + (DASHBOARD_ROUTES if dashboard else []): + api_routes = [r for r in ROUTES if r[1] == "/api/health"] if cdm_only else ROUTES + for method, path, handler, remote in api_routes + (DASHBOARD_ROUTES if dashboard else []): if remote_only and not remote: continue add[method](path, handler)