summaryrefslogtreecommitdiff
path: root/stacks/web/proxy/max25-ws-proxy.py
blob: fe593e8f69857b26047b6a09802e954e9b78b954 (plain)
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())
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com