diff --git a/docs/dev/rest-api/authentication.md b/docs/dev/rest-api/authentication.md index 831050b3..0f707182 100644 --- a/docs/dev/rest-api/authentication.md +++ b/docs/dev/rest-api/authentication.md @@ -299,7 +299,7 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located The header and config are identical between modes, but the code that enforces them differs: -- **API-only server** (`--api-only`, or `--remote-only` which forces API-only). An `api_key_authentication` middleware runs on every request. It lets `GET /api/health` through unconditionally, then reads `X-Secret-Key`. An empty header returns the *"Secret Key is Empty."* body; a header that is not a configured user returns *"Secret Key is Invalid."*. In this mode the accepted-keys map contains exactly one entry: your `api_secret` (labelled `api_user`, with no devices). With `--no-key`, this middleware is simply not installed and the users map is empty. +- **API-only server** (`--api-only`, or `--remote-only` which forces API-only). An `api_key_authentication` middleware runs on every request. It lets `GET /api/health` through unconditionally, then reads `X-Secret-Key`. An empty header returns the *"Secret Key is Empty."* body; a header that is not a configured user returns *"Secret Key is Invalid."*. In this mode the accepted-keys map holds your `api_secret` (labelled `api_user`, with no devices) plus every key under `serve.users`, so a per-user key authenticates here exactly as it does on the integrated server, and that user's `services` allowlist applies as usual. With `--no-key`, this middleware is simply not installed and the users map is empty. - **Integrated server** (default). A `serve_authentication` middleware wraps pywidevine's authentication (and pyplayready's for `/playready*` paths), and the REST routes live in the same application, guarded by the same check. Here the accepted-keys map is your full `serve` config: `api_secret` (auto-added with access to all loaded devices) plus every entry under `users`, each with its declared `devices` / `playready_devices`. diff --git a/docs/reference/configuration/services.md b/docs/reference/configuration/services.md index 3ba1043a..95ee8ff8 100644 --- a/docs/reference/configuration/services.md +++ b/docs/reference/configuration/services.md @@ -82,9 +82,18 @@ keep auth tokens in localStorage rather than in HTTP cookies need. Extraction is Definitions of remote unshackle service servers, used by the `--remote` mode. Each entry is named by you (pick it with `--server`, or omit that flag when only one is configured) and gives -the server's `url` (required), an optional `api_key` sent as `X-Secret-Key`, an optional -`server_cdm` boolean, and an optional `services` sub-dict of per-service local overrides such -as `title_map`. In `--remote` mode unshackle turns the server's service list into synthetic CLI +the server's `url` (required), an optional `api_key`, an optional `auth_headers` list, an +optional `server_cdm` boolean, and an optional `services` sub-dict of per-service local +overrides such as `title_map`. + +`auth_headers` lists extra header names to send the API key in, tried before the defaults +`X-Secret-Key` and `X-Api-Key`, which are always appended as fallbacks. unshackle sends the +first name; if the server answers `401`, it retries the same request with the next name, and +keeps the one that works for the rest of the session. Names you list keep your spelling and are +not repeated in the fallbacks, so `auth_headers: ["Authorization", "x-secret-key"]` is tried as +`Authorization`, `x-secret-key`, `X-Api-Key`. + +In `--remote` mode unshackle turns the server's service list into synthetic CLI commands that run against it, falling back to the tags in that `services` sub-dict when the list cannot be fetched. See [remote sessions](../../dev/rest-api/remote-sessions.md) for the full setup. diff --git a/tests/remote/unit/test_remote_client.py b/tests/remote/unit/test_remote_client.py index 4f6ef202..9ee379d9 100644 --- a/tests/remote/unit/test_remote_client.py +++ b/tests/remote/unit/test_remote_client.py @@ -85,6 +85,65 @@ def test_4xx_raises_systemexit_with_logged_error(client: RemoteClient, caplog: p assert "no such session" in caplog.text +@responses.activate +def test_401_falls_back_to_next_auth_header(client: RemoteClient) -> None: + seen: list[tuple[str | None, str | None]] = [] + + def cb(request): + seen.append((request.headers.get("X-Secret-Key"), request.headers.get("X-Api-Key"))) + if request.headers.get("X-Api-Key") == "secret-xyz": + return (200, {}, json.dumps({"status": "ok"})) + return (401, {}, json.dumps({"message": "Secret Key is Invalid."})) + + responses.add_callback(responses.GET, "http://srv:8786/api/services", callback=cb) + + assert client.get("/api/services") == {"status": "ok"} + assert seen == [("secret-xyz", None), (None, "secret-xyz")] + + # the winning header is sticky: no second attempt on the next request + seen.clear() + assert client.get("/api/services") == {"status": "ok"} + assert seen == [(None, "secret-xyz")] + + +@responses.activate +def test_non_401_error_does_not_fall_back(client: RemoteClient, caplog: pytest.LogCaptureFixture) -> None: + responses.add( + responses.GET, + "http://srv:8786/api/services", + json={"error_code": "FORBIDDEN", "message": "nope"}, + status=403, + ) + with caplog.at_level("ERROR"), pytest.raises(SystemExit): + client.get("/api/services") + assert len(responses.calls) == 1 + assert client.session.headers.get("X-Secret-Key") == "secret-xyz" + + +@responses.activate +def test_401_on_every_header_exits(client: RemoteClient, caplog: pytest.LogCaptureFixture) -> None: + responses.add( + responses.GET, + "http://srv:8786/api/services", + json={"error_code": "UNAUTHORIZED", "message": "Secret Key is Invalid."}, + status=401, + ) + with caplog.at_level("ERROR"), pytest.raises(SystemExit): + client.get("/api/services") + assert len(responses.calls) == 2 + assert "Secret Key is Invalid." in caplog.text + + +@responses.activate +def test_single_configured_auth_header_does_not_retry() -> None: + c = RemoteClient(server_url="http://srv:8786", api_key="k", auth_headers=["X-Api-Key"]) + responses.add(responses.GET, "http://srv:8786/api/services", json={"message": "nope"}, status=401) + with pytest.raises(SystemExit): + c.get("/api/services") + assert len(responses.calls) == 1 + assert c.session.headers.get("X-Api-Key") == "k" + + @responses.activate def test_connection_error_raises_systemexit(client: RemoteClient) -> None: import requests diff --git a/tests/remote/unit/test_remote_resolve.py b/tests/remote/unit/test_remote_resolve.py index ae31e77b..5d264a32 100644 --- a/tests/remote/unit/test_remote_resolve.py +++ b/tests/remote/unit/test_remote_resolve.py @@ -49,6 +49,43 @@ def multi_remote_services(monkeypatch: pytest.MonkeyPatch) -> None: ) +def test_resolve_server_default_auth_headers(single_remote_service) -> None: + _, _, services = resolve_server("primary") + assert services["_auth_headers"] == ["X-Secret-Key", "X-Api-Key"] + + +def test_resolve_server_custom_auth_headers_extend_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + from unshackle.core import remote_service as rs + + monkeypatch.setattr( + rs.config, "remote_services", {"p": {"url": "https://p:8080", "auth_headers": ["Authorization"]}} + ) + _, _, services = resolve_server("p") + assert services["_auth_headers"] == ["Authorization", "X-Secret-Key", "X-Api-Key"] + + +def test_resolve_server_auth_headers_dedup_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + from unshackle.core import remote_service as rs + + monkeypatch.setattr( + rs.config, + "remote_services", + {"p": {"url": "https://p:8080", "auth_headers": ["Authorization", "x-secret-key"]}}, + ) + _, _, services = resolve_server("p") + assert services["_auth_headers"] == ["Authorization", "x-secret-key", "X-Api-Key"] + + +@pytest.mark.parametrize("bad", ["X-Api-Key", [], [""], ["X-Api-Key", 3]]) +def test_resolve_server_bad_auth_headers_raises(monkeypatch: pytest.MonkeyPatch, bad: object) -> None: + from unshackle.core import remote_service as rs + + monkeypatch.setattr(rs.config, "remote_services", {"p": {"url": "https://p:8080", "auth_headers": bad}}) + with pytest.raises(click.ClickException) as exc: + resolve_server("p") + assert "auth_headers" in str(exc.value.message) + + def test_resolve_server_no_config_raises_click(empty_remote_services) -> None: with pytest.raises(click.ClickException) as exc: resolve_server(None) diff --git a/tests/remote/unit/test_serve_cli.py b/tests/remote/unit/test_serve_cli.py index 5ae0165c..55bc61a4 100644 --- a/tests/remote/unit/test_serve_cli.py +++ b/tests/remote/unit/test_serve_cli.py @@ -69,6 +69,61 @@ def test_serve_no_key_without_api_secret_does_not_require_secret( assert result.exit_code == 0, result.output +async def test_api_only_accepts_serve_users_keys( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch, aiohttp_client +) -> None: + """In api-only keyed mode the accepted keys are api_secret plus every `serve.users` key.""" + from aiohttp import web + + captured: dict = {} + + def capture_app(app, **kwargs): + captured["app"] = app + + monkeypatch.setattr(web, "run_app", capture_app) + from unshackle.core.config import config as cfg + + monkeypatch.setattr( + cfg, + "serve", + {"api_secret": "master-key", "users": {"user-key": {"username": "alice", "services": ["EXAMPLE"]}}}, + ) + + result = runner.invoke(serve, ["--api-only", "--remote-only"]) + assert result.exit_code == 0, result.output + + app = captured["app"] + assert set(app["config"]["users"]) == {"master-key", "user-key"} + assert app["config"]["users"]["user-key"]["username"] == "alice" + + client = await aiohttp_client(app) + # an unrouted path still passes through the auth middleware first + assert (await client.get("/api/unknown", headers={"X-Secret-Key": "user-key"})).status == 404 + assert (await client.get("/api/unknown", headers={"X-Secret-Key": "master-key"})).status == 404 + assert (await client.get("/api/unknown", headers={"X-Secret-Key": "nope"})).status == 401 + + +async def test_api_only_coerces_non_str_user_keys( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch, aiohttp_client +) -> None: + """A yaml users key that parses as int must still authenticate (compare_digest needs str).""" + from aiohttp import web + + captured: dict = {} + monkeypatch.setattr(web, "run_app", lambda app, **kwargs: captured.update(app=app)) + from unshackle.core.config import config as cfg + + monkeypatch.setattr(cfg, "serve", {"api_secret": "master-key", "users": {123456: {"username": "bob"}}}) + + result = runner.invoke(serve, ["--api-only", "--remote-only"]) + assert result.exit_code == 0, result.output + assert "123456" in captured["app"]["config"]["users"] + + client = await aiohttp_client(captured["app"]) + assert (await client.get("/api/unknown", headers={"X-Secret-Key": "123456"})).status == 404 + assert (await client.get("/api/unknown", headers={"X-Secret-Key": "master-key"})).status == 404 + + def test_serve_without_no_key_requires_api_secret(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None: from unshackle.core.config import config as cfg diff --git a/unshackle/commands/serve.py b/unshackle/commands/serve.py index f46dc9a1..3480f9de 100644 --- a/unshackle/commands/serve.py +++ b/unshackle/commands/serve.py @@ -160,6 +160,10 @@ def serve( if global_services: log.info(f"Global service allowlist: {', '.join(global_services)}") 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 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: @@ -173,7 +177,10 @@ def serve( app["config"] = {"users": {}} else: app = web.Application(middlewares=[cors_middleware, api_key_authentication, compression_middleware]) - app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}} + api_users: dict = {api_secret: {"devices": [], "username": "api_user"}} + if isinstance(users, dict): + api_users.update(users) + app["config"] = {"users": api_users} app["debug_api"] = debug_api # Start session cleanup loop for remote-dl sessions diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 214a30df..53ab60c1 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -37,6 +37,8 @@ log = logging.getLogger("remote_service") SENSITIVE_DATA_KEYS = ("credential", "credentials", "password", "token", "api_key") +DEFAULT_AUTH_HEADERS = ["X-Secret-Key", "X-Api-Key"] + def redact_secrets(text: str, data: Optional[Dict[str, Any]] = None) -> str: """Mask URL userinfo and any request-payload secrets before the text is logged.""" @@ -47,9 +49,11 @@ def redact_secrets(text: str, data: Optional[Dict[str, Any]] = None) -> str: class RemoteClient: """HTTP client for the unshackle serve API.""" - def __init__(self, server_url: str, api_key: str) -> None: + def __init__(self, server_url: str, api_key: str, auth_headers: Optional[list[str]] = None) -> None: self.server_url = server_url.rstrip("/") self.api_key = api_key + self.auth_headers = list(auth_headers) if auth_headers else list(DEFAULT_AUTH_HEADERS) + self._auth_header_index = 0 self._session: Optional[requests.Session] = None @property @@ -60,20 +64,35 @@ class RemoteClient: self._session = requests.Session() self._session.headers["User-Agent"] = f"unshackle/{__version__}" if self.api_key: - self._session.headers["X-Secret-Key"] = self.api_key + self._session.headers[self.auth_headers[self._auth_header_index]] = self.api_key return self._session + def _next_auth_header(self) -> bool: + """Move the api key onto the next candidate header. False once they are exhausted.""" + if not self.api_key or self._auth_header_index + 1 >= len(self.auth_headers): + return False + session = self.session + session.headers.pop(self.auth_headers[self._auth_header_index], None) + self._auth_header_index += 1 + session.headers[self.auth_headers[self._auth_header_index]] = self.api_key + return True + def _request(self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: url = f"{self.server_url}{endpoint}" - try: - resp = getattr(self.session, method)(url, json=data, timeout=120 if method == "post" else 30) - except requests.ConnectionError: - server_url = safe_display_url(self.server_url) - log.error(f"Could not connect to remote server at {server_url}. Is it running? (unshackle serve)") - raise SystemExit(1) - except requests.Timeout: - log.error(f"Request to remote server timed out: {endpoint}") - raise SystemExit(1) + while True: + try: + resp = getattr(self.session, method)(url, json=data, timeout=120 if method == "post" else 30) + except requests.ConnectionError: + server_url = safe_display_url(self.server_url) + log.error(f"Could not connect to remote server at {server_url}. Is it running? (unshackle serve)") + raise SystemExit(1) + except requests.Timeout: + log.error(f"Request to remote server timed out: {endpoint}") + raise SystemExit(1) + # servers differ on which header carries the key, so retry the rest before giving up + if resp.status_code == 401 and self._next_auth_header(): + continue + break result = resp.json() if resp.status_code >= 400: error_msg = redact_secrets(str(result.get("message", resp.text)), data) @@ -319,8 +338,31 @@ def _build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Un return title +def _resolve_auth_headers(svc: dict, server_name: str) -> list[str]: + """Configured header names first, then the defaults as fallbacks.""" + auth_headers = svc.get("auth_headers") + if auth_headers is None: + return list(DEFAULT_AUTH_HEADERS) + if ( + not isinstance(auth_headers, list) + or not auth_headers + or not all(isinstance(h, str) and h.strip() for h in auth_headers) + ): + raise click.ClickException( + f"Remote service '{server_name}': 'auth_headers' must be a list of header names, e.g.\n\n" + ' auth_headers: ["Authorization"]' + ) + resolved = [h.strip() for h in auth_headers] + seen = {h.lower() for h in resolved} + resolved.extend(h for h in DEFAULT_AUTH_HEADERS if h.lower() not in seen) + return resolved + + def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: - """Resolve server URL, API key, and per-service config from remote_services.""" + """Resolve server URL, API key, and per-service config from remote_services. + + The per-service config carries the ``_auth_headers`` candidate list for RemoteClient. + """ remote_services = config.remote_services if not remote_services: raise click.ClickException( @@ -338,6 +380,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: raise click.ClickException(f"Remote service '{server_name}' not found. Available: {available}") services = svc.get("services", {}) services["_server_cdm"] = svc.get("server_cdm", False) + services["_auth_headers"] = _resolve_auth_headers(svc, server_name) return svc["url"], svc.get("api_key", ""), services if len(remote_services) == 1: @@ -345,6 +388,7 @@ def resolve_server(server_name: Optional[str]) -> tuple[str, str, dict]: log.info(f"Using remote service: {name}") services = svc.get("services", {}) services["_server_cdm"] = svc.get("server_cdm", False) + services["_auth_headers"] = _resolve_auth_headers(svc, name) return svc["url"], svc.get("api_key", ""), services available = ", ".join(remote_services.keys()) @@ -415,7 +459,7 @@ class RemoteService: self.service_tag = service_tag self.title_id = title_id - self.client = RemoteClient(server_url, api_key) + self.client = RemoteClient(server_url, api_key, services_config.get("_auth_headers")) self.ctx = ctx self._service_params = service_params or {} self.log = logging.getLogger(service_tag) diff --git a/unshackle/core/services.py b/unshackle/core/services.py index aa3ca24d..88fa50f9 100644 --- a/unshackle/core/services.py +++ b/unshackle/core/services.py @@ -204,8 +204,8 @@ class Services(click.Group): from unshackle.core.remote_service import RemoteClient, resolve_server server_name = ctx.params.get("server") - server_url, api_key, _ = resolve_server(server_name) - client = RemoteClient(server_url, api_key) + server_url, api_key, services_config = resolve_server(server_name) + client = RemoteClient(server_url, api_key, services_config.get("_auth_headers")) result = client.get("/api/services") Services._remote_services_cache = result.get("services", []) return Services._remote_services_cache