mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user