diff --git a/webv4/components/mqtt.xjs b/webv4/components/mqtt.xjs new file mode 100644 index 0000000000000000000000000000000000000000..723573919cfa0b6bcdb9fcc5a674e075549355eb --- /dev/null +++ b/webv4/components/mqtt.xjs @@ -0,0 +1,165 @@ +<? + // Ensure the user is a sysop, mqtt broker handles sensitive info that should not be available to non-sysop users + // (Ideally the page loading this component will check for SysOp status, but it's included here to be super safe) + if (!user.is_sysop) { +?> + <!-- TODOZ Translate --> + <div class="alert alert-danger"><h3>You must be a SysOp to use MQTT-based features.</h3></div> +<? + exit(); + } + + var request = require({}, settings.web_lib + 'request.js', 'request'); + + // Check for form posting back mqtt ports + var mqtt_post_error = ''; + if (request.get_param('mqtt_ws_port') && request.get_param('mqtt_wss_port')) { + var new_ws_port = parseInt(request.get_param('mqtt_ws_port'), 10); + var new_wss_port = parseInt(request.get_param('mqtt_wss_port'), 10); + + if ((new_ws_port >= 1) && (new_ws_port <= 65535) && (new_wss_port >= 1) && (new_wss_port <= 65535)) { + var f = new File(system.ctrl_dir + 'modopts.ini'); + f.open("r+"); + f.iniSetValue('web', 'mqtt_ws_port', new_ws_port); + f.iniSetValue('web', 'mqtt_wss_port', new_wss_port); + f.close(); + + settings.mqtt_ws_port = new_ws_port; + settings.mqtt_wss_port = new_wss_port; + } else { + mqtt_post_error = 'ERROR: Ports must be in the range of 1 to 65535'; + } + } + + // Read mqtt settings + var f = new File(system.ctrl_dir + 'main.ini'); + f.open("r"); + var broker_addr = f.iniGetValue('MQTT', 'Broker_addr', 'localhost'); + var broker_enabled = f.iniGetValue('MQTT', 'Enabled', false); + var broker_password = f.iniGetValue('MQTT', 'Password', ''); + var broker_username = f.iniGetValue('MQTT', 'Username', ''); + var broker_ws_port = settings.mqtt_ws_port; + var broker_wss_port = settings.mqtt_wss_port; + f.close(); + + // Abort if the mqtt broker is not enabled + if (!broker_enabled) { +?> + <!-- TODOZ Translate --> + <div class="alert alert-danger"><h3>You must enable the MQTT broker in SCFG -> Networks -> MQTT before you can use the node spy</h3></div> +<? + exit(); + } + + // Display configuration form if mqtt ws/wss ports are not set yet + if (!broker_ws_port || !broker_wss_port) { +?> + <!-- TODOZ Translate --> + <div class="container"> + <div class="col-lg-6 col-lg-offset-3 col-md-7 col-md-offset-3 col-sm-9 col-sm-offset-2"> + <div class="panel panel-default"> + <div class="panel-heading"> + <h3>Missing MQTT port settings</h3> + </div> + <div class="panel-body"> + <p>Please provide the WebSocket ports your MQTT server is listening on:</p> + + <form method="POST" class="form-horizontal"> + <? if (mqtt_post_error) { ?> + <div class="alert alert-danger"><? write(mqtt_post_error); ?></div> + <? } ?> + + <div class="form-group"> + <label for="mqtt_ws_port" class="col-sm-3 control-label">WS:// port</label> + <div class="col-sm-9"> + <input type="text" id="mqtt_ws_port" name="mqtt_ws_port" class="form-control" value="<? write(broker_ws_port === undefined ? '' : broker_ws_port); ?>" /> + </div> + </div> + + <div class="form-group"> + <label for="mqtt_wss_port" class="col-sm-3 control-label">WSS:// port</label> + <div class="col-sm-9"> + <input type="text" id="mqtt_wss_port" name="mqtt_wss_port" class="form-control" value="<? write(broker_wss_port === undefined ? '' : broker_wss_port); ?>" /> + </div> + </div> + + <div class="form-group"> + <div class="col-sm-offset-3 col-sm-9"> + <button class="btn btn-primary">Submit</button> + </div> + </div> + </form> + </div> + <div class="panel-footer"> + <p> + Alternatively, you can edit <strong><? write(system.ctrl_dir + 'modopts.ini'); ?></strong> and add <strong>mqtt_ws_port</strong> and + <strong>mqtt_wss_port</strong> keys to the <strong>[web]</strong> section. + </p> + </div> + </div> + </div> + </div> +<? + exit(); + } +?> + + + +<!-- A hidden-by-default div with a message shown when ws:// connections fail --> +<!-- TODOZ Translate --> +<div class="alert alert-danger" id="mqtt-ws-reconnecting-message" style="display: none;"> + <p class="lead"> + Looks like your browser is having troubles connecting to the MQTT server over ws://. A few common causes: + </p> + + <ul> + <li>The MQTT broker is offline.</li> + <li>The wrong hostname was specified in SCFG -> Networks -> MQTT (currently=<? write(broker_addr); ?>).</li> + <li>The wrong port was specified for the mqtt_ws_port setting in modopts.ini (currently=<? write(broker_ws_port); ?>).</li> + <li>A firewall is blocking the connection from your current location.</li> + </ul> + + <p> + Double-check your settings and try reloading this page. + </p> +</div> + +<!-- A hidden-by-default div witha message sown when wss:// connections fail --> +<!-- TODOZ Translate --> +<div class="alert alert-danger" id="mqtt-wss-reconnecting-message" style="display: none;"> + <p class="lead"> + Looks like your browser is having troubles connecting to the MQTT server over wss://. A few common causes: + </p> + + <ul> + <li>The MQTT broker is offline.</li> + <li>The wrong hostname was specified in SCFG -> Networks -> MQTT (currently=<? write(broker_addr); ?>).</li> + <li>The wrong port was specified for the mqtt_wss_port setting in modopts.ini (currently=<? write(broker_wss_port); ?>).</li> + <li>The SSL cert has expired.</li> + <li>You're connecting to the broker via a hostname not found in the SSL cert.</li> + <li>You're connecting to the broker via an IP address.</li> + <li>A firewall is blocking the connection from your current location.</li> + </ul> + + <p> + You can click the link below to try accessing the MQTT broker in a new tab/window, which might shed some light on what is going wrong, + and the in the case of an untrusted cert, allow you to accept it (e.g. by clicking Proceed in Chrome):<br /> + <a href="" id="mqtt-wss-reconnecting-link" target="_blank">Access the MQTT server</a> + </p> +</div> + + + +<script src="https://unpkg.com/mqtt@5.0.2/dist/mqtt.min.js"></script> + +<script> + // Set javascript variables for system variables referenced by mqtt.js + var system_qwk_id = '<? write(system.qwk_id); ?>'; + var broker_addr = '<? write(broker_addr); ?>'; + var broker_password = '<? write(broker_password); ?>'; + var broker_username = '<? write(broker_username); ?>'; + var broker_ws_port = <? write(broker_ws_port); ?>; + var broker_wss_port = <? write(broker_wss_port); ?>; +</script> +<script src="./js/mqtt.js"></script> diff --git a/webv4/pages/More/001-nodespy.xjs b/webv4/pages/More/001-nodespy.xjs new file mode 100644 index 0000000000000000000000000000000000000000..fd85ec66142abe7948bdb8d5d6dd1e1e50bd13b9 --- /dev/null +++ b/webv4/pages/More/001-nodespy.xjs @@ -0,0 +1,52 @@ +<!--NO_SIDEBAR:Node Spy--> + +<? + // Ensure the user is a sysop to avoid normal users from spying + if (!user.is_sysop) { +?> + <div class="alert alert-danger"><h3>You must be a SysOp to view the node spy</h3></div> <!-- TODOX Translate --> +<? + exit(); + } + + // Load fTelnet-related files + load(settings.web_lib + 'ftelnet.js'); + load('ftelnethelper.js'); +?> + +<style> + .fTelnetStatusBar { display : none !important; } /* Don't show the status bar, we only want to use fTelnet for display purposes not connect purposes */ + div.active { background-color: #ddd; padding-bottom: 15px; } /* Highlight the div that has keyboard focus */ + label.normal { font-weight: normal !important; margin-bottom: 0 !important; } /* Sanely format the label elements */ + + /* Introduce a col-xl-6 to display two columns on large screens -- allows seeing all 4 of my nodes at once */ + .col-xl-6 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; + } + @media (min-width: 1600px) { + .col-xl-6 { + float: left; + width: 50%; + } + } +</style> + +<h1 align="center">Node Spy</h1> + +<? loadComponent('mqtt.xjs'); ?> + +<!-- Multiple fTelnet instances (one per node) will be added to this wrapper div --> +<div id="ClientsWrapper"></div> + +<script id="fTelnetScript" src="<? write(get_url()); ?>"></script> +<script src="./js/utf8_cp437.js"></script> + +<script> + // Set javascript variables for system variables referenced by nodespy.js + var system_nodes = <? write(system.nodes); ?>; + var system_qwk_id = '<? write(system.qwk_id); ?>'; +</script> +<script src="./js/nodespy.js"></script> diff --git a/webv4/pages/More/002-nodespy.xjs b/webv4/pages/More/002-nodespy.xjs deleted file mode 100644 index 692a3c8a1cbbe3d91964b321a8a3aa643631bbbb..0000000000000000000000000000000000000000 --- a/webv4/pages/More/002-nodespy.xjs +++ /dev/null @@ -1,346 +0,0 @@ -<!--NO_SIDEBAR:Node Spy--> - -<?xjs - // Ensure the user is a sysop to avoid normal users from spying - if (!user.is_sysop) { -?> - <div class="alert alert-danger"><h3>You must be a SysOp to view the node spy</h3></div> <!-- TODOX Translate --> -<?xjs - exit(); - } - - // Read mqtt settings - var f = new File(system.ctrl_dir + 'main.ini'); - f.open("r"); - var broker_addr = f.iniGetValue('MQTT', 'Broker_addr', 'localhost'); - var broker_enabled = f.iniGetValue('MQTT', 'Enabled', false); - var broker_password = f.iniGetValue('MQTT', 'Password', ''); - var broker_username = f.iniGetValue('MQTT', 'Username', ''); - var broker_ws_port = settings.mqtt_ws_port; - var broker_wss_port = settings.mqtt_wss_port; - - // Abort if the mqtt broker is not enabled - if (!broker_enabled) { -?> - <div class="alert alert-danger"><h3>You must enable the MQTT broker in SCFG -> Networks -> MQTT before you can use the node spy</h3></div> <!-- TODOX Translate --> -<?xjs - exit(); - } - - // Abort if no ws(s) port set - if (!broker_ws_port || !broker_wss_port) { -?> - <div class="alert alert-danger"><h3>You must specify the MQTT broker's WS (mqtt_ws_port) and WSS (mqtt_wss_port) ports in the [web] section of <?xjs write(system.ctrl_dir + 'modopts.ini'); ?> before you can use the node spy</h3></div> <!-- TODOX Translate --> -<?xjs - exit(); - } - - // Load fTelnet-related files - load(settings.web_lib + 'ftelnet.js'); - load('ftelnethelper.js'); -?> - -<style> - .fTelnetStatusBar { display : none !important; } /* Don't show the status bar, we only want to use fTelnet for display purposes not connect purposes */ - div.active { background-color: #ddd; padding-bottom: 15px; } /* Highlight the div that has keyboard focus */ - label { font-weight: normal !important; margin-bottom: 0 !important; } /* Sanely format the label elements */ - - /* Introduce a col-xl-6 to display two columns on large screens -- allows seeing all 4 of my nodes at once */ - .col-xl-6 { - position: relative; - min-height: 1px; - padding-right: 15px; - padding-left: 15px; - } - @media (min-width: 1600px) { - .col-xl-6 { - float: left; - width: 50%; - } - } -</style> - -<h1 align="center">Node Spy</h1> - -<!-- A hidden-by-default div with a link to access the MQTT server in the browser, to see if the cert is trusted --> -<div id="HttpsMqttDiv" style="display: none;"> - <!-- TODOX Translate --> - Looks like your browser is having troubles connecting to the MQTT server over WSS. A few common causes: - <ul> - <li>The MQTT broker is offline</li> - <li>The SSL cert has expired</li> - <li>You're connecting to the broker via a hostname not found in the SSL cert</li> - <li>You're connecting to the broker via an IP address</li> - </ul> - You can click the link below to try accessing the MQTT broker in a new tab/window, which might shed some light on what is going wrong, and the in the case of an - untrusted cert, allow you to accept it (eg clicking Proceed in Chrome):<br /> - <a href="" id="HttpsMqttLink" target="_blank">Access the MQTT server</a> -</div> - -<!-- Multiple fTelnet instances (one per node) will be added to this wrapper div --> -<div id="ClientsWrapper"></div> - -<script id="fTelnetScript" src="<?xjs write(get_url()); ?>"></script> -<script src="https://unpkg.com/mqtt@5.0.2/dist/mqtt.min.js"></script> -<script src="./js/utf8_cp437.js"></script> -<script> - var charsets = []; - var fTelnetControls = []; - var mqttClient; - - // Check if we're targeting a specific node - const urlParams = new URLSearchParams(window.location.search); - const targetNode = parseInt(urlParams.get('node'), 10); - - // Connect to the mqtt broker and subscribe to some topics - function connect_to_mqtt_broker() { - const options = { - keepalive: 60, - clientId: '<?xjs write(system.qwk_id); ?>' + Math.random().toString(16).substr(2, 8), - username: '<?xjs write(broker_username); ?>', - password: '<?xjs write(broker_password); ?>', - protocolId: 'MQTT', - protocolVersion: 4, - clean: true, - reconnectPeriod: 5000, - connectTimeout: 30 * 1000, - will: { - topic: 'WillMsg', - payload: 'Connection Closed abnormally..!', - qos: 0, - retain: false - }, - } - - // Build the host string to connect to based on whether ws:// or wss:// is required - var protocol = location.protocol === 'https:' ? 'wss' : 'ws'; - var port = location.protocol === 'https:' ? <?xjs write(broker_wss_port); ?> : <?xjs write(broker_ws_port); ?>; - const host = protocol + '://<?xjs write(broker_addr) ?>:' + port + '/mqtt'; - - // Set the debugging link to a modified version of the host string - $('#HttpsMqttLink').attr('href', host.replace('wss://', 'https://')); - - // Connect to the mqtt broker - log_ftelnet('Connecting to mqtt broker (' + host + ')'); - mqttClient = mqtt.connect(host, options); - - // Subscribe to topics on connect - mqttClient.on('connect', () => { - log_ftelnet('Connected'); - $('#HttpsMqttDiv').hide(); - - for (var node in fTelnetControls) { - mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '', { qos: 2 }); - mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/output', { qos: 2 }); - mqttClient.subscribe('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/terminal', { qos: 2 }); - } - }); - - // Display an error when connection fails - mqttClient.on('error', (err) => { - log_ftelnet('Connection error: ', err); - mqttClient.end(); - }); - - // Handle messages for subscribed topics - mqttClient.on('message', (topic, message, packet) => { - var match = topic.match(/node[\/](\d*)([\/](output|terminal))?/); - var node = match[1]; - var messageType = match[2]; - - switch (messageType) { - case undefined: - // message contains the node's current activity (ie waiting for caller, or user is running an external, etc) - $('#Status' + node).html(message.toString()); - break; - - case '/output': - // message contains the raw output being sent to the user, so write it to the fTelnet instance - var buf = new Uint8Array(message); - - var str = ''; - for (var i = 0; i < message.length; i++) { - str += String.fromCharCode(buf[i]); - } - - // UTF-8 decode, if necessary - if (charsets[node] === 'UTF-8') { - str = utf8_cp437(str); - } - - fTelnetControls[node]._Ansi.Write(str); - break; - - case '/terminal': - // message contains tab-separated terminal information, most importantly the column and row count as the first - // and second elements, so resize fTelnet and tell it to reload the best font based on the new size. - var arr = message.toString().split('\t'); - fTelnetControls[node]._Crt.SetScreenSize(parseInt(arr[0], 10), parseInt(arr[1], 10)); - fTelnetControls[node]._Crt.SetFont(fTelnetControls[node]._Crt.Font.Name); - charsets[node] = arr[4]; - // TODOX When arr[4] is 'CBM-ASCII' things get messy -- did I add support for PETSCII to fTelnet? - break; - } - }); - - // Display a message when the connection drops and the client is attempting to reconnect - // Also show the debugging message if https is being used, because the sysop may need to resolve issues with their ssl cert - mqttClient.on('reconnect', () => { - log_ftelnet('Reconnecting...'); - - if (location.protocol === 'https:') { - $('#HttpsMqttDiv').show(); - } - }); - } - - // Create an fTelnet instance for each node - function create_ftelnet_instances() { - // Loop through the node range - for (var node = 1; node <= <?xjs write(system.nodes) ?>; node++) { - if (!targetNode || (targetNode === node)) { - // Build a node-specific link - var nodeUrl = location.href; - if (!nodeUrl.includes("&node=")) { - nodeUrl += '&node=' + node; - } - - // Build a div containing the node status, keyboard checkbox, and fTelnet instance - $('#ClientsWrapper').append(` - <div id="Client${node}" class="${targetNode ? '' : 'col-xl-6'}"> - <h4 style="text-align: center;"> - <a href="${nodeUrl}">Node ${node}</a> - - <span id="Status${node}">Status Unknown</span> - <span style="white-space: nowrap;"> - <input type="checkbox" class="keyboard-input" id="chkKeyboard${node}" value="${node}" /> - <label for="chkKeyboard${node}">Enable Keyboard Input</label> - </span> - </h4> - <div id="fTelnetContainer${node}" class="fTelnetContainer"></div> - </div> - `); - - // And initialize a new fTelnet instance - var Options = new fTelnetOptions(); - Options.AllowModernScrollback = false; - Options.BareLFtoCRLF = false; - Options.BitsPerSecond = 57600; - Options.ConnectionType = 'telnet'; - Options.Emulation = 'ansi-bbs'; - Options.Enter = '\r'; - Options.Font = 'CP437'; - Options.ForceWss = false; - Options.Hostname = 'unused'; - Options.LocalEcho = false; - Options.Port = 11235; - Options.ScreenColumns = 80; - Options.ScreenRows = 25; - Options.SplashScreen = ' '; - fTelnetControls[node] = new fTelnetClient('fTelnetContainer' + node, Options); - charsets[node] = 'CP437'; - } - } - } - - // Setup the keyboard handler - function init_keyboard_handler() { - // Handle clicks on the keyboard checkboxes - $('.keyboard-input').click(function() { - var selectedNode = this.value; - - // Highlight the new selected div, and un-highlight+un-check the other divs and checkboxes - for (var node in fTelnetControls) { - if ((node === selectedNode) && this.checked) { - $('#Client' + node).addClass('active'); - } else { - $('#Client' + node).removeClass('active'); - $('.keyboard-input[value=' + node + ']').removeAttr('checked'); - } - } - }); - - // Handle keydown event, which is where control keys and special keys are handled - // Code is copy/pasted (with slight modifications) from fTelnet - window.addEventListener('keydown', function (ke) { - var node = $('.keyboard-input:checked').attr('value'); - if (node) { - var keyString = ''; - - if (ke.ctrlKey) { - // Handle control + letter keys - if ((ke.keyCode >= 65) && (ke.keyCode <= 90)) { - keyString = String.fromCharCode(ke.keyCode - 64); - } else if ((ke.keyCode >= 97) && (ke.keyCode <= 122)) { - keyString = String.fromCharCode(ke.keyCode - 96); - } - } else { - switch (ke.keyCode) { - // Handle special keys - case KeyboardKeys.BACKSPACE: keyString = '\b'; break; - case KeyboardKeys.DELETE: keyString = '\x7F'; break; - case KeyboardKeys.DOWN: keyString = '\x1B[B'; break; - case KeyboardKeys.END: keyString = '\x1B[K'; break; - case KeyboardKeys.ENTER: keyString = '\r\n'; break; - case KeyboardKeys.ESCAPE: keyString = '\x1B'; break; - case KeyboardKeys.F1: keyString = '\x1BOP'; break; - case KeyboardKeys.F2: keyString = '\x1BOQ'; break; - case KeyboardKeys.F3: keyString = '\x1BOR'; break; - case KeyboardKeys.F4: keyString = '\x1BOS'; break; - case KeyboardKeys.F5: keyString = '\x1BOt'; break; - case KeyboardKeys.F6: keyString = '\x1B[17~'; break; - case KeyboardKeys.F7: keyString = '\x1B[18~'; break; - case KeyboardKeys.F8: keyString = '\x1B[19~'; break; - case KeyboardKeys.F9: keyString = '\x1B[20~'; break; - case KeyboardKeys.F10: keyString = '\x1B[21~'; break; - case KeyboardKeys.F11: keyString = '\x1B[23~'; break; - case KeyboardKeys.F12: keyString = '\x1B[24~'; break; - case KeyboardKeys.HOME: keyString = '\x1B[H'; break; - case KeyboardKeys.INSERT: keyString = '\x1B@'; break; - case KeyboardKeys.LEFT: keyString = '\x1B[D'; break; - case KeyboardKeys.PAGE_DOWN: keyString = '\x1B[U'; break; - case KeyboardKeys.PAGE_UP: keyString = '\x1B[V'; break; - case KeyboardKeys.RIGHT: keyString = '\x1B[C'; break; - case KeyboardKeys.SPACE: keyString = ' '; break; - case KeyboardKeys.TAB: keyString = '\t'; break; - case KeyboardKeys.UP: keyString = '\x1B[A'; break; - } - } - - // If we have a keyString, then publish it so sbbs sees it as input - if (keyString) { - mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', keyString, { qos: 1, retain: false }); - } - - // If we have a keyString, or CTRL is being held, prevent further handling (so the keypress event won't be hit next) - if ((keyString) || (ke.ctrlKey)) { - ke.preventDefault(); - } - } - }); - - // Handle keypress event, which is where standard keys are handled - // Code is copy/pasted (with slight modifications) from fTelnet - window.addEventListener('keypress', function (ke) { - var node = $('.keyboard-input:checked').attr('value'); - if (node) { - if (ke.charCode >= 33) { - mqttClient.publish('sbbs/<?xjs write(system.qwk_id) ?>/node/' + node + '/input', String.fromCharCode(ke.charCode), { qos: 1, retain: false }); - } - } - }); - } - - // Log a message to each of the fTelnet instances - function log_ftelnet(message) { - for (var node in fTelnetControls) { - fTelnetControls[node]._Crt.WriteLn(message); - } - } - - // Add an onload handler to setup the node spy - window.addEventListener('load', (event) => { - create_ftelnet_instances(); - init_keyboard_handler(); - connect_to_mqtt_broker(); - }); -</script> \ No newline at end of file diff --git a/webv4/root/js/mqtt.js b/webv4/root/js/mqtt.js new file mode 100644 index 0000000000000000000000000000000000000000..51d77b3ffd866760a05da60f274482807dbe89a4 --- /dev/null +++ b/webv4/root/js/mqtt.js @@ -0,0 +1,73 @@ +var mqtt_client; + +// Connect to the mqtt broker and subscribe to some topics +function mqtt_connect(topics, message_callback, log_callback) { + const options = { + keepalive: 60, + clientId: system_qwk_id + Math.random().toString(16).substr(2, 8), + username: broker_username, + password: broker_password, + protocolId: 'MQTT', + protocolVersion: 4, + clean: true, + reconnectPeriod: 5000, + connectTimeout: 30 * 1000, + will: { + topic: 'WillMsg', + payload: 'Connection Closed abnormally..!', + qos: 0, + retain: false + }, + }; + + // Build the host string to connect to based on whether ws:// or wss:// is required + var protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + var port = location.protocol === 'https:' ? broker_wss_port : broker_ws_port; + const host = protocol + '://' + broker_addr + ':' + port + '/mqtt'; + + // Set the debugging link to a modified version of the host string + $('#mqtt-wss-reconnecting-link').attr('href', host.replace('wss://', 'https://')); + + // Connect to the mqtt broker + log_callback('Connecting to mqtt broker (' + host + ')'); + mqtt_client = mqtt.connect(host, options); + + // Subscribe to topics on connect + mqtt_client.on('connect', () => { + log_callback('Connected'); + + for (var i = 0; i < topics.length; i++) { + mqtt_client.subscribe(topics[i], { qos: 2 }); + } + + $('#mqtt-ws-reconnecting-message').hide(); + $('#mqtt-wss-reconnecting-message').hide(); + }); + + // Display an error when connection fails + mqtt_client.on('error', (err) => { + log_callback('Connection error: ', err); + mqtt_client.end(); + }); + + // Handle messages for subscribed topics + mqtt_client.on('message', (topic, message, packet) => { + message_callback(topic, message, packet); + }); + + // Display a message when the connection drops and the client is attempting to reconnect + // Also show the debugging message if https is being used, because the sysop may need to resolve issues with their ssl cert + mqtt_client.on('reconnect', () => { + log_callback('Reconnecting...'); + + if (location.protocol === 'https:') { + $('#mqtt-wss-reconnecting-message').show(); + } else { + $('#mqtt-ws-reconnecting-message').show(); + } + }); +} + +function mqtt_publish(topic, value) { + mqtt_client.publish(topic, value, { qos: 1, retain: false }); +} diff --git a/webv4/root/js/nodespy.js b/webv4/root/js/nodespy.js new file mode 100644 index 0000000000000000000000000000000000000000..2f85bae8029022c7d7903bbc3717eb0071a9f847 --- /dev/null +++ b/webv4/root/js/nodespy.js @@ -0,0 +1,204 @@ +var spy_nodes = []; + +// Check if we're targeting a specific node +const urlParams = new URLSearchParams(window.location.search); +const targetNode = parseInt(urlParams.get('node'), 10); + +// Create an fTelnet instance for each node +function create_ftelnet_instances() { + // Loop through the node range + for (var node = 1; node <= system_nodes; node++) { + if (!targetNode || (targetNode === node)) { + // Build a node-specific link + var nodeUrl = location.href; + if (!nodeUrl.includes("&node=")) { + nodeUrl += '&node=' + node; + } + + // Build a div containing the node status, keyboard checkbox, and fTelnet instance + $('#ClientsWrapper').append(` + <div id="Client${node}" class="${targetNode ? '' : 'col-xl-6'}"> + <h4 style="text-align: center;"> + <a href="${nodeUrl}">Node ${node}</a> - + <span id="Status${node}">Status Unknown</span> + <span style="white-space: nowrap;"> + <input type="checkbox" class="keyboard-input" id="chkKeyboard${node}" value="${node}" /> + <label class="normal" for="chkKeyboard${node}">Enable Keyboard Input</label> + </span> + </h4> + <div id="fTelnetContainer${node}" class="fTelnetContainer"></div> + </div> + `); + + // And initialize a new fTelnet instance + var Options = new fTelnetOptions(); + Options.AllowModernScrollback = false; + Options.BareLFtoCRLF = false; + Options.BitsPerSecond = 57600; + Options.ConnectionType = 'telnet'; + Options.Emulation = 'ansi-bbs'; + Options.Enter = '\r'; + Options.Font = 'CP437'; + Options.ForceWss = false; + Options.Hostname = 'unused'; + Options.LocalEcho = false; + Options.Port = 11235; + Options.ScreenColumns = 80; + Options.ScreenRows = 25; + Options.SplashScreen = ' '; + spy_nodes[node] = { + charset: 'CP437', + ftelnet: new fTelnetClient('fTelnetContainer' + node, Options), + }; + } + } +} + +// Setup the keyboard handler +function init_keyboard_handler() { + // Handle clicks on the keyboard checkboxes + $('.keyboard-input').click(function() { + var selectedNode = this.value; + + // Highlight the new selected div, and un-highlight+un-check the other divs and checkboxes + for (var node in spy_nodes) { + if ((node === selectedNode) && this.checked) { + $('#Client' + node).addClass('active'); + } else { + $('#Client' + node).removeClass('active'); + $('.keyboard-input[value=' + node + ']').removeAttr('checked'); + } + } + }); + + // Handle keydown event, which is where control keys and special keys are handled + // Code is copy/pasted (with slight modifications) from fTelnet + window.addEventListener('keydown', function (ke) { + var node = $('.keyboard-input:checked').attr('value'); + if (node) { + var keyString = ''; + + if (ke.ctrlKey) { + // Handle control + letter keys + if ((ke.keyCode >= 65) && (ke.keyCode <= 90)) { + keyString = String.fromCharCode(ke.keyCode - 64); + } else if ((ke.keyCode >= 97) && (ke.keyCode <= 122)) { + keyString = String.fromCharCode(ke.keyCode - 96); + } + } else { + switch (ke.keyCode) { + // Handle special keys + case KeyboardKeys.BACKSPACE: keyString = '\b'; break; + case KeyboardKeys.DELETE: keyString = '\x7F'; break; + case KeyboardKeys.DOWN: keyString = '\x1B[B'; break; + case KeyboardKeys.END: keyString = '\x1B[K'; break; + case KeyboardKeys.ENTER: keyString = '\r\n'; break; + case KeyboardKeys.ESCAPE: keyString = '\x1B'; break; + case KeyboardKeys.F1: keyString = '\x1BOP'; break; + case KeyboardKeys.F2: keyString = '\x1BOQ'; break; + case KeyboardKeys.F3: keyString = '\x1BOR'; break; + case KeyboardKeys.F4: keyString = '\x1BOS'; break; + case KeyboardKeys.F5: keyString = '\x1BOt'; break; + case KeyboardKeys.F6: keyString = '\x1B[17~'; break; + case KeyboardKeys.F7: keyString = '\x1B[18~'; break; + case KeyboardKeys.F8: keyString = '\x1B[19~'; break; + case KeyboardKeys.F9: keyString = '\x1B[20~'; break; + case KeyboardKeys.F10: keyString = '\x1B[21~'; break; + case KeyboardKeys.F11: keyString = '\x1B[23~'; break; + case KeyboardKeys.F12: keyString = '\x1B[24~'; break; + case KeyboardKeys.HOME: keyString = '\x1B[H'; break; + case KeyboardKeys.INSERT: keyString = '\x1B@'; break; + case KeyboardKeys.LEFT: keyString = '\x1B[D'; break; + case KeyboardKeys.PAGE_DOWN: keyString = '\x1B[U'; break; + case KeyboardKeys.PAGE_UP: keyString = '\x1B[V'; break; + case KeyboardKeys.RIGHT: keyString = '\x1B[C'; break; + case KeyboardKeys.SPACE: keyString = ' '; break; + case KeyboardKeys.TAB: keyString = '\t'; break; + case KeyboardKeys.UP: keyString = '\x1B[A'; break; + } + } + + // If we have a keyString, then publish it so sbbs sees it as input + if (keyString) { + mqtt_publish('sbbs/' + system_qwk_id + '/node/' + node + '/input', keyString); + } + + // If we have a keyString, or CTRL is being held, prevent further handling (so the keypress event won't be hit next) + if ((keyString) || (ke.ctrlKey)) { + ke.preventDefault(); + } + } + }); + + // Handle keypress event, which is where standard keys are handled + // Code is copy/pasted (with slight modifications) from fTelnet + window.addEventListener('keypress', function (ke) { + var node = $('.keyboard-input:checked').attr('value'); + if (node) { + if (ke.charCode >= 33) { + mqtt_publish('sbbs/' + system_qwk_id + '/node/' + node + '/input', String.fromCharCode(ke.charCode)); + } + } + }); +} + +// Log a message to each of the fTelnet instances +function log_ftelnet(message) { + for (var node in spy_nodes) { + spy_nodes[node].ftelnet._Crt.WriteLn(message); + } +} + +function mqtt_message(topic, message, packet) { + var match = topic.match(/node[\/](\d*)([\/](output|terminal))?/); + var node = match[1]; + var messageType = match[2]; + + switch (messageType) { + case undefined: + // message contains the node's current activity (ie waiting for caller, or user is running an external, etc) + $('#Status' + node).html(message.toString()); + break; + + case '/output': + // message contains the raw output being sent to the user, so write it to the fTelnet instance + var buf = new Uint8Array(message); + + var str = ''; + for (var i = 0; i < message.length; i++) { + str += String.fromCharCode(buf[i]); + } + + // UTF-8 decode, if necessary + if (spy_nodes[node].charset === 'UTF-8') { + str = utf8_cp437(str); + } + + spy_nodes[node].ftelnet._Ansi.Write(str); + break; + + case '/terminal': + // message contains tab-separated terminal information, most importantly the column and row count as the first + // and second elements, so resize fTelnet and tell it to reload the best font based on the new size. + var arr = message.toString().split('\t'); + spy_nodes[node].ftelnet._Crt.SetScreenSize(parseInt(arr[0], 10), parseInt(arr[1], 10)); + spy_nodes[node].ftelnet._Crt.SetFont(spy_nodes[node].ftelnet._Crt.Font.Name); + spy_nodes[node].charset = arr[4]; + // TODOX When arr[4] is 'CBM-ASCII' things get messy -- did I add support for PETSCII to fTelnet? + break; + } +} + +// Add an onload handler to setup the node spy +window.addEventListener('load', (event) => { + create_ftelnet_instances(); + init_keyboard_handler(); + + var topics = []; + for (var node in spy_nodes) { + topics.push('sbbs/' + system_qwk_id + '/node/' + node + ''); + topics.push('sbbs/' + system_qwk_id + '/node/' + node + '/output'); + topics.push('sbbs/' + system_qwk_id + '/node/' + node + '/terminal'); + } + mqtt_connect(topics, mqtt_message, log_ftelnet); +});