wsbuilder 0.1.0__py3-none-any.whl
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.
- wsbuilder/__init__.py +27 -0
- wsbuilder/__main__.py +17 -0
- wsbuilder/framework.py +662 -0
- wsbuilder/ws_demo.py +1837 -0
- wsbuilder-0.1.0.dist-info/METADATA +77 -0
- wsbuilder-0.1.0.dist-info/RECORD +10 -0
- wsbuilder-0.1.0.dist-info/WHEEL +5 -0
- wsbuilder-0.1.0.dist-info/entry_points.txt +3 -0
- wsbuilder-0.1.0.dist-info/licenses/LICENSE +21 -0
- wsbuilder-0.1.0.dist-info/top_level.txt +1 -0
wsbuilder/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""wsbuilder: mini framework HTTP + WebSocket extraido de PortHound4."""
|
|
2
|
+
|
|
3
|
+
from .framework import (
|
|
4
|
+
App,
|
|
5
|
+
HTTPServer,
|
|
6
|
+
Request,
|
|
7
|
+
Response,
|
|
8
|
+
Route,
|
|
9
|
+
Router,
|
|
10
|
+
WebSocket,
|
|
11
|
+
parse_close_payload,
|
|
12
|
+
parse_query_string,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"App",
|
|
19
|
+
"HTTPServer",
|
|
20
|
+
"Request",
|
|
21
|
+
"Response",
|
|
22
|
+
"Route",
|
|
23
|
+
"Router",
|
|
24
|
+
"WebSocket",
|
|
25
|
+
"parse_close_payload",
|
|
26
|
+
"parse_query_string",
|
|
27
|
+
]
|
wsbuilder/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from .ws_demo import HOST, PORT, run_server
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
parser = argparse.ArgumentParser(
|
|
8
|
+
description="Levanta el demo HTTP + WebSocket incluido en wsbuilder"
|
|
9
|
+
)
|
|
10
|
+
parser.add_argument("--host", default=HOST, help="Host de escucha")
|
|
11
|
+
parser.add_argument("--port", type=int, default=PORT, help="Puerto de escucha")
|
|
12
|
+
args = parser.parse_args()
|
|
13
|
+
run_server(args.host, args.port)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
wsbuilder/framework.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import ssl
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
MAGIC_WS = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
8
|
+
DEFAULT_CORS_ALLOW_ORIGIN = str(
|
|
9
|
+
os.getenv("WSBUILDER_CORS_ALLOW_ORIGIN", os.getenv("PORTHOUND_CORS_ALLOW_ORIGIN", ""))
|
|
10
|
+
).strip()
|
|
11
|
+
|
|
12
|
+
STATUS_MESSAGES = {
|
|
13
|
+
100: "Continue",
|
|
14
|
+
101: "Switching Protocols",
|
|
15
|
+
102: "Processing",
|
|
16
|
+
103: "Early Hints",
|
|
17
|
+
200: "OK",
|
|
18
|
+
201: "Created",
|
|
19
|
+
202: "Accepted",
|
|
20
|
+
203: "Non-Authoritative Information",
|
|
21
|
+
204: "No Content",
|
|
22
|
+
205: "Reset Content",
|
|
23
|
+
206: "Partial Content",
|
|
24
|
+
207: "Multi-Status",
|
|
25
|
+
208: "Already Reported",
|
|
26
|
+
226: "IM Used",
|
|
27
|
+
300: "Multiple Choices",
|
|
28
|
+
301: "Moved Permanently",
|
|
29
|
+
302: "Found",
|
|
30
|
+
303: "See Other",
|
|
31
|
+
304: "Not Modified",
|
|
32
|
+
305: "Use Proxy",
|
|
33
|
+
307: "Temporary Redirect",
|
|
34
|
+
308: "Permanent Redirect",
|
|
35
|
+
400: "Bad Request",
|
|
36
|
+
401: "Unauthorized",
|
|
37
|
+
402: "Payment Required",
|
|
38
|
+
403: "Forbidden",
|
|
39
|
+
404: "Not Found",
|
|
40
|
+
405: "Method Not Allowed",
|
|
41
|
+
406: "Not Acceptable",
|
|
42
|
+
407: "Proxy Authentication Required",
|
|
43
|
+
408: "Request Timeout",
|
|
44
|
+
409: "Conflict",
|
|
45
|
+
410: "Gone",
|
|
46
|
+
411: "Length Required",
|
|
47
|
+
412: "Precondition Failed",
|
|
48
|
+
413: "Payload Too Large",
|
|
49
|
+
414: "URI Too Long",
|
|
50
|
+
415: "Unsupported Media Type",
|
|
51
|
+
416: "Range Not Satisfiable",
|
|
52
|
+
417: "Expectation Failed",
|
|
53
|
+
418: "I'm a teapot",
|
|
54
|
+
421: "Misdirected Request",
|
|
55
|
+
422: "Unprocessable Entity",
|
|
56
|
+
423: "Locked",
|
|
57
|
+
424: "Failed Dependency",
|
|
58
|
+
425: "Too Early",
|
|
59
|
+
426: "Upgrade Required",
|
|
60
|
+
428: "Precondition Required",
|
|
61
|
+
429: "Too Many Requests",
|
|
62
|
+
431: "Request Header Fields Too Large",
|
|
63
|
+
451: "Unavailable For Legal Reasons",
|
|
64
|
+
500: "Internal Server Error",
|
|
65
|
+
501: "Not Implemented",
|
|
66
|
+
502: "Bad Gateway",
|
|
67
|
+
503: "Service Unavailable",
|
|
68
|
+
504: "Gateway Timeout",
|
|
69
|
+
505: "HTTP Version Not Supported",
|
|
70
|
+
506: "Variant Also Negotiates",
|
|
71
|
+
507: "Insufficient Storage",
|
|
72
|
+
508: "Loop Detected",
|
|
73
|
+
510: "Not Extended",
|
|
74
|
+
511: "Network Authentication Required",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_query_string(qs):
|
|
79
|
+
params = {}
|
|
80
|
+
if not qs:
|
|
81
|
+
return params
|
|
82
|
+
parts = qs.split("&")
|
|
83
|
+
for part in parts:
|
|
84
|
+
if not part:
|
|
85
|
+
continue
|
|
86
|
+
if "=" in part:
|
|
87
|
+
k, v = part.split("=", 1)
|
|
88
|
+
else:
|
|
89
|
+
k, v = part, ""
|
|
90
|
+
params[k] = v
|
|
91
|
+
return params
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Request:
|
|
95
|
+
def __init__(self, method, path, query_string, headers, body, client, tls=None):
|
|
96
|
+
self.method = (method or "").upper()
|
|
97
|
+
self.path = path or "/"
|
|
98
|
+
self.query_string = query_string or ""
|
|
99
|
+
self.query = parse_query_string(self.query_string)
|
|
100
|
+
self.headers = headers or {}
|
|
101
|
+
self.body = body or b""
|
|
102
|
+
self.client = client
|
|
103
|
+
self.tls = tls or {}
|
|
104
|
+
|
|
105
|
+
def text(self, encoding="utf-8"):
|
|
106
|
+
return self.body.decode(encoding, errors="ignore")
|
|
107
|
+
|
|
108
|
+
def json(self):
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(self.text())
|
|
111
|
+
except Exception:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Response:
|
|
116
|
+
def __init__(self, status=200, body=b"", headers=None, reason=None):
|
|
117
|
+
self.status = status
|
|
118
|
+
self.reason = reason
|
|
119
|
+
if isinstance(body, (bytes, bytearray)):
|
|
120
|
+
self.body = bytes(body)
|
|
121
|
+
else:
|
|
122
|
+
self.body = str(body).encode("utf-8")
|
|
123
|
+
self.headers = headers or {}
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def json(cls, data, status=200, headers=None):
|
|
127
|
+
body = json.dumps(data).encode("utf-8")
|
|
128
|
+
hdrs = {"Content-Type": "application/json; charset=utf-8"}
|
|
129
|
+
if headers:
|
|
130
|
+
hdrs.update(headers)
|
|
131
|
+
return cls(status=status, body=body, headers=hdrs)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def text(cls, text, status=200, headers=None):
|
|
135
|
+
hdrs = {"Content-Type": "text/plain; charset=utf-8"}
|
|
136
|
+
if headers:
|
|
137
|
+
hdrs.update(headers)
|
|
138
|
+
return cls(status=status, body=text, headers=hdrs)
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def html(cls, html, status=200, headers=None):
|
|
142
|
+
hdrs = {"Content-Type": "text/html; charset=utf-8"}
|
|
143
|
+
if headers:
|
|
144
|
+
hdrs.update(headers)
|
|
145
|
+
return cls(status=status, body=html, headers=hdrs)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Route:
|
|
149
|
+
def __init__(self, path, methods, handler, kind):
|
|
150
|
+
self.path = path
|
|
151
|
+
self.methods = {m.upper() for m in methods}
|
|
152
|
+
self.handler = handler
|
|
153
|
+
self.kind = kind
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Router:
|
|
157
|
+
def __init__(self):
|
|
158
|
+
self.routes = []
|
|
159
|
+
|
|
160
|
+
def add(self, route):
|
|
161
|
+
self.routes.append(route)
|
|
162
|
+
|
|
163
|
+
def resolve(self, path, method=None):
|
|
164
|
+
for route in self.routes:
|
|
165
|
+
if route.path != path:
|
|
166
|
+
continue
|
|
167
|
+
if method is None or method.upper() in route.methods:
|
|
168
|
+
return route
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class App:
|
|
173
|
+
def __init__(self):
|
|
174
|
+
self.router = Router()
|
|
175
|
+
self.ws_routes = {}
|
|
176
|
+
self.startup_hooks = []
|
|
177
|
+
|
|
178
|
+
def route(self, path, methods=("GET",), kind="plain"):
|
|
179
|
+
def decorator(func):
|
|
180
|
+
self.router.add(Route(path, methods, func, kind))
|
|
181
|
+
return func
|
|
182
|
+
return decorator
|
|
183
|
+
|
|
184
|
+
def view(self, path, methods=("GET",)):
|
|
185
|
+
return self.route(path, methods=methods, kind="plain")
|
|
186
|
+
|
|
187
|
+
def api(self, path, methods=("GET",)):
|
|
188
|
+
return self.route(path, methods=methods, kind="api")
|
|
189
|
+
|
|
190
|
+
def ws(self, path):
|
|
191
|
+
def decorator(func):
|
|
192
|
+
self.ws_routes[path] = func
|
|
193
|
+
return func
|
|
194
|
+
return decorator
|
|
195
|
+
|
|
196
|
+
def add_startup(self, func):
|
|
197
|
+
self.startup_hooks.append(func)
|
|
198
|
+
|
|
199
|
+
def dispatch(self, request):
|
|
200
|
+
if request.method == "OPTIONS":
|
|
201
|
+
route = self.router.resolve(request.path, method=None)
|
|
202
|
+
if route:
|
|
203
|
+
allow = sorted(route.methods | {"OPTIONS"})
|
|
204
|
+
headers = {
|
|
205
|
+
"Access-Control-Allow-Methods": ", ".join(allow),
|
|
206
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
|
|
207
|
+
}
|
|
208
|
+
if DEFAULT_CORS_ALLOW_ORIGIN:
|
|
209
|
+
headers["Access-Control-Allow-Origin"] = DEFAULT_CORS_ALLOW_ORIGIN
|
|
210
|
+
if DEFAULT_CORS_ALLOW_ORIGIN != "*":
|
|
211
|
+
headers["Vary"] = "Origin"
|
|
212
|
+
return Response(status=200, body=b"", headers=headers)
|
|
213
|
+
|
|
214
|
+
route = self.router.resolve(request.path, request.method)
|
|
215
|
+
if not route:
|
|
216
|
+
return Response.text("Not Found", status=404)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
result = route.handler(request)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"[http] handler error {request.method} {request.path}: {e}")
|
|
222
|
+
if route.kind == "api":
|
|
223
|
+
headers = {}
|
|
224
|
+
if DEFAULT_CORS_ALLOW_ORIGIN:
|
|
225
|
+
headers["Access-Control-Allow-Origin"] = DEFAULT_CORS_ALLOW_ORIGIN
|
|
226
|
+
if DEFAULT_CORS_ALLOW_ORIGIN != "*":
|
|
227
|
+
headers["Vary"] = "Origin"
|
|
228
|
+
return Response.json(
|
|
229
|
+
{"status": "error", "message": "Internal Server Error"},
|
|
230
|
+
status=500,
|
|
231
|
+
headers=headers,
|
|
232
|
+
)
|
|
233
|
+
return Response.text("Internal Server Error", status=500)
|
|
234
|
+
|
|
235
|
+
if isinstance(result, Response):
|
|
236
|
+
resp = result
|
|
237
|
+
elif route.kind == "api" and isinstance(result, (dict, list)):
|
|
238
|
+
resp = Response.json(result)
|
|
239
|
+
else:
|
|
240
|
+
resp = Response.text("" if result is None else str(result))
|
|
241
|
+
|
|
242
|
+
if route.kind == "api":
|
|
243
|
+
if DEFAULT_CORS_ALLOW_ORIGIN:
|
|
244
|
+
resp.headers.setdefault("Access-Control-Allow-Origin", DEFAULT_CORS_ALLOW_ORIGIN)
|
|
245
|
+
if DEFAULT_CORS_ALLOW_ORIGIN != "*":
|
|
246
|
+
resp.headers.setdefault("Vary", "Origin")
|
|
247
|
+
|
|
248
|
+
return resp
|
|
249
|
+
|
|
250
|
+
def run(self, host, port, ssl_context=None):
|
|
251
|
+
server = HTTPServer(host, port, self, ssl_context=ssl_context)
|
|
252
|
+
server.serve_forever()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class HTTPServer:
|
|
256
|
+
MAX_CONNECTION_WORKERS = 64
|
|
257
|
+
MAX_REQUEST_HEADER_BYTES = 64 * 1024
|
|
258
|
+
MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024
|
|
259
|
+
|
|
260
|
+
def __init__(self, host, port, app, ssl_context=None):
|
|
261
|
+
self.host = host
|
|
262
|
+
self.port = port
|
|
263
|
+
self.app = app
|
|
264
|
+
self._sock = None
|
|
265
|
+
self.ssl_context = ssl_context
|
|
266
|
+
|
|
267
|
+
def serve_forever(self):
|
|
268
|
+
for hook in self.app.startup_hooks:
|
|
269
|
+
try:
|
|
270
|
+
hook()
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f"[startup] error: {e}")
|
|
273
|
+
|
|
274
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
275
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
276
|
+
s.bind((self.host, self.port))
|
|
277
|
+
s.listen(128)
|
|
278
|
+
self._sock = s
|
|
279
|
+
scheme = "https" if self.ssl_context else "http"
|
|
280
|
+
print(f"Server listening on {scheme}://{self.host}:{self.port}/")
|
|
281
|
+
interrupted = False
|
|
282
|
+
try:
|
|
283
|
+
with ThreadPoolExecutor(
|
|
284
|
+
max_workers=self.MAX_CONNECTION_WORKERS,
|
|
285
|
+
thread_name_prefix="framework-http",
|
|
286
|
+
) as pool:
|
|
287
|
+
while True:
|
|
288
|
+
conn, addr = s.accept()
|
|
289
|
+
pool.submit(self.handle_conn, conn, addr)
|
|
290
|
+
except KeyboardInterrupt:
|
|
291
|
+
interrupted = True
|
|
292
|
+
print("\n[shutdown] interrupted by user (Ctrl+C). stopping server...")
|
|
293
|
+
finally:
|
|
294
|
+
s.close()
|
|
295
|
+
if interrupted:
|
|
296
|
+
print("[shutdown] server stopped.")
|
|
297
|
+
|
|
298
|
+
def handle_conn(self, conn, addr):
|
|
299
|
+
tls_meta = {
|
|
300
|
+
"enabled": bool(self.ssl_context),
|
|
301
|
+
"peer_cert": None,
|
|
302
|
+
"cipher": None,
|
|
303
|
+
"version": None,
|
|
304
|
+
}
|
|
305
|
+
if self.ssl_context:
|
|
306
|
+
try:
|
|
307
|
+
conn = self.ssl_context.wrap_socket(conn, server_side=True)
|
|
308
|
+
tls_meta["peer_cert"] = conn.getpeercert()
|
|
309
|
+
tls_meta["cipher"] = conn.cipher()
|
|
310
|
+
tls_meta["version"] = conn.version()
|
|
311
|
+
except ssl.SSLError as e:
|
|
312
|
+
print(f"[tls] handshake error from {addr}: {e}")
|
|
313
|
+
try:
|
|
314
|
+
conn.close()
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
return
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print(f"[tls] wrap error from {addr}: {e}")
|
|
320
|
+
try:
|
|
321
|
+
conn.close()
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
with conn:
|
|
327
|
+
try:
|
|
328
|
+
req = parse_http_request(
|
|
329
|
+
conn,
|
|
330
|
+
max_header_bytes=self.MAX_REQUEST_HEADER_BYTES,
|
|
331
|
+
)
|
|
332
|
+
except ValueError as e:
|
|
333
|
+
message = str(e).lower()
|
|
334
|
+
status = 431 if "header" in message else 400
|
|
335
|
+
send_http_response(conn, Response.text(str(e), status=status))
|
|
336
|
+
return
|
|
337
|
+
if not req:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
headers = req["headers"]
|
|
341
|
+
body = req["remainder"]
|
|
342
|
+
|
|
343
|
+
if "content-length" in headers:
|
|
344
|
+
try:
|
|
345
|
+
cl = int(headers["content-length"])
|
|
346
|
+
except Exception:
|
|
347
|
+
cl = 0
|
|
348
|
+
if cl < 0:
|
|
349
|
+
cl = 0
|
|
350
|
+
if cl > self.MAX_REQUEST_BODY_BYTES:
|
|
351
|
+
send_http_response(
|
|
352
|
+
conn,
|
|
353
|
+
Response.text("Payload Too Large", status=413),
|
|
354
|
+
)
|
|
355
|
+
return
|
|
356
|
+
if len(body) < cl:
|
|
357
|
+
need = cl - len(body)
|
|
358
|
+
if need > 0:
|
|
359
|
+
body += recv_exact(conn, need)
|
|
360
|
+
if len(body) > self.MAX_REQUEST_BODY_BYTES:
|
|
361
|
+
send_http_response(
|
|
362
|
+
conn,
|
|
363
|
+
Response.text("Payload Too Large", status=413),
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
raw_path = req["path"]
|
|
368
|
+
path, _, query = raw_path.partition("?")
|
|
369
|
+
|
|
370
|
+
request = Request(
|
|
371
|
+
method=req["method"],
|
|
372
|
+
path=path,
|
|
373
|
+
query_string=query,
|
|
374
|
+
headers=headers,
|
|
375
|
+
body=body,
|
|
376
|
+
client=addr,
|
|
377
|
+
tls=tls_meta,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if is_ws_request(headers):
|
|
381
|
+
handler = self.app.ws_routes.get(path)
|
|
382
|
+
if not handler:
|
|
383
|
+
send_http_response(conn, Response.text("Not Found", status=404))
|
|
384
|
+
return
|
|
385
|
+
ws = handshake_websocket(conn, addr, headers)
|
|
386
|
+
if not ws:
|
|
387
|
+
return
|
|
388
|
+
try:
|
|
389
|
+
handler(ws, request)
|
|
390
|
+
except Exception as e:
|
|
391
|
+
print(f"[ws] error: {e}")
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
response = self.app.dispatch(request)
|
|
395
|
+
send_http_response(conn, response)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def parse_http_request(conn, max_header_bytes=65536):
|
|
399
|
+
data = b""
|
|
400
|
+
while b"\r\n\r\n" not in data:
|
|
401
|
+
chunk = conn.recv(1024)
|
|
402
|
+
if not chunk:
|
|
403
|
+
break
|
|
404
|
+
data += chunk
|
|
405
|
+
if len(data) > int(max_header_bytes):
|
|
406
|
+
raise ValueError("Request headers too large")
|
|
407
|
+
header_text, _, remainder = data.partition(b"\r\n\r\n")
|
|
408
|
+
lines = header_text.decode("utf-8", errors="ignore").split("\r\n")
|
|
409
|
+
if not lines:
|
|
410
|
+
return None
|
|
411
|
+
request_line = lines[0]
|
|
412
|
+
parts = request_line.split()
|
|
413
|
+
if len(parts) != 3:
|
|
414
|
+
return None
|
|
415
|
+
method, path, version = parts
|
|
416
|
+
if not version.startswith("HTTP/"):
|
|
417
|
+
return None
|
|
418
|
+
headers = {}
|
|
419
|
+
for line in lines[1:]:
|
|
420
|
+
if ":" in line:
|
|
421
|
+
k, v = line.split(":", 1)
|
|
422
|
+
headers[k.strip().lower()] = v.strip()
|
|
423
|
+
return {
|
|
424
|
+
"method": method,
|
|
425
|
+
"path": path,
|
|
426
|
+
"version": version,
|
|
427
|
+
"headers": headers,
|
|
428
|
+
"remainder": remainder,
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def send_http_response(conn, response):
|
|
433
|
+
status_code = response.status
|
|
434
|
+
reason = response.reason or STATUS_MESSAGES.get(status_code, "OK")
|
|
435
|
+
headers = response.headers or {}
|
|
436
|
+
lowermap = {k.lower(): v for k, v in headers.items()}
|
|
437
|
+
if "content-length" not in lowermap:
|
|
438
|
+
headers["Content-Length"] = str(len(response.body))
|
|
439
|
+
if "connection" not in lowermap:
|
|
440
|
+
headers["Connection"] = "close"
|
|
441
|
+
status_line = f"HTTP/1.1 {status_code} {reason}\r\n"
|
|
442
|
+
hdrs = ""
|
|
443
|
+
for k, v in headers.items():
|
|
444
|
+
hdrs += f"{k}: {v}\r\n"
|
|
445
|
+
resp = status_line + hdrs + "\r\n"
|
|
446
|
+
try:
|
|
447
|
+
conn.sendall(resp.encode("utf-8") + response.body)
|
|
448
|
+
except Exception as e:
|
|
449
|
+
print(f"[http] send error {status_code}: {e}")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def is_ws_request(headers):
|
|
453
|
+
return headers.get("upgrade", "").lower() == "websocket"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def handshake_websocket(conn, addr, headers):
|
|
457
|
+
key = headers.get("sec-websocket-key", "")
|
|
458
|
+
if not key:
|
|
459
|
+
send_http_response(conn, Response.text("Missing Sec-WebSocket-Key", status=400))
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
subprotocol = ""
|
|
463
|
+
offered = headers.get("sec-websocket-protocol", "")
|
|
464
|
+
if offered:
|
|
465
|
+
parts = [p.strip() for p in offered.split(",") if p.strip()]
|
|
466
|
+
if parts:
|
|
467
|
+
subprotocol = parts[0]
|
|
468
|
+
|
|
469
|
+
accept_src = (key + MAGIC_WS).encode("utf-8")
|
|
470
|
+
digest = sha1(accept_src)
|
|
471
|
+
accept = base64_encode(digest)
|
|
472
|
+
|
|
473
|
+
resp_headers = {
|
|
474
|
+
"Upgrade": "websocket",
|
|
475
|
+
"Connection": "Upgrade",
|
|
476
|
+
"Sec-WebSocket-Accept": accept,
|
|
477
|
+
}
|
|
478
|
+
if subprotocol:
|
|
479
|
+
resp_headers["Sec-WebSocket-Protocol"] = subprotocol
|
|
480
|
+
|
|
481
|
+
send_http_response(conn, Response(status=101, body=b"", headers=resp_headers))
|
|
482
|
+
return WebSocket(conn, addr, subprotocol, headers)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class WebSocket:
|
|
486
|
+
def __init__(self, sock, addr, subprotocol, headers):
|
|
487
|
+
self.sock = sock
|
|
488
|
+
self.addr = addr
|
|
489
|
+
self.subprotocol = subprotocol or ""
|
|
490
|
+
self.headers = headers or {}
|
|
491
|
+
|
|
492
|
+
def recv_frame(self):
|
|
493
|
+
return read_ws_frame_raw(self.sock)
|
|
494
|
+
|
|
495
|
+
def send_frame(self, opcode, payload=b""):
|
|
496
|
+
self.sock.sendall(make_ws_frame_bytes(opcode, payload))
|
|
497
|
+
|
|
498
|
+
def send_text(self, text):
|
|
499
|
+
self.send_frame(0x1, text.encode("utf-8"))
|
|
500
|
+
|
|
501
|
+
def send_binary(self, data):
|
|
502
|
+
self.send_frame(0x2, data)
|
|
503
|
+
|
|
504
|
+
def send_ping(self, payload=b""):
|
|
505
|
+
self.send_frame(0x9, payload)
|
|
506
|
+
|
|
507
|
+
def send_pong(self, payload=b""):
|
|
508
|
+
self.send_frame(0xA, payload)
|
|
509
|
+
|
|
510
|
+
def close(self, code=1000, reason=""):
|
|
511
|
+
payload = code.to_bytes(2, "big")
|
|
512
|
+
if reason:
|
|
513
|
+
payload += reason.encode("utf-8")
|
|
514
|
+
self.send_frame(0x8, payload)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def recv_exact(conn, n):
|
|
518
|
+
data = b""
|
|
519
|
+
while len(data) < n:
|
|
520
|
+
chunk = conn.recv(n - len(data))
|
|
521
|
+
if not chunk:
|
|
522
|
+
raise ConnectionError("connection closed")
|
|
523
|
+
data += chunk
|
|
524
|
+
return data
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def read_ws_frame_raw(conn):
|
|
528
|
+
hdr = recv_exact(conn, 2)
|
|
529
|
+
b1, b2 = hdr[0], hdr[1]
|
|
530
|
+
fin = (b1 >> 7) & 1
|
|
531
|
+
opcode = b1 & 0x0F
|
|
532
|
+
masked = (b2 >> 7) & 1
|
|
533
|
+
payload_len = b2 & 0x7F
|
|
534
|
+
|
|
535
|
+
if payload_len == 126:
|
|
536
|
+
ext = recv_exact(conn, 2)
|
|
537
|
+
payload_len = int.from_bytes(ext, "big")
|
|
538
|
+
elif payload_len == 127:
|
|
539
|
+
ext = recv_exact(conn, 8)
|
|
540
|
+
payload_len = int.from_bytes(ext, "big")
|
|
541
|
+
|
|
542
|
+
mask = None
|
|
543
|
+
if masked:
|
|
544
|
+
mask = recv_exact(conn, 4)
|
|
545
|
+
|
|
546
|
+
payload = b""
|
|
547
|
+
if payload_len:
|
|
548
|
+
payload = recv_exact(conn, payload_len)
|
|
549
|
+
if masked and mask:
|
|
550
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
551
|
+
return fin, opcode, payload, bool(masked), mask
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def make_ws_frame_bytes(opcode, payload=b""):
|
|
555
|
+
fin = 0x80
|
|
556
|
+
b1 = fin | (opcode & 0x0F)
|
|
557
|
+
payload_len = len(payload)
|
|
558
|
+
header = bytes([b1])
|
|
559
|
+
|
|
560
|
+
if payload_len <= 125:
|
|
561
|
+
header += bytes([payload_len])
|
|
562
|
+
elif payload_len <= 65535:
|
|
563
|
+
header += bytes([126]) + payload_len.to_bytes(2, "big")
|
|
564
|
+
else:
|
|
565
|
+
header += bytes([127]) + payload_len.to_bytes(8, "big")
|
|
566
|
+
|
|
567
|
+
return header + payload
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def parse_close_payload(payload):
|
|
571
|
+
if not payload:
|
|
572
|
+
return None, None
|
|
573
|
+
if len(payload) >= 2:
|
|
574
|
+
code = int.from_bytes(payload[:2], "big")
|
|
575
|
+
reason = ""
|
|
576
|
+
if len(payload) > 2:
|
|
577
|
+
try:
|
|
578
|
+
reason = payload[2:].decode("utf-8", errors="ignore")
|
|
579
|
+
except Exception:
|
|
580
|
+
reason = ""
|
|
581
|
+
return code, reason
|
|
582
|
+
return None, None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _left_rotate(n, b):
|
|
586
|
+
return ((n << b) | (n >> (32 - b))) & 0xFFFFFFFF
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def sha1(data_bytes):
|
|
590
|
+
message = bytearray(data_bytes)
|
|
591
|
+
orig_len_bits = (8 * len(message)) & 0xFFFFFFFFFFFFFFFF
|
|
592
|
+
message.append(0x80)
|
|
593
|
+
while (len(message) * 8) % 512 != 448:
|
|
594
|
+
message.append(0)
|
|
595
|
+
message += orig_len_bits.to_bytes(8, "big")
|
|
596
|
+
|
|
597
|
+
h0 = 0x67452301
|
|
598
|
+
h1 = 0xEFCDAB89
|
|
599
|
+
h2 = 0x98BADCFE
|
|
600
|
+
h3 = 0x10325476
|
|
601
|
+
h4 = 0xC3D2E1F0
|
|
602
|
+
|
|
603
|
+
for i in range(0, len(message), 64):
|
|
604
|
+
w = [0] * 80
|
|
605
|
+
chunk = message[i : i + 64]
|
|
606
|
+
for j in range(16):
|
|
607
|
+
w[j] = int.from_bytes(chunk[j * 4 : (j + 1) * 4], "big")
|
|
608
|
+
for j in range(16, 80):
|
|
609
|
+
w[j] = _left_rotate(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1)
|
|
610
|
+
|
|
611
|
+
a, b, c, d, e = h0, h1, h2, h3, h4
|
|
612
|
+
for t in range(80):
|
|
613
|
+
if 0 <= t <= 19:
|
|
614
|
+
f = (b & c) | ((~b) & d)
|
|
615
|
+
k = 0x5A827999
|
|
616
|
+
elif 20 <= t <= 39:
|
|
617
|
+
f = b ^ c ^ d
|
|
618
|
+
k = 0x6ED9EBA1
|
|
619
|
+
elif 40 <= t <= 59:
|
|
620
|
+
f = (b & c) | (b & d) | (c & d)
|
|
621
|
+
k = 0x8F1BBCDC
|
|
622
|
+
else:
|
|
623
|
+
f = b ^ c ^ d
|
|
624
|
+
k = 0xCA62C1D6
|
|
625
|
+
tmp = (_left_rotate(a, 5) + f + e + k + w[t]) & 0xFFFFFFFF
|
|
626
|
+
e = d
|
|
627
|
+
d = c
|
|
628
|
+
c = _left_rotate(b, 30)
|
|
629
|
+
b = a
|
|
630
|
+
a = tmp
|
|
631
|
+
|
|
632
|
+
h0 = (h0 + a) & 0xFFFFFFFF
|
|
633
|
+
h1 = (h1 + b) & 0xFFFFFFFF
|
|
634
|
+
h2 = (h2 + c) & 0xFFFFFFFF
|
|
635
|
+
h3 = (h3 + d) & 0xFFFFFFFF
|
|
636
|
+
h4 = (h4 + e) & 0xFFFFFFFF
|
|
637
|
+
|
|
638
|
+
digest = b"".join(x.to_bytes(4, "big") for x in [h0, h1, h2, h3, h4])
|
|
639
|
+
return digest
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def base64_encode(data_bytes):
|
|
646
|
+
res = []
|
|
647
|
+
i = 0
|
|
648
|
+
n = len(data_bytes)
|
|
649
|
+
while i < n:
|
|
650
|
+
b = data_bytes[i : i + 3]
|
|
651
|
+
i += 3
|
|
652
|
+
pad = 3 - len(b)
|
|
653
|
+
val = 0
|
|
654
|
+
for x in b:
|
|
655
|
+
val = (val << 8) + x
|
|
656
|
+
val <<= pad * 8
|
|
657
|
+
for j in range(18, -1, -6):
|
|
658
|
+
idx = (val >> j) & 0x3F
|
|
659
|
+
res.append(B64_ALPHABET[idx])
|
|
660
|
+
if pad:
|
|
661
|
+
res[-pad:] = "=" * pad
|
|
662
|
+
return "".join(res)
|