diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index abc12adc..bb95c2dd 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -155,6 +155,16 @@ 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. +!!! info "Reload `serve` settings without a restart" + Send `SIGHUP` to a running server (`systemctl reload` with + `ExecReload=kill -HUP $MAINPID` in the unit) after you edit the file. The server reads + the whole file again, globs the device directories again, and swaps in the new `users`, + `tiers`, `services` and device lists in one step. Open CDM sessions and remote sessions + continue; an API key you removed gets `401` on its next request. A file that does not parse + or validate keeps the running settings and logs the reason. A change to `remote_only`, + `services_refresh_interval`, the first PlayReady device, or any command-line flag needs + a restart, and the log says so. Windows has no `SIGHUP`: restart the server there. + `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 diff --git a/tests/remote/unit/test_serve_reload.py b/tests/remote/unit/test_serve_reload.py new file mode 100644 index 00000000..af6fcf1c --- /dev/null +++ b/tests/remote/unit/test_serve_reload.py @@ -0,0 +1,78 @@ +"""Pins the SIGHUP config reload: a valid file swaps the users in, a bad or restart-only file leaves the old one.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest +import yaml +from aiohttp import web + +from unshackle.commands import serve as serve_cmd +from unshackle.core import config as config_module +from unshackle.core.config import config + +pytestmark = pytest.mark.unit + +LOG = logging.getLogger("serve") + + +@pytest.fixture +def running(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple[web.Application, Path]: + """An app serving the key ``old`` with one open CDM session, and a writable config path.""" + wvds = tmp_path / "wvds" + prds = tmp_path / "prds" + wvds.mkdir() + prds.mkdir() + (wvds / "dev.wvd").write_bytes(b"x") + monkeypatch.setattr(config.directories, "wvds", wvds) + monkeypatch.setattr(config.directories, "prds", prds) + monkeypatch.setattr(config, "serve", {"api_secret": "api", "users": {"old": {"devices": ["dev"]}}}) + path = tmp_path / "unshackle.yaml" + monkeypatch.setattr(config_module, "config_path", path) + + app = web.Application() + app["config"] = {"users": {"old": {"devices": ["dev"]}, "api": {"devices": ["dev"]}}, "devices": {}} + app["cdms"] = {("old", "dev"): object()} + return app, path + + +def _write(path: Path, **serve: object) -> None: + path.write_text(yaml.safe_dump({"serve": {"api_secret": "api", **serve}}), encoding="utf8") + + +def _reload(app: web.Application) -> bool: + return serve_cmd.reload_config(app, no_key=False, serve_widevine=True, serve_playready=True) + + +def test_valid_reload_swaps_the_accepted_keys(running: tuple[web.Application, Path]) -> None: + app, path = running + _write(path, users={"new": {"devices": ["dev"]}}) + + assert _reload(app) is True + assert set(app["config"]["users"]) == {"new", "api"} + assert app["config"]["devices"] == {"dev": path.parent / "wvds" / "dev.wvd"} + assert config.serve["users"] == {"new": {"devices": ["dev"]}} + assert ("old", "dev") in app["cdms"] + + +def test_invalid_file_keeps_the_running_config(running: tuple[web.Application, Path]) -> None: + app, path = running + _write(path, users={"new": {"devices": ["dev"], "tier": "gold"}}) + + assert _reload(app) is False + assert set(app["config"]["users"]) == {"old", "api"} + assert config.serve["users"] == {"old": {"devices": ["dev"]}} + + +def test_restart_only_change_is_reported_not_applied( + running: tuple[web.Application, Path], caplog: pytest.LogCaptureFixture +) -> None: + app, path = running + _write(path, users={"new": {"devices": ["dev"]}}, remote_only=True) + + with caplog.at_level(logging.WARNING, logger=LOG.name): + assert _reload(app) is False + assert "restart" in caplog.text and "remote_only" in caplog.text + assert set(app["config"]["users"]) == {"old", "api"} diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index 3c25c278..64b4b216 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -2,14 +2,19 @@ import asyncio import hmac import logging import re +import signal import subprocess import sys from contextlib import suppress from datetime import datetime +from functools import partial +from pathlib import Path +from typing import Any, Optional import click from aiohttp import web +import unshackle.core.config as config_module from unshackle.core import binaries from unshackle.core.api import cors_middleware, setup_routes, setup_swagger from unshackle.core.api.compression import compression_middleware @@ -27,13 +32,14 @@ from unshackle.core.api.handlers import ( ) from unshackle.core.api.stats import ( cdm_log_middleware, + key_id, key_rate_limit, rate_limit_error, ring, stats, stats_middleware, ) -from unshackle.core.config import config +from unshackle.core.config import Config, config from unshackle.core.console import console from unshackle.core.constants import context_settings from unshackle.core.downloaders import format_speed, parse_speed_limit, set_speed_limit @@ -99,6 +105,189 @@ def _install_service_refresh(app: web.Application) -> None: app.on_cleanup.append(stop) +RESTART_KEYS = ("remote_only", "services_refresh_interval") +"""``serve.*`` keys a reload cannot apply: they decide routes or tasks fixed at startup.""" + +log = logging.getLogger("serve") + + +def _prepare_serve(serve: dict, no_key: bool) -> Optional[str]: + """Glob the device directories into ``serve``, validate it in place and return the api_secret. + + Raises ValueError on a bad value. The api_secret is None under ``--no-key``. + """ + api_secret = None if no_key else serve.get("api_secret") + if not no_key and not api_secret: + raise ValueError( + "API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config." + ) + + for key, directory, pattern in ( + ("devices", config.directories.wvds, "*.wvd"), + ("playready_devices", config.directories.prds, "*.prd"), + ): + if not serve.get(key): + serve[key] = [] + serve[key].extend(directory.glob(pattern)) + for device in serve[key]: + if not Path(device).is_file(): + raise ValueError(f"serve.{key}: device file does not exist: {device}") + + try: + parse_speed_limit(serve.get("global_speed_limit")) + except ValueError as e: + raise ValueError(f"serve.global_speed_limit: {e}") + + users = serve.get("users", {}) + if isinstance(users, dict): + users = {str(k): v for k, v in users.items()} + serve["users"] = users + tiers = serve.get("tiers") or {} + if not isinstance(tiers, dict): + raise ValueError("serve.tiers must be a mapping of tier name to settings") + for tier_name, tier_cfg in tiers.items(): + if not isinstance(tier_cfg, dict): + raise ValueError(f"serve.tiers.{tier_name} must be a mapping of setting to value") + if problem := rate_limit_error(f"serve.tiers.{tier_name}", tier_cfg.get("rate_limit")): + raise ValueError(problem) + 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 + username = user_cfg.get("username", user_key[:8] + "...") if isinstance(user_cfg, dict) else user_key[:8] + if user_services: + log.info(f"User '{username}' restricted to services: {', '.join(user_services)}") + tier = user_cfg.get("tier") if isinstance(user_cfg, dict) else None + if tier and tier not in tiers: + raise ValueError(f"serve.users.{username}.tier: no such tier '{tier}' under serve.tiers") + if isinstance(user_cfg, dict): + if problem := rate_limit_error(f"serve.users.{username}", user_cfg.get("rate_limit")): + raise ValueError(problem) + if problem := server_cdm_max_height_error(f"serve.users.{username}", user_cfg.get("server_cdm_max_height")): + raise ValueError(problem) + limit = key_rate_limit(user_key, serve) + if limit: + log.info(f"User '{username}' rate limited to {limit} requests/hour") + return api_secret + + +def _build_cdm_config( + serve: dict, api_secret: Optional[str], serve_widevine: bool, serve_playready: bool +) -> tuple[dict, dict]: + """The ``app["config"]`` for the main app and the PlayReady sub-app, derived from a prepared ``serve``. + + The main config carries the api_secret user and a ``playready_devices`` list per user; the + sub-app config maps each user's ``playready_devices`` to ``devices`` for pyplayready. + """ + serve_config = dict(serve) + wvd_devices = serve_config.get("devices", []) if serve_widevine else [] + prd_devices = serve_config.get("playready_devices", []) if serve_playready else [] + wvd_device_names = [Path(d).stem for d in wvd_devices] + prd_device_names = [Path(d).stem for d in prd_devices] + + users_cfg = serve_config.get("users") + serve_config["users"] = {k: dict(v) for k, v in users_cfg.items()} if isinstance(users_cfg, dict) else {} + if api_secret and api_secret not in serve_config["users"]: + serve_config["users"][api_secret] = { + "devices": wvd_device_names, + "playready_devices": prd_device_names, + "username": "api_user", + } + for user_key, user_config in serve_config["users"].items(): + if "playready_devices" not in user_config: + user_config["playready_devices"] = [] + if serve_playready: + log.warning( + f'User "{key_id(user_key)}" has no "playready_devices" configured; PlayReady access disabled ' + f"for this user. Available PlayReady devices: {prd_device_names}" + ) + serve_config["devices"] = wvd_devices + serve_config["playready_devices"] = prd_devices + + playready_config = { + "devices": prd_devices, + "users": { + user_key: { + "devices": user_cfg.get("playready_devices", []), + "username": user_cfg.get("username", "user"), + } + for user_key, user_cfg in serve_config["users"].items() + } + if api_secret + else {}, + } + return serve_config, playready_config + + +def _device_map(devices: list[Any]) -> dict[str, Path]: + """The ``{stem: path}`` form pywidevine and pyplayready rewrite ``app["config"]["devices"]`` into at startup.""" + return {Path(d).stem: Path(d) for d in devices} + + +def _device_stems(serve: dict) -> set[str]: + return {Path(d).stem for d in serve.get("devices", []) + serve.get("playready_devices", [])} + + +def reload_config(app: web.Application, *, no_key: bool, serve_widevine: bool, serve_playready: bool) -> bool: + """Re-read the config file and swap it in; return True when the new file is in use. + + The swap replaces every ``Config`` section, so services read new credentials and CDM mappings + too. Runs synchronously on the event loop so no request sees a half-updated config. A file + that fails to parse or validate, or that changes a startup-only setting, keeps the running + config. The reload leaves open CDM sessions alone: a revoked key fails authentication on + its next call. + """ + try: + if not config_module.config_path: + raise ValueError("no config file found") + fresh = Config.from_yaml(config_module.config_path) + serve = fresh.serve + api_secret = _prepare_serve(serve, no_key) + except Exception as e: + log.error(f"Config reload rejected, keeping the running config: {e}") + return False + + changed = [k for k in RESTART_KEYS if serve.get(k) != config.serve.get(k)] + if serve_playready and bool(serve.get("playready_devices")) != ("playready_app" in app): + changed.append("playready_devices (first device added or last removed)") + if changed: + log.warning(f"Config reload skipped, restart to apply: {', '.join(changed)}") + return False + + serve_config, playready_config = _build_cdm_config(serve, api_secret, serve_widevine, serve_playready) + old_users, new_users = set(app["config"]["users"]), set(serve_config["users"]) + old_devices, new_devices = _device_stems(config.serve), _device_stems(serve) + vars(config).update(vars(fresh)) + app["config"]["users"] = serve_config["users"] + if serve_widevine: + app["config"]["devices"] = _device_map(serve_config["devices"]) + if playready_app := app.get("playready_app"): + playready_app["config"]["users"] = playready_config["users"] + playready_app["config"]["devices"] = _device_map(playready_config["devices"]) + set_speed_limit(parse_speed_limit(serve.get("global_speed_limit")), lock=True) + log.info( + "Config reloaded: " + f"keys added {sorted(key_id(k) for k in new_users - old_users)}, " + f"removed {sorted(key_id(k) for k in old_users - new_users)}; " + f"devices added {sorted(new_devices - old_devices)}, removed {sorted(old_devices - new_devices)}" + ) + return True + + +def _install_config_reload(app: web.Application, *, no_key: bool, serve_widevine: bool, serve_playready: bool) -> None: + """Reload the config on SIGHUP, so ``systemctl reload`` applies a config edit without a restart.""" + + async def start(_app: web.Application) -> None: + reload = partial( + reload_config, _app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready + ) + try: + asyncio.get_running_loop().add_signal_handler(signal.SIGHUP, reload) + except (AttributeError, NotImplementedError): + return + log.info("Config hot-reload: send SIGHUP (systemctl reload) after editing unshackle.yaml") + + app.on_startup.append(start) + + @click.command( short_help="Serve your Local Widevine/PlayReady Devices and REST API for Remote Access.", context_settings=context_settings, @@ -180,8 +369,6 @@ def serve( from pyplayready.remote import serve as pyplayready_serve from pywidevine import serve as pywidevine_serve - log = logging.getLogger("serve") - if quiet: logging.basicConfig( force=True, @@ -213,14 +400,7 @@ def serve( logging.getLogger().addHandler(ring) logging.getLogger("aiohttp.access").addFilter(_MaskAccessKey()) - if not no_key: - api_secret = config.serve.get("api_secret") - if not api_secret: - raise click.ClickException( - "API secret key is not configured. Please add 'api_secret' to the 'serve' section in your config." - ) - else: - api_secret = None + if no_key: log.warning("Running with --no-key: Authentication is DISABLED for all API endpoints!") if debug_api: @@ -239,13 +419,10 @@ def serve( caddy_p = None try: - if not config.serve.get("devices"): - config.serve["devices"] = [] - config.serve["devices"].extend(list(config.directories.wvds.glob("*.wvd"))) - - if not config.serve.get("playready_devices"): - config.serve["playready_devices"] = [] - config.serve["playready_devices"].extend(list(config.directories.prds.glob("*.prd"))) + try: + api_secret = _prepare_serve(config.serve, no_key) + except ValueError as e: + raise click.ClickException(str(e)) remote_only = remote_only or config.serve.get("remote_only", False) if remote_only: @@ -258,10 +435,7 @@ def serve( elif quiet: log.info("Developer dashboard disabled: set serve.dashboard.key in unshackle.yaml to enable it") - try: - global_speed_limit = parse_speed_limit(config.serve.get("global_speed_limit")) - except ValueError as e: - raise click.ClickException(f"serve.global_speed_limit: {e}") + 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)") @@ -279,37 +453,11 @@ def serve( 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") - users = config.serve.get("users", {}) - if isinstance(users, dict): - # yaml keys can parse as int; hmac.compare_digest and allowlist lookups need str - users = {str(k): v for k, v in users.items()} - config.serve["users"] = users - tiers = config.serve.get("tiers") or {} - if not isinstance(tiers, dict): - raise click.ClickException("serve.tiers must be a mapping of tier name to settings") - for tier_name, tier_cfg in tiers.items(): - if not isinstance(tier_cfg, dict): - raise click.ClickException(f"serve.tiers.{tier_name} must be a mapping of setting to value") - if problem := rate_limit_error(f"serve.tiers.{tier_name}", tier_cfg.get("rate_limit")): - raise click.ClickException(problem) - 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 - username = user_cfg.get("username", user_key[:8] + "...") if isinstance(user_cfg, dict) else user_key[:8] - if user_services: - log.info(f"User '{username}' restricted to services: {', '.join(user_services)}") - tier = user_cfg.get("tier") if isinstance(user_cfg, dict) else None - if tier and tier not in tiers: - raise click.ClickException(f"serve.users.{username}.tier: no such tier '{tier}' under serve.tiers") - if isinstance(user_cfg, dict): - if problem := rate_limit_error(f"serve.users.{username}", user_cfg.get("rate_limit")): - raise click.ClickException(problem) - if problem := server_cdm_max_height_error( - f"serve.users.{username}", user_cfg.get("server_cdm_max_height") - ): - raise click.ClickException(problem) - limit = key_rate_limit(user_key) - if limit: - log.info(f"User '{username}' rate limited to {limit} requests/hour") + + serve_widevine = not api_only and not no_widevine + serve_playready = not api_only and not no_playready + serve_config, playready_config = _build_cdm_config(config.serve, api_secret, serve_widevine, serve_playready) + prd_devices = playready_config["devices"] if api_only: log.info("Starting REST API server (pywidevine/pyplayready CDM disabled)") @@ -317,7 +465,6 @@ def serve( app = web.Application( middlewares=[cors_middleware, stats_middleware, dashboard_authentication, compression_middleware] ) - app["config"] = {"users": {}} else: app = web.Application( middlewares=[ @@ -328,10 +475,7 @@ def serve( compression_middleware, ] ) - api_users: dict = {api_secret: {"devices": [], "username": "api_user"}} - if isinstance(users, dict): - api_users.update(users) - app["config"] = {"users": api_users} + app["config"] = serve_config app["debug_api"] = debug_api from unshackle.core.api.session_store import get_session_store @@ -348,6 +492,7 @@ def serve( app.on_startup.append(start_session_cleanup) app.on_cleanup.append(stop_session_cleanup) _install_service_refresh(app) + _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) setup_routes(app, remote_only=remote_only, dashboard=dashboard) if not remote_only: @@ -359,13 +504,6 @@ def serve( log.info("(Press CTRL+C to quit)") web.run_app(app, host=host, port=port, print=None) else: - serve_widevine = not no_widevine - serve_playready = not no_playready - - serve_config = dict(config.serve) - wvd_devices = serve_config.get("devices", []) if serve_widevine else [] - prd_devices = serve_config.get("playready_devices", []) if serve_playready else [] - cdm_parts = [] if serve_widevine: cdm_parts.append("pywidevine CDM") @@ -373,28 +511,6 @@ def serve( cdm_parts.append("pyplayready CDM") log.info(f"Starting integrated server ({' + '.join(cdm_parts)} + REST API)") - wvd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in wvd_devices] - prd_device_names = [d.stem if hasattr(d, "stem") else str(d) for d in prd_devices] - - users_cfg = serve_config.get("users") - serve_config["users"] = dict(users_cfg) if isinstance(users_cfg, dict) else {} - - if not no_key and api_secret not in serve_config["users"]: - serve_config["users"][api_secret] = { - "devices": wvd_device_names, - "playready_devices": prd_device_names, - "username": "api_user", - } - - for user_key, user_config in serve_config["users"].items(): - if "playready_devices" not in user_config: - # Require explicit PlayReady device access per user (default: no access). - user_config["playready_devices"] = [] - log.warning( - f'User "{user_key}" has no "playready_devices" configured; PlayReady access disabled for this user. ' - f"Available PlayReady devices: {prd_device_names}" - ) - def create_serve_authentication(serve_playready_flag: bool): @web.middleware async def serve_authentication(request: web.Request, handler) -> web.StreamResponse: @@ -469,6 +585,7 @@ def serve( app.on_startup.append(start_session_cleanup) app.on_cleanup.append(stop_session_cleanup) _install_service_refresh(app) + _install_config_reload(app, no_key=no_key, serve_widevine=serve_widevine, serve_playready=serve_playready) if serve_widevine: app.on_startup.append(pywidevine_serve._startup) @@ -480,20 +597,6 @@ def serve( playready_app = web.Application() else: playready_app = web.Application(middlewares=[pyplayready_serve.authentication]) - - # PlayReady subapp config maps playready_devices to "devices" for pyplayready compatibility - playready_config = { - "devices": prd_devices, - "users": { - user_key: { - "devices": user_cfg.get("playready_devices", []), - "username": user_cfg.get("username", "user"), - } - for user_key, user_cfg in serve_config["users"].items() - } - if not no_key - else {}, - } playready_app["config"] = playready_config playready_app.on_startup.append(pyplayready_serve._startup) playready_app.on_cleanup.append(pyplayready_serve._cleanup) diff --git a/unshackle/core/api/stats.py b/unshackle/core/api/stats.py index 8e544ec7..3b150510 100644 --- a/unshackle/core/api/stats.py +++ b/unshackle/core/api/stats.py @@ -87,15 +87,21 @@ def key_tier(key: Optional[str]) -> Optional[str]: return str(tier) if tier else None -def key_rate_limit(key: Optional[str]) -> Optional[int]: - """Requests per hour allowed for a key: its own override, else its tier's, else unlimited.""" - user = _user_config(key) - if user is None: +def key_rate_limit(key: Optional[str], serve: Optional[Dict[str, Any]] = None) -> Optional[int]: + """Requests per hour allowed for a key: its own override, else its tier's, else unlimited. + + ``serve`` reads a config block that is not yet the live one, as the reload validator does. + """ + if serve is None: + serve = config.serve or {} + users = serve.get("users") + user = users.get(key) if isinstance(users, dict) and key else None + if not isinstance(user, dict): return None own = user.get("rate_limit") if isinstance(own, int) and own > 0: return own - tiers = (config.serve or {}).get("tiers") + tiers = serve.get("tiers") tier = tiers.get(user.get("tier")) if isinstance(tiers, dict) and user.get("tier") else None limit = tier.get("rate_limit") if isinstance(tier, dict) else None return limit if isinstance(limit, int) and limit > 0 else None