feat(serve): reload unshackle.yaml on SIGHUP without a restart

Extract the serve validation and app config builder so startup and reload share them. A bad file or a restart-only change keeps the running config; open CDM sessions survive.
This commit is contained in:
imSp4rky
2026-10-06 17:56:47 -06:00
parent eea4b41b70
commit f86359de5d
4 changed files with 303 additions and 106 deletions
+10
View File
@@ -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
+78
View File
@@ -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"}
+204 -101
View File
@@ -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)
+11 -5
View File
@@ -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