fix(api)!: key the health route and keep server internals from user keys

/api/health needs X-Secret-Key but stays outside the rate limit. Only the admin key gets load_errors from /api/services, default error details no longer name the exception class, and a prepare signal replaces aiohttp's Server banner on every response while the CDM routes keep their own.
This commit is contained in:
imSp4rky
2026-11-03 11:53:55 -07:00
parent b6429ecdfd
commit 87ccd6666c
16 changed files with 105 additions and 47 deletions
+6 -7
View File
@@ -16,7 +16,7 @@ You start the API with the [`unshackle serve`](../../guide/cli-reference.md) com
1. Add an `api_secret` to the `serve` section of your `unshackle.yaml`.
2. Start the server with `unshackle serve`.
3. Send that secret in the `X-Secret-Key` header on every request except `GET /api/health`.
3. Send that secret in the `X-Secret-Key` header on every request, `GET /api/health` included.
```bash title="A minimal authenticated request"
curl -H "X-Secret-Key: your-secret-key-here" \
@@ -152,12 +152,12 @@ The value must match one of the configured API keys exactly (`api_secret`, or on
!!! tip "The reference client sets it for you"
unshackle's own [remote download](../../guide/downloading.md) client (`RemoteService`) sends `X-Secret-Key` automatically, reading the API key from your `remote_services` config. It also sends a `User-Agent` of `unshackle/<version>`. You only deal with the header directly when writing your own client.
### The health check is always open
### The health check needs an API key too
One endpoint is exempt from authentication: `GET /api/health`. It never requires an API key, so you can use it for liveness and version probes without embedding a secret in your monitoring:
`GET /api/health` reports the running version and commit, so it sits behind the same `X-Secret-Key` check as every other route. A monitor that probes it sends an API key; a request without one gets the usual `401` body, which still proves the server is up:
```bash title="No key needed"
curl http://127.0.0.1:8786/api/health
```bash title="Liveness probe"
curl -H "X-Secret-Key: your-secret-key-here" http://127.0.0.1:8786/api/health
```
```json title="Response"
@@ -307,7 +307,6 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located
| Auth header | `X-Secret-Key` |
| Query-parameter alternative | `?secret_key=` on `GET /api/download/jobs/{job_id}/events` only |
| Default bind address | `127.0.0.1:8786` |
| Exempt endpoint | `GET /api/health` |
| Failure status | `401 Unauthorized`, or `429 Too Many Requests` for a key over its rate limit |
| Missing-header body | `{"status": 401, "message": "Secret Key is Empty."}` |
| Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` |
@@ -326,7 +325,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 holds your `api_secret` (labelled `api_user`, with no devices) plus every key under `serve.users`. A per-user key therefore 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 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 reads `X-Secret-Key` on every request, `GET /api/health` included. 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`. A per-user key therefore 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 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`.
+1 -1
View File
@@ -199,7 +199,7 @@ here, so use `id` as the identity and `label` only for display.
| `bytes_out` | Response bodies only. An event stream reports no body length, so it counts as zero. |
| `last_seen` | `null` until the API key makes its first request. |
Only a configured API key gets a row of counters. A request on an open route such as `/api/health` with an unknown key counts as `anonymous`, so an unauthenticated caller cannot grow the table.
Only a configured API key gets a row of counters. A request that answers with an unknown key counts as `anonymous`, so an unauthenticated caller cannot grow the table.
Every API key the server counts has a row here, the dashboard key included, so a
`requests_by_key` bucket other than `anonymous` always matches one. A "traffic by key" panel
+5 -5
View File
@@ -8,11 +8,11 @@ Start the server with [`unshackle serve`](../../guide/cli-reference.md). By defa
All examples below assume a base URL of `http://127.0.0.1:8786`. Replace the host and port so that they match your own `unshackle serve -h <host> -p <port>` invocation.
!!! tip "Interactive docs"
The server mounts a live Swagger UI at **`/api/docs/`** whenever it does not operate in `--remote-only` mode. Its title is "Unshackle REST API" and its version tracks the installed package version. In API-only mode with an API key configured, the Swagger UI itself sits behind `X-Secret-Key`. Only `/api/health` is exempt.
The server mounts a live Swagger UI at **`/api/docs/`** whenever it does not operate in `--remote-only` mode. Its title is "Unshackle REST API" and its version tracks the installed package version. In API-only mode with an API key configured, the Swagger UI itself sits behind `X-Secret-Key`, like every other route.
## Conventions
- **Authentication.** Every request except `GET /api/health` requires the `X-Secret-Key` header when you configure an API key. `--no-key` disables the check entirely. See [Authentication](authentication.md).
- **Authentication.** Every request, `GET /api/health` included, requires the `X-Secret-Key` header when you configure an API key. `--no-key` disables the check entirely. See [Authentication](authentication.md).
- **Content type.** Request bodies are JSON (`Content-Type: application/json`). All success responses are JSON unless this page documents a `204 No Content`.
- **CORS.** Every response carries permissive CORS headers, and the server answers `OPTIONS` preflight requests automatically.
- **Compression.** JSON responses of 256 bytes or more are gzip-compressed when the client sends `Accept-Encoding: gzip`. A [slow response](#slow-responses) is not compressed.
@@ -30,12 +30,12 @@ These endpoints report server state and enumerate what is available. `GET /api/h
### `GET /api/health`
Health check. **This is the only route exempt from authentication**: you can call it without an `X-Secret-Key` header. It reports the running version and a best-effort update check.
Health check. It reports the running version and commit and a best-effort update check, so it needs the `X-Secret-Key` header like every other route. A request without an API key gets a `401`, which still shows the server is up.
=== "Request"
```bash
curl http://127.0.0.1:8786/api/health
curl -H "X-Secret-Key: $SECRET" http://127.0.0.1:8786/api/health
```
=== "Response `200`"
@@ -68,7 +68,7 @@ If the update check fails (for example, no network), `update_available` and `lat
### `GET /api/services`
Show the streaming services available on this server, filtered by your allowlist. Each entry gives the service's tag, matching rules, capability flags, and its CLI parameters (useful when you make a UI that accepts service-specific options). `load_errors` lists the services the server skipped because they failed to import, at startup or after a service repository refresh; the server also writes each one to its log. An entry marked `pending_update` has newer code staged: it keeps its current code until its running or queued jobs and its live remote sessions finish.
Show the streaming services available on this server, filtered by your allowlist. Each entry gives the service's tag, matching rules, capability flags, and its CLI parameters (useful when you make a UI that accepts service-specific options). For the admin key, `load_errors` lists the services the server skipped because they failed to import, at startup or after a service repository refresh; the server also writes each one to its log. Any other key gets an empty list; with `--no-key` every caller is the admin. An entry marked `pending_update` has newer code staged: it keeps its current code until its running or queued jobs and its live remote sessions finish.
=== "Request"
+1 -2
View File
@@ -221,8 +221,7 @@ body that uses an integer `status` and no `error_code` or `timestamp`.
!!! warning "Handle both shapes"
A client must expect two error formats: the standard structured error
(string `status` of `"error"`, with `error_code`), and this authentication error
(integer `status`, no `error_code`). The `GET /api/health` endpoint is exempt
from authentication, so it never returns the key-error shape.
(integer `status`, no `error_code`).
## Late failures with status `200`
+5 -5
View File
@@ -126,12 +126,12 @@ health check is `GET http://127.0.0.1:8786/api/health`.
available at [`/api/docs/`](http://127.0.0.1:8786/api/docs/). The server generates
it from the same route table it serves, so it always matches the version you
operate. Note that in `--api-only` mode the docs page is itself behind the
API key. Only `/api/health` is exempt from authentication.
API key, like every other route.
A quick smoke test that needs no API key:
A quick smoke test:
```shell title="Confirm the server is up"
curl http://127.0.0.1:8786/api/health
curl -H "X-Secret-Key: your-api-secret" http://127.0.0.1:8786/api/health
```
```json title="Response"
@@ -156,7 +156,7 @@ request and response in full.
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/api/health` | Liveness and version check. Never requires an API key. |
| `GET` | `/api/health` | Liveness and version check. |
| `GET` | `/api/services` | List the services available on this server. |
| `POST` | `/api/search` | Run a service's search for a query. |
| `POST` | `/api/list-titles` | List the titles behind a service title ID. |
@@ -220,7 +220,7 @@ request and response in full.
## Authentication in one line
With the default configuration, send your API secret in the `X-Secret-Key` header on
every request except `/api/health`:
every request:
```shell title="An authenticated request"
curl -X POST http://127.0.0.1:8786/api/search \
+3 -3
View File
@@ -93,7 +93,7 @@ Swagger UI available at http://127.0.0.1:8786/api/docs/
```
!!! note "Swagger UI is also behind auth"
In `--api-only` mode (without `--no-key`), the Swagger UI at `/api/docs/` requires the `X-Secret-Key` header, the same as every other route. Only `/api/health` is exempt from authentication.
In `--api-only` mode (without `--no-key`), the Swagger UI at `/api/docs/` requires the `X-Secret-Key` header, the same as every other route.
For the rest of this page we will assume the server is reachable at `http://127.0.0.1:8786` and store the secret in an environment variable:
@@ -106,10 +106,10 @@ export BASE="http://127.0.0.1:8786"
## Step 3: Make sure that the server is alive
The health endpoint is the one route that never requires an API key. Use it to make sure that the server operates, and to read its version:
Use the health endpoint to make sure that the server operates, and to read its version. It needs the API key like every other route:
```shell
curl "$BASE/api/health"
curl -H "X-Secret-Key: $SECRET" "$BASE/api/health"
```
```json title="200 OK"