feat(remote): accept serve.users keys in api-only mode and add client auth-header fallback

api-only/remote-only serve now authenticates every serve.users key (str-coerced), not just api_secret, so per-user service allowlists apply. RemoteClient tries X-Secret-Key then X-Api-Key on 401 (sticky winner); remote_services.auth_headers prepends extra header names to the defaults.
This commit is contained in:
imSp4rky
2026-08-12 17:58:55 -06:00
parent 274b2fc308
commit 5796729bf3
8 changed files with 231 additions and 20 deletions
+1 -1
View File
@@ -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`.
+12 -3
View File
@@ -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.
+59
View File
@@ -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
+37
View File
@@ -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)
+55
View File
@@ -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
+8 -1
View File
@@ -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
+57 -13
View File
@@ -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)
+2 -2
View File
@@ -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