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 [dashboard rate limits](dashboard.md#rate-limits) for the config and the counters, and
[services configuration](../../reference/configuration/services.md) for the keys themselves. [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" !!! 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 `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 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."}` | | Missing-header body | `{"status": 401, "message": "Secret Key is Empty."}` |
| Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` | | Invalid-key body | `{"status": 401, "message": "Secret Key is Invalid."}` |
| Over-limit body | `{"status": 429, "message": "Rate limit exceeded."}`, plus a `Retry-After` header | | 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` | | Rate limit config | `serve.tiers.<name>.rate_limit`, overridden by `serve.users.<key>.rate_limit` |
| Disable auth | `--no-key` (all requests allowed) | | Disable auth | `--no-key` (all requests allowed) |
| Config location | `serve.api_secret` and `serve.users` in `unshackle.yaml` | | 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 | | Method | Path | Purpose |
|---|---|---| |---|---|---|
| `POST` | `/api/session/create` | Build + authenticate a service instance; returns a session ID immediately | | `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/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 | | `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 | | `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 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 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 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 the client then stops with `The tunnel to the server closed`. A client that exits closes its
open at once. 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) ## 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. `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 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 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 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 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 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 positive whole number, stops the server at startup, because the alternative is an API key that
@@ -341,6 +348,8 @@ serve:
f3a4b5c6: f3a4b5c6:
services: [EXAMPLE1] services: [EXAMPLE1]
server_vault: [EXAMPLE1] # the client's device licenses; the server vault still serves keys 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`" !!! note "`dl` keys inside `serve`"
+5 -1
View File
@@ -252,9 +252,13 @@ async def test_rate_limited_request_returns_429(aiohttp_client, monkeypatch) ->
setup_routes(app, remote_only=True, dashboard=True) setup_routes(app, remote_only=True, dashboard=True)
client = await aiohttp_client(app) client = await aiohttp_client(app)
assert (await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})).status == 200 resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})
assert resp.status == 200
assert (resp.headers["X-RateLimit-Limit"], resp.headers["X-RateLimit-Remaining"]) == ("1", "0")
assert 0 < int(resp.headers["X-RateLimit-Reset"]) <= 3600
resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"}) resp = await client.get("/api/services", headers={"X-Secret-Key": "tier-key"})
assert resp.status == 429 and int(resp.headers["Retry-After"]) > 0 assert resp.status == 429 and int(resp.headers["Retry-After"]) > 0
assert resp.headers["X-RateLimit-Remaining"] == "0"
assert (await client.get("/api/health", headers={"X-Secret-Key": "tier-key"})).status == 200 assert (await client.get("/api/health", headers={"X-Secret-Key": "tier-key"})).status == 200
assert (await client.get("/api/health")).status == 401 assert (await client.get("/api/health")).status == 401
# The dashboard key leaves the middleware before the limit check, so it always answers. # The dashboard key leaves the middleware before the limit check, so it always answers.
+91
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import click
import pytest import pytest
import responses import responses
@@ -244,3 +245,93 @@ def test_proxy_error_page_logs_its_title(client: RemoteClient, caplog: pytest.Lo
client.get("/api/session/s/titles") client.get("/api/session/s/titles")
assert "HTTP 524: example.com | 524: A timeout occurred" in caplog.text assert "HTTP 524: example.com | 524: A timeout occurred" in caplog.text
assert "DOCTYPE" not in caplog.text assert "DOCTYPE" not in caplog.text
@responses.activate
def test_a_429_with_retry_after_waits_then_retries(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
waits: list[int] = []
monkeypatch.setattr("unshackle.core.remote_service.wait_retry_after", waits.append)
url = "http://srv:8786/api/services"
responses.add(responses.GET, url, status=429, json={"status": 429}, headers={"Retry-After": "7"})
responses.add(responses.GET, url, json={"status": "ok"})
assert client.get("/api/services") == {"status": "ok"}
assert waits == [7]
@responses.activate
def test_a_429_without_retry_after_fails_at_once(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("unshackle.core.remote_service.wait_retry_after", pytest.fail)
responses.add(responses.GET, "http://srv:8786/api/services", status=429, json={"error_code": "RATE_LIMITED"})
with pytest.raises(SystemExit):
client.get("/api/services")
def test_wait_retry_after_waits_out_its_own_deadline(monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core import remote_service
clock = [0.0]
monkeypatch.setattr(remote_service.time, "monotonic", lambda: clock[0])
monkeypatch.setattr(remote_service.time, "sleep", lambda s: clock.__setitem__(0, clock[0] + s))
remote_service.wait_retry_after(3)
assert clock[0] == 3.0
remote_service.wait_retry_after(99999)
assert clock[0] == 3.0 + remote_service.RETRY_AFTER_MAX
def test_a_rate_limited_tunnel_waits_then_opens(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core import remote_service
attempts: list[object] = []
class Refused:
def __init__(self, *_: object) -> None:
self.retry_after = 5 if not attempts else None
self.at_limit = len(attempts) == 1
attempts.append(self)
def start(self) -> str:
if self.retry_after or self.at_limit:
raise ConnectionError("HTTP 429")
return "id"
waits: list[int] = []
monkeypatch.setattr(remote_service, "TunnelClient", Refused)
monkeypatch.setattr(remote_service, "wait_retry_after", lambda s, *_: waits.append(s))
assert remote_service.start_tunnel(client) is attempts[2]
assert waits == [5, remote_service.TUNNEL_SLOT_POLL]
def test_a_tunnel_refused_for_another_reason_fails_at_once(
client: RemoteClient, monkeypatch: pytest.MonkeyPatch
) -> None:
from unshackle.core import remote_service
class Refused:
retry_after = None
at_limit = False
def __init__(self, *_: object) -> None:
pass
def start(self) -> str:
raise ConnectionError("HTTP 403")
monkeypatch.setattr(remote_service, "TunnelClient", Refused)
monkeypatch.setattr(remote_service, "wait_retry_after", pytest.fail)
with pytest.raises(click.ClickException, match="HTTP 403"):
remote_service.start_tunnel(client)
@responses.activate
def test_the_429_wait_gives_up_after_its_tries(client: RemoteClient, monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core import remote_service
waits: list[int] = []
monkeypatch.setattr(remote_service, "wait_retry_after", waits.append)
responses.add(responses.GET, "http://srv:8786/api/services", status=429, json={}, headers={"Retry-After": "0"})
with pytest.raises(SystemExit):
client.get("/api/services")
assert waits == [1] * remote_service.RATE_LIMIT_TRIES
+103 -1
View File
@@ -16,6 +16,7 @@ import pytest
import requests import requests
import responses import responses
from aiohttp import web from aiohttp import web
from multidict import CIMultiDict
from unshackle.core import tunnel from unshackle.core import tunnel
from unshackle.core.api.routes import tunnel as tunnel_route from unshackle.core.api.routes import tunnel as tunnel_route
@@ -356,6 +357,7 @@ def test_a_malformed_frame_still_leaves_the_tunnel_to_expire(server: str) -> Non
async with http.ws_connect(server + ROUTE) as ws: async with http.ws_connect(server + ROUTE) as ws:
tunnel_id = (await ws.receive_json())["id"] tunnel_id = (await ws.receive_json())["id"]
await ws.send_bytes(b"\x01") await ws.send_bytes(b"\x01")
await ws.close(code=aiohttp.WSCloseCode.GOING_AWAY)
return str(tunnel_id) return str(tunnel_id)
tunnel_id = asyncio.run(send_short_frame()) tunnel_id = asyncio.run(send_short_frame())
@@ -369,12 +371,112 @@ def test_an_api_key_has_a_tunnel_limit(server: str) -> None:
clients = [TunnelClient(server, {"X-Secret-Key": "bob"}) for _ in range(tunnel.MAX_TUNNELS)] clients = [TunnelClient(server, {"X-Secret-Key": "bob"}) for _ in range(tunnel.MAX_TUNNELS)]
for client in clients: for client in clients:
client.start() client.start()
with pytest.raises(ConnectionError, match="429"): with pytest.raises(ConnectionError, match="429, the API key already has the maximum number of tunnels open"):
TunnelClient(server, {"X-Secret-Key": "bob"}).start() TunnelClient(server, {"X-Secret-Key": "bob"}).start()
for client in clients: for client in clients:
client.stop() client.stop()
@pytest.mark.parametrize("shared", [True, False])
def test_a_shared_api_key_counts_tunnels_for_each_client(
server: str, monkeypatch: pytest.MonkeyPatch, shared: bool
) -> None:
from unshackle.core.config import config
monkeypatch.setitem(config.serve, "users", {"tier": {"shared": True} if shared else {}})
first = [TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"}) for _ in range(tunnel.MAX_TUNNELS)]
second = TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u2"})
try:
for client in first:
client.start()
if shared:
second.start()
else:
with pytest.raises(ConnectionError, match="maximum number of tunnels"):
second.start()
with pytest.raises(ConnectionError, match="maximum number of tunnels"):
TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"}).start()
finally:
for client in [*first, second]:
client.stop()
def test_another_client_of_a_shared_api_key_cannot_reattach(server: str, monkeypatch: pytest.MonkeyPatch) -> None:
from unshackle.core.config import config
monkeypatch.setitem(config.serve, "users", {"tier": {"shared": True}})
owner = TunnelClient(server, {"X-Secret-Key": "tier", "X-Client-Id": "u1"})
tunnel_id = owner.start()
try:
resp = requests.get(
f"{server}{ROUTE}?id={tunnel_id}", headers={"X-Secret-Key": "tier", "X-Client-Id": "u2"}, timeout=10
)
assert resp.status_code == 404
finally:
owner.stop()
def test_stopping_one_client_leaves_the_other_tunnels_of_its_api_key_open(
server: str, target: int, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(tunnel, "is_public_ip", lambda address: True)
monkeypatch.setattr(tunnel, "dialled_itself", lambda writer: False)
clients = [TunnelClient(server, {"X-Secret-Key": "erin"}) for _ in range(3)]
stopped, *running = [client.start() for client in clients]
try:
clients[0].stop()
assert wait_until(lambda: stopped not in TUNNELS, 5)
for tunnel_id in running:
proxy = tunnel_proxy(tunnel_id, "erin")
assert proxy
resp = requests.get(f"http://127.0.0.1:{target}/", proxies={"all": proxy}, timeout=10)
assert resp.text == "hello through the tunnel"
finally:
for client in clients[1:]:
client.stop()
@pytest.mark.parametrize(
("value", "seconds"),
[
("5", 5),
("0", 1),
(None, None),
("", None),
("\u00b2", None),
("9" * 5000, None),
("Wed, 21 Oct 2026 07:28:00 GMT", None),
],
ids=["seconds", "zero", "absent", "empty", "superscript", "huge", "http-date"],
)
def test_parse_retry_after(value: str | None, seconds: int | None) -> None:
assert tunnel.parse_retry_after(value) == seconds
def test_a_client_that_exits_frees_its_tunnel_at_once(server: str) -> None:
async def open_then_leave() -> str:
async with aiohttp.ClientSession(headers={"X-Secret-Key": "dave"}) as http:
async with http.ws_connect(server + ROUTE) as ws:
return str((await ws.receive_json())["id"])
tunnel_id = asyncio.run(open_then_leave())
assert wait_until(lambda: tunnel_id not in TUNNELS, 5)
@pytest.mark.parametrize(
("status", "headers", "reason"),
[
(429, {"Retry-After": "120"}, "HTTP 429, the API key is over its hourly request limit, retry in 120s"),
(429, {"retry-after": "120"}, "HTTP 429, the API key is over its hourly request limit, retry in 120s"),
(429, {}, "HTTP 429, the API key already has the maximum number of tunnels open"),
(404, None, "HTTP 404"),
],
)
def test_a_refused_tunnel_names_the_reason(status: int, headers: dict[str, str] | None, reason: str) -> None:
error = aiohttp.WSServerHandshakeError(None, (), status=status, headers=CIMultiDict(headers or {})) # type: ignore[arg-type]
assert tunnel.refusal(error) == reason
def test_a_connection_that_stops_reading_is_dropped() -> None: def test_a_connection_that_stops_reading_is_dropped() -> None:
sent: list = [] sent: list = []
+3
View File
@@ -21,6 +21,7 @@ from unshackle.core.api.compression import compression_middleware
from unshackle.core.api.events import publish_refresh_events, publish_service_event from unshackle.core.api.events import publish_refresh_events, publish_service_event
from unshackle.core.api.handlers import ( from unshackle.core.api.handlers import (
DASHBOARD_PREFIX, DASHBOARD_PREFIX,
add_rate_limit_headers,
api_key_authentication, api_key_authentication,
dashboard_authentication, dashboard_authentication,
dashboard_key, dashboard_key,
@@ -559,6 +560,8 @@ def serve(
f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}" f"https://git.gay/ready-dl/pyplayready serve v{pyplayready_version}"
) )
if request.path != "/api/health" and secret_key and known:
add_rate_limit_headers(response, secret_key)
return response return response
return serve_authentication return serve_authentication
+26 -5
View File
@@ -1354,6 +1354,7 @@ CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-Secret-Key, Authorization", "Access-Control-Allow-Headers": "Content-Type, X-Secret-Key, Authorization",
"Access-Control-Max-Age": "3600", "Access-Control-Max-Age": "3600",
"Access-Control-Expose-Headers": "Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset",
} }
@@ -1408,10 +1409,19 @@ def rate_limit_response(secret_key: str) -> Optional[web.Response]:
return web.json_response( return web.json_response(
{"status": 429, "message": "Rate limit exceeded."}, {"status": 429, "message": "Rate limit exceeded."},
status=429, status=429,
headers={"Retry-After": str(retry_after)}, headers={"Retry-After": str(retry_after), **stats.rate_limit_headers(secret_key)},
) )
def add_rate_limit_headers(response: web.StreamResponse, secret_key: str) -> web.StreamResponse:
"""Put *secret_key*'s rate-limit headers on *response*; a streamed response has sent its headers already."""
from unshackle.core.api.stats import stats
if not response.prepared:
response.headers.update(stats.rate_limit_headers(secret_key))
return response
@web.middleware @web.middleware
async def api_key_authentication(request: web.Request, handler: Any) -> web.StreamResponse: async def api_key_authentication(request: web.Request, handler: Any) -> web.StreamResponse:
"""Gate every non-dashboard route behind an API key in ``app["config"]["users"]``, then rate limit it. """Gate every non-dashboard route behind an API key in ``app["config"]["users"]``, then rate limit it.
@@ -1433,7 +1443,7 @@ async def api_key_authentication(request: web.Request, handler: Any) -> web.Stre
return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401) return web.json_response({"status": 401, "message": "Secret Key is Invalid."}, status=401)
if request.path == "/api/health": if request.path == "/api/health":
return await handler(request) return await handler(request)
return rate_limit_response(secret_key) or await handler(request) return rate_limit_response(secret_key) or add_rate_limit_headers(await handler(request), secret_key)
def caller_key(request: Optional[web.Request] = None) -> str: def caller_key(request: Optional[web.Request] = None) -> str:
@@ -5649,19 +5659,30 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request]
return web.json_response(response) return web.json_response(response)
def shared_key_client(request: web.Request) -> Optional[str]:
"""The person behind a ``shared: true`` API key, from the ``X-Client-Id`` its gateway sets; None for other keys.
Only a shared key reads the header, so a client that talks to the server directly cannot use it to pass a limit.
"""
if serve_user_config(request_secret_key(request) or "").get("shared") is not True:
return None
return request.headers.get("X-Client-Id", "")[:64] or None
async def tunnel_handler(request: web.Request) -> web.StreamResponse: async def tunnel_handler(request: web.Request) -> web.StreamResponse:
"""Open a ``--local-proxy`` tunnel, or reattach to the caller's tunnel named by ``?id=``.""" """Open a ``--local-proxy`` tunnel, or reattach to the caller's tunnel named by ``?id=``."""
owner = request_secret_key(request) owner = request_secret_key(request)
client = shared_key_client(request)
tunnel_id = request.query.get("id") tunnel_id = request.query.get("id")
tunnel = TUNNELS.get(tunnel_id) if tunnel_id else None tunnel = TUNNELS.get(tunnel_id) if tunnel_id else None
if tunnel_id and (not tunnel or tunnel.owner != owner): if tunnel_id and (not tunnel or tunnel.owner != owner or tunnel.client != client):
raise APIError(APIErrorCode.NOT_FOUND, "No open tunnel has this id.") raise APIError(APIErrorCode.NOT_FOUND, "No open tunnel has this id.")
if not tunnel and sum(t.owner == owner for t in TUNNELS.values()) >= MAX_TUNNELS: if not tunnel and sum(t.owner == owner and t.client == client for t in TUNNELS.values()) >= MAX_TUNNELS:
raise APIError(APIErrorCode.RATE_LIMITED, f"An API key can have {MAX_TUNNELS} tunnels open at once.") raise APIError(APIErrorCode.RATE_LIMITED, f"An API key can have {MAX_TUNNELS} tunnels open at once.")
ws = web.WebSocketResponse(heartbeat=HEARTBEAT, max_msg_size=MAX_MESSAGE) ws = web.WebSocketResponse(heartbeat=HEARTBEAT, max_msg_size=MAX_MESSAGE)
await ws.prepare(request) # a request that is not a WebSocket upgrade fails here, before a tunnel exists await ws.prepare(request) # a request that is not a WebSocket upgrade fails here, before a tunnel exists
if not tunnel: if not tunnel:
tunnel = Tunnel(owner) tunnel = Tunnel(owner, client)
await tunnel.start() await tunnel.start()
await tunnel.serve(ws) await tunnel.serve(ws)
return ws return ws
+6 -2
View File
@@ -2074,7 +2074,9 @@ async def tunnel(request: web.Request) -> web.StreamResponse:
description: >- description: >-
A WebSocket that a --remote client holds open. The server sends the requests of a remote session A WebSocket that a --remote client holds open. The server sends the requests of a remote session
created with proxy tunnel:<id> through it, and the client makes them from its own connection. created with proxy tunnel:<id> through it, and the client makes them from its own connection.
The first message is {"id": <tunnel id>}. The first message is {"id": <tunnel id>}. A client that has finished closes with code 1000,
which frees the tunnel at once; after any other close the tunnel waits 60 seconds for a reconnect.
An API key with shared: true counts its tunnels for each X-Client-Id value, which its gateway sets.
parameters: parameters:
- name: id - name: id
in: query in: query
@@ -2088,7 +2090,9 @@ async def tunnel(request: web.Request) -> web.StreamResponse:
'404': '404':
description: No open tunnel with this id for this API key description: No open tunnel with this id for this API key
'429': '429':
description: The API key has the maximum number of tunnels open description: >-
The API key has the maximum number of tunnels open, or, with a Retry-After header,
the API key is over its hourly request limit
""" """
return await tunnel_handler(request) return await tunnel_handler(request)
+12
View File
@@ -183,6 +183,18 @@ class ServerStats:
entry.window_used += 1 entry.window_used += 1
return None return None
def rate_limit_headers(self, key: str) -> Dict[str, str]:
"""The ``X-RateLimit-*`` headers for ``key``'s current window; none for an API key with no limit."""
limit = key_rate_limit(key)
if limit is None:
return {}
entry = self.key_stats(key)
return {
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": str(max(0, limit - entry.window_used)),
"X-RateLimit-Reset": str(max(0, int(entry.window_start + RATE_LIMIT_WINDOW - time.time()))),
}
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
"version": __version__, "version": __version__,
+49 -7
View File
@@ -13,6 +13,7 @@ import contextlib
import hashlib import hashlib
import json import json
import logging import logging
import math
import random import random
import re import re
import shlex import shlex
@@ -35,9 +36,10 @@ from langcodes import Language
from requests.adapters import HTTPAdapter, Retry from requests.adapters import HTTPAdapter, Retry
from rich.padding import Padding from rich.padding import Padding
from rich.rule import Rule from rich.rule import Rule
from rich.spinner import Spinner
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.console import console, prompt_user, show_notice from unshackle.core.console import SyncLive, console, prompt_user, show_notice
from unshackle.core.constants import AnyTrack from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential from unshackle.core.credential import Credential
from unshackle.core.drm import BlowfishStripe, cbcs, real_kids from unshackle.core.drm import BlowfishStripe, cbcs, real_kids
@@ -52,7 +54,7 @@ from unshackle.core.titles.music import SONG_FIELDS, Album, Song
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.track import Track from unshackle.core.tracks.track import Track
from unshackle.core.tunnel import TunnelClient from unshackle.core.tunnel import TunnelClient, parse_retry_after
from unshackle.core.utils import run_proxy from unshackle.core.utils import run_proxy
from unshackle.core.utils.collections import ci_get from unshackle.core.utils.collections import ci_get
from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url from unshackle.core.utils.redact import redact_path, redact_text, safe_display_url
@@ -69,6 +71,10 @@ DEFAULT_AUTH_HEADERS = ["X-Secret-Key", "X-Api-Key"]
SESSION_LOST = ("SESSION_NOT_FOUND", "CONNECT", "TIMEOUT", "TUNNEL_CLOSED") SESSION_LOST = ("SESSION_NOT_FOUND", "CONNECT", "TIMEOUT", "TUNNEL_CLOSED")
# what a recovering call can meet on its way, so these log only once the recovery fails # what a recovering call can meet on its way, so these log only once the recovery fails
RECOVERABLE = (*SESSION_LOST, "SERVER_DRAINING") RECOVERABLE = (*SESSION_LOST, "SERVER_DRAINING")
RETRY_AFTER_MAX = 3600
RETRY_AFTER_LOCK = Lock()
RATE_LIMIT_TRIES = 3
TUNNEL_SLOT_POLL = 15
REOPEN_WAIT = 1800.0 # seconds a lost session waits for its server to come back; covers a deploy's whole drain REOPEN_WAIT = 1800.0 # seconds a lost session waits for its server to come back; covers a deploy's whole drain
_recovering = local() _recovering = local()
@@ -92,6 +98,23 @@ def redact_secrets(text: str, data: Optional[Dict[str, Any]] = None) -> str:
return redact_text(text, secrets) or "" return redact_text(text, secrets) or ""
def wait_retry_after(seconds: int, reason: str = "Rate limited by the server") -> None:
"""Count down a wait on the console, as ``--slow`` does, so the caller can retry; one hour at most, the rate-limit window.
Threads refused together share one countdown, each still waits out its own deadline.
"""
deadline = time.monotonic() + min(seconds, RETRY_AFTER_MAX)
with RETRY_AFTER_LOCK:
if deadline <= time.monotonic():
return
log.info(f"{reason}, retrying in {math.ceil(deadline - time.monotonic())}s")
spinner = Spinner("dots")
with SyncLive(Padding(spinner, (0, 5)), console=console, refresh_per_second=12.5, transient=True):
while (left := deadline - time.monotonic()) > 0:
spinner.update(text=f"{reason}, retrying in {math.ceil(left)} seconds...")
time.sleep(min(1.0, left))
class RemoteError(SystemExit): class RemoteError(SystemExit):
"""A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``. """A remote call that failed: logged, then exits a CLI run like ``SystemExit(1)``.
@@ -172,6 +195,7 @@ class RemoteClient:
{"json": data} if body is None else {"data": body, "headers": {"Content-Type": "application/octet-stream"}} {"json": data} if body is None else {"data": body, "headers": {"Content-Type": "application/octet-stream"}}
) )
gateway_retried = False gateway_retried = False
rate_waits = 0
while True: while True:
try: try:
with self.in_flight(endpoint): with self.in_flight(endpoint):
@@ -186,6 +210,14 @@ class RemoteClient:
# servers differ on which header carries the key, so retry the rest before giving up # 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(): if resp.status_code == 401 and self.next_auth_header():
continue continue
if (
resp.status_code == 429
and rate_waits < RATE_LIMIT_TRIES
and (retry_after := parse_retry_after(resp.headers.get("Retry-After")))
):
rate_waits += 1
wait_retry_after(retry_after)
continue
# a proxy HTML page, not the server's JSON, means a dropped tunnel stream, which one retry gets past # a proxy HTML page, not the server's JSON, means a dropped tunnel stream, which one retry gets past
if ( if (
resp.status_code in (502, 503, 504) resp.status_code in (502, 503, 504)
@@ -831,11 +863,21 @@ def resolve_remote_proxy_arg(proxy_arg: str, providers: Optional[list] = None) -
def start_tunnel(client: RemoteClient) -> TunnelClient: def start_tunnel(client: RemoteClient) -> TunnelClient:
"""Open a tunnel that sends the server's requests out through this machine's own connection.""" """Open a tunnel that sends the server's requests out through this machine's own connection."""
headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"} headers = {k: str(v) for k, v in client.session.headers.items() if k.lower() != "connection"}
tunnel = TunnelClient(client.server_url, headers) slot_deadline = time.monotonic() + RETRY_AFTER_MAX
try: rate_waits = 0
tunnel.start() while True:
except ConnectionError as e: tunnel = TunnelClient(client.server_url, headers)
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}") try:
tunnel.start()
break
except ConnectionError as e:
if tunnel.retry_after and rate_waits < RATE_LIMIT_TRIES:
rate_waits += 1
wait_retry_after(tunnel.retry_after)
elif tunnel.at_limit and time.monotonic() < slot_deadline:
wait_retry_after(TUNNEL_SLOT_POLL, "Every tunnel of this API key is in use")
else:
raise click.ClickException(f"--local-proxy could not open a tunnel to the server: {e}")
client.tunnel = tunnel client.tunnel = tunnel
return tunnel return tunnel
+34 -5
View File
@@ -20,7 +20,8 @@ import threading
from typing import Any, Optional from typing import Any, Optional
import aiohttp import aiohttp
from aiohttp import WSMsgType, web from aiohttp import WSCloseCode, WSMsgType, web
from multidict import CIMultiDict
log = logging.getLogger("tunnel") log = logging.getLogger("tunnel")
@@ -144,10 +145,11 @@ TUNNELS: dict[str, "Tunnel"] = {}
class Tunnel: class Tunnel:
"""The server side of one client's tunnel: a loopback SOCKS5 listener and the client's current link.""" """The server side of one client's tunnel: a loopback SOCKS5 listener and the client's current link."""
def __init__(self, owner: Optional[str]) -> None: def __init__(self, owner: Optional[str], client: Optional[str] = None) -> None:
self.id = secrets.token_urlsafe(16) self.id = secrets.token_urlsafe(16)
self.secret = secrets.token_urlsafe(24) self.secret = secrets.token_urlsafe(24)
self.owner = owner self.owner = owner
self.client = client
self.link: Optional[Link] = None self.link: Optional[Link] = None
self.pending: dict[int, asyncio.Future[bool]] = {} self.pending: dict[int, asyncio.Future[bool]] = {}
self.sids = itertools.count(1) self.sids = itertools.count(1)
@@ -290,7 +292,9 @@ class Tunnel:
self.link = None self.link = None
self.linked.clear() self.linked.clear()
link.end_all() link.end_all()
if self.id in TUNNELS: if ws.close_code == WSCloseCode.OK:
self.expire()
elif self.id in TUNNELS:
self.expiry = asyncio.get_running_loop().call_later(GRACE, self.expire) self.expiry = asyncio.get_running_loop().call_later(GRACE, self.expire)
def expire(self) -> None: def expire(self) -> None:
@@ -377,6 +381,27 @@ async def connect_public(host: str, port: int) -> tuple[asyncio.StreamReader, as
raise error raise error
def parse_retry_after(value: Optional[str]) -> Optional[int]:
"""Whole seconds, at least 1, from a delay-seconds Retry-After; None for an absent, HTTP-date or malformed one."""
if not value or len(value) > 9 or not (value.isascii() and value.isdigit()):
return None
return max(1, int(value))
def retry_after(e: aiohttp.WSServerHandshakeError) -> Optional[int]:
"""The Retry-After seconds of a rate-limit refusal, or None for any other refusal."""
return parse_retry_after(CIMultiDict(e.headers or {}).get("Retry-After")) if e.status == 429 else None
def refusal(e: aiohttp.WSServerHandshakeError) -> str:
"""Why the server refused a tunnel. aiohttp drops the response body, so a 429 is told apart by its headers."""
if e.status != 429:
return f"HTTP {e.status}"
if (wait := retry_after(e)) is not None:
return f"HTTP 429, the API key is over its hourly request limit, retry in {wait}s"
return "HTTP 429, the API key already has the maximum number of tunnels open"
class TunnelClient: class TunnelClient:
"""The client side: a background thread that keeps the tunnel's WebSocket open and dials for the server.""" """The client side: a background thread that keeps the tunnel's WebSocket open and dials for the server."""
@@ -385,6 +410,8 @@ class TunnelClient:
self.headers = headers self.headers = headers
self.id: Optional[str] = None self.id: Optional[str] = None
self.error: Optional[BaseException] = None self.error: Optional[BaseException] = None
self.retry_after: Optional[int] = None
self.at_limit = False
self.lost = False self.lost = False
self.ready = threading.Event() self.ready = threading.Event()
self.relays: set[asyncio.Task[None]] = set() self.relays: set[asyncio.Task[None]] = set()
@@ -398,7 +425,7 @@ class TunnelClient:
self.thread.start() self.thread.start()
if not self.ready.wait(timeout) or not self.id: if not self.ready.wait(timeout) or not self.id:
self.stop() # or the thread opens the tunnel later and nobody uses it self.stop() # or the thread opens the tunnel later and nobody uses it
raise ConnectionError(f"Could not open a tunnel to the server: {self.error or 'timed out'}") raise ConnectionError(str(self.error or "timed out"))
return self.id return self.id
def stop(self) -> None: def stop(self) -> None:
@@ -431,7 +458,9 @@ class TunnelClient:
backoff = 1 backoff = 1
await self.serve(Link(ws)) await self.serve(Link(ws))
except aiohttp.WSServerHandshakeError as e: except aiohttp.WSServerHandshakeError as e:
self.error = e self.error = ConnectionError(refusal(e))
self.retry_after = retry_after(e)
self.at_limit = e.status == 429 and self.retry_after is None
# a 404 means the tunnel expired or the server has no tunnel route; a 5xx is a proxy in between # a 404 means the tunnel expired or the server has no tunnel route; a 5xx is a proxy in between
if not self.id or e.status < 500: if not self.id or e.status < 500:
if self.id: if self.id: