From 04d965d67a7264a1c7c211494aebda1953df7603 Mon Sep 17 00:00:00 2001 From: "info@mode42.com" Date: Fri, 7 Aug 2026 18:25:13 +0000 Subject: Initial push --- stacks/web/proxy/test_ws_proxy.py | 237 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100755 stacks/web/proxy/test_ws_proxy.py (limited to 'stacks/web/proxy/test_ws_proxy.py') diff --git a/stacks/web/proxy/test_ws_proxy.py b/stacks/web/proxy/test_ws_proxy.py new file mode 100755 index 0000000..fccba84 --- /dev/null +++ b/stacks/web/proxy/test_ws_proxy.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""CI-safe smoke: max25-ws-proxy forwards M25/1 PING (no UART / no stack).""" +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import os +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +ROOT = Path(__file__).resolve().parents[3] +PROXY = ROOT / "stacks/web/proxy/max25-ws-proxy.py" +DAEMON = ROOT / "stacks/daemon/max25d" + + +def ws_accept(sec_key: str) -> str: + digest = hashlib.sha1((sec_key + WS_GUID).encode()).digest() + return base64.b64encode(digest).decode() + + +def free_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +def wait_for_unix(path: str, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + return False + if Path(path).exists(): + try: + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(0.25) + probe.connect(path) + probe.close() + return True + except OSError: + pass + time.sleep(0.05) + return False + + +def wait_for_port(port: int, proc: subprocess.Popen[str], timeout: float = 8.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + return False + try: + probe = socket.create_connection(("127.0.0.1", port), timeout=0.25) + probe.close() + return True + except OSError: + time.sleep(0.05) + return False + + +def drain_stderr(proc: subprocess.Popen[str]) -> str: + if not proc.stderr: + return "" + try: + return proc.stderr.read() or "" + except OSError: + return "" + + +async def ws_exchange(host: str, port: int, path: str, send: str) -> str: + reader, writer = await asyncio.open_connection(host, port) + key = base64.b64encode(os.urandom(16)).decode() + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ) + writer.write(req.encode()) + await writer.drain() + status = await reader.readline() + if b"101" not in status: + raise RuntimeError(f"upgrade failed: {status!r}") + while True: + line = await reader.readline() + if line in (b"\r\n", b"\n", b""): + break + + payload = send.encode() + header = bytes([0x81, len(payload)]) + writer.write(header + payload) + await writer.drain() + + hdr = await reader.readexactly(2) + length = hdr[1] & 0x7F + if length == 126: + length = struct.unpack("!H", await reader.readexactly(2))[0] + data = await reader.readexactly(length) + writer.close() + await writer.wait_closed() + return data.decode() + + +def stop(proc: subprocess.Popen[str]) -> None: + if proc.poll() is not None: + return + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + + +def main() -> int: + if not DAEMON.is_file(): + print(f"max25_web_smoke: missing daemon launcher {DAEMON}", file=sys.stderr) + return 1 + if not PROXY.is_file(): + print(f"max25_web_smoke: missing proxy {PROXY}", file=sys.stderr) + return 1 + + with tempfile.TemporaryDirectory(prefix="max25-web-smoke-") as tmp: + sock = str(Path(tmp) / "modem.sock") + tcp_port = free_port() + proxy_port = free_port() + ini = Path(tmp) / "max25d.ini" + proxy_ini = Path(tmp) / "web-proxy.ini" + # /dev/null: CI must not open a real UART (ttyS0 is flaky / absent). + ini.write_text( + "[daemon]\nmode=standalone\nhardware=tncs\ndevice=tnc2c\n" + "[network]\ntcp_host=127.0.0.1\n" + f"tcp_port={tcp_port}\nunix_socket={sock}\ntcp_password=\n" + "[devices]\ntnc2c=/dev/null\n", + encoding="utf-8", + ) + proxy_ini.write_text( + f"[proxy]\nbind=127.0.0.1\nport={proxy_port}\npath=/max25\n" + f"[upstream]\nhost=127.0.0.1\nport={tcp_port}\ntcp_password=\n", + encoding="utf-8", + ) + + daemon = subprocess.Popen( + [ + str(DAEMON), + "--no-stack", + "--no-serial", + "-c", + str(ini), + "--tcp-port", + str(tcp_port), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + proxy: subprocess.Popen[str] | None = None + try: + if not wait_for_unix(sock, daemon): + err = drain_stderr(daemon) + print( + f"max25_web_smoke: max25d unix sock not ready: {err.strip()}", + file=sys.stderr, + ) + return 1 + if not wait_for_port(tcp_port, daemon): + err = drain_stderr(daemon) + print( + f"max25_web_smoke: max25d TCP not listening: {err.strip()}", + file=sys.stderr, + ) + return 1 + + proxy = subprocess.Popen( + [sys.executable, str(PROXY), "-c", str(proxy_ini)], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + if not wait_for_port(proxy_port, proxy): + err = drain_stderr(proxy) + print( + f"max25_web_smoke: proxy not listening: {err.strip()}", + file=sys.stderr, + ) + return 1 + + deadline = time.time() + 8 + last_exc: Exception | None = None + while time.time() < deadline: + if daemon.poll() is not None or proxy.poll() is not None: + break + try: + out = asyncio.run( + ws_exchange("127.0.0.1", proxy_port, "/max25", "PING\n") + ) + if "OK" in out or "PONG" in out or "STATUS" in out: + print("max25_web_smoke: ok") + return 0 + last_exc = RuntimeError(f"unexpected reply: {out!r}") + except ( + ConnectionRefusedError, + OSError, + RuntimeError, + asyncio.IncompleteReadError, + ) as exc: + last_exc = exc + time.sleep(0.15) + + d_err = drain_stderr(daemon) + p_err = drain_stderr(proxy) if proxy else "" + detail = f"last={last_exc!r}" if last_exc else "no M25/1 response" + print(f"max25_web_smoke: failed — {detail}", file=sys.stderr) + if d_err.strip(): + print(f"max25d stderr:\n{d_err.rstrip()}", file=sys.stderr) + if p_err.strip(): + print(f"proxy stderr:\n{p_err.rstrip()}", file=sys.stderr) + return 1 + finally: + if proxy is not None: + stop(proxy) + stop(daemon) + + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.3.1