summaryrefslogtreecommitdiff
path: root/stacks/web
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
commit04d965d67a7264a1c7c211494aebda1953df7603 (patch)
tree0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/web
Initial push
Diffstat (limited to 'stacks/web')
-rw-r--r--stacks/web/CMakeLists.txt42
-rw-r--r--stacks/web/README.md112
-rwxr-xr-xstacks/web/proxy/max25-ws-proxy.py399
-rwxr-xr-xstacks/web/proxy/test_ws_proxy.py237
-rw-r--r--stacks/web/scripts/dev-router.php21
-rwxr-xr-xstacks/web/scripts/dev-server.sh32
-rw-r--r--stacks/web/share/reverse-proxy/README.txt10
-rw-r--r--stacks/web/share/reverse-proxy/apache2.conf.example21
-rw-r--r--stacks/web/share/reverse-proxy/docroot/README.txt7
-rw-r--r--stacks/web/share/reverse-proxy/docroot/max25-websocket/config.php98
-rw-r--r--stacks/web/share/reverse-proxy/docroot/max25-websocket/index.php180
-rw-r--r--stacks/web/share/reverse-proxy/docroot/max25-websocket/max25-terminal.js609
-rw-r--r--stacks/web/share/reverse-proxy/lighttpd.conf.example27
-rw-r--r--stacks/web/share/reverse-proxy/nginx.conf.example27
-rw-r--r--stacks/web/share/web-proxy.ini.example15
-rw-r--r--stacks/web/share/web-terminal.ini.example18
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 &lt;id&gt; · 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, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+ }
+
+ function escapeAttr(s) {
+ return escapeHtml(s).replace(/"/g, '&quot;');
+ }
+
+ 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
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com