summaryrefslogtreecommitdiff
path: root/stacks/web/share/reverse-proxy
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/share/reverse-proxy
Initial push
Diffstat (limited to 'stacks/web/share/reverse-proxy')
-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
8 files changed, 979 insertions, 0 deletions
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
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com