From 7d14746ae88a83163a7e34daae70b264285bbe56 Mon Sep 17 00:00:00 2001 From: Sergey Ponomarev Date: Mon, 4 Dec 2023 20:27:53 +0200 Subject: New app: luci-app-sshtunnel for SSH tunnels (#6424) * luci-app-sshtunnel: SSH tunnels The app helps to configure SSH tunnels. You can also generate an SSH key and see known hosts. Signed-off-by: Sergey Ponomarev --- .../resources/view/sshtunnel/ssh_hosts.js | 64 ++++++++ .../resources/view/sshtunnel/ssh_keys.js | 131 ++++++++++++++++ .../resources/view/sshtunnel/ssh_servers.js | 147 ++++++++++++++++++ .../resources/view/sshtunnel/ssh_tunnels.js | 164 +++++++++++++++++++++ 4 files changed, 506 insertions(+) create mode 100644 applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_hosts.js create mode 100644 applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_keys.js create mode 100644 applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_servers.js create mode 100644 applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_tunnels.js (limited to 'applications/luci-app-sshtunnel/htdocs/luci-static/resources') diff --git a/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_hosts.js b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_hosts.js new file mode 100644 index 0000000000..b82eae2617 --- /dev/null +++ b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_hosts.js @@ -0,0 +1,64 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require view'; + + +return view.extend({ + load: function () { + return Promise.all([ + fs.lines('/root/.ssh/known_hosts'), + ]); + }, + + render: function (data) { + var knownHosts = data[0]; + + var m, s, o; + + m = new form.Map('sshtunnel', _('SSH Tunnels'), + _('This configures SSH Tunnels') + .format('href="https://openwrt.org/docs/guide-user/services/ssh/sshtunnel"') + ); + + s = m.section(form.GridSection, '_known_hosts'); + s.render = L.bind(_renderKnownHosts, this, knownHosts); + + return m.render(); + }, +}); + +function _renderKnownHosts(knownHosts) { + var table = E('table', {'class': 'table cbi-section-table', 'id': 'known_hosts'}, [ + E('tr', {'class': 'tr table-titles'}, [ + E('th', {'class': 'th'}, _('Hostname')), + E('th', {'class': 'th'}, _('Public Key')), + ]) + ]); + + var rows = _splitKnownHosts(knownHosts); + cbi_update_table(table, rows); + + return E('div', {'class': 'cbi-section cbi-tblsection'}, [ + E('h3', _('Known hosts ')), + E('div', {'class': 'cbi-section-descr'}, + _('Keys of SSH servers found in %s.').format('/root/.ssh/known_hosts') + ), + table + ]); +} + +function _splitKnownHosts(knownHosts) { + var knownHostsMap = []; + for (var i = 0; i < knownHosts.length; i++) { + var sp = knownHosts[i].indexOf(' '); + if (sp < 0) { + continue; + } + var hostname = knownHosts[i].substring(0, sp); + var pub = knownHosts[i].substring(sp + 1); + knownHostsMap.push([hostname, '' + pub + '']); + } + return knownHostsMap; +} diff --git a/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_keys.js b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_keys.js new file mode 100644 index 0000000000..6c83454b0d --- /dev/null +++ b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_keys.js @@ -0,0 +1,131 @@ +'use strict'; +'require form'; +'require fs'; +'require ui'; +'require view'; + +var allSshKeys = {}; +var hasSshKeygen = false; + +return view.extend({ + load: function () { + return L.resolveDefault(fs.list('/root/.ssh/'), []).then(function (entries) { + var tasks = [ + L.resolveDefault(fs.stat('/usr/bin/ssh-keygen'), {}), + ]; + // read pub keys + for (var i = 0; i < entries.length; i++) { + if (entries[i].type === 'file' && entries[i].name.match(/\.pub$/)) { + tasks.push(Promise.resolve(entries[i].name)); + tasks.push(fs.lines('/root/.ssh/' + entries[i].name)); + } + } + return Promise.all(tasks); + }); + }, + + render: function (data) { + hasSshKeygen = data[0].type === 'file'; + var sshKeys = _splitSshKeys(data.splice(1)); + + var m, s, o; + + m = new form.Map('sshtunnel', _('SSH Tunnels'), + _('This configures SSH Tunnels') + .format('href="https://openwrt.org/docs/guide-user/services/ssh/sshtunnel"') + ); + + s = m.section(form.GridSection, '_keys'); + s.render = L.bind(_renderSshKeys, this, sshKeys); + + return m.render(); + }, +}); + +function _splitSshKeys(sshFiles) { + var sshKeys = {}; + for (var i = 0; i < sshFiles.length; i++) { + var sshPubKeyName = sshFiles[i]; + var sshKeyName = sshPubKeyName.substring(0, sshPubKeyName.length - 4); + i++; + var sshPub = sshFiles[i]; + sshKeys[sshKeyName] = '' + sshPub + ''; + } + allSshKeys = sshKeys; + return sshKeys; +} + +function _renderSshKeys(sshKeys) { + var table = E('table', {'class': 'table cbi-section-table', 'id': 'keys_table'}, [ + E('tr', {'class': 'tr table-titles'}, [ + E('th', {'class': 'th'}, _('Name')), + E('th', {'class': 'th'}, _('Public Key')), + ]) + ]); + + var rows = Object.entries(sshKeys); + cbi_update_table(table, rows, null); + + var keyGenBtn = E('div', {}, [ + E('form', { + 'submit': _handleKeyGenSubmit, + }, [ + E('label', {}, _('Generate a new key') + ': '), + E('span', {'class': 'control-group'}, [ + E('input', { + 'type': 'text', + 'name': 'keyName', + 'value': 'id_ed25519', + 'pattern': '^[a-zA-Z][a-zA-Z0-9_\.]+', + 'required': 'required', + 'maxsize': '35', + 'autocomplete': 'off', + }), + E('button', { + 'id': 'btnGenerateKey', + 'type': 'submit', + 'class': 'btn cbi-button cbi-button-action', + }, [_('Generate')]) + ]) + ]) + ]); + return E('div', {'class': 'cbi-section cbi-tblsection'}, [ + E('h3', _('SSH Keys')), + E('div', {'class': 'cbi-section-descr'}, + _('Add the pub key to %s or %s.') + .format('/root/.ssh/authorized_keys', '/etc/dropbear/authorized_keys') + ' ' + + _('In LuCI you can do that with System / Administration / SSH-Keys') + .format('href="/cgi-bin/luci/admin/system/admin/sshkeys"') + ), + keyGenBtn, table + ]); +} + +function _handleKeyGenSubmit(event) { + event.preventDefault(); + var keyName = document.querySelector('input[name="keyName"]').value; + if (allSshKeys[keyName]) { + document.body.scrollTop = document.documentElement.scrollTop = 0; + ui.addNotification(null, E('p', _('A key with that name already exists.'), 'error')); + return false; + } + + let command = '/usr/bin/ssh-keygen'; + let commandArgs = ['-t', 'ed25519', '-q', '-N', '', '-f', '/root/.ssh/' + keyName]; + if (!hasSshKeygen) { + command = '/usr/bin/dropbearkey'; + commandArgs = ['-t', 'ed25519', '-f', '/root/.ssh/' + keyName]; + } + fs.exec(command, commandArgs).then(function (res) { + if (res.code === 0) { + // refresh the page to see the new key + location.reload(); + } else { + throw new Error(res.stdout + ' ' + res.stderr); + } + }).catch(function (e) { + document.body.scrollTop = document.documentElement.scrollTop = 0; + ui.addNotification(null, E('p', _('Unable to generate a key: %s').format(e.message)), 'error'); + }); + return false; +} diff --git a/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_servers.js b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_servers.js new file mode 100644 index 0000000000..5aa33e6df8 --- /dev/null +++ b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_servers.js @@ -0,0 +1,147 @@ +'use strict'; +'require form'; +'require fs'; +'require uci'; +'require ui'; +'require view'; + +var allSshKeys = {}; + +return view.extend({ + load: function () { + return L.resolveDefault(fs.list('/root/.ssh/'), []).then(function (entries) { + var tasks = []; + for (var i = 0; i < entries.length; i++) { + if (entries[i].type === 'file' && entries[i].name.match(/\.pub$/)) { + tasks.push(Promise.resolve(entries[i].name)); + } + } + return Promise.all(tasks); + }); + }, + + render: function (data) { + var sshKeys = _splitSshKeys(data); + if (sshKeys.length === 0) { + ui.addNotification(null, E('p', _('No SSH keys found, generate a new one').format('href="./ssh_keys"')), 'warning'); + } + + var m, s, o; + + m = new form.Map('sshtunnel', _('SSH Tunnels'), + _('This configures SSH Tunnels') + .format('href="https://openwrt.org/docs/guide-user/services/ssh/sshtunnel"') + ); + + s = m.section(form.GridSection, 'server', _('Servers')); + s.anonymous = false; + s.addremove = true; + s.nodescriptions = true; + + o = s.tab('general', _('General Settings')); + o = s.tab('advanced', _('Advanced Settings')); + + o = s.taboption('general', form.Value, 'hostname', _('Hostname')); + o.placeholder = 'example.com'; + o.datatype = 'host'; + o.rmempty = false; + + o = s.taboption('general', form.Value, 'port', _('Port')); + o.placeholder = '22'; + o.datatype = 'port'; + + o = s.taboption('general', form.Value, 'user', _('User')); + o.default = 'root'; + + o = s.taboption('general', form.ListValue, 'IdentityFile', _('Key file'), + _('Private key file with authentication identity. ' + + 'See ssh_config IdentityFile') + ); + o.value(''); + Object.keys(sshKeys).forEach(function (keyName) { + o.value('/root/.ssh/' + keyName, keyName); + }); + o.optional = true; + + + o = s.taboption('advanced', form.ListValue, 'LogLevel', _('Log level'), 'See ssh_config LogLevel'); + o.value('QUIET', 'QUIET'); + o.value('FATAL', 'FATAL'); + o.value('ERROR', 'ERROR'); + o.value('INFO', 'INFO'); + o.value('VERBOSE', 'VERBOSE'); + o.value('DEBUG', 'DEBUG'); + o.value('DEBUG2', 'DEBUG2'); + o.value('DEBUG3', 'DEBUG3'); + o.default = 'INFO'; + o.modalonly = true; + + o = s.taboption('advanced', form.ListValue, 'Compression', _('Use compression'), + _('Compression may be useful on slow connections. ' + + 'See ssh_config Compression') + ); + o.value('yes', _('Yes')); + o.value('no', _('No')); + o.default = 'no'; + o.modalonly = true; + + o = s.taboption('advanced', form.Value, 'retrydelay', _('Retry delay'), + _('Delay after a connection failure before trying to reconnect.') + ); + o.placeholder = '10'; + o.default = '10'; + o.datatype = 'uinteger'; + o.optional = true; + o.modalonly = true; + + o = s.taboption('advanced', form.Value, 'ServerAliveCountMax', _('Server alive count max'), + _('The number of server alive messages which may be sent before SSH disconnects from the server. ' + + 'See ssh_config ServerAliveCountMax') + ); + o.placeholder = '3'; + o.datatype = 'uinteger'; + o.optional = true; + o.modalonly = true; + + o = s.taboption('advanced', form.Value, 'ServerAliveInterval', _('Server alive interval'), + _('Keep-alive interval (seconds). ' + + 'See ssh_config ServerAliveInterval') + ); + o.optional = true; + o.default = '60'; + o.datatype = 'uinteger'; + o.modalonly = true; + + o = s.taboption('advanced', form.ListValue, 'CheckHostIP', _('Check host IP'), + _('Check the host IP address in the known_hosts file. ' + + 'This allows ssh to detect whether a host key changed due to DNS spoofing. ' + + 'See ssh_config CheckHostIP') + ); + o.value('yes', _('Yes')); + o.value('no', _('No')); + o.default = 'no'; + o.modalonly = true; + + o = s.taboption('advanced', form.ListValue, 'StrictHostKeyChecking', _('Strict host key checking'), + _('Refuse to connect to hosts whose host key has changed. ' + + 'See ssh_config StrictHostKeyChecking')); + o.value('accept-new', _('Accept new and check if not changed')); + o.value('yes', _('Yes')); + o.value('no', _('No')); + o.default = 'accept-new'; + o.modalonly = true; + + return m.render(); + }, +}); + +function _splitSshKeys(sshFiles) { + var sshKeys = {}; + for (var i = 0; i < sshFiles.length; i++) { + var sshPubKeyName = sshFiles[i]; + var sshKeyName = sshPubKeyName.substring(0, sshPubKeyName.length - 4); + sshKeys[sshKeyName] = ''; + } + allSshKeys = sshKeys; + return sshKeys; +} diff --git a/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_tunnels.js b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_tunnels.js new file mode 100644 index 0000000000..34fceab2f0 --- /dev/null +++ b/applications/luci-app-sshtunnel/htdocs/luci-static/resources/view/sshtunnel/ssh_tunnels.js @@ -0,0 +1,164 @@ +'use strict'; +'require form'; +'require fs'; +'require uci'; +'require ui'; +'require view'; + +return view.extend({ + load: function () { + return Promise.all([ + uci.load('sshtunnel'), + ]); + }, + + render: function (data) { + var m, s, o; + + m = new form.Map('sshtunnel', _('SSH Tunnels'), + _('This configures SSH Tunnels') + .format('href="https://openwrt.org/docs/guide-user/services/ssh/sshtunnel"') + ); + + s = m.section(form.GridSection, 'tunnelR', _('Remote Tunnels'), + _('Forward a port on the remote host to a service on the local host.') + ); + s.anonymous = true; + s.addremove = true; + s.nodescriptions = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + + o = _addServerOption(s); + + o = s.option(form.Value, 'remoteaddress', _('Remote address'), + _('Bind IP address e.g. 192.168.1.1 or hostname e.g. localhost.') + '
' + + _('* means to listen all interfaces including public.') + ); + o.datatype = 'or(host, "*")'; + o.default = '*'; + o.rmempty = false; + + o = s.option(form.Value, 'remoteport', _('Remote port')); + o.placeholder = '80'; + o.datatype = 'port'; + o.rmempty = false; + + o = s.option(form.Value, 'localaddress', _('Local address'), + _('Bind IP address e.g. 192.168.1.1 or hostname e.g. localhost.') + ); + o.datatype = 'host'; + o.default = 'localhost'; + o.rmempty = false; + + o = s.option(form.Value, 'localport', _('Local port')); + o.datatype = 'port'; + o.placeholder = '80'; + o.rmempty = false; + + + s = m.section(form.GridSection, 'tunnelL', _('Local Tunnels'), + _('Forward a port on the local host to a service on the remote host.') + ); + s.anonymous = true; + s.addremove = true; + s.nodescriptions = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + + o = _addServerOption(s); + + o = s.option(form.Value, 'localaddress', _('Local address'), + _('Bind IP address e.g. 192.168.1.1 or hostname e.g. localhost.') + '
' + + _('* means to listen all interfaces including public.') + ); + o.datatype = 'or(host, "*")'; + o.placeholder = '192.168.1.1'; // not the default * public iface because a user must explicitly configure it + o.rmempty = false; + + o = s.option(form.Value, 'localport', _('Local port')); + o.datatype = 'port'; + o.placeholder = '80'; + o.rmempty = false; + + o = s.option(form.Value, 'remoteaddress', _('Remote address'), + _('Bind IP address e.g. 192.168.1.1 or hostname e.g. localhost.') + ); + o.datatype = 'host'; + o.default = 'localhost'; + o.rmempty = false; + + o = s.option(form.Value, 'remoteport', _('Remote port')); + o.datatype = 'port'; + o.default = '80'; + o.rmempty = false; + + + s = m.section(form.GridSection, 'tunnelD', _('Dynamic Tunnels'), + _('SOCKS proxy via remote host.') + ); + s.anonymous = true; + s.addremove = true; + s.nodescriptions = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + + o = _addServerOption(s); + + o = s.option(form.Value, 'localaddress', _('Local address'), + _('Bind IP address e.g. 192.168.1.1 or hostname e.g. localhost.') + '
' + + _('* means to listen all interfaces including public.') + ); + o.datatype = 'or(host, "*")'; + o.placeholder = '192.168.1.1'; // not the default * public iface because a user must explicitly configure it + o.rmempty = false; + + o = s.option(form.Value, 'localport', _('Local port')); + o.datatype = 'port'; + o.default = '1080'; + o.rmempty = false; + + + s = m.section(form.GridSection, 'tunnelW', _('VPN Tunnels'), + _('Configure TUN/TAP devices for VPN tunnels.') + ); + s.anonymous = true; + s.addremove = true; + s.nodescriptions = true; + + o = s.option(form.Flag, 'enabled', _('Enabled')); + o.default = '1'; + + o = _addServerOption(s); + + o = s.option(form.ListValue, 'vpntype', _('VPN type')); + o.value('point-to-point', 'TUN (point-to-point)'); + o.value('ethernet', 'TAP (ethernet)'); + o.default = 'point-to-point'; + + o = s.option(form.Value, 'localdev', _('Local dev')); + o.default = 'any'; + o.datatype = 'or("any", min(0))'; + o.rmempty = false; + + o = s.option(form.Value, 'remotedev', _('Remote dev')); + o.default = 'any'; + o.datatype = 'or("any", min(0))'; + o.rmempty = false; + + return m.render(); + }, +}); + +function _addServerOption(s) { + var o = s.option(form.ListValue, 'server', _('Server')); + o.datatype = 'uciname'; + o.rmempty = false; + uci.sections('sshtunnel', 'server', function (s, sectionName) { + o.value(sectionName, s.hostname ? '%s (%s)'.format(sectionName, s.hostname) : sectionName); + }); + return o; +} -- cgit v1.2.3