1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
|
#!/usr/bin/env python3
"""
max25-ws-proxy — WebSocket forward-proxy to max25d M25/1 TCP (loopback only).
Accepts RFC6455 text frames and forwards bytes to max25d without protocol
translation. Optional upstream AUTH uses [upstream] tcp_password from config.
Intended behind a TLS reverse proxy; see stacks/web/share/reverse-proxy/.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import configparser
import hashlib
import logging
import os
import signal
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
LOG = logging.getLogger("max25-ws-proxy")
@dataclass
class ProxyConfig:
bind: str = "127.0.0.1"
port: int = 7326
path: str = "/max25"
upstream_host: str = "127.0.0.1"
upstream_port: int = 7325
tcp_password: str = ""
max_connections: int = 10
def load_config(path: Optional[str]) -> ProxyConfig:
cfg = ProxyConfig()
if not path:
return cfg
ini = configparser.ConfigParser()
ini.read(path)
if ini.has_section("proxy"):
sec = ini["proxy"]
cfg.bind = sec.get("bind", cfg.bind)
cfg.port = sec.getint("port", fallback=cfg.port)
cfg.path = sec.get("path", cfg.path)
cfg.max_connections = sec.getint("max_connections", fallback=cfg.max_connections)
if ini.has_section("upstream"):
sec = ini["upstream"]
cfg.upstream_host = sec.get("host", cfg.upstream_host)
cfg.upstream_port = sec.getint("port", fallback=cfg.upstream_port)
cfg.tcp_password = sec.get("tcp_password", cfg.tcp_password)
return cfg
def resolve_config_path(explicit: Optional[str]) -> Optional[str]:
if explicit:
return explicit
env = os.environ.get("MAX25_WEB_PROXY_INI")
if env:
return env
for candidate in (
"/etc/max25/web-proxy.ini",
):
if Path(candidate).is_file():
return candidate
return None
async def read_http_request(reader: asyncio.StreamReader) -> tuple[str, dict[str, str]]:
request_line = (await reader.readline()).decode("ascii", errors="replace").strip()
if not request_line:
raise ConnectionError("empty request")
headers: dict[str, str] = {}
while True:
line = (await reader.readline()).decode("ascii", errors="replace").strip()
if line == "":
break
if ":" not in line:
continue
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return request_line, headers
def ws_accept_key(sec_key: str) -> str:
digest = hashlib.sha1((sec_key + WS_GUID).encode("ascii")).digest()
return base64.b64encode(digest).decode("ascii")
async def ws_handshake(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
expected_path: str,
) -> None:
request_line, headers = await read_http_request(reader)
parts = request_line.split()
if len(parts) < 2:
raise ConnectionError("bad request line")
path = parts[1].split("?", 1)[0]
if path != expected_path:
raise ConnectionError(f"path mismatch: {path!r}")
upgrade = headers.get("upgrade", "").lower()
connection = headers.get("connection", "").lower()
sec_key = headers.get("sec-websocket-key", "")
if upgrade != "websocket" or "upgrade" not in connection or not sec_key:
raise ConnectionError("not a websocket upgrade")
accept = ws_accept_key(sec_key)
response = (
"HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Accept: {accept}\r\n"
"\r\n"
)
writer.write(response.encode("ascii"))
await writer.drain()
async def ws_read_frame(reader: asyncio.StreamReader) -> tuple[int, bytes]:
header = await reader.readexactly(2)
b1, b2 = header[0], header[1]
opcode = b1 & 0x0F
masked = bool(b2 & 0x80)
length = b2 & 0x7F
if length == 126:
length = struct.unpack("!H", await reader.readexactly(2))[0]
elif length == 127:
length = struct.unpack("!Q", await reader.readexactly(8))[0]
mask = await reader.readexactly(4) if masked else b""
payload = await reader.readexactly(length) if length else b""
if masked:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
return opcode, payload
async def ws_write_text(writer: asyncio.StreamWriter, text: bytes) -> None:
length = len(text)
header = bytearray([0x81]) # FIN + text frame
if length < 126:
header.append(length)
elif length < 65536:
header.append(126)
header.extend(struct.pack("!H", length))
else:
header.append(127)
header.extend(struct.pack("!Q", length))
writer.write(bytes(header) + text)
await writer.drain()
async def ws_write_pong(writer: asyncio.StreamWriter, payload: bytes) -> None:
length = len(payload)
header = bytearray([0x8A]) # FIN + pong
if length < 126:
header.append(length)
else:
header.append(126)
header.extend(struct.pack("!H", length))
writer.write(bytes(header) + payload)
await writer.drain()
async def read_line(reader: asyncio.StreamReader) -> Optional[str]:
line = await reader.readline()
if not line:
return None
text = line.decode("utf-8", errors="replace")
if text.endswith("\n"):
text = text[:-1]
if text.endswith("\r"):
text = text[:-1]
return text
async def upstream_handshake(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
tcp_password: str,
) -> list[str]:
"""Complete max25d connect handshake; return lines to forward to the client."""
first = await read_line(reader)
if first is None:
raise ConnectionError("upstream closed during handshake")
forwarded: list[str] = []
if first == "AUTH required":
if not tcp_password:
forwarded.append(first)
return forwarded
writer.write(f"AUTH {tcp_password}\n".encode("utf-8"))
await writer.drain()
reply = await read_line(reader)
if reply != "OK":
raise ConnectionError(f"upstream auth failed: {reply!r}")
forwarded.append("OK")
status = await read_line(reader)
if status is None:
raise ConnectionError("upstream closed after auth")
forwarded.append(status)
return forwarded
if first != "OK":
raise ConnectionError(f"unexpected upstream greeting: {first!r}")
forwarded.append(first)
status = await read_line(reader)
if status is None:
raise ConnectionError("upstream closed after OK")
forwarded.append(status)
return forwarded
async def pipe_tcp_to_ws(
tcp_reader: asyncio.StreamReader,
ws_writer: asyncio.StreamWriter,
) -> None:
while True:
chunk = await tcp_reader.read(4096)
if not chunk:
break
await ws_write_text(ws_writer, chunk)
async def pipe_ws_to_tcp(
ws_reader: asyncio.StreamReader,
ws_writer: asyncio.StreamWriter,
tcp_writer: asyncio.StreamWriter,
) -> None:
while True:
opcode, payload = await ws_read_frame(ws_reader)
if opcode == 0x8:
break
if opcode == 0x9:
await ws_write_pong(ws_writer, payload)
continue
if opcode == 0xA:
continue
if opcode != 0x1:
LOG.warning("ignoring non-text websocket frame opcode=%s", opcode)
continue
tcp_writer.write(payload)
await tcp_writer.drain()
class ConnectionLimiter:
def __init__(self, limit: int) -> None:
self.limit = limit
self.active = 0
def try_acquire(self) -> bool:
if self.active >= self.limit:
return False
self.active += 1
return True
def release(self) -> None:
if self.active > 0:
self.active -= 1
async def handle_client(
ws_reader: asyncio.StreamReader,
ws_writer: asyncio.StreamWriter,
cfg: ProxyConfig,
limiter: ConnectionLimiter,
) -> None:
peer = ws_writer.get_extra_info("peername")
if not limiter.try_acquire():
LOG.warning("connection limit reached; rejecting %s", peer)
ws_writer.close()
await ws_writer.wait_closed()
return
try:
await ws_handshake(ws_reader, ws_writer, cfg.path)
except Exception as exc:
LOG.warning("handshake failed from %s: %s", peer, exc)
limiter.release()
ws_writer.close()
await ws_writer.wait_closed()
return
LOG.info("websocket accepted from %s", peer)
tcp_reader: Optional[asyncio.StreamReader] = None
tcp_writer: Optional[asyncio.StreamWriter] = None
try:
tcp_reader, tcp_writer = await asyncio.open_connection(
cfg.upstream_host, cfg.upstream_port
)
lines = await upstream_handshake(tcp_reader, tcp_writer, cfg.tcp_password)
for line in lines:
await ws_write_text(ws_writer, (line + "\n").encode("utf-8"))
tcp_task = asyncio.create_task(pipe_tcp_to_ws(tcp_reader, ws_writer))
ws_task = asyncio.create_task(pipe_ws_to_tcp(ws_reader, ws_writer, tcp_writer))
done, pending = await asyncio.wait(
{tcp_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
except Exception as exc:
LOG.warning("session ended for %s: %s", peer, exc)
try:
await ws_write_text(ws_writer, f"\n[proxy error: {exc}]\n".encode("utf-8"))
except Exception:
pass
finally:
if tcp_writer is not None:
tcp_writer.close()
try:
await tcp_writer.wait_closed()
except Exception:
pass
ws_writer.close()
try:
await ws_writer.wait_closed()
except Exception:
pass
limiter.release()
LOG.info("websocket closed for %s", peer)
async def run_server(cfg: ProxyConfig) -> None:
limiter = ConnectionLimiter(cfg.max_connections)
server = await asyncio.start_server(
lambda r, w: handle_client(r, w, cfg, limiter),
host=cfg.bind,
port=cfg.port,
)
addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
LOG.info(
"listening on %s path=%s -> %s:%s",
addrs,
cfg.path,
cfg.upstream_host,
cfg.upstream_port,
)
async with server:
await server.serve_forever()
def main() -> int:
parser = argparse.ArgumentParser(description="MAX25 WebSocket forward-proxy to max25d TCP")
parser.add_argument("-c", "--config", help="web-proxy.ini path")
parser.add_argument("--bind", help="override [proxy] bind")
parser.add_argument("--port", type=int, help="override [proxy] port")
parser.add_argument("--path", help="override [proxy] path")
parser.add_argument("--upstream-host", help="override [upstream] host")
parser.add_argument("--upstream-port", type=int, help="override [upstream] port")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
config_path = resolve_config_path(args.config)
cfg = load_config(config_path)
if args.bind:
cfg.bind = args.bind
if args.port:
cfg.port = args.port
if args.path:
cfg.path = args.path
if args.upstream_host:
cfg.upstream_host = args.upstream_host
if args.upstream_port:
cfg.upstream_port = args.upstream_port
if config_path:
LOG.info("config: %s", config_path)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, loop.stop)
try:
loop.run_until_complete(run_server(cfg))
except KeyboardInterrupt:
pass
finally:
loop.close()
return 0
if __name__ == "__main__":
sys.exit(main())
|