feat(tunnel): free finished tunnels at once and wait out 429s on --remote

A tunnel closed with code 1000 is freed without the 60s grace, a shared: true API key counts tunnels per X-Client-Id, and --remote waits out Retry-After and a full tunnel cap with a countdown; responses carry X-RateLimit-* headers.
This commit is contained in:
imSp4rky
2026-11-09 15:43:33 -07:00
parent 4b6d15cc2a
commit b10fd2d3a4
13 changed files with 358 additions and 25 deletions
+14
View File
@@ -224,6 +224,19 @@ A limit comes from `serve.tiers.<name>.rate_limit` or from the API key's own
[dashboard rate limits](dashboard.md#rate-limits) for the config and the counters, and
[services configuration](../../reference/configuration/services.md) for the keys themselves.
Every response to an API key that has a limit carries these headers, the `429` too. A
WebSocket or event stream response sends its headers before the handler returns, so it has none.
The CORS headers expose these headers and `Retry-After` to a browser client.
| Header | Value |
|---|---|
| `X-RateLimit-Limit` | Requests per hour that the API key may make. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | Seconds until the window resets. |
A `--remote` client that gets a `429` with `Retry-After` shows a countdown, waits that time
(one hour at most), and then sends the request again. It stops after 3 waits for one request.
!!! warning "`429` is not the `RATE_LIMITED` error body"
`RATE_LIMITED` in [Errors](errors.md) is a *service* rate limit that a handler raises, and it
arrives in the standard envelope with an `error_code`. The `429` here comes from the
@@ -311,6 +324,7 @@ With `--caddy`, unshackle also starts `caddy run` using the `Caddyfile` located
| Missing-header body | `{"status": 401, "message": "Secret Key is Empty."}` |
| Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` |
| Over-limit body | `{"status": 429, "message": "Rate limit exceeded."}`, plus a `Retry-After` header |
| Rate limit headers | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` on each response to a limited API key |
| Rate limit config | `serve.tiers.<name>.rate_limit`, overridden by `serve.users.<key>.rate_limit` |
| Disable auth | `--no-key` (all requests allowed) |
| Config location | `serve.api_secret` and `serve.users` in `unshackle.yaml` |
+1 -1
View File
@@ -448,7 +448,7 @@ The server mounts all these routes, even in `--remote-only` mode. Paths use the
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/api/session/create` | Build + authenticate a service instance; returns a session ID immediately |
| `GET` | `/api/tunnel` | WebSocket that relays the connections of a `tunnel:<id>` proxy to the client; `?id=` reconnects |
| `GET` | `/api/tunnel` | WebSocket that relays the connections of a `tunnel:<id>` proxy to the client; `?id=` reconnects, close code 1000 frees the tunnel at once |
| `GET` | `/api/tunnel/check?id=` | Exit IP address and lookup time through the caller's tunnel, for `unshackle tunnel-test` |
| `GET` | `/api/session/{id}/titles` | Fetch and cache the title list |
| `POST` | `/api/session/{id}/tracks` | Resolve tracks, manifests, chapters, headers/cookies for a title |
+4 -2
View File
@@ -336,8 +336,10 @@ A service that uses a server account does not work with `--local-proxy`, because
logs in with its own account. If the tunnel drops, the client connects again, and a new request
from the server waits up to 10 seconds for it. A request that was in progress when the tunnel
dropped fails. The server closes a tunnel when its client has not connected for 60 seconds, and
the client then stops with `The tunnel to the server closed`. An API key can have 4 tunnels
open at once.
the client then stops with `The tunnel to the server closed`. A client that exits closes its
tunnel at once. An API key can have 4 tunnels open at once (4 for each person on a key with
`shared: true`, see [services configuration](../reference/configuration/services.md)). A fifth run waits for a free
tunnel and tries again every 15 seconds, for one hour at most.
## Basic (static proxies)
+10 -1
View File
@@ -240,10 +240,17 @@ server vault with this grant, are the only source, and a missing content key sto
`admin` is a boolean that lets the API key run the maintenance endpoints (clear-cache, clear-temp, refresh-services). It is `false` unless the entry sets it. Keys that have no `users` entry, such as `api_secret`, keep that access.
`shared` is a boolean for an API key that a gateway in front of the server gives to many people.
The gateway puts its own ID for each person in the `X-Client-Id` header, and the server then
counts the `--local-proxy` tunnels of that API key for each person. Without `shared: true` the
server ignores the header, because a client that connects to the server directly can set any
value. Set it only when the gateway always replaces the header that the client sends.
A `tier` names an entry under `serve.tiers`, which holds the settings that several API keys
share. Today a tier carries `rate_limit`, the requests per hour that API key may make; an API
key can also set its own `rate_limit`, which wins over its tier's. An API key with neither has
no limit. Going over the limit answers `429` with a `Retry-After` header. The server keeps a
no limit. Going over the limit answers `429` with a `Retry-After` header, and a `--remote`
client waits that time before it tries again. The server keeps a
fixed window rather than a sliding one: it opens on the first request and resets an hour
later. A `tier` that names no entry under `serve.tiers`, or a `rate_limit` that is not a
positive whole number, stops the server at startup, because the alternative is an API key that
@@ -341,6 +348,8 @@ serve:
f3a4b5c6:
services: [EXAMPLE1]
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys
d7e8f9a0:
shared: true # a gateway gives this key to many people; tunnels count per X-Client-Id
```
!!! note "`dl` keys inside `serve`"