diff options
| author | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
|---|---|---|
| committer | info@mode42.com <info@mode42.com> | 2026-08-07 18:25:13 +0000 |
| commit | 04d965d67a7264a1c7c211494aebda1953df7603 (patch) | |
| tree | 0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/web/share/reverse-proxy | |
Initial push
Diffstat (limited to 'stacks/web/share/reverse-proxy')
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 <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 |
