diff options
Diffstat (limited to 'stacks/web')
| -rw-r--r-- | stacks/web/CMakeLists.txt | 42 | ||||
| -rw-r--r-- | stacks/web/README.md | 112 | ||||
| -rwxr-xr-x | stacks/web/proxy/max25-ws-proxy.py | 399 | ||||
| -rwxr-xr-x | stacks/web/proxy/test_ws_proxy.py | 237 | ||||
| -rw-r--r-- | stacks/web/scripts/dev-router.php | 21 | ||||
| -rwxr-xr-x | stacks/web/scripts/dev-server.sh | 32 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/README.txt | 10 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/apache2.conf.example | 21 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/docroot/README.txt | 7 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php | 98 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php | 180 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js | 609 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/lighttpd.conf.example | 27 | ||||
| -rw-r--r-- | stacks/web/share/reverse-proxy/nginx.conf.example | 27 | ||||
| -rw-r--r-- | stacks/web/share/web-proxy.ini.example | 15 | ||||
| -rw-r--r-- | stacks/web/share/web-terminal.ini.example | 18 |
16 files changed, 1855 insertions, 0 deletions
diff --git a/stacks/web/CMakeLists.txt b/stacks/web/CMakeLists.txt new file mode 100644 index 0000000..e24e4a5 --- /dev/null +++ b/stacks/web/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.16) + +if(NOT MAX25_BUILD_WEB) + return() +endif() + +set(_MAX25_WEB_SRC "${CMAKE_SOURCE_DIR}/stacks/web") + +execute_process( + COMMAND "${CMAKE_COMMAND}" -E chmod u+x + "${_MAX25_WEB_SRC}/proxy/max25-ws-proxy.py" + "${_MAX25_WEB_SRC}/scripts/dev-server.sh" + ERROR_QUIET +) + +install(PROGRAMS "${_MAX25_WEB_SRC}/proxy/max25-ws-proxy.py" + DESTINATION "${CMAKE_INSTALL_BINDIR}" + RENAME max25-ws-proxy) + +install(FILES "${_MAX25_WEB_SRC}/share/web-proxy.ini.example" + "${_MAX25_WEB_SRC}/share/web-terminal.ini.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(FILES "${_MAX25_WEB_SRC}/share/web-terminal.ini.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(FILES "${CMAKE_SOURCE_DIR}/share/max25/max25-ws-proxy.service.example" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25") + +install(DIRECTORY "${_MAX25_WEB_SRC}/share/reverse-proxy/" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/reverse-proxy") + +install(PROGRAMS "${_MAX25_WEB_SRC}/scripts/dev-server.sh" + DESTINATION "${CMAKE_INSTALL_DATADIR}/max25/web" + RENAME max25-web-dev) + +add_custom_target(max25_web_smoke + COMMAND "${CMAKE_COMMAND}" -E env PYTHONPATH= + python3 "${_MAX25_WEB_SRC}/proxy/test_ws_proxy.py" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + COMMENT "MAX25 web proxy smoke test" +) diff --git a/stacks/web/README.md b/stacks/web/README.md new file mode 100644 index 0000000..18cc291 --- /dev/null +++ b/stacks/web/README.md @@ -0,0 +1,112 @@ +# MAX25 Web UI — browser remote terminal + +> **Status: available** — full remote terminal (M25/1 via WebSocket proxy). Same session model as `max25-terminal`. Not a replacement for the CLI client. + +**Operator guide:** [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md) · **Roadmap:** [docs/V2.0.0-SCOPE.md](../../docs/V2.0.0-SCOPE.md#dev-levels-roadmap-stack-wide) + +Browser terminal for **max25d** M25/1 — hyBBX-style httpd + reverse-proxy pattern, MAX25-correct separate loopback proxy. + +| Layer | Component | Role | +|-------|-----------|------| +| Browser UI | PHP + JS (`docroot/max25-websocket/`) | Remote terminal; `/` commands + menu | +| Session defaults | `web-terminal.ini` | CALLERID, CALLID, device, ax25_ui per session | +| HTTP reverse-proxy | nginx / Apache / lighttpd examples | `WS /max25-websocket/ws` → loopback proxy | +| WebSocket proxy | `max25-ws-proxy.py` | RFC6455 ↔ M25/1 TCP forward (no translation) | +| Daemon | `max25d` | M25/1 on TCP `7325` | + +CLI operator client remains **max25-terminal** — see [docs/MAX25-TERMINAL.md](../../docs/MAX25-TERMINAL.md). + +## Implementation status + +| Item | State | +|------|--------| +| PHP + JS UI (`docroot/max25-websocket/`) | **Available** — header, dropdown menu, device select | +| `max25-terminal.js` | **Available** — F10-equivalent `/` commands, bootstrap from INI | +| `web-terminal.ini.example` | **Available** — session defaults from max25d logic | +| `max25-ws-proxy.py` | **Available** — CI smoke test passes | +| httpd examples | Draft configs (nginx, Apache, lighttpd) | +| systemd unit example | Shipped — `share/max25/max25-ws-proxy.service.example` | +| Admin dashboard | Later | + +## Browser commands (F10 equivalent) + +| `/command` | M25/1 | +|------------|-------| +| `/callerid` `/callid` | `SET CALLERID` / `SET CALLID` | +| `/status` | `GET STATUS` | +| `/send <text>` | `SEND` | +| `/monitor` | `MONITOR on\|off` | +| `/connect` `/disconnect` | `CONNECT` / `DISCONNECT` | +| `/devices` | `GET DEVICES` | +| `/device <id>` | `SET DEVICE` (max25d ids only) | +| `/ax25_ui on\|off` | `SET AX25_UI` | +| `/help` | Command list | + +Plain text without `/` → `SEND` (same as max25-terminal Enter). **Menu ▾** dropdown mirrors these actions. + +One **device per session**; change via dropdown or `/device`. Device list always from **GET DEVICES** — unknown ids rejected. + +## Quick start (local dev) + +```bash +# Terminal 1: max25d +python3 stacks/daemon/max25d.py --tcp-port 7325 + +# Terminal 2: dev stack (proxy + PHP) +./stacks/web/scripts/dev-server.sh +``` + +Browser: `http://127.0.0.1:8080/` + +Copy `share/web-terminal.ini.example` → `/etc/max25/web-terminal.ini` for session defaults (CALLERID, CALLID, device from max25d.ini logic). + +## Configuration + +### WebSocket proxy (`web-proxy.ini`) + +Copy `share/web-proxy.ini.example` to `/etc/max25/web-proxy.ini` (or `MAX25_WEB_PROXY_INI`). + +| Section | Key | Default | Meaning | +|---------|-----|---------|---------| +| `[proxy]` | `bind` | `127.0.0.1` | Loopback only | +| `[proxy]` | `port` | `7326` | WebSocket listen port | +| `[proxy]` | `path` | `/max25` | Upgrade path | +| `[upstream]` | `host` / `port` | `127.0.0.1` / `7325` | max25d M25/1 | +| `[upstream]` | `tcp_password` | *(empty)* | Proxy-side AUTH | + +### Browser session (`web-terminal.ini`) + +Copy `share/web-terminal.ini.example` to `/etc/max25/web-terminal.ini` (or `MAX25_WEB_TERMINAL_INI`). + +Merges with max25d `[modem]` and `[devices] default` when keys omitted — see [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md). + +## Production deploy + +```bash +cmake --install $BUILD --prefix $PREFIX +``` + +Installs: `max25-ws-proxy`, `web-proxy.ini.example`, `web-terminal.ini.example`, `reverse-proxy/` tree. + +1. Copy `docroot/max25-websocket/` to httpd docroot +2. Add httpd WS snippet → `127.0.0.1:7326/max25` +3. Configure `web-proxy.ini` + `web-terminal.ini` +4. Run `max25-ws-proxy` (systemd example shipped) + +## Architecture + +``` +Browser → httpd (wss) → max25-ws-proxy :7326 → max25d M25/1 :7325 +``` + +M25/1 uses `\n` line endings. Proxy forwards bytes after connect handshake (`OK` + `STATUS`). + +## CI + +Target `max25_web_smoke` — PING through max25d + proxy + WebSocket (offline, no UART). + +## Related + +- [docs/WEBSOCKET.md](../../docs/WEBSOCKET.md) — operator guide +- [include/max25/protocol.md](../../include/max25/protocol.md) — M25/1 spec +- [docs/MAX25-CLIENT.md](../../docs/MAX25-CLIENT.md) — client contract diff --git a/stacks/web/proxy/max25-ws-proxy.py b/stacks/web/proxy/max25-ws-proxy.py new file mode 100755 index 0000000..fe593e8 --- /dev/null +++ b/stacks/web/proxy/max25-ws-proxy.py @@ -0,0 +1,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()) 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()) diff --git a/stacks/web/scripts/dev-router.php b/stacks/web/scripts/dev-router.php new file mode 100644 index 0000000..fd44559 --- /dev/null +++ b/stacks/web/scripts/dev-router.php @@ -0,0 +1,21 @@ +<?php +/** + * PHP built-in server router for local dev (direct loopback WebSocket URL). + */ +$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'; +$root = __DIR__ . '/../share/reverse-proxy/docroot/max25-websocket'; + +if ($uri === '/' || $uri === '/index.php') { + $ws_url = getenv('MAX25_DEV_WS_URL') ?: 'ws://127.0.0.1:7326/max25'; + require $root . '/index.php'; + return true; +} + +$file = $root . $uri; +if (is_file($file)) { + return false; +} + +http_response_code(404); +echo "not found\n"; +return true; diff --git a/stacks/web/scripts/dev-server.sh b/stacks/web/scripts/dev-server.sh new file mode 100755 index 0000000..00b9f0c --- /dev/null +++ b/stacks/web/scripts/dev-server.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Local dev: max25-ws-proxy + PHP built-in server (direct WebSocket to loopback proxy). +set -euo pipefail + +SRC="$(cd "$(dirname "$0")/../../.." && pwd)" +WEB="$SRC/stacks/web" +DOCROOT="$WEB/share/reverse-proxy/docroot/max25-websocket" +PROXY_INI="${MAX25_WEB_PROXY_INI:-$WEB/share/web-proxy.ini.example}" +PHP_HOST="${MAX25_WEB_PHP_HOST:-127.0.0.1}" +PHP_PORT="${MAX25_WEB_PHP_PORT:-8080}" +PROXY_PORT="${MAX25_WEB_PROXY_PORT:-7326}" + +cleanup() { + if [[ -n "${PROXY_PID:-}" ]]; then + kill "$PROXY_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "MAX25 web dev" +echo " max25d expected on 127.0.0.1:7325 (start separately)" +echo " proxy ini: $PROXY_INI" +echo " WebSocket: ws://127.0.0.1:$PROXY_PORT/max25" +echo " PHP UI: http://$PHP_HOST:$PHP_PORT/" +echo "" + +python3 "$WEB/proxy/max25-ws-proxy.py" -c "$PROXY_INI" --port "$PROXY_PORT" & +PROXY_PID=$! +sleep 0.3 + +export MAX25_DEV_WS_URL="ws://127.0.0.1:${PROXY_PORT}/max25" +php -S "$PHP_HOST:$PHP_PORT" -t "$DOCROOT" "$WEB/scripts/dev-router.php" diff --git a/stacks/web/share/reverse-proxy/README.txt b/stacks/web/share/reverse-proxy/README.txt new file mode 100644 index 0000000..43d2748 --- /dev/null +++ b/stacks/web/share/reverse-proxy/README.txt @@ -0,0 +1,10 @@ +MAX25 reverse-proxy examples. WebSocket proxy = M25/1 data on loopback only. + +1. HTTPD_DOCROOT=/srv/www # your httpd document root +2. cp -r docroot/max25-websocket $HTTPD_DOCROOT/ +3. Add nginx/apache/lighttpd snippet (adjust paths) — see docs/WEBSOCKET.md +4. web-proxy.ini — copy share/web-proxy.ini.example to /etc/max25/web-proxy.ini +5. web-terminal.ini — copy share/web-terminal.ini.example to /etc/max25/web-terminal.ini +6. max25-ws-proxy -c /etc/max25/web-proxy.ini (systemd example in share/max25/) + +Match URL paths in web-proxy.ini and the httpd snippets in docs/WEBSOCKET.md. diff --git a/stacks/web/share/reverse-proxy/apache2.conf.example b/stacks/web/share/reverse-proxy/apache2.conf.example new file mode 100644 index 0000000..f702fe0 --- /dev/null +++ b/stacks/web/share/reverse-proxy/apache2.conf.example @@ -0,0 +1,21 @@ +# MAX25 — Apache httpd 2.4. HTTPD_DOCROOT example: /srv/www + +<IfModule mod_proxy.c> + ProxyPreserveHost On + ProxyTimeout 3600 + + ProxyPass "/max25-websocket/ws" "ws://127.0.0.1:7326/max25" timeout=3600 keepalive=On + ProxyPassReverse "/max25-websocket/ws" "ws://127.0.0.1:7326/max25" +</IfModule> + +Alias /max25-websocket /srv/www/max25-websocket +<Directory /srv/www/max25-websocket> + DirectoryIndex index.php + Require all granted + <FilesMatch \.php$> + SetHandler application/x-httpd-php + </FilesMatch> +</Directory> + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/reverse-proxy/docroot/README.txt b/stacks/web/share/reverse-proxy/docroot/README.txt new file mode 100644 index 0000000..a23c84b --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/README.txt @@ -0,0 +1,7 @@ +Copy docroot/max25-websocket/ to HTTPD_DOCROOT (not $PREFIX). +max25-ws-proxy serves M25/1 on loopback only (default 127.0.0.1:7326). + + HTTPD_DOCROOT=/srv/www + cp -r docroot/max25-websocket $HTTPD_DOCROOT/ + +Match URL paths in web-proxy.ini and the httpd snippets in docs/WEBSOCKET.md. diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php b/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php new file mode 100644 index 0000000..7a7f8cf --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php @@ -0,0 +1,98 @@ +<?php +/** + * Browser terminal defaults — merged from web-terminal.ini and max25d.ini. + */ +declare(strict_types=1); + +function max25_web_ini_path(): string +{ + $p = getenv('MAX25_WEB_TERMINAL_INI'); + if ($p !== false && $p !== '') { + return $p; + } + if (is_readable('/etc/max25/web-terminal.ini')) { + return '/etc/max25/web-terminal.ini'; + } + return __DIR__ . '/../../../web-terminal.ini.example'; +} + +function max25_web_parse_bool(string $v, bool $default): bool +{ + $v = strtolower(trim($v)); + if ($v === '') { + return $default; + } + return in_array($v, ['1', 'yes', 'true', 'on'], true); +} + +/** @return array{device:string,callerid:string,callid:string,ax25_ui:bool,connect_on_start:bool} */ +function max25_web_session_defaults(): array +{ + $defaults = [ + 'device' => '', + 'callerid' => '', + 'callid' => '', + 'ax25_ui' => true, + 'connect_on_start' => true, + ]; + + $web_ini = max25_web_ini_path(); + $web = @parse_ini_file($web_ini, true, INI_SCANNER_RAW); + $sec = []; + if (is_array($web)) { + $sec = $web['session'] ?? []; + if (isset($sec['device'])) { + $defaults['device'] = trim((string)$sec['device']); + } + if (isset($sec['callerid']) && trim((string)$sec['callerid']) !== '') { + $defaults['callerid'] = strtoupper(trim((string)$sec['callerid'])); + } + if (isset($sec['callid']) && trim((string)$sec['callid']) !== '') { + $defaults['callid'] = strtoupper(trim((string)$sec['callid'])); + } + if (isset($sec['ax25_ui'])) { + $defaults['ax25_ui'] = max25_web_parse_bool((string)$sec['ax25_ui'], true); + } + if (isset($sec['connect_on_start'])) { + $defaults['connect_on_start'] = max25_web_parse_bool((string)$sec['connect_on_start'], true); + } + $max25d_ini = trim((string)($web['max25-web-terminal']['max25d_ini'] ?? '')); + } else { + $max25d_ini = ''; + } + + if ($max25d_ini === '') { + $env = getenv('MAX25D_INI'); + $max25d_ini = ($env !== false && $env !== '') ? $env : '/etc/max25/max25d.ini'; + } + if (!is_readable($max25d_ini)) { + return $defaults; + } + + $md = @parse_ini_file($max25d_ini, true, INI_SCANNER_RAW); + if (!is_array($md)) { + return $defaults; + } + + $modem = $md['modem'] ?? []; + $devices = $md['devices'] ?? []; + + if ($defaults['device'] === '') { + if (isset($devices['default'])) { + $defaults['device'] = trim((string)$devices['default']); + } elseif (isset($md['daemon']['device'])) { + $defaults['device'] = trim((string)$md['daemon']['device']); + } + } + if ($defaults['callerid'] === '' && isset($modem['callerid'])) { + $defaults['callerid'] = strtoupper(trim((string)$modem['callerid'])); + } + if ($defaults['callid'] === '' && isset($modem['callid'])) { + $defaults['callid'] = strtoupper(trim((string)$modem['callid'])); + } + if (!isset($sec['ax25_ui']) && isset($modem['ax25_ui'])) { + $defaults['ax25_ui'] = max25_web_parse_bool((string)$modem['ax25_ui'], true); + } + + return $defaults; +} diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php b/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php new file mode 100644 index 0000000..9876c61 --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php @@ -0,0 +1,180 @@ +<?php +/** + * MAX25 browser terminal — remote operator UI (M25/1 via WebSocket proxy). + */ +require_once __DIR__ . '/config.php'; + +$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/'); +$ws_path = ($base !== '' ? $base : '/max25-websocket') . '/ws'; +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$dev_ws = getenv('MAX25_DEV_WS_URL'); +$ws_url = ($dev_ws !== false && $dev_ws !== '') ? $dev_ws : ($scheme . '://' . $host . $ws_path); +$session_defaults = max25_web_session_defaults(); +?><!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>MAX25 Terminal</title> + <style> + html, body { height: 100%; margin: 0; } + body { + display: flex; + flex-direction: column; + background: #0a0a12; + color: #c8d0d8; + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + font-size: 14px; + } + #topbar { + flex: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem 1rem; + padding: .45rem .75rem; + background: #12121c; + border-bottom: 1px solid #2a2a38; + z-index: 2; + } + #topbar .brand { font-weight: 600; color: #e8ecf0; margin-right: .25rem; } + #topbar .field { color: #9aa4b2; font-size: .85rem; } + #topbar .field b { color: #dce2ea; font-weight: 500; } + #conn-status { margin-left: auto; font-size: .8rem; color: #8a94a4; } + #menu-wrap { position: relative; } + #menu-btn { + background: #1e1e2e; + border: 1px solid #3a3a4a; + color: #c8d0d8; + font: inherit; + font-size: .85rem; + padding: .25rem .65rem; + border-radius: 4px; + cursor: pointer; + } + #menu-btn:hover, #menu-btn[aria-expanded="true"] { background: #262638; } + #menu-panel { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 15rem; + background: #161622; + border: 1px solid #3a3a4a; + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0,0,0,.45); + z-index: 10; + padding: .35rem 0; + } + #menu-panel.open { display: block; } + #menu-panel button { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + color: #c8d0d8; + font: inherit; + font-size: .85rem; + padding: .4rem .85rem; + cursor: pointer; + } + #menu-panel button:hover { background: #222230; } + #menu-panel hr { border: none; border-top: 1px solid #2a2a38; margin: .25rem 0; } + #device-select { + background: #1e1e2e; + border: 1px solid #3a3a4a; + color: #dce2ea; + font: inherit; + font-size: .85rem; + padding: .2rem .45rem; + border-radius: 4px; + max-width: 11rem; + } + #term { + flex: 1; + min-height: 0; + box-sizing: border-box; + width: 100%; + margin: 0; + padding: .75rem 1rem; + white-space: pre-wrap; + overflow-y: auto; + overflow-x: hidden; + } + #input-bar { + flex: none; + display: flex; + width: 100%; + box-sizing: border-box; + border-top: 1px solid #2a2a38; + background: #12121c; + } + #cmd { + flex: 1; + width: 100%; + min-width: 0; + box-sizing: border-box; + border: none; + border-radius: 0; + background: #12121c; + color: #e0e4e8; + font: inherit; + font-size: 1rem; + line-height: 1.4; + padding: .75rem 1rem; + outline: none; + } + #cmd::placeholder { color: #6a7280; } + #cmd:focus { background: #161622; } + #cmd:disabled { opacity: .55; cursor: not-allowed; } + .hint { font-size: .75rem; color: #6a7280; padding: 0 .75rem .35rem; } + </style> +</head> +<body> + <header id="topbar"> + <span class="brand">MAX25</span> + <div id="menu-wrap"> + <button type="button" id="menu-btn" aria-expanded="false" aria-haspopup="true">Menu ▾</button> + <div id="menu-panel" role="menu"> + <button type="button" data-action="callerid">/callerid — Change CALLERID</button> + <button type="button" data-action="callid">/callid — Change CALLID</button> + <button type="button" data-action="status">/status — GET STATUS</button> + <button type="button" data-action="send">/send — Send line</button> + <button type="button" data-action="monitor">/monitor — Toggle MONITOR</button> + <button type="button" data-action="connect">/connect — Connection on/off</button> + <hr> + <button type="button" data-action="devices">/devices — List devices</button> + <button type="button" data-action="ax25_ui">/ax25_ui — Toggle AX.25 UI</button> + <hr> + <button type="button" data-action="help">/help — Commands</button> + </div> + </div> + <span class="field">DEVICE: <b id="hdr-device">-</b></span> + <label class="field" for="device-select">select + <select id="device-select" disabled title="max25d registered devices only"> + <option value="">—</option> + </select> + </label> + <span class="field">CALLERID: <b id="hdr-callerid">-</b></span> + <span class="field">CALLID: <b id="hdr-callid">-</b></span> + <span class="field">ax25-ui: <b id="hdr-ax25">-</b></span> + <span class="field">connected: <b id="hdr-connected">-</b></span> + <span id="conn-status">disconnected</span> + </header> + <pre id="term"></pre> + <form id="input-bar" autocomplete="off"> + <input type="text" id="cmd" disabled + autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" + enterkeyhint="send" + placeholder="Type to SEND — or /help for commands"> + </form> + <div class="hint">One device per session · /device <id> · plain text = SEND · only max25d devices</div> + <script> + window.MAX25_WS_URL = <?php echo json_encode($ws_url, JSON_UNESCAPED_SLASHES); ?>; + window.MAX25_SESSION_DEFAULTS = <?php echo json_encode($session_defaults, JSON_UNESCAPED_SLASHES); ?>; + </script> + <script src="max25-terminal.js"></script> +</body> +</html> diff --git a/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js new file mode 100644 index 0000000..9988a77 --- /dev/null +++ b/stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js @@ -0,0 +1,609 @@ +(function () { + 'use strict'; + + var term = document.getElementById('term'); + var connStatus = document.getElementById('conn-status'); + var form = document.getElementById('input-bar'); + var input = document.getElementById('cmd'); + var menuBtn = document.getElementById('menu-btn'); + var menuPanel = document.getElementById('menu-panel'); + var deviceSelect = document.getElementById('device-select'); + var hdrDevice = document.getElementById('hdr-device'); + var hdrCallerid = document.getElementById('hdr-callerid'); + var hdrCallid = document.getElementById('hdr-callid'); + var hdrAx25 = document.getElementById('hdr-ax25'); + var hdrConnected = document.getElementById('hdr-connected'); + + var wsUrl = window.MAX25_WS_URL; + var defaults = window.MAX25_SESSION_DEFAULTS || {}; + var ws = null; + var reconnectTimer = null; + var reconnectDelayMs = 2000; + var reconnectAttempt = 0; + var lineBuf = ''; + var rxLines = []; + var RX_MAX = 200; + var waitingReply = false; + var pendingCmd = null; + var devices = []; + var sessionReady = false; + var bootstrapQueue = []; + + var status = { + device: '', + callerid: '', + callid: '', + ax25_ui: true, + connected: false, + monitor: false, + stack: '' + }; + + function classifyLine(line) { + if (!line) { + return 'ignore'; + } + if (line === 'OK') { + return 'ok'; + } + if (line.indexOf('ERR ') === 0) { + return 'err'; + } + if (line.indexOf('STATUS ') === 0) { + return 'status'; + } + if (line.indexOf('RX ') === 0) { + return 'rx'; + } + if (line.indexOf('EVENT ') === 0) { + return 'event'; + } + if (line.indexOf('DEVICE ') === 0) { + return 'device'; + } + return 'other'; + } + + function parseKvLine(payload) { + var out = {}; + var parts = payload.split(' '); + var i; + for (i = 0; i < parts.length; i++) { + var eq = parts[i].indexOf('='); + if (eq > 0) { + out[parts[i].slice(0, eq)] = parts[i].slice(eq + 1); + } + } + return out; + } + + function parseStatus(line) { + var kv = parseKvLine(line.slice(7)); + if (kv.device !== undefined) { + status.device = kv.device; + } + if (kv.callerid !== undefined) { + status.callerid = kv.callerid; + } + if (kv.callid !== undefined) { + status.callid = kv.callid; + } + if (kv.ax25_ui !== undefined) { + status.ax25_ui = kv.ax25_ui === 'on'; + } + if (kv.connected !== undefined) { + status.connected = kv.connected === 'yes'; + } + if (kv.stack !== undefined) { + status.stack = kv.stack; + } + updateHeader(); + } + + function parseDeviceLine(line) { + var kv = parseKvLine(line.slice(7)); + if (!kv.id) { + return null; + } + return { + id: kv.id, + hardware: kv.hardware || '', + serial: kv.serial || '', + stack: kv.stack || '', + enabled: kv.enabled || 'yes', + error: kv.error || '', + voice: kv.voice || '' + }; + } + + function validCallsign(value) { + if (!value) { + return false; + } + var m = /^([A-Z0-9]{1,6})(?:-([0-9]{1,2}))?$/i.exec(value.trim()); + if (!m) { + return false; + } + if (m[2] !== undefined && parseInt(m[2], 10) > 15) { + return false; + } + return true; + } + + function updateHeader() { + hdrDevice.textContent = status.device || '-'; + hdrCallerid.textContent = status.callerid || '-'; + hdrCallid.textContent = status.callid || '-'; + hdrAx25.textContent = status.ax25_ui ? 'on' : 'off'; + hdrConnected.textContent = status.connected ? 'yes' : 'no'; + } + + function refreshDeviceSelect() { + var html = ''; + var i; + if (devices.length === 0) { + deviceSelect.innerHTML = '<option value="">—</option>'; + deviceSelect.disabled = true; + return; + } + for (i = 0; i < devices.length; i++) { + var d = devices[i]; + var sel = d.id === status.device ? ' selected' : ''; + html += '<option value="' + escapeAttr(d.id) + '"' + sel + '>' + + escapeHtml(d.id) + '</option>'; + } + deviceSelect.innerHTML = html; + deviceSelect.disabled = !sessionReady; + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); + } + + function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, '"'); + } + + function appendRx(text) { + var lines = String(text).split('\n'); + var i; + for (i = 0; i < lines.length; i++) { + if (lines[i] === '' && i === lines.length - 1) { + continue; + } + rxLines.push(lines[i]); + if (rxLines.length > RX_MAX) { + rxLines.shift(); + } + } + term.textContent = rxLines.join('\n') + (rxLines.length ? '\n' : ''); + term.scrollTop = term.scrollHeight; + } + + function setConnStatus(s) { + connStatus.textContent = s; + } + + function setInputEnabled(enabled) { + input.disabled = !enabled; + if (enabled) { + input.focus(); + } + } + + function sendRaw(cmd) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return false; + } + ws.send(cmd + '\n'); + return true; + } + + function sendCmd(cmd, label) { + pendingCmd = label || cmd; + waitingReply = true; + return sendRaw(cmd); + } + + function applyEvent(line) { + if (line === 'EVENT connected') { + status.connected = true; + updateHeader(); + return true; + } + if (line === 'EVENT disconnected') { + status.connected = false; + updateHeader(); + return true; + } + return false; + } + + var bootstrapped = false; + + function handleLine(line) { + var kind = classifyLine(line); + + if (kind === 'rx') { + appendRx(line.slice(3)); + return; + } + + if (kind === 'event') { + if (applyEvent(line)) { + appendRx(line); + } + return; + } + + if (waitingReply) { + if (kind === 'status') { + parseStatus(line); + appendRx('STATUS device=' + (status.device || '-') + + ' stack=' + (status.stack || '-') + + ' connected=' + (status.connected ? 'yes' : 'no') + + ' callerid=' + (status.callerid || '-') + + ' callid=' + (status.callid || '-')); + return; + } + if (kind === 'device') { + var dev = parseDeviceLine(line); + if (dev) { + devices.push(dev); + } + if (pendingCmd === 'GET DEVICES') { + appendRx(line); + } + return; + } + if (kind === 'ok') { + waitingReply = false; + if (pendingCmd === 'GET DEVICES') { + refreshDeviceSelect(); + } + pendingCmd = null; + if (bootstrapQueue.length > 0) { + window.setTimeout(runBootstrapStep, 0); + } + return; + } + if (kind === 'err') { + waitingReply = false; + appendRx(line); + pendingCmd = null; + if (bootstrapQueue.length > 0) { + bootstrapQueue = []; + } + return; + } + return; + } + + if (kind === 'status') { + parseStatus(line); + if (!bootstrapped) { + bootstrapped = true; + startBootstrap(); + } + return; + } + if (kind === 'err') { + appendRx(line); + } + } + + function feedText(chunk) { + lineBuf += chunk; + var idx; + while ((idx = lineBuf.indexOf('\n')) >= 0) { + var line = lineBuf.slice(0, idx); + lineBuf = lineBuf.slice(idx + 1); + if (line.endsWith('\r')) { + line = line.slice(0, -1); + } + handleLine(line); + } + } + + function queueBootstrap(cmd, label) { + bootstrapQueue.push({ cmd: cmd, label: label || cmd }); + } + + function runBootstrapStep() { + if (!sessionReady || waitingReply || bootstrapQueue.length === 0) { + return; + } + var step = bootstrapQueue.shift(); + sendCmd(step.cmd, step.label); + } + + function startBootstrap() { + bootstrapQueue = []; + devices = []; + refreshDeviceSelect(); + sessionReady = true; + + if (defaults.callerid && defaults.callerid !== status.callerid) { + queueBootstrap('SET CALLERID ' + defaults.callerid.toUpperCase()); + } + if (defaults.callid && defaults.callid !== status.callid) { + queueBootstrap('SET CALLID ' + defaults.callid.toUpperCase()); + } + queueBootstrap('SET AX25_UI ' + (defaults.ax25_ui !== false ? 'on' : 'off')); + if (defaults.device) { + queueBootstrap('SET DEVICE ' + defaults.device); + } + queueBootstrap('GET DEVICES', 'GET DEVICES'); + if (defaults.connect_on_start !== false) { + queueBootstrap('CONNECT'); + } + queueBootstrap('GET STATUS'); + + sessionReady = true; + runBootstrapStep(); + } + + function fetchDevices() { + devices = []; + sendCmd('GET DEVICES', 'GET DEVICES'); + } + + function setDevice(id) { + if (!id) { + return; + } + var known = false; + var i; + for (i = 0; i < devices.length; i++) { + if (devices[i].id === id) { + known = true; + break; + } + } + if (devices.length === 0) { + appendRx('ERR device list empty — run /devices first'); + return; + } + if (!known) { + appendRx('ERR unknown device (not in max25d): ' + id); + return; + } + sendCmd('SET DEVICE ' + id); + window.setTimeout(function () { + sendCmd('GET STATUS'); + }, 50); + } + + function slashHelp() { + appendRx( + '-- MAX25 browser commands (F10 menu equivalent) --\n' + + '/callerid [id] /callid [id] /status\n' + + '/send <text> /monitor [on|off|toggle]\n' + + '/connect /disconnect\n' + + '/device <id> /devices\n' + + '/ax25_ui on|off /help\n' + + 'Plain text (no /) is sent as SEND (like max25-terminal Enter).' + ); + } + + function handleSlash(line) { + var parts = line.slice(1).trim().split(/\s+/); + var cmd = (parts[0] || '').toLowerCase(); + var arg = parts.slice(1).join(' '); + + menuPanel.classList.remove('open'); + menuBtn.setAttribute('aria-expanded', 'false'); + + if (cmd === 'help' || cmd === 'menu') { + slashHelp(); + return; + } + if (cmd === 'callerid' || cmd === 'caller') { + if (arg) { + if (!validCallsign(arg)) { + appendRx('ERR invalid CALLERID'); + return; + } + sendCmd('SET CALLERID ' + arg.toUpperCase()); + } else { + var v = window.prompt('CALLERID', status.callerid || defaults.callerid || ''); + if (v && validCallsign(v)) { + sendCmd('SET CALLERID ' + v.toUpperCase()); + } + } + return; + } + if (cmd === 'callid' || cmd === 'call') { + if (arg) { + if (!validCallsign(arg)) { + appendRx('ERR invalid CALLID'); + return; + } + sendCmd('SET CALLID ' + arg.toUpperCase()); + } else { + var w = window.prompt('CALLID', status.callid || defaults.callid || ''); + if (w && validCallsign(w)) { + sendCmd('SET CALLID ' + w.toUpperCase()); + } + } + return; + } + if (cmd === 'status') { + sendCmd('GET STATUS'); + return; + } + if (cmd === 'send') { + if (!arg) { + arg = window.prompt('Send line', ''); + } + if (arg) { + sendCmd('SEND ' + arg); + } + return; + } + if (cmd === 'monitor') { + var on; + if (arg === 'on' || arg === 'yes' || arg === '1') { + on = true; + } else if (arg === 'off' || arg === 'no' || arg === '0') { + on = false; + } else { + on = !status.monitor; + } + status.monitor = on; + sendCmd('MONITOR ' + (on ? 'on' : 'off')); + appendRx(on ? 'MONITOR on' : 'MONITOR off'); + return; + } + if (cmd === 'connect') { + sendCmd(status.connected ? 'DISCONNECT' : 'CONNECT'); + return; + } + if (cmd === 'disconnect') { + sendCmd('DISCONNECT'); + return; + } + if (cmd === 'devices') { + fetchDevices(); + return; + } + if (cmd === 'device') { + if (!arg) { + fetchDevices(); + appendRx('Pick device from dropdown or: /device <id>'); + return; + } + setDevice(arg); + return; + } + if (cmd === 'ax25_ui' || cmd === 'ax25-ui') { + var flag = (arg || '').toLowerCase(); + if (flag !== 'on' && flag !== 'off') { + flag = status.ax25_ui ? 'off' : 'on'; + } + sendCmd('SET AX25_UI ' + flag); + return; + } + if (cmd === 'quit' || cmd === 'exit') { + if (ws) { + ws.close(); + } + return; + } + appendRx('ERR unknown /command — try /help'); + } + + function scheduleReconnect() { + if (reconnectTimer) { + return; + } + reconnectAttempt += 1; + setConnStatus('reconnecting in ' + (reconnectDelayMs / 1000) + 's'); + reconnectTimer = window.setTimeout(function () { + reconnectTimer = null; + connect(); + }, reconnectDelayMs); + } + + function connect() { + setConnStatus('connecting'); + setInputEnabled(false); + sessionReady = false; + lineBuf = ''; + ws = new WebSocket(wsUrl); + + ws.onopen = function () { + reconnectAttempt = 0; + setConnStatus('connected'); + setInputEnabled(true); + }; + + ws.onmessage = function (ev) { + feedText(ev.data); + }; + + ws.onclose = function (ev) { + var reason = ev.reason ? ' (' + ev.reason + ')' : ''; + appendRx('\n[websocket disconnected: code ' + ev.code + reason + ']\n'); + setConnStatus('disconnected: ' + ev.code); + ws = null; + sessionReady = false; + setInputEnabled(false); + deviceSelect.disabled = true; + scheduleReconnect(); + }; + + ws.onerror = function () { + setConnStatus('error'); + setInputEnabled(false); + }; + } + + form.addEventListener('submit', function (ev) { + ev.preventDefault(); + if (!ws || ws.readyState !== WebSocket.OPEN || waitingReply) { + return; + } + var text = input.value; + input.value = ''; + if (!text) { + return; + } + if (text.charAt(0) === '/') { + handleSlash(text); + return; + } + sendCmd('SEND ' + text); + }); + + menuBtn.addEventListener('click', function (ev) { + ev.stopPropagation(); + var open = menuPanel.classList.toggle('open'); + menuBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + + document.addEventListener('click', function () { + menuPanel.classList.remove('open'); + menuBtn.setAttribute('aria-expanded', 'false'); + }); + + menuPanel.addEventListener('click', function (ev) { + ev.stopPropagation(); + var btn = ev.target.closest('button[data-action]'); + if (!btn) { + return; + } + var action = btn.getAttribute('data-action'); + if (action === 'callerid') { + handleSlash('/callerid'); + } else if (action === 'callid') { + handleSlash('/callid'); + } else if (action === 'status') { + handleSlash('/status'); + } else if (action === 'send') { + handleSlash('/send'); + } else if (action === 'monitor') { + handleSlash('/monitor toggle'); + } else if (action === 'connect') { + handleSlash('/connect'); + } else if (action === 'devices') { + handleSlash('/devices'); + } else if (action === 'ax25_ui') { + handleSlash('/ax25_ui'); + } else if (action === 'help') { + handleSlash('/help'); + } + }); + + deviceSelect.addEventListener('change', function () { + if (deviceSelect.value) { + setDevice(deviceSelect.value); + } + }); + + updateHeader(); + connect(); +})(); diff --git a/stacks/web/share/reverse-proxy/lighttpd.conf.example b/stacks/web/share/reverse-proxy/lighttpd.conf.example new file mode 100644 index 0000000..d1e2c15 --- /dev/null +++ b/stacks/web/share/reverse-proxy/lighttpd.conf.example @@ -0,0 +1,27 @@ +# MAX25 — lighttpd 1.4. HTTPD_DOCROOT example: /srv/www + +# Raise idle limits for upgraded WebSocket connections. +server.max-read-idle = 3600 +server.max-write-idle = 3600 + +$HTTP["url"] == "/max25-websocket/ws" { + proxy.server = ( "" => ( + "max25" => ( + "proxy" => "http://127.0.0.1:7326/max25", + "upgrade" => "enable" + ) + )) + proxy.header = ( "upgrade" => "enable" ) +} + +alias.url += ( "/max25-websocket/" => "/srv/www/max25-websocket/" ) +index-file.names += ( "index.php" ) + +fastcgi.server += ( ".php" => + ( "localhost" => + ( "socket" => "/run/php-fpm/www.sock" ) + ) +) + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/reverse-proxy/nginx.conf.example b/stacks/web/share/reverse-proxy/nginx.conf.example new file mode 100644 index 0000000..ef49632 --- /dev/null +++ b/stacks/web/share/reverse-proxy/nginx.conf.example @@ -0,0 +1,27 @@ +# MAX25 — nginx. Set HTTPD_DOCROOT (example: /srv/www). + +location = /max25-websocket/ws { + proxy_pass http://127.0.0.1:7326/max25; + proxy_http_version 1.1; + proxy_socket_keepalive on; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location /max25-websocket/ { + root /srv/www; + index index.php; + location ~ \.php$ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:/run/php-fpm/www.sock; + } +} + +# UI: cp -r reverse-proxy/docroot/max25-websocket $HTTPD_DOCROOT/ +# Proxy: max25-ws-proxy -c /etc/max25/web-proxy.ini diff --git a/stacks/web/share/web-proxy.ini.example b/stacks/web/share/web-proxy.ini.example new file mode 100644 index 0000000..89a755f --- /dev/null +++ b/stacks/web/share/web-proxy.ini.example @@ -0,0 +1,15 @@ +; MAX25 WebSocket forward-proxy (loopback only) +; Copy to /etc/max25/web-proxy.ini or pass -c / MAX25_WEB_PROXY_INI= + +[proxy] +bind = 127.0.0.1 +port = 7326 +path = /max25 +max_connections = 10 + +[upstream] +; max25d M25/1 TCP endpoint +host = 127.0.0.1 +port = 7325 +; Match max25d.ini [network] tcp_password when set (proxy performs AUTH once) +tcp_password = diff --git a/stacks/web/share/web-terminal.ini.example b/stacks/web/share/web-terminal.ini.example new file mode 100644 index 0000000..d259b66 --- /dev/null +++ b/stacks/web/share/web-terminal.ini.example @@ -0,0 +1,18 @@ +; MAX25 browser terminal — session defaults (per WebSocket connection). +; Copy to /etc/max25/web-terminal.ini or set MAX25_WEB_TERMINAL_INI. +; +; Values mirror max25d [modem] + [devices] default. Omitted keys are read from +; max25d_ini when set. Device list at runtime always comes from GET DEVICES. + +[max25-web-terminal] +max25d_ini = /etc/max25/max25d.ini + +[session] +; One device per session — must be an id registered in max25d [devices]. +; Empty = use [devices] default from max25d.ini. +device = +callerid = CB-0 +callid = QST +ax25_ui = yes +; CONNECT after session setup (same as max25-terminal) +connect_on_start = yes |
