From bc3be1709b5b72b1e4a1244af51487a7cda5b23c Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sun, 8 Mar 2026 17:54:30 +0100 Subject: [PATCH] feat(rtty-remote): Add Web Terminal view (Phase 3) Integrates ttyd WebSocket terminal into RTTY Remote module: - New "Web Terminal" menu item in System Hub - Iframe-based terminal embedding ttyd on port 7681 - Node selector for local/remote terminal access - Remote node detection (direct ttyd or SSH fallback) - Fullscreen and refresh controls - RPCD method: start_terminal for remote node terminal info Co-Authored-By: Claude Opus 4.5 --- .../resources/view/rtty-remote/terminal.js | 269 ++++++++++++++++++ .../root/usr/libexec/rpcd/luci.rtty-remote | 41 +++ .../luci/menu.d/luci-app-rtty-remote.json | 11 + .../share/rpcd/acl.d/luci-rtty-remote.json | 1 + 4 files changed, 322 insertions(+) create mode 100644 package/secubox/luci-app-rtty-remote/htdocs/luci-static/resources/view/rtty-remote/terminal.js diff --git a/package/secubox/luci-app-rtty-remote/htdocs/luci-static/resources/view/rtty-remote/terminal.js b/package/secubox/luci-app-rtty-remote/htdocs/luci-static/resources/view/rtty-remote/terminal.js new file mode 100644 index 00000000..121e0680 --- /dev/null +++ b/package/secubox/luci-app-rtty-remote/htdocs/luci-static/resources/view/rtty-remote/terminal.js @@ -0,0 +1,269 @@ +'use strict'; +'require view'; +'require dom'; +'require poll'; +'require rpc'; +'require ui'; +'require uci'; + +var callStatus = rpc.declare({ + object: 'luci.rtty-remote', + method: 'status', + expect: {} +}); + +var callGetNodes = rpc.declare({ + object: 'luci.rtty-remote', + method: 'get_nodes', + expect: {} +}); + +var callTokenGenerate = rpc.declare({ + object: 'luci.rtty-remote', + method: 'token_generate', + params: ['ttl', 'permissions'], + expect: {} +}); + +var callStartTerminal = rpc.declare({ + object: 'luci.rtty-remote', + method: 'start_terminal', + params: ['node_id'], + expect: {} +}); + +return view.extend({ + handleSaveApply: null, + handleSave: null, + handleReset: null, + + currentNode: null, + ttydPort: 7681, + + load: function() { + return Promise.all([ + uci.load('ttyd'), + callStatus(), + callGetNodes() + ]); + }, + + render: function(data) { + var self = this; + + // Get ttyd port from config + this.ttydPort = uci.get_first('ttyd', 'ttyd', 'port') || '7681'; + + var status = data[1] || {}; + var nodesData = data[2] || {}; + var nodes = nodesData.nodes || []; + + return E('div', { 'class': 'cbi-map' }, [ + this.renderHeader(), + this.renderNodeSelector(nodes), + this.renderTerminalFrame() + ]); + }, + + renderHeader: function() { + return E('div', { 'class': 'cbi-section' }, [ + E('h2', { 'style': 'margin: 0;' }, 'Web Terminal'), + E('p', { 'style': 'color: #666;' }, + 'Access local or remote node shell via WebSocket terminal.') + ]); + }, + + renderNodeSelector: function(nodes) { + var self = this; + + // Build options + var options = [ + E('option', { 'value': 'local', 'selected': 'selected' }, 'Local (this device)') + ]; + + nodes.forEach(function(node) { + options.push(E('option', { 'value': node.address || node.id }, + (node.name || node.id) + ' (' + (node.address || '?') + ')')); + }); + + return E('div', { 'class': 'cbi-section', 'style': 'display: flex; gap: 1em; align-items: center;' }, [ + E('label', {}, 'Target: '), + E('select', { + 'id': 'node-selector', + 'class': 'cbi-input-select', + 'change': L.bind(this.handleNodeChange, this) + }, options), + E('input', { + 'type': 'text', + 'id': 'manual-ip', + 'placeholder': 'Or enter IP address', + 'class': 'cbi-input-text', + 'style': 'width: 150px;' + }), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': L.bind(this.handleConnect, this) + }, 'Connect'), + E('span', { 'id': 'connection-status', 'style': 'margin-left: 1em;' }, '') + ]); + }, + + renderTerminalFrame: function() { + var url = 'http://' + window.location.hostname + ':' + this.ttydPort; + + return E('div', { 'class': 'cbi-section' }, [ + E('div', { + 'id': 'terminal-wrapper', + 'style': 'background: #1a1a2e; border-radius: 8px; overflow: hidden;' + }, [ + E('div', { + 'style': 'background: #2d2d44; padding: 0.5em 1em; display: flex; justify-content: space-between; align-items: center;' + }, [ + E('span', { 'id': 'terminal-title', 'style': 'color: #fff;' }, 'Local Terminal'), + E('div', {}, [ + E('button', { + 'class': 'cbi-button', + 'style': 'padding: 0.25em 0.5em;', + 'click': L.bind(this.handleFullscreen, this) + }, 'Fullscreen'), + E('button', { + 'class': 'cbi-button', + 'style': 'padding: 0.25em 0.5em; margin-left: 0.5em;', + 'click': L.bind(this.handleRefresh, this) + }, 'Refresh') + ]) + ]), + E('iframe', { + 'id': 'terminal-iframe', + 'src': url, + 'style': 'width: 100%; height: 500px; border: none;' + }) + ]) + ]); + }, + + handleNodeChange: function(ev) { + var selector = document.getElementById('node-selector'); + var value = selector.value; + + if (value === 'local') { + this.connectLocal(); + } + }, + + handleConnect: function() { + var selector = document.getElementById('node-selector'); + var manualIp = document.getElementById('manual-ip').value.trim(); + var target = manualIp || selector.value; + + if (target === 'local' || target === window.location.hostname || target === '192.168.255.1') { + this.connectLocal(); + } else { + this.connectRemote(target); + } + }, + + connectLocal: function() { + var iframe = document.getElementById('terminal-iframe'); + var title = document.getElementById('terminal-title'); + var status = document.getElementById('connection-status'); + + var url = 'http://' + window.location.hostname + ':' + this.ttydPort; + iframe.src = url; + title.textContent = 'Local Terminal'; + status.textContent = ''; + status.style.color = ''; + + this.currentNode = 'local'; + }, + + connectRemote: function(address) { + var self = this; + var iframe = document.getElementById('terminal-iframe'); + var title = document.getElementById('terminal-title'); + var status = document.getElementById('connection-status'); + + status.textContent = 'Connecting to ' + address + '...'; + status.style.color = '#f90'; + + // For remote connections, we have two options: + // 1. Connect to remote ttyd directly (if running on remote node) + // 2. Use SSH through local ttyd + + // Try direct ttyd first (assumes remote node has ttyd on same port) + var remoteUrl = 'http://' + address + ':' + this.ttydPort; + + // Create a test image to check connectivity + var img = new Image(); + img.onload = function() { + // Remote ttyd is accessible + iframe.src = remoteUrl; + title.textContent = 'Remote Terminal: ' + address; + status.textContent = 'Connected (direct)'; + status.style.color = '#4a9'; + self.currentNode = address; + }; + img.onerror = function() { + // Remote ttyd not accessible, fall back to SSH via local ttyd + // This requires starting a new ttyd instance with ssh command + status.textContent = 'Direct connection failed. Use SSH instead.'; + status.style.color = '#f44'; + + // Show SSH instructions + title.textContent = 'SSH to: ' + address; + self.showSshInstructions(address); + }; + + // Timeout for connectivity check + setTimeout(function() { + if (status.textContent.indexOf('Connecting') !== -1) { + img.onerror(); + } + }, 3000); + + // This won't actually load, but triggers the event + img.src = remoteUrl + '/favicon.ico?' + Date.now(); + }, + + showSshInstructions: function(address) { + var wrapper = document.getElementById('terminal-wrapper'); + var iframe = document.getElementById('terminal-iframe'); + + // Replace iframe with instructions + var instructions = E('div', { + 'style': 'padding: 2em; color: #fff; text-align: center;' + }, [ + E('h3', { 'style': 'color: #4a9;' }, 'Remote Node: ' + address), + E('p', {}, 'The remote node does not have a web terminal accessible.'), + E('p', {}, 'Use SSH to connect:'), + E('code', { + 'style': 'display: block; background: #000; padding: 1em; border-radius: 4px; font-size: 1.2em; margin: 1em 0;' + }, 'ssh root@' + address), + E('p', { 'style': 'margin-top: 1em;' }, 'Or use the local terminal and run the SSH command.'), + E('button', { + 'class': 'cbi-button cbi-button-action', + 'style': 'margin-top: 1em;', + 'click': L.bind(function() { + this.connectLocal(); + }, this) + }, 'Open Local Terminal') + ]); + + iframe.style.display = 'none'; + wrapper.appendChild(instructions); + }, + + handleFullscreen: function() { + var iframe = document.getElementById('terminal-iframe'); + if (iframe.requestFullscreen) { + iframe.requestFullscreen(); + } else if (iframe.webkitRequestFullscreen) { + iframe.webkitRequestFullscreen(); + } + }, + + handleRefresh: function() { + var iframe = document.getElementById('terminal-iframe'); + iframe.src = iframe.src; + } +}); diff --git a/package/secubox/luci-app-rtty-remote/root/usr/libexec/rpcd/luci.rtty-remote b/package/secubox/luci-app-rtty-remote/root/usr/libexec/rpcd/luci.rtty-remote index 3174ca3f..13c7fc21 100644 --- a/package/secubox/luci-app-rtty-remote/root/usr/libexec/rpcd/luci.rtty-remote +++ b/package/secubox/luci-app-rtty-remote/root/usr/libexec/rpcd/luci.rtty-remote @@ -366,6 +366,45 @@ method_token_revoke() { json_dump } +# Start terminal session to remote node +method_start_terminal() { + local node_id + read -r input + json_load "$input" + json_get_var node_id node_id + + [ -z "$node_id" ] && { + echo '{"success":false,"error":"Missing node_id"}' + return + } + + # Get node address + local addr=$($RTTYCTL node "$node_id" 2>&1 | grep "Address:" | awk '{print $2}') + [ -z "$addr" ] && addr="$node_id" + + # Check if we can reach the remote ttyd directly + local remote_port=7681 + if curl -s -m 2 "http://${addr}:${remote_port}/" >/dev/null 2>&1; then + # Remote ttyd is accessible + json_init + json_add_boolean "success" 1 + json_add_string "type" "direct" + json_add_string "url" "http://${addr}:${remote_port}" + json_add_string "node_id" "$node_id" + json_add_string "address" "$addr" + json_dump + else + # Remote ttyd not accessible, provide SSH info + json_init + json_add_boolean "success" 1 + json_add_string "type" "ssh" + json_add_string "ssh_command" "ssh root@${addr}" + json_add_string "node_id" "$node_id" + json_add_string "address" "$addr" + json_dump + fi +} + # Execute RPC with token authentication (no LuCI session needed) method_token_rpc() { local code object method params @@ -419,6 +458,7 @@ case "$1" in "set_settings": {"config": "object"}, "replay_session": {"session_id": "integer", "target_node": "string"}, "connect": {"node_id": "string"}, + "start_terminal": {"node_id": "string"}, "token_generate": {"ttl": 3600, "permissions": "rpc,terminal"}, "token_list": {}, "token_validate": {"code": "string"}, @@ -441,6 +481,7 @@ EOF set_settings) method_set_settings ;; replay_session) method_replay_session ;; connect) method_connect ;; + start_terminal) method_start_terminal ;; token_generate) method_token_generate ;; token_list) method_token_list ;; token_validate) method_token_validate ;; diff --git a/package/secubox/luci-app-rtty-remote/root/usr/share/luci/menu.d/luci-app-rtty-remote.json b/package/secubox/luci-app-rtty-remote/root/usr/share/luci/menu.d/luci-app-rtty-remote.json index 85dd87d1..48484f16 100644 --- a/package/secubox/luci-app-rtty-remote/root/usr/share/luci/menu.d/luci-app-rtty-remote.json +++ b/package/secubox/luci-app-rtty-remote/root/usr/share/luci/menu.d/luci-app-rtty-remote.json @@ -20,5 +20,16 @@ "depends": { "acl": ["luci-app-rtty-remote"] } + }, + "admin/secubox/system/system-hub/terminal": { + "title": "Web Terminal", + "order": 17, + "action": { + "type": "view", + "path": "rtty-remote/terminal" + }, + "depends": { + "acl": ["luci-app-rtty-remote"] + } } } diff --git a/package/secubox/luci-app-rtty-remote/root/usr/share/rpcd/acl.d/luci-rtty-remote.json b/package/secubox/luci-app-rtty-remote/root/usr/share/rpcd/acl.d/luci-rtty-remote.json index e0eae02c..82da475b 100644 --- a/package/secubox/luci-app-rtty-remote/root/usr/share/rpcd/acl.d/luci-rtty-remote.json +++ b/package/secubox/luci-app-rtty-remote/root/usr/share/rpcd/acl.d/luci-rtty-remote.json @@ -25,6 +25,7 @@ "set_settings", "replay_session", "connect", + "start_terminal", "token_generate", "token_revoke", "token_rpc"