create-caspian-app 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.py
CHANGED
|
@@ -48,7 +48,6 @@ from casp.auth import (
|
|
|
48
48
|
from casp.rpc import register_rpc_routes, rpc_limiter
|
|
49
49
|
from casp.layout import (
|
|
50
50
|
render_with_nested_layouts,
|
|
51
|
-
compile_template,
|
|
52
51
|
_finalize_page_region,
|
|
53
52
|
_runtime_injections,
|
|
54
53
|
_runtime_metadata,
|
|
@@ -1396,25 +1395,83 @@ if mcp_app is not None:
|
|
|
1396
1395
|
# ====
|
|
1397
1396
|
|
|
1398
1397
|
|
|
1398
|
+
async def _render_special_page(
|
|
1399
|
+
page_path: str,
|
|
1400
|
+
request: Request,
|
|
1401
|
+
default_metadata: dict[str, str],
|
|
1402
|
+
context_data: dict[str, Any],
|
|
1403
|
+
) -> tuple[str, str]:
|
|
1404
|
+
"""Render an app-level Python page through the normal page/layout pipeline."""
|
|
1405
|
+
_runtime_metadata.set(None)
|
|
1406
|
+
_runtime_injections.set({"head": [], "body": []})
|
|
1407
|
+
|
|
1408
|
+
module = load_route_module(page_path)
|
|
1409
|
+
if not hasattr(module, "page"):
|
|
1410
|
+
raise AttributeError(f"Missing 'def page():' in {page_path}")
|
|
1411
|
+
|
|
1412
|
+
signature = get_page_signature(page_path, module.page)
|
|
1413
|
+
accepts_kwargs = any(
|
|
1414
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
1415
|
+
for parameter in signature.parameters.values()
|
|
1416
|
+
)
|
|
1417
|
+
call_context = {"request": request, **context_data}
|
|
1418
|
+
call_kwargs = {
|
|
1419
|
+
name: value
|
|
1420
|
+
for name, value in call_context.items()
|
|
1421
|
+
if accepts_kwargs or name in signature.parameters
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
result = module.page(**call_kwargs)
|
|
1425
|
+
if inspect.isawaitable(result):
|
|
1426
|
+
result = await result
|
|
1427
|
+
if isinstance(result, Response):
|
|
1428
|
+
raise TypeError(f"Special page {page_path} must return markup, not a Response")
|
|
1429
|
+
|
|
1430
|
+
page_layout_props: dict[str, Any] = {}
|
|
1431
|
+
page_content = result
|
|
1432
|
+
if isinstance(result, tuple):
|
|
1433
|
+
page_content = result[0]
|
|
1434
|
+
if len(result) >= 2 and isinstance(result[1], dict):
|
|
1435
|
+
page_layout_props = result[1]
|
|
1436
|
+
|
|
1437
|
+
page_metadata = default_metadata.copy()
|
|
1438
|
+
for metadata_obj in (getattr(module, "metadata", None), _runtime_metadata.get()):
|
|
1439
|
+
if not metadata_obj:
|
|
1440
|
+
continue
|
|
1441
|
+
if metadata_obj.title:
|
|
1442
|
+
page_metadata["title"] = metadata_obj.title
|
|
1443
|
+
if metadata_obj.description:
|
|
1444
|
+
page_metadata["description"] = metadata_obj.description
|
|
1445
|
+
if metadata_obj.extra:
|
|
1446
|
+
page_metadata.update(metadata_obj.extra)
|
|
1447
|
+
|
|
1448
|
+
page_source = getattr(page_content, "source_path", page_path)
|
|
1449
|
+
html_output, root_layout_id = await render_with_nested_layouts(
|
|
1450
|
+
children=str(page_content),
|
|
1451
|
+
route_dir=os.path.dirname(page_path),
|
|
1452
|
+
page_metadata=page_metadata,
|
|
1453
|
+
page_layout_props=page_layout_props,
|
|
1454
|
+
context_data={**call_context, **page_layout_props},
|
|
1455
|
+
page_component_source=page_source,
|
|
1456
|
+
control_mode=True,
|
|
1457
|
+
component_compiler=transform_components,
|
|
1458
|
+
)
|
|
1459
|
+
return finalize_html(html_output), root_layout_id
|
|
1460
|
+
|
|
1461
|
+
|
|
1399
1462
|
@app.exception_handler(StarletteHTTPException)
|
|
1400
1463
|
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
|
|
1401
1464
|
if exc.status_code == 404:
|
|
1402
|
-
not_found_path = os.path.join('src', 'app', '
|
|
1465
|
+
not_found_path = os.path.join('src', 'app', 'not_found.py')
|
|
1403
1466
|
if os.path.exists(not_found_path):
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
route_dir='src/app',
|
|
1409
|
-
page_metadata={
|
|
1467
|
+
html_output, root_layout_id = await _render_special_page(
|
|
1468
|
+
page_path=not_found_path,
|
|
1469
|
+
request=request,
|
|
1470
|
+
default_metadata={
|
|
1410
1471
|
'title': "Page Not Found",
|
|
1411
1472
|
'description': "The page you are looking for does not exist."
|
|
1412
1473
|
},
|
|
1413
|
-
|
|
1414
|
-
context_data={'request': request},
|
|
1415
|
-
page_component_source=not_found_path,
|
|
1416
|
-
control_mode=True,
|
|
1417
|
-
transform_fn=finalize_html
|
|
1474
|
+
context_data={},
|
|
1418
1475
|
)
|
|
1419
1476
|
resp = HTMLResponse(content=html_output, status_code=404)
|
|
1420
1477
|
resp.headers['X-PP-Root-Layout'] = root_layout_id
|
|
@@ -1429,33 +1486,25 @@ async def custom_general_exception_handler(request: Request, exc: Exception):
|
|
|
1429
1486
|
error_message = _client_error_message(exc)
|
|
1430
1487
|
error_trace = full_trace if not IS_PRODUCTION else None
|
|
1431
1488
|
|
|
1432
|
-
error_page_path = os.path.join('src', 'app', 'error.
|
|
1489
|
+
error_page_path = os.path.join('src', 'app', 'error.py')
|
|
1433
1490
|
if os.path.exists(error_page_path):
|
|
1434
|
-
with open(error_page_path, 'r', encoding='utf-8') as f:
|
|
1435
|
-
raw_content = f.read()
|
|
1436
1491
|
context_data = {'request': request,
|
|
1437
1492
|
'error_message': error_message, 'error_trace': error_trace}
|
|
1438
1493
|
try:
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
route_dir='src/app',
|
|
1444
|
-
page_metadata={
|
|
1494
|
+
html_output, root_layout_id = await _render_special_page(
|
|
1495
|
+
page_path=error_page_path,
|
|
1496
|
+
request=request,
|
|
1497
|
+
default_metadata={
|
|
1445
1498
|
'title': 'Application Error',
|
|
1446
1499
|
'description': 'An unexpected error occurred.'
|
|
1447
1500
|
},
|
|
1448
|
-
page_layout_props=None,
|
|
1449
1501
|
context_data=context_data,
|
|
1450
|
-
page_component_source=error_page_path,
|
|
1451
|
-
control_mode=True,
|
|
1452
|
-
transform_fn=finalize_html
|
|
1453
1502
|
)
|
|
1454
1503
|
resp = HTMLResponse(content=html_output, status_code=500)
|
|
1455
1504
|
resp.headers['X-PP-Root-Layout'] = root_layout_id
|
|
1456
1505
|
return resp
|
|
1457
1506
|
except Exception as render_exc:
|
|
1458
|
-
print("Error rendering error.
|
|
1507
|
+
print("Error rendering error.py:", render_exc)
|
|
1459
1508
|
return HTMLResponse(
|
|
1460
1509
|
content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
|
|
1461
1510
|
status_code=500
|
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""Named sockets: the server half of `pp.socket(...)`.
|
|
2
|
+
|
|
3
|
+
An rpc is a question with one answer, and an rpc stream is an answer that
|
|
4
|
+
arrives in pieces. A socket is the third shape: both sides may speak, at any
|
|
5
|
+
time, for as long as the page is open. The client half is `pp.socket(...)` in
|
|
6
|
+
the shipped PulsePoint runtime; this module is the server half, and
|
|
7
|
+
`@socket()` is what puts a function on it:
|
|
8
|
+
|
|
9
|
+
from src.lib.websocket.sockets import Socket, socket
|
|
10
|
+
|
|
11
|
+
@socket()
|
|
12
|
+
async def echo(label: str, socket: Socket):
|
|
13
|
+
while (text := await socket.recv()) is not None:
|
|
14
|
+
if not await socket.send(f"{label}: {text}"):
|
|
15
|
+
break # The browser is gone.
|
|
16
|
+
|
|
17
|
+
From the browser:
|
|
18
|
+
|
|
19
|
+
const sock = pp.socket("echo", { label: "you" }, {
|
|
20
|
+
onMessage: (value) => append(value),
|
|
21
|
+
});
|
|
22
|
+
sock.send("hello");
|
|
23
|
+
|
|
24
|
+
## The wire
|
|
25
|
+
|
|
26
|
+
Every socket connects to one endpoint, `SOCKET_PATH`, naming its function in
|
|
27
|
+
the `name` query parameter. The arguments do not travel in the URL -- a URL is
|
|
28
|
+
logged by every proxy on the way -- but as the first text frame, one JSON
|
|
29
|
+
object, exactly the payload `pp.rpc` would have posted. Every frame after
|
|
30
|
+
that is one JSON value, in either direction.
|
|
31
|
+
|
|
32
|
+
One endpoint rather than one per page because an upgrade is a GET, and the
|
|
33
|
+
page already owns GET on its own URL. The names are therefore
|
|
34
|
+
application-wide, like global component rpc names, and a duplicate is refused
|
|
35
|
+
at registration time.
|
|
36
|
+
|
|
37
|
+
## Failure
|
|
38
|
+
|
|
39
|
+
There is no status line inside an open connection, so failure is a frame:
|
|
40
|
+
`{"error": "..."}` -- that key alone -- followed by a close. The client
|
|
41
|
+
runtime routes it to `onError` rather than `onMessage`.
|
|
42
|
+
|
|
43
|
+
## Registration timing
|
|
44
|
+
|
|
45
|
+
A `@socket()` in a route's `index.py` registers when that module is first
|
|
46
|
+
imported, which happens when the route first renders. The page that opens
|
|
47
|
+
`pp.socket(...)` has necessarily rendered first, so its sockets exist by the
|
|
48
|
+
time the browser connects. A shared socket used by several routes belongs in
|
|
49
|
+
`src/lib/**`, imported by each owning route.
|
|
50
|
+
|
|
51
|
+
## Security posture
|
|
52
|
+
|
|
53
|
+
The endpoint keeps the same protections as the channel endpoints in
|
|
54
|
+
`main.py`: the anti-CSWSH origin check runs before the handshake is accepted,
|
|
55
|
+
auth delegates to Caspian's `Auth` (HTTP middleware never sees websocket
|
|
56
|
+
scopes), and every connection is subject to the shared connection cap,
|
|
57
|
+
message-size limit, per-connection message rate, and idle timeout. The socket
|
|
58
|
+
session is read-only: mutations are not persisted back to the cookie over a
|
|
59
|
+
WebSocket.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import asyncio
|
|
65
|
+
import inspect
|
|
66
|
+
import json
|
|
67
|
+
import os
|
|
68
|
+
import time
|
|
69
|
+
import traceback
|
|
70
|
+
from dataclasses import dataclass, field
|
|
71
|
+
from typing import Any, Awaitable, Callable
|
|
72
|
+
from urllib.parse import urlparse
|
|
73
|
+
|
|
74
|
+
from fastapi import WebSocket, status
|
|
75
|
+
|
|
76
|
+
from casp.auth import Auth
|
|
77
|
+
|
|
78
|
+
# Private, but deliberately the same serializer rpc responses go through, so a
|
|
79
|
+
# dataclass or model travels identically over both wires.
|
|
80
|
+
from casp.rpc import _serialize_result
|
|
81
|
+
from casp.runtime_security import is_production_environment
|
|
82
|
+
|
|
83
|
+
# Where every named socket connects. The path carries the PulsePoint
|
|
84
|
+
# runtime's name, not the server framework's, because `pp.socket(...)` is the
|
|
85
|
+
# same client whichever backend serves it — its DEFAULT_PATH and this
|
|
86
|
+
# constant must stay identical.
|
|
87
|
+
SOCKET_PATH = "/__pulsepoint/ws"
|
|
88
|
+
|
|
89
|
+
# How long the server waits for the first frame -- the arguments -- before
|
|
90
|
+
# giving up on a connection that opened and said nothing.
|
|
91
|
+
ARGS_TIMEOUT_SECONDS = 10
|
|
92
|
+
|
|
93
|
+
# How far a sender may run ahead of the wire before `send` applies
|
|
94
|
+
# backpressure by awaiting queue space.
|
|
95
|
+
SEND_QUEUE_SIZE = 32
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _idle_timeout_seconds() -> int:
|
|
99
|
+
return max(10, int(os.getenv("WEBSOCKET_IDLE_TIMEOUT_SECONDS", 120)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _max_message_bytes() -> int:
|
|
103
|
+
return max(256, int(os.getenv("MAX_WEBSOCKET_MESSAGE_BYTES", 4096)))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _messages_per_window() -> int:
|
|
107
|
+
return max(1, int(os.getenv("MAX_WEBSOCKET_MESSAGES_PER_WINDOW", 20)))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _rate_window_seconds() -> int:
|
|
111
|
+
return max(1, int(os.getenv("WEBSOCKET_RATE_WINDOW_SECONDS", 10)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ==== HANDSHAKE SECURITY ====
|
|
115
|
+
#
|
|
116
|
+
# The anti-CSWSH origin check and the connection ceiling, run before the
|
|
117
|
+
# handshake upgrades. Authorization itself is per socket (`require_auth=` /
|
|
118
|
+
# `allowed_roles=` delegated to Caspian's `Auth`), because HTTP middleware in
|
|
119
|
+
# `main.py` early-returns on every non-`http` scope -- a WebSocket handshake
|
|
120
|
+
# (`scope["type"] == "websocket"`) is never seen by `AuthMiddleware`, so this
|
|
121
|
+
# endpoint authorizes each connection itself.
|
|
122
|
+
|
|
123
|
+
# Ceiling on simultaneous open sockets. Every open connection is a live task
|
|
124
|
+
# plus a broadcast target, so an unbounded count lets cheap clients grow
|
|
125
|
+
# server memory and turn each broadcast into an amplification.
|
|
126
|
+
MAX_WEBSOCKET_CONNECTIONS = max(
|
|
127
|
+
1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_production() -> bool:
|
|
132
|
+
# Shared fail-closed resolution: an unset or misspelled APP_ENV must not
|
|
133
|
+
# silently enable the development handshake relaxations below.
|
|
134
|
+
return is_production_environment()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalized_origin(value: str) -> str:
|
|
138
|
+
return (value or "").strip().rstrip("/")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _configured_websocket_origins() -> set[str]:
|
|
142
|
+
raw_values: list[str] = []
|
|
143
|
+
for env_name in (
|
|
144
|
+
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
145
|
+
"CORS_ALLOWED_ORIGINS",
|
|
146
|
+
"APP_BASE_URL",
|
|
147
|
+
):
|
|
148
|
+
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
_normalized_origin(origin)
|
|
152
|
+
for origin in raw_values
|
|
153
|
+
if _normalized_origin(origin)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
158
|
+
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
159
|
+
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
163
|
+
"""Anti-CSWSH origin check. NOT authentication.
|
|
164
|
+
|
|
165
|
+
A browser cannot forge the `Origin` header, so this blocks cross-site
|
|
166
|
+
script-driven handshakes. A raw client (wscat, python websockets) can send
|
|
167
|
+
any origin, which is exactly why authentication is a separate gate.
|
|
168
|
+
"""
|
|
169
|
+
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
170
|
+
if not origin:
|
|
171
|
+
# No Origin header: tolerate local tooling in dev, reject in production.
|
|
172
|
+
return not _is_production()
|
|
173
|
+
|
|
174
|
+
parsed_origin = urlparse(origin)
|
|
175
|
+
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
179
|
+
return parsed_origin.scheme == "http"
|
|
180
|
+
|
|
181
|
+
allowed_origins = _configured_websocket_origins()
|
|
182
|
+
|
|
183
|
+
# The same-origin fallback is derived from the Host header, which a client
|
|
184
|
+
# controls directly and a misconfigured proxy will forward verbatim: sending
|
|
185
|
+
# `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
|
|
186
|
+
# this check against itself. It is a convenience for development only --
|
|
187
|
+
# production must name its origins explicitly.
|
|
188
|
+
if not _is_production():
|
|
189
|
+
allowed_origins.add(_websocket_same_origin(websocket))
|
|
190
|
+
|
|
191
|
+
return origin in allowed_origins
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ==== REGISTRY ====
|
|
195
|
+
|
|
196
|
+
SocketHandler = Callable[..., Awaitable[None]]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class SocketEntry:
|
|
201
|
+
name: str
|
|
202
|
+
source: str
|
|
203
|
+
require_auth: bool
|
|
204
|
+
allowed_roles: tuple[str, ...]
|
|
205
|
+
handler: SocketHandler
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
SOCKET_REGISTRY: dict[str, SocketEntry] = {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def socket(require_auth: bool = False,
|
|
212
|
+
allowed_roles: list[str] | None = None):
|
|
213
|
+
"""Register an async function as a named socket.
|
|
214
|
+
|
|
215
|
+
The function's own name is the name the browser connects with, so it must
|
|
216
|
+
be unique application-wide. The function declares its arguments as normal
|
|
217
|
+
parameters -- they arrive in the connection's first frame -- plus one
|
|
218
|
+
parameter named `socket`, which receives the open `Socket`.
|
|
219
|
+
|
|
220
|
+
- `require_auth=True` refuses the connection unless the request carries an
|
|
221
|
+
authenticated session, before the handler runs.
|
|
222
|
+
- `allowed_roles=[...]` adds RBAC via `Auth.check_role`, the same rule as
|
|
223
|
+
HTTP routes and rpc.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def decorator(func: SocketHandler) -> SocketHandler:
|
|
227
|
+
if not inspect.iscoroutinefunction(func):
|
|
228
|
+
raise TypeError(
|
|
229
|
+
f"@socket() function `{func.__name__}` must be `async def`: "
|
|
230
|
+
"a socket is a long-lived conversation, not a call."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
parameters = inspect.signature(func).parameters
|
|
234
|
+
if "socket" not in parameters:
|
|
235
|
+
raise TypeError(
|
|
236
|
+
f"@socket() function `{func.__name__}` must declare a "
|
|
237
|
+
"`socket` parameter -- it receives the open connection."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
source = f"{func.__module__}.{func.__qualname__}"
|
|
241
|
+
existing = SOCKET_REGISTRY.get(func.__name__)
|
|
242
|
+
if existing is not None and existing.source != source:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Two sockets are named `{func.__name__}`:\n"
|
|
245
|
+
f" {existing.source}\n {source}\n"
|
|
246
|
+
"The client connects with a name and nothing else, so socket "
|
|
247
|
+
"names must be unique application-wide."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
SOCKET_REGISTRY[func.__name__] = SocketEntry(
|
|
251
|
+
name=func.__name__,
|
|
252
|
+
source=source,
|
|
253
|
+
require_auth=require_auth,
|
|
254
|
+
allowed_roles=tuple(allowed_roles or ()),
|
|
255
|
+
handler=func,
|
|
256
|
+
)
|
|
257
|
+
return func
|
|
258
|
+
|
|
259
|
+
return decorator
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ==== WIRE HELPERS ====
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _error_frame(message: str) -> str:
|
|
266
|
+
"""`{"error": "..."}` -- the frame shape the client routes to `onError`.
|
|
267
|
+
|
|
268
|
+
Reserved on this wire the way the `error` key is reserved in an rpc
|
|
269
|
+
failure; `send` refuses to emit it as an ordinary message.
|
|
270
|
+
"""
|
|
271
|
+
return json.dumps({"error": message})
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_reserved_error_shape(value: Any) -> bool:
|
|
275
|
+
return (
|
|
276
|
+
isinstance(value, dict)
|
|
277
|
+
and set(value.keys()) == {"error"}
|
|
278
|
+
and isinstance(value.get("error"), str)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class _MessageRate:
|
|
283
|
+
"""Sliding-window receive budget for one connection.
|
|
284
|
+
|
|
285
|
+
Per-socket rather than per-IP: whatever pool the handler broadcasts into
|
|
286
|
+
is the shared resource being protected, and one abusive connection must
|
|
287
|
+
not spend another client's budget.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(self, limit: int, window_seconds: int):
|
|
291
|
+
self._limit = limit
|
|
292
|
+
self._window_seconds = window_seconds
|
|
293
|
+
self._timestamps: list[float] = []
|
|
294
|
+
|
|
295
|
+
def allow(self) -> bool:
|
|
296
|
+
now = time.monotonic()
|
|
297
|
+
cutoff = now - self._window_seconds
|
|
298
|
+
self._timestamps = [t for t in self._timestamps if t > cutoff]
|
|
299
|
+
if len(self._timestamps) >= self._limit:
|
|
300
|
+
return False
|
|
301
|
+
self._timestamps.append(now)
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ==== SOCKET ====
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class _Shared:
|
|
309
|
+
"""State the `Socket` and every cloned `SocketSender` see together."""
|
|
310
|
+
|
|
311
|
+
def __init__(self) -> None:
|
|
312
|
+
self.outgoing: asyncio.Queue[str | None] = asyncio.Queue(
|
|
313
|
+
maxsize=SEND_QUEUE_SIZE
|
|
314
|
+
)
|
|
315
|
+
self.closed = False
|
|
316
|
+
self.last_activity = time.monotonic()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class SocketSender:
|
|
320
|
+
"""The sending half of a [`Socket`], detached from the conversation.
|
|
321
|
+
|
|
322
|
+
Cheap to hand around, so a broadcast pool is a list of these: keep one per
|
|
323
|
+
connection in shared state, and a send that returns False is a connection
|
|
324
|
+
to forget. See [`SocketPool`].
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, shared: _Shared) -> None:
|
|
328
|
+
self._shared = shared
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def is_open(self) -> bool:
|
|
332
|
+
return not self._shared.closed
|
|
333
|
+
|
|
334
|
+
async def send(self, value: Any) -> bool:
|
|
335
|
+
"""Send one JSON value. False means nobody is listening any more --
|
|
336
|
+
the browser navigated away or closed the tab. That is the signal to
|
|
337
|
+
stop, not an error to report."""
|
|
338
|
+
if self._shared.closed:
|
|
339
|
+
return False
|
|
340
|
+
serialized = _serialize_result(value)
|
|
341
|
+
if _is_reserved_error_shape(serialized):
|
|
342
|
+
raise ValueError(
|
|
343
|
+
'The frame shape {"error": "..."} is reserved for failures. '
|
|
344
|
+
"Wrap the value or rename the key."
|
|
345
|
+
)
|
|
346
|
+
try:
|
|
347
|
+
frame = json.dumps(serialized)
|
|
348
|
+
except (TypeError, ValueError) as e:
|
|
349
|
+
print(f"[Socket] A frame cannot be written as JSON: {e}")
|
|
350
|
+
return False
|
|
351
|
+
await self._shared.outgoing.put(frame)
|
|
352
|
+
return not self._shared.closed
|
|
353
|
+
|
|
354
|
+
async def _error(self, message: str) -> None:
|
|
355
|
+
"""The error frame, then the close. The conversation is over."""
|
|
356
|
+
if self._shared.closed:
|
|
357
|
+
return
|
|
358
|
+
await self._shared.outgoing.put(_error_frame(message))
|
|
359
|
+
await self._shared.outgoing.put(None)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class SocketPool:
|
|
363
|
+
"""A broadcast pool: one `SocketSender` per open connection.
|
|
364
|
+
|
|
365
|
+
Holds senders rather than raw websockets so a handler can share its
|
|
366
|
+
connection without sharing the receive side. Keep authenticated and guest
|
|
367
|
+
traffic in separate pools so a private broadcast can never fan out to a
|
|
368
|
+
guest connection.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def __init__(self) -> None:
|
|
372
|
+
self._senders: list[SocketSender] = []
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def count(self) -> int:
|
|
376
|
+
return len(self._senders)
|
|
377
|
+
|
|
378
|
+
def add(self, sender: SocketSender) -> None:
|
|
379
|
+
self._senders.append(sender)
|
|
380
|
+
|
|
381
|
+
def discard(self, sender: SocketSender) -> None:
|
|
382
|
+
self._senders = [s for s in self._senders if s is not sender]
|
|
383
|
+
|
|
384
|
+
async def broadcast(self, value: Any) -> None:
|
|
385
|
+
"""Send one value to everyone; connections whose browser is gone are
|
|
386
|
+
pruned on the way."""
|
|
387
|
+
stale: list[SocketSender] = []
|
|
388
|
+
for sender in list(self._senders):
|
|
389
|
+
if not await sender.send(value):
|
|
390
|
+
stale.append(sender)
|
|
391
|
+
for sender in stale:
|
|
392
|
+
self.discard(sender)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class Socket:
|
|
396
|
+
"""One open connection, as the handler holds it.
|
|
397
|
+
|
|
398
|
+
The `socket` parameter of every `@socket()` function. Receiving is the
|
|
399
|
+
handler's alone; sending may be shared -- `sender()` hands out a handle
|
|
400
|
+
another task or a [`SocketPool`] may hold, which is how a chat room
|
|
401
|
+
reaches the people in it.
|
|
402
|
+
|
|
403
|
+
When the handler returns, the connection closes: a handler that returns
|
|
404
|
+
is a conversation that ends.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def __init__(self, name: str, websocket: WebSocket) -> None:
|
|
408
|
+
self._name = name
|
|
409
|
+
self._websocket = websocket
|
|
410
|
+
self._shared = _Shared()
|
|
411
|
+
self._sender = SocketSender(self._shared)
|
|
412
|
+
self._rate = _MessageRate(_messages_per_window(), _rate_window_seconds())
|
|
413
|
+
# One task owns the write side of the wire, so cloned senders on other
|
|
414
|
+
# tasks never interleave partial sends.
|
|
415
|
+
self._writer = asyncio.create_task(self._pump_outgoing())
|
|
416
|
+
|
|
417
|
+
async def _pump_outgoing(self) -> None:
|
|
418
|
+
try:
|
|
419
|
+
while True:
|
|
420
|
+
frame = await self._shared.outgoing.get()
|
|
421
|
+
if frame is None:
|
|
422
|
+
break
|
|
423
|
+
await self._websocket.send_text(frame)
|
|
424
|
+
self._shared.last_activity = time.monotonic()
|
|
425
|
+
except Exception:
|
|
426
|
+
# The browser is gone; every later `send` answers False.
|
|
427
|
+
pass
|
|
428
|
+
finally:
|
|
429
|
+
self._shared.closed = True
|
|
430
|
+
try:
|
|
431
|
+
await self._websocket.close(code=status.WS_1000_NORMAL_CLOSURE)
|
|
432
|
+
except Exception:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
async def send(self, value: Any) -> bool:
|
|
436
|
+
"""Send one JSON value. False means the browser is gone."""
|
|
437
|
+
return await self._sender.send(value)
|
|
438
|
+
|
|
439
|
+
async def recv(self) -> Any | None:
|
|
440
|
+
"""The next value the browser sent, or None when the connection
|
|
441
|
+
closes -- which is how every socket conversation eventually ends. The
|
|
442
|
+
natural loop is `while (value := await socket.recv()) is not None:`.
|
|
443
|
+
|
|
444
|
+
Raises ValueError on a frame that arrived and is not valid JSON: a
|
|
445
|
+
frame the handler cannot read is a client bug worth surfacing, and an
|
|
446
|
+
uncaught raise travels back as the error frame.
|
|
447
|
+
"""
|
|
448
|
+
text = await self.recv_text()
|
|
449
|
+
if text is None:
|
|
450
|
+
return None
|
|
451
|
+
try:
|
|
452
|
+
return json.loads(text)
|
|
453
|
+
except json.JSONDecodeError as e:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
f"`{self._name}` could not read a frame -- {e}. Each frame is "
|
|
456
|
+
"one JSON value."
|
|
457
|
+
) from e
|
|
458
|
+
|
|
459
|
+
async def recv_text(self) -> str | None:
|
|
460
|
+
"""The next frame as it arrived, for a handler that would rather
|
|
461
|
+
parse it itself. None is the connection closing.
|
|
462
|
+
|
|
463
|
+
Enforces the shared limits: an oversized frame closes with 1009, a
|
|
464
|
+
flooding connection closes with 1008, and a connection idle in both
|
|
465
|
+
directions past the timeout closes with 1000.
|
|
466
|
+
"""
|
|
467
|
+
idle_timeout = _idle_timeout_seconds()
|
|
468
|
+
while not self._shared.closed:
|
|
469
|
+
try:
|
|
470
|
+
text = await asyncio.wait_for(
|
|
471
|
+
self._websocket.receive_text(), timeout=idle_timeout
|
|
472
|
+
)
|
|
473
|
+
except asyncio.TimeoutError:
|
|
474
|
+
# Outbound traffic counts as liveness: a passive listener in
|
|
475
|
+
# an active room is not idle, it is listening.
|
|
476
|
+
if time.monotonic() - self._shared.last_activity >= idle_timeout:
|
|
477
|
+
await self.close()
|
|
478
|
+
return None
|
|
479
|
+
continue
|
|
480
|
+
except Exception:
|
|
481
|
+
# Disconnect, or receive after close: the conversation ended.
|
|
482
|
+
self._shared.closed = True
|
|
483
|
+
return None
|
|
484
|
+
|
|
485
|
+
self._shared.last_activity = time.monotonic()
|
|
486
|
+
|
|
487
|
+
if len(text.encode("utf-8")) > _max_message_bytes():
|
|
488
|
+
await self._close_with(status.WS_1009_MESSAGE_TOO_BIG)
|
|
489
|
+
return None
|
|
490
|
+
if not self._rate.allow():
|
|
491
|
+
await self._sender._error("Too many messages. Slow down.")
|
|
492
|
+
return None
|
|
493
|
+
return text
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
def sender(self) -> SocketSender:
|
|
497
|
+
"""A sending handle another task, or a [`SocketPool`], may hold."""
|
|
498
|
+
return self._sender
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def is_open(self) -> bool:
|
|
502
|
+
return not self._shared.closed
|
|
503
|
+
|
|
504
|
+
async def close(self) -> None:
|
|
505
|
+
"""Say goodbye first. Returning from the handler closes too; this is
|
|
506
|
+
for closing mid-conversation."""
|
|
507
|
+
if not self._shared.closed:
|
|
508
|
+
await self._shared.outgoing.put(None)
|
|
509
|
+
await self._finish()
|
|
510
|
+
|
|
511
|
+
async def _close_with(self, code: int) -> None:
|
|
512
|
+
self._shared.closed = True
|
|
513
|
+
self._writer.cancel()
|
|
514
|
+
try:
|
|
515
|
+
await self._websocket.close(code=code)
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
async def _finish(self) -> None:
|
|
520
|
+
"""Let queued frames drain, then reclaim the writer task."""
|
|
521
|
+
try:
|
|
522
|
+
await asyncio.wait_for(self._writer, timeout=5)
|
|
523
|
+
except (asyncio.CancelledError, Exception):
|
|
524
|
+
self._writer.cancel()
|
|
525
|
+
self._shared.closed = True
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ==== ENDPOINT ====
|
|
529
|
+
|
|
530
|
+
# Named sockets share one cap with everything else that holds a connection
|
|
531
|
+
# open: every open socket is a live task plus a broadcast target.
|
|
532
|
+
_open_connections = 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def open_connection_count() -> int:
|
|
536
|
+
return _open_connections
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@dataclass
|
|
540
|
+
class _Refusal:
|
|
541
|
+
message: str
|
|
542
|
+
close_code: int = field(default=status.WS_1008_POLICY_VIOLATION)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _resolve_entry(websocket: WebSocket) -> SocketEntry | _Refusal:
|
|
546
|
+
name = (websocket.query_params.get("name") or "").strip()
|
|
547
|
+
if not name:
|
|
548
|
+
return _Refusal(
|
|
549
|
+
"This connection named no socket. Open it as "
|
|
550
|
+
'pp.socket("name", { ... }) -- the client runtime sends the name '
|
|
551
|
+
"in the `name` query parameter."
|
|
552
|
+
)
|
|
553
|
+
entry = SOCKET_REGISTRY.get(name)
|
|
554
|
+
if entry is None:
|
|
555
|
+
return _Refusal(
|
|
556
|
+
f"No socket named `{name}`. Mark the function @socket() -- the "
|
|
557
|
+
"name the client connects with is the function's own -- and note "
|
|
558
|
+
"that a socket declared in a route's index.py registers when that "
|
|
559
|
+
"route first renders."
|
|
560
|
+
)
|
|
561
|
+
return entry
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _authorize(websocket: WebSocket, entry: SocketEntry) -> _Refusal | None:
|
|
565
|
+
"""Auth for one handshake, delegated to Caspian's `Auth`.
|
|
566
|
+
|
|
567
|
+
HTTP middleware never sees websocket scopes, so the endpoint authorizes
|
|
568
|
+
itself by binding the socket as the request context -- `Auth` reads only
|
|
569
|
+
`.session`, which `SessionMiddleware` exposes on websockets too. Failures
|
|
570
|
+
travel as this wire's `{"error": ...}` frame. The session is read-only.
|
|
571
|
+
"""
|
|
572
|
+
Auth.set_request(websocket) # type: ignore[arg-type]
|
|
573
|
+
auth = Auth.get_instance()
|
|
574
|
+
|
|
575
|
+
if auth.is_authenticated():
|
|
576
|
+
payload = auth.get_payload() or {}
|
|
577
|
+
if entry.allowed_roles and not auth.check_role(
|
|
578
|
+
payload, list(entry.allowed_roles)
|
|
579
|
+
):
|
|
580
|
+
return _Refusal(f"The socket `{entry.name}` is not available to this account.")
|
|
581
|
+
return None
|
|
582
|
+
|
|
583
|
+
if entry.require_auth or entry.allowed_roles:
|
|
584
|
+
return _Refusal(
|
|
585
|
+
f"The socket `{entry.name}` needs a signed-in session. It is "
|
|
586
|
+
"@socket(require_auth=True), so it answers only while the browser "
|
|
587
|
+
"carries one."
|
|
588
|
+
)
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
async def _first_frame(websocket: WebSocket) -> dict[str, Any] | _Refusal | None:
|
|
593
|
+
"""The arguments: one JSON object, as the first text frame.
|
|
594
|
+
|
|
595
|
+
None is a connection that closed before saying anything -- a refresh
|
|
596
|
+
mid-handshake. Not an error, and nobody left to tell.
|
|
597
|
+
"""
|
|
598
|
+
try:
|
|
599
|
+
text = await asyncio.wait_for(
|
|
600
|
+
websocket.receive_text(), timeout=ARGS_TIMEOUT_SECONDS
|
|
601
|
+
)
|
|
602
|
+
except asyncio.TimeoutError:
|
|
603
|
+
return _Refusal(
|
|
604
|
+
"This socket opened and sent no arguments. The first frame is the "
|
|
605
|
+
"payload -- one JSON object, {} when the function takes nothing. "
|
|
606
|
+
"pp.socket sends it on open."
|
|
607
|
+
)
|
|
608
|
+
except Exception:
|
|
609
|
+
return None
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
value = json.loads(text)
|
|
613
|
+
except json.JSONDecodeError:
|
|
614
|
+
value = None
|
|
615
|
+
if not isinstance(value, dict):
|
|
616
|
+
return _Refusal(
|
|
617
|
+
"The first frame of a socket is not a JSON object. Arguments are "
|
|
618
|
+
'named, so they arrive as { "room": ... } -- '
|
|
619
|
+
'pp.socket("name", { room }) is what sends them.'
|
|
620
|
+
)
|
|
621
|
+
return value
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _call_kwargs(
|
|
625
|
+
entry: SocketEntry, args: dict[str, Any], sock: Socket
|
|
626
|
+
) -> dict[str, Any] | _Refusal:
|
|
627
|
+
"""Filter the payload against the handler's own signature.
|
|
628
|
+
|
|
629
|
+
The payload is client-controlled, so -- exactly as with rpc -- a parameter
|
|
630
|
+
is settable only when declared, and `socket` itself can never be supplied
|
|
631
|
+
from the wire.
|
|
632
|
+
"""
|
|
633
|
+
parameters = inspect.signature(entry.handler).parameters.values()
|
|
634
|
+
accepts_kwargs = any(
|
|
635
|
+
p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
accepted = {
|
|
639
|
+
p.name
|
|
640
|
+
for p in parameters
|
|
641
|
+
if p.kind in (
|
|
642
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
643
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
644
|
+
)
|
|
645
|
+
and p.name != "socket"
|
|
646
|
+
}
|
|
647
|
+
missing = [
|
|
648
|
+
p.name
|
|
649
|
+
for p in parameters
|
|
650
|
+
if p.name in accepted
|
|
651
|
+
and p.default is inspect.Parameter.empty
|
|
652
|
+
and p.name not in args
|
|
653
|
+
]
|
|
654
|
+
if missing:
|
|
655
|
+
return _Refusal(
|
|
656
|
+
f"`{entry.name}` is missing its `{missing[0]}` argument. The "
|
|
657
|
+
f'browser opens pp.socket("{entry.name}", '
|
|
658
|
+
f'{{ {missing[0]}: ... }}).'
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
if accepts_kwargs:
|
|
662
|
+
kwargs = {k: v for k, v in args.items() if k != "socket"}
|
|
663
|
+
else:
|
|
664
|
+
kwargs = {k: v for k, v in args.items() if k in accepted}
|
|
665
|
+
kwargs["socket"] = sock
|
|
666
|
+
return kwargs
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
async def _refuse_open(websocket: WebSocket, refusal: _Refusal) -> None:
|
|
670
|
+
"""Refuse after the handshake: the error frame, then the close, so the
|
|
671
|
+
browser gets a readable message instead of a bare close code."""
|
|
672
|
+
try:
|
|
673
|
+
await websocket.send_text(_error_frame(refusal.message))
|
|
674
|
+
await websocket.close(code=refusal.close_code)
|
|
675
|
+
except Exception:
|
|
676
|
+
pass
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
async def serve_named_socket(websocket: WebSocket) -> None:
|
|
680
|
+
"""The whole endpoint: who is calling, whether they may, then the pump.
|
|
681
|
+
|
|
682
|
+
Wired in `main.py` as `@app.websocket(SOCKET_PATH)`, gated on
|
|
683
|
+
`caspian.config.json` `websocket: true`.
|
|
684
|
+
"""
|
|
685
|
+
global _open_connections
|
|
686
|
+
|
|
687
|
+
# Refused before the handshake upgrades, like every channel endpoint.
|
|
688
|
+
if not is_websocket_origin_allowed(websocket):
|
|
689
|
+
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
690
|
+
return
|
|
691
|
+
if _open_connections >= MAX_WEBSOCKET_CONNECTIONS:
|
|
692
|
+
await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
await websocket.accept()
|
|
696
|
+
|
|
697
|
+
entry = _resolve_entry(websocket)
|
|
698
|
+
if isinstance(entry, _Refusal):
|
|
699
|
+
await _refuse_open(websocket, entry)
|
|
700
|
+
return
|
|
701
|
+
|
|
702
|
+
refusal = _authorize(websocket, entry)
|
|
703
|
+
if refusal is not None:
|
|
704
|
+
await _refuse_open(websocket, refusal)
|
|
705
|
+
return
|
|
706
|
+
|
|
707
|
+
args = await _first_frame(websocket)
|
|
708
|
+
if args is None:
|
|
709
|
+
return
|
|
710
|
+
if isinstance(args, _Refusal):
|
|
711
|
+
await _refuse_open(websocket, args)
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
sock = Socket(entry.name, websocket)
|
|
715
|
+
kwargs = _call_kwargs(entry, args, sock)
|
|
716
|
+
if isinstance(kwargs, _Refusal):
|
|
717
|
+
await sock.sender()._error(kwargs.message)
|
|
718
|
+
await sock._finish()
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
_open_connections += 1
|
|
722
|
+
try:
|
|
723
|
+
await entry.handler(**kwargs)
|
|
724
|
+
await sock.close()
|
|
725
|
+
except ValueError as e:
|
|
726
|
+
# The rpc convention: a ValueError is a message meant for the caller.
|
|
727
|
+
await sock.sender()._error(str(e))
|
|
728
|
+
await sock._finish()
|
|
729
|
+
except Exception as e:
|
|
730
|
+
print(f"[Socket Error] {entry.name}: {e}")
|
|
731
|
+
traceback.print_exc()
|
|
732
|
+
message = (
|
|
733
|
+
"Internal server error"
|
|
734
|
+
if is_production_environment()
|
|
735
|
+
else f"{entry.name}: {e}"
|
|
736
|
+
)
|
|
737
|
+
await sock.sender()._error(message)
|
|
738
|
+
await sock._finish()
|
|
739
|
+
finally:
|
|
740
|
+
_open_connections -= 1
|
package/package.json
CHANGED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
from typing import Any
|
|
5
|
-
from urllib.parse import urlparse
|
|
6
|
-
|
|
7
|
-
from fastapi import WebSocket, status
|
|
8
|
-
|
|
9
|
-
from casp.auth import Auth
|
|
10
|
-
from casp.runtime_security import is_production_environment
|
|
11
|
-
|
|
12
|
-
# ====
|
|
13
|
-
# WebSocket security: ONE guard that reuses Caspian's `Auth` as the source of
|
|
14
|
-
# truth, so socket auth lines up with HTTP route auth instead of duplicating it.
|
|
15
|
-
#
|
|
16
|
-
# Why this lives here and not in `AuthMiddleware`:
|
|
17
|
-
# HTTP middleware in `main.py` early-returns on every non-`http` scope, so a
|
|
18
|
-
# WebSocket handshake (`scope["type"] == "websocket"`) is never seen by
|
|
19
|
-
# `AuthMiddleware`. Each socket endpoint must therefore authorize itself. We do
|
|
20
|
-
# that by delegating to the same `Auth` instance that powers page privacy.
|
|
21
|
-
# ====
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _is_production() -> bool:
|
|
25
|
-
# Shared fail-closed resolution: an unset or misspelled APP_ENV must not
|
|
26
|
-
# silently enable the development handshake relaxations below.
|
|
27
|
-
return is_production_environment()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _normalized_origin(value: str) -> str:
|
|
31
|
-
return (value or "").strip().rstrip("/")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _configured_websocket_origins() -> set[str]:
|
|
35
|
-
raw_values: list[str] = []
|
|
36
|
-
for env_name in (
|
|
37
|
-
"WEBSOCKET_ALLOWED_ORIGINS",
|
|
38
|
-
"CORS_ALLOWED_ORIGINS",
|
|
39
|
-
"APP_BASE_URL",
|
|
40
|
-
):
|
|
41
|
-
raw_values.extend(os.getenv(env_name, "").split(","))
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
_normalized_origin(origin)
|
|
45
|
-
for origin in raw_values
|
|
46
|
-
if _normalized_origin(origin)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _websocket_same_origin(websocket: WebSocket) -> str:
|
|
51
|
-
scheme = "https" if websocket.url.scheme == "wss" else "http"
|
|
52
|
-
return _normalized_origin(f"{scheme}://{websocket.url.netloc}")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def is_websocket_origin_allowed(websocket: WebSocket) -> bool:
|
|
56
|
-
"""Anti-CSWSH origin check. NOT authentication.
|
|
57
|
-
|
|
58
|
-
A browser cannot forge the `Origin` header, so this blocks cross-site
|
|
59
|
-
script-driven handshakes. A raw client (wscat, python websockets) can send
|
|
60
|
-
any origin, which is exactly why authentication is a separate gate.
|
|
61
|
-
"""
|
|
62
|
-
origin = _normalized_origin(websocket.headers.get("origin", ""))
|
|
63
|
-
if not origin:
|
|
64
|
-
# No Origin header: tolerate local tooling in dev, reject in production.
|
|
65
|
-
return not _is_production()
|
|
66
|
-
|
|
67
|
-
parsed_origin = urlparse(origin)
|
|
68
|
-
if not parsed_origin.scheme or not parsed_origin.netloc:
|
|
69
|
-
return False
|
|
70
|
-
|
|
71
|
-
if not _is_production() and parsed_origin.hostname in {"localhost", "127.0.0.1"}:
|
|
72
|
-
return parsed_origin.scheme == "http"
|
|
73
|
-
|
|
74
|
-
allowed_origins = _configured_websocket_origins()
|
|
75
|
-
|
|
76
|
-
# The same-origin fallback is derived from the Host header, which a client
|
|
77
|
-
# controls directly and a misconfigured proxy will forward verbatim: sending
|
|
78
|
-
# `Host: evil.tld` with `Origin: https://evil.tld` would otherwise satisfy
|
|
79
|
-
# this check against itself. It is a convenience for development only --
|
|
80
|
-
# production must name its origins explicitly.
|
|
81
|
-
if not _is_production():
|
|
82
|
-
allowed_origins.add(_websocket_same_origin(websocket))
|
|
83
|
-
|
|
84
|
-
return origin in allowed_origins
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
async def authorize_websocket(
|
|
88
|
-
websocket: WebSocket,
|
|
89
|
-
*,
|
|
90
|
-
require_auth: bool,
|
|
91
|
-
roles: list[str] | None = None,
|
|
92
|
-
) -> dict[str, Any] | None:
|
|
93
|
-
"""Single entry point for socket authorization.
|
|
94
|
-
|
|
95
|
-
Returns the connecting identity on success, or `None` after closing the
|
|
96
|
-
socket on failure. The return value gates the connection only; do not echo
|
|
97
|
-
it to other clients (it may contain the authenticated user's payload).
|
|
98
|
-
|
|
99
|
-
- `require_auth=True` -> authenticated session required, else close 1008.
|
|
100
|
-
- `require_auth=False` -> guests allowed; authenticated users keep identity.
|
|
101
|
-
- `roles` -> RBAC via `Auth.check_role`, same rule as HTTP routes.
|
|
102
|
-
|
|
103
|
-
Auth is read from the session cookie that `SessionMiddleware` (the one
|
|
104
|
-
middleware that runs on websocket scopes) exposes as `websocket.session`.
|
|
105
|
-
Treat this as read-only: session writes are not persisted back to the cookie
|
|
106
|
-
over a WebSocket, so this guard must not rely on `refresh_session`.
|
|
107
|
-
"""
|
|
108
|
-
# 1. Origin / CSWSH check runs before accept so rejected handshakes never
|
|
109
|
-
# upgrade.
|
|
110
|
-
if not is_websocket_origin_allowed(websocket):
|
|
111
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
112
|
-
return None
|
|
113
|
-
|
|
114
|
-
# 2. Reuse Caspian `Auth` as the single source of truth. `Auth` only reads
|
|
115
|
-
# `request.session`, and a WebSocket exposes `.session`, so binding the
|
|
116
|
-
# socket as the request context lets us reuse the exact HTTP auth logic.
|
|
117
|
-
Auth.set_request(websocket) # type: ignore[arg-type]
|
|
118
|
-
auth = Auth.get_instance()
|
|
119
|
-
|
|
120
|
-
if auth.is_authenticated():
|
|
121
|
-
payload = auth.get_payload() or {}
|
|
122
|
-
if roles and not auth.check_role(payload, roles):
|
|
123
|
-
await _reject(websocket, "Forbidden.")
|
|
124
|
-
return None
|
|
125
|
-
return payload
|
|
126
|
-
|
|
127
|
-
if require_auth:
|
|
128
|
-
await _reject(websocket, "Authentication required.")
|
|
129
|
-
return None
|
|
130
|
-
|
|
131
|
-
return {"guest": True, "scope": "public"}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
async def _reject(websocket: WebSocket, message: str) -> None:
|
|
135
|
-
"""Accept, surface a JSON error so the browser UI can react, then close."""
|
|
136
|
-
await websocket.accept()
|
|
137
|
-
await websocket.send_json({"type": "error", "message": message})
|
|
138
|
-
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Ceiling on simultaneous sockets in one pool. Every open connection is a live
|
|
142
|
-
# task plus a broadcast target, so an unbounded pool lets cheap clients grow
|
|
143
|
-
# server memory and turn each broadcast into an amplification.
|
|
144
|
-
MAX_WEBSOCKET_CONNECTIONS = max(
|
|
145
|
-
1, int(os.getenv("MAX_WEBSOCKET_CONNECTIONS", 200))
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
class WebSocketConnectionManager:
|
|
150
|
-
def __init__(self, max_connections: int = MAX_WEBSOCKET_CONNECTIONS) -> None:
|
|
151
|
-
self._connections: set[WebSocket] = set()
|
|
152
|
-
self._max_connections = max_connections
|
|
153
|
-
|
|
154
|
-
@property
|
|
155
|
-
def connection_count(self) -> int:
|
|
156
|
-
return len(self._connections)
|
|
157
|
-
|
|
158
|
-
async def connect(self, websocket: WebSocket) -> bool:
|
|
159
|
-
"""Accept and register the socket. Returns False when the pool is full.
|
|
160
|
-
|
|
161
|
-
The capacity check runs before `accept()`, so a refused client is closed
|
|
162
|
-
during the handshake and never reaches an open state. The caller must
|
|
163
|
-
stop on a False result rather than run a read loop on a socket that was
|
|
164
|
-
never added to the pool.
|
|
165
|
-
"""
|
|
166
|
-
if len(self._connections) >= self._max_connections:
|
|
167
|
-
await websocket.close(code=status.WS_1013_TRY_AGAIN_LATER)
|
|
168
|
-
return False
|
|
169
|
-
|
|
170
|
-
await websocket.accept()
|
|
171
|
-
self._connections.add(websocket)
|
|
172
|
-
return True
|
|
173
|
-
|
|
174
|
-
def disconnect(self, websocket: WebSocket) -> None:
|
|
175
|
-
self._connections.discard(websocket)
|
|
176
|
-
|
|
177
|
-
async def broadcast_json(self, payload: dict[str, Any]) -> None:
|
|
178
|
-
stale: list[WebSocket] = []
|
|
179
|
-
for websocket in list(self._connections):
|
|
180
|
-
try:
|
|
181
|
-
await websocket.send_json(payload)
|
|
182
|
-
except RuntimeError:
|
|
183
|
-
stale.append(websocket)
|
|
184
|
-
|
|
185
|
-
for websocket in stale:
|
|
186
|
-
self.disconnect(websocket)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
# Separate pools keep authenticated traffic isolated from guest traffic, so a
|
|
190
|
-
# private broadcast can never fan out to a public (guest) connection.
|
|
191
|
-
websocket_connections = WebSocketConnectionManager()
|
|
192
|
-
public_websocket_connections = WebSocketConnectionManager()
|