feat(serve): add --cdm-only for a CDM backend with no REST API

Mounts only the pywidevine and pyplayready routes plus /api/health and the dashboard; no session store, service refresh or Swagger.
This commit is contained in:
imSp4rky
2026-10-06 18:24:21 -06:00
parent f86359de5d
commit 3fd69e8039
5 changed files with 88 additions and 50 deletions
+8
View File
@@ -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
+10
View File
@@ -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)
+8
View File
@@ -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
+55 -47
View File
@@ -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)
+7 -3
View File
@@ -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)