diff options
Diffstat (limited to 'protocols/luci-proto-wireguard')
6 files changed, 1063 insertions, 25 deletions
diff --git a/protocols/luci-proto-wireguard/Makefile b/protocols/luci-proto-wireguard/Makefile index 3540a74acd..12137fb19b 100644 --- a/protocols/luci-proto-wireguard/Makefile +++ b/protocols/luci-proto-wireguard/Makefile @@ -7,9 +7,11 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=Support for WireGuard VPN -LUCI_DEPENDS:=+kmod-wireguard +wireguard-tools +LUCI_DEPENDS:=+wireguard-tools +ucode LUCI_PKGARCH:=all +PKG_PROVIDES:=luci-app-wireguard + include ../../luci.mk # call BuildPackage - OpenWrt buildroot signature diff --git a/protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js b/protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js index e88c07c339..58537f38f8 100644 --- a/protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js +++ b/protocols/luci-proto-wireguard/htdocs/luci-static/resources/protocol/wireguard.js @@ -1,7 +1,33 @@ 'use strict'; +'require fs'; +'require ui'; +'require dom'; 'require uci'; +'require rpc'; 'require form'; 'require network'; +'require validation'; + +var generateKey = rpc.declare({ + object: 'luci.wireguard', + method: 'generateKeyPair', + expect: { keys: {} } +}); + +var getPublicAndPrivateKeyFromPrivate = rpc.declare({ + object: 'luci.wireguard', + method: 'getPublicAndPrivateKeyFromPrivate', + params: ['privkey'], + expect: { keys: {} } +}); + +var generatePsk = rpc.declare({ + object: 'luci.wireguard', + method: 'generatePsk', + expect: { psk: '' } +}); + +var qrIcon = '<svg viewBox="0 0 29 29" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M0 0h29v29H0z"/><path d="M4 4h1v1H4zM5 4h1v1H5zM6 4h1v1H6zM7 4h1v1H7zM8 4h1v1H8zM9 4h1v1H9zM10 4h1v1h-1zM12 4h1v1h-1zM13 4h1v1h-1zM14 4h1v1h-1zM15 4h1v1h-1zM16 4h1v1h-1zM18 4h1v1h-1zM19 4h1v1h-1zM20 4h1v1h-1zM21 4h1v1h-1zM22 4h1v1h-1zM23 4h1v1h-1zM24 4h1v1h-1zM4 5h1v1H4zM10 5h1v1h-1zM12 5h1v1h-1zM14 5h1v1h-1zM16 5h1v1h-1zM18 5h1v1h-1zM24 5h1v1h-1zM4 6h1v1H4zM6 6h1v1H6zM7 6h1v1H7zM8 6h1v1H8zM10 6h1v1h-1zM12 6h1v1h-1zM18 6h1v1h-1zM20 6h1v1h-1zM21 6h1v1h-1zM22 6h1v1h-1zM24 6h1v1h-1zM4 7h1v1H4zM6 7h1v1H6zM7 7h1v1H7zM8 7h1v1H8zM10 7h1v1h-1zM12 7h1v1h-1zM13 7h1v1h-1zM14 7h1v1h-1zM15 7h1v1h-1zM18 7h1v1h-1zM20 7h1v1h-1zM21 7h1v1h-1zM22 7h1v1h-1zM24 7h1v1h-1zM4 8h1v1H4zM6 8h1v1H6zM7 8h1v1H7zM8 8h1v1H8zM10 8h1v1h-1zM16 8h1v1h-1zM18 8h1v1h-1zM20 8h1v1h-1zM21 8h1v1h-1zM22 8h1v1h-1zM24 8h1v1h-1zM4 9h1v1H4zM10 9h1v1h-1zM12 9h1v1h-1zM13 9h1v1h-1zM15 9h1v1h-1zM18 9h1v1h-1zM24 9h1v1h-1zM4 10h1v1H4zM5 10h1v1H5zM6 10h1v1H6zM7 10h1v1H7zM8 10h1v1H8zM9 10h1v1H9zM10 10h1v1h-1zM12 10h1v1h-1zM14 10h1v1h-1zM16 10h1v1h-1zM18 10h1v1h-1zM19 10h1v1h-1zM20 10h1v1h-1zM21 10h1v1h-1zM22 10h1v1h-1zM23 10h1v1h-1zM24 10h1v1h-1zM13 11h1v1h-1zM14 11h1v1h-1zM15 11h1v1h-1zM16 11h1v1h-1zM4 12h1v1H4zM5 12h1v1H5zM8 12h1v1H8zM9 12h1v1H9zM10 12h1v1h-1zM13 12h1v1h-1zM15 12h1v1h-1zM19 12h1v1h-1zM21 12h1v1h-1zM22 12h1v1h-1zM23 12h1v1h-1zM24 12h1v1h-1zM5 13h1v1H5zM6 13h1v1H6zM8 13h1v1H8zM11 13h1v1h-1zM13 13h1v1h-1zM14 13h1v1h-1zM15 13h1v1h-1zM16 13h1v1h-1zM19 13h1v1h-1zM22 13h1v1h-1zM4 14h1v1H4zM5 14h1v1H5zM9 14h1v1H9zM10 14h1v1h-1zM11 14h1v1h-1zM15 14h1v1h-1zM18 14h1v1h-1zM19 14h1v1h-1zM20 14h1v1h-1zM21 14h1v1h-1zM22 14h1v1h-1zM23 14h1v1h-1zM7 15h1v1H7zM8 15h1v1H8zM9 15h1v1H9zM11 15h1v1h-1zM12 15h1v1h-1zM13 15h1v1h-1zM17 15h1v1h-1zM18 15h1v1h-1zM20 15h1v1h-1zM21 15h1v1h-1zM23 15h1v1h-1zM4 16h1v1H4zM6 16h1v1H6zM10 16h1v1h-1zM11 16h1v1h-1zM13 16h1v1h-1zM14 16h1v1h-1zM16 16h1v1h-1zM17 16h1v1h-1zM18 16h1v1h-1zM22 16h1v1h-1zM23 16h1v1h-1zM24 16h1v1h-1zM12 17h1v1h-1zM16 17h1v1h-1zM17 17h1v1h-1zM18 17h1v1h-1zM4 18h1v1H4zM5 18h1v1H5zM6 18h1v1H6zM7 18h1v1H7zM8 18h1v1H8zM9 18h1v1H9zM10 18h1v1h-1zM14 18h1v1h-1zM16 18h1v1h-1zM17 18h1v1h-1zM21 18h1v1h-1zM22 18h1v1h-1zM23 18h1v1h-1zM4 19h1v1H4zM10 19h1v1h-1zM12 19h1v1h-1zM13 19h1v1h-1zM15 19h1v1h-1zM16 19h1v1h-1zM19 19h1v1h-1zM21 19h1v1h-1zM23 19h1v1h-1zM24 19h1v1h-1zM4 20h1v1H4zM6 20h1v1H6zM7 20h1v1H7zM8 20h1v1H8zM10 20h1v1h-1zM12 20h1v1h-1zM13 20h1v1h-1zM15 20h1v1h-1zM18 20h1v1h-1zM19 20h1v1h-1zM20 20h1v1h-1zM22 20h1v1h-1zM23 20h1v1h-1zM24 20h1v1h-1zM4 21h1v1H4zM6 21h1v1H6zM7 21h1v1H7zM8 21h1v1H8zM10 21h1v1h-1zM13 21h1v1h-1zM15 21h1v1h-1zM16 21h1v1h-1zM19 21h1v1h-1zM21 21h1v1h-1zM23 21h1v1h-1zM24 21h1v1h-1zM4 22h1v1H4zM6 22h1v1H6zM7 22h1v1H7zM8 22h1v1H8zM10 22h1v1h-1zM13 22h1v1h-1zM15 22h1v1h-1zM18 22h1v1h-1zM19 22h1v1h-1zM20 22h1v1h-1zM21 22h1v1h-1zM22 22h1v1h-1zM4 23h1v1H4zM10 23h1v1h-1zM12 23h1v1h-1zM13 23h1v1h-1zM14 23h1v1h-1zM17 23h1v1h-1zM18 23h1v1h-1zM20 23h1v1h-1zM22 23h1v1h-1zM4 24h1v1H4zM5 24h1v1H5zM6 24h1v1H6zM7 24h1v1H7zM8 24h1v1H8zM9 24h1v1H9zM10 24h1v1h-1zM12 24h1v1h-1zM13 24h1v1h-1zM14 24h1v1h-1zM16 24h1v1h-1zM17 24h1v1h-1zM18 24h1v1h-1zM22 24h1v1h-1zM24 24h1v1h-1z"/></svg>'; function validateBase64(section_id, value) { if (value.length == 0) @@ -16,6 +42,78 @@ function validateBase64(section_id, value) { return true; } +var stubValidator = { + factory: validation, + apply: function(type, value, args) { + if (value != null) + this.value = value; + + return validation.types[type].apply(this, args); + }, + assert: function(condition) { + return !!condition; + } +}; + +function generateDescription(name, texts) { + return E('li', { 'style': 'color: inherit;' }, [ + E('span', name), + E('ul', texts.map(function (text) { + return E('li', { 'style': 'color: inherit;' }, text); + })) + ]); +} + +function invokeQREncode(data, code) { + return fs.exec_direct('/usr/bin/qrencode', [ + '--inline', '--8bit', '--type=SVG', + '--output=-', '--', data + ]).then(function(svg) { + code.style.opacity = ''; + dom.content(code, Object.assign(E(svg), { style: 'width:100%;height:auto' })); + }).catch(function(error) { + code.style.opacity = ''; + + if (L.isObject(error) && error.name == 'NotFoundError') { + dom.content(code, [ + Object.assign(E(qrIcon), { style: 'width:32px;height:32px;opacity:.2' }), + E('p', _('The <em>qrencode</em> package is required for generating an QR code image of the configuration.')) + ]); + } + else { + dom.content(code, [ + _('Unable to generate QR code: %s').format(L.isObject(error) ? error.message : error) + ]); + } + }); +} + +var cbiKeyPairGenerate = form.DummyValue.extend({ + cfgvalue: function(section_id, value) { + return E('button', { + 'class': 'btn', + 'click': ui.createHandlerFn(this, function(section_id, ev) { + var prv = this.section.getUIElement(section_id, 'private_key'), + pub = this.section.getUIElement(section_id, 'public_key'), + map = this.map; + + if ((prv.getValue() || pub.getValue()) && !confirm(_('Do you want to replace the current keys?'))) + return; + + return generateKey().then(function(keypair) { + prv.setValue(keypair.priv); + pub.setValue(keypair.pub); + map.save(null, true); + }); + }, section_id) + }, [ _('Generate new key pair') ]); + } +}); + +function handleWindowDragDropIgnore(ev) { + ev.preventDefault() +} + return network.registerProtocol('wireguard', { getI18n: function() { return _('WireGuard VPN'); @@ -46,7 +144,7 @@ return network.registerProtocol('wireguard', { }, renderFormOptions: function(s) { - var o, ss; + var o, ss, ss2; // -- general --------------------------------------------------------------------- @@ -55,6 +153,26 @@ return network.registerProtocol('wireguard', { o.validate = validateBase64; o.rmempty = false; + var serverName = this.getIfname(); + + o = s.taboption('general', form.Value, 'public_key', _('Public Key'), _('Base64-encoded public key of this interface for sharing.')); + o.rmempty = false; + o.write = function() {/* write nothing */}; + + o.load = function(section_id) { + var privKey = s.formvalue(section_id, 'private_key') || uci.get('network', section_id, 'private_key'); + + return getPublicAndPrivateKeyFromPrivate(privKey).then( + function(keypair) { + return keypair.pub || ''; + }, + function(error) { + return _('Error getting PublicKey'); + }, this) + }; + + s.taboption('general', cbiKeyPairGenerate, '_gen_server_keypair', ' '); + o = s.taboption('general', form.Value, 'listen_port', _('Listen Port'), _('Optional. UDP port used for outgoing and incoming packets.')); o.datatype = 'port'; o.placeholder = _('random'); @@ -67,22 +185,23 @@ return network.registerProtocol('wireguard', { o = s.taboption('general', form.Flag, 'nohostroute', _('No Host Routes'), _('Optional. Do not create host routes to peers.')); o.optional = true; - // -- advanced -------------------------------------------------------------------- + o = s.taboption('general', form.Button, '_import', _('Import configuration'), _('Imports settings from an existing WireGuard configuration file')); + o.inputtitle = _('Load configuration…'); + o.onclick = function() { + return ss.handleConfigImport('full'); + }; - o = s.taboption('advanced', form.Value, 'metric', _('Metric'), _('Optional')); - o.datatype = 'uinteger'; - o.placeholder = '0'; - o.optional = true; + // -- advanced -------------------------------------------------------------------- o = s.taboption('advanced', form.Value, 'mtu', _('MTU'), _('Optional. Maximum Transmission Unit of tunnel interface.')); - o.datatype = 'range(1280,1420)'; + o.datatype = 'range(0,8940)'; o.placeholder = '1420'; o.optional = true; o = s.taboption('advanced', form.Value, 'fwmark', _('Firewall Mark'), _('Optional. 32-bit mark for outgoing encrypted packets. Enter value in hex, starting with <code>0x</code>.')); o.optional = true; o.validate = function(section_id, value) { - if (value.length > 0 && !value.match(/^0x[a-fA-F0-9]{1,4}$/)) + if (value.length > 0 && !value.match(/^0x[a-fA-F0-9]{1,8}$/)) return _('Invalid hexadecimal value'); return true; @@ -96,59 +215,655 @@ return network.registerProtocol('wireguard', { } catch(e) {} - o = s.taboption('peers', form.SectionValue, '_peers', form.TypedSection, 'wireguard_%s'.format(s.section)); + o = s.taboption('peers', form.SectionValue, '_peers', form.GridSection, 'wireguard_%s'.format(s.section)); o.depends('proto', 'wireguard'); ss = o.subsection; ss.anonymous = true; ss.addremove = true; ss.addbtntitle = _('Add peer'); + ss.nodescriptions = true; + ss.modaltitle = _('Edit peer'); - ss.renderSectionPlaceholder = function() { - return E([], [ - E('br'), - E('em', _('No peers defined yet')) + ss.handleDragConfig = function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; + }; + + ss.handleDropConfig = function(mode, ev) { + var file = ev.dataTransfer.files[0], + nodes = ev.currentTarget, + input = nodes.querySelector('textarea'), + reader = new FileReader(); + + if (file) { + reader.onload = function(rev) { + input.value = rev.target.result.trim(); + ss.handleApplyConfig(mode, nodes, file.name, ev); + }; + + reader.readAsText(file); + } + + ev.stopPropagation(); + ev.preventDefault(); + }; + + ss.parseConfig = function(data) { + var lines = String(data).split(/(\r?\n)+/), + section = null, + config = { peers: [] }, + s; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i].replace(/#.*$/, '').trim(); + + if (line.match(/^\[(\w+)\]$/)) { + section = RegExp.$1.toLowerCase(); + + if (section == 'peer') + config.peers.push(s = {}); + else + s = config; + } + else if (section && line.match(/^(\w+)\s*=\s*(.+)$/)) { + var key = RegExp.$1, + val = RegExp.$2.trim(); + + if (val.length) + s[section + '_' + key.toLowerCase()] = val; + } + } + + if (config.interface_address) { + config.interface_address = config.interface_address.split(/[, ]+/); + + for (var i = 0; i < config.interface_address.length; i++) + if (!stubValidator.apply('ipaddr', config.interface_address[i])) + return _('Address setting is invalid'); + } + + if (config.interface_dns) { + config.interface_dns = config.interface_dns.split(/[, ]+/); + + for (var i = 0; i < config.interface_dns.length; i++) + if (!stubValidator.apply('ipaddr', config.interface_dns[i], ['nomask'])) + return _('DNS setting is invalid'); + } + + if (!config.interface_privatekey || validateBase64(null, config.interface_privatekey) !== true) + return _('PrivateKey setting is missing or invalid'); + + if (!stubValidator.apply('port', config.interface_listenport || '0')) + return _('ListenPort setting is invalid'); + + for (var i = 0; i < config.peers.length; i++) { + var pconf = config.peers[i]; + + if (pconf.peer_publickey != null && validateBase64(null, pconf.peer_publickey) !== true) + return _('PublicKey setting is invalid'); + + if (pconf.peer_presharedkey != null && validateBase64(null, pconf.peer_presharedkey) !== true) + return _('PresharedKey setting is invalid'); + + if (pconf.peer_allowedips) { + pconf.peer_allowedips = pconf.peer_allowedips.split(/[, ]+/); + + for (var j = 0; j < pconf.peer_allowedips.length; j++) + if (!stubValidator.apply('ipaddr', pconf.peer_allowedips[j])) + return _('AllowedIPs setting is invalid'); + } + else { + pconf.peer_allowedips = [ '0.0.0.0/0', '::/0' ]; + } + + if (pconf.peer_endpoint) { + var host_port = pconf.peer_endpoint.match(/^\[([a-fA-F0-9:]+)\]:(\d+)$/) || pconf.peer_endpoint.match(/^(.+):(\d+)$/); + + if (!host_port || !stubValidator.apply('host', host_port[1]) || !stubValidator.apply('port', host_port[2])) + return _('Endpoint setting is invalid'); + + pconf.peer_endpoint = [ host_port[1], host_port[2] ]; + } + + if (pconf.peer_persistentkeepalive == 'off' || pconf.peer_persistentkeepalive == '0') + delete pconf.peer_persistentkeepalive; + + if (!stubValidator.apply('port', pconf.peer_persistentkeepalive || '0')) + return _('PersistentKeepAlive setting is invalid'); + } + + return config; + }; + + ss.handleApplyConfig = function(mode, nodes, comment, ev) { + var input = nodes.querySelector('textarea').value, + error = nodes.querySelector('.alert-message'), + cancel = nodes.nextElementSibling.querySelector('.btn'), + config = this.parseConfig(input); + + if (typeof(config) == 'string') { + error.firstChild.data = _('Cannot parse configuration: %s').format(config); + error.style.display = 'block'; + return; + } + + if (mode == 'full') { + var prv = s.formvalue(s.section, 'private_key'); + + if (prv && prv != config.interface_privatekey && !confirm(_('Overwrite the current settings with the imported configuration?'))) + return; + + return getPublicAndPrivateKeyFromPrivate(config.interface_privatekey).then(function(keypair) { + s.getOption('private_key').getUIElement(s.section).setValue(keypair.priv); + s.getOption('public_key').getUIElement(s.section).setValue(keypair.pub); + s.getOption('listen_port').getUIElement(s.section).setValue(config.interface_listenport || ''); + s.getOption('addresses').getUIElement(s.section).setValue(config.interface_address); + + if (config.interface_dns) + s.getOption('dns').getUIElement(s.section).setValue(config.interface_dns); + + for (var i = 0; i < config.peers.length; i++) { + var pconf = config.peers[i]; + var sid = uci.add('network', 'wireguard_' + s.section); + + uci.sections('network', 'wireguard_' + s.section, function(peer) { + if (peer.public_key == pconf.peer_publickey) + uci.remove('network', peer['.name']); + }); + + uci.set('network', sid, 'description', comment || _('Imported peer configuration')); + uci.set('network', sid, 'public_key', pconf.peer_publickey); + uci.set('network', sid, 'preshared_key', pconf.peer_presharedkey); + uci.set('network', sid, 'allowed_ips', pconf.peer_allowedips); + uci.set('network', sid, 'persistent_keepalive', pconf.peer_persistentkeepalive); + + if (pconf.peer_endpoint) { + uci.set('network', sid, 'endpoint_host', pconf.peer_endpoint[0]); + uci.set('network', sid, 'endpoint_port', pconf.peer_endpoint[1]); + } + } + + return s.map.save(null, true); + }).then(function() { + cancel.click(); + }); + } + else { + return getPublicAndPrivateKeyFromPrivate(config.interface_privatekey).then(function(keypair) { + var sid = uci.add('network', 'wireguard_' + s.section); + var pub = s.formvalue(s.section, 'public_key'); + + uci.sections('network', 'wireguard_' + s.section, function(peer) { + if (peer.public_key == keypair.pub) + uci.remove('network', peer['.name']); + }); + + uci.set('network', sid, 'description', comment || _('Imported peer configuration')); + uci.set('network', sid, 'public_key', keypair.pub); + uci.set('network', sid, 'private_key', keypair.priv); + + for (var i = 0; i < config.peers.length; i++) { + var pconf = config.peers[i]; + + if (pconf.peer_publickey == pub) { + uci.set('network', sid, 'preshared_key', pconf.peer_presharedkey); + uci.set('network', sid, 'allowed_ips', pconf.peer_allowedips); + uci.set('network', sid, 'persistent_keepalive', pconf.peer_persistentkeepalive); + break; + } + } + + return s.map.save(null, true); + }).then(function() { + cancel.click(); + }); + } + }; + + ss.handleConfigImport = function(mode) { + var mapNode = ss.getActiveModalMap(), + headNode = mapNode.parentNode.querySelector('h4'), + parent = this.map; + + var nodes = E('div', { + 'dragover': this.handleDragConfig, + 'drop': this.handleDropConfig.bind(this, mode) + }, [ + E([], (mode == 'full') ? [ + E('p', _('Drag or paste a valid <em>*.conf</em> file below to configure the local WireGuard interface.')) + ] : [ + E('p', _('Paste or drag a WireGuard configuration (commonly <em>wg0.conf</em>) from another system below to create a matching peer entry allowing that system to connect to the local WireGuard interface.')), + E('p', _('To fully configure the local WireGuard interface from an existing (e.g. provider supplied) configuration file, use the <strong><a class="full-import" href="#">configuration import</a></strong> instead.')) + ]), + E('p', [ + E('textarea', { + 'placeholder': (mode == 'full') + ? _('Paste or drag supplied WireGuard configuration file…') + : _('Paste or drag WireGuard peer configuration (wg0.conf) file…'), + 'style': 'height:5em;width:100%; white-space:pre' + }) + ]), + E('div', { + 'class': 'alert-message', + 'style': 'display:none' + }, ['']) ]); + + var cancelFn = function() { + nodes.parentNode.removeChild(nodes.nextSibling); + nodes.parentNode.removeChild(nodes); + mapNode.classList.remove('hidden'); + mapNode.nextSibling.classList.remove('hidden'); + headNode.removeChild(headNode.lastChild); + window.removeEventListener('dragover', handleWindowDragDropIgnore); + window.removeEventListener('drop', handleWindowDragDropIgnore); + }; + + var a = nodes.querySelector('a.full-import'); + + if (a) { + a.addEventListener('click', ui.createHandlerFn(this, function(mode) { + cancelFn(); + this.handleConfigImport('full'); + })); + } + + mapNode.classList.add('hidden'); + mapNode.nextElementSibling.classList.add('hidden'); + + headNode.appendChild(E('span', [ ' » ', (mode == 'full') ? _('Import configuration') : _('Import as peer') ])); + mapNode.parentNode.appendChild(E([], [ + nodes, + E('div', { + 'class': 'right' + }, [ + E('button', { + 'class': 'btn', + 'click': cancelFn + }, [ _('Cancel') ]), + ' ', + E('button', { + 'class': 'btn primary', + 'click': ui.createHandlerFn(this, 'handleApplyConfig', mode, nodes, null) + }, [ _('Import settings') ]) + ]) + ])); + + window.addEventListener('dragover', handleWindowDragDropIgnore); + window.addEventListener('drop', handleWindowDragDropIgnore); }; + ss.renderSectionAdd = function(/* ... */) { + var nodes = this.super('renderSectionAdd', arguments); + + nodes.appendChild(E('button', { + 'class': 'btn', + 'click': ui.createHandlerFn(this, 'handleConfigImport', 'peer') + }, [ _('Import configuration as peer…') ])); + + return nodes; + }; + + ss.renderSectionPlaceholder = function() { + return E('em', _('No peers defined yet.')); + }; + + o = ss.option(form.Flag, 'disabled', _('Peer disabled'), _('Enable / Disable peer. Restart wireguard interface to apply changes.')); + o.modalonly = true; + o.optional = true; + o = ss.option(form.Value, 'description', _('Description'), _('Optional. Description of peer.')); o.placeholder = 'My Peer'; o.datatype = 'string'; o.optional = true; + o.width = '30%'; + o.textvalue = function(section_id) { + var dis = ss.getOption('disabled'), + pub = ss.getOption('public_key'), + prv = ss.getOption('private_key'), + psk = ss.getOption('preshared_key'), + name = this.cfgvalue(section_id), + key = pub.cfgvalue(section_id); + + var desc = [ + E('p', [ + name ? E('span', [ name ]) : E('em', [ _('Untitled peer') ]) + ]) + ]; + + if (dis.cfgvalue(section_id) == '1') + desc.push(E('span', { + 'class': 'ifacebadge', + 'data-tooltip': _('WireGuard peer is disabled') + }, [ + E('em', [ _('Disabled', 'Label indicating that WireGuard peer is disabled') ]) + ]), ' '); + + if (!key || !pub.isValid(section_id)) { + desc.push(E('span', { + 'class': 'ifacebadge', + 'data-tooltip': _('Public key is missing') + }, [ + E('em', [ _('Key missing', 'Label indicating that WireGuard peer lacks public key') ]) + ])); + } + else { + desc.push( + E('span', { + 'class': 'ifacebadge', + 'data-tooltip': _('Public key: %h', 'Tooltip displaying full WireGuard peer public key').format(key) + }, [ + E('code', [ key.replace(/^(.{5}).+(.{6})$/, '$1…$2') ]) + ]), + ' ', + (prv.cfgvalue(section_id) && prv.isValid(section_id)) + ? E('span', { + 'class': 'ifacebadge', + 'data-tooltip': _('Private key present') + }, [ _('Private', 'Label indicating that WireGuard peer private key is stored') ]) : '', + ' ', + (psk.cfgvalue(section_id) && psk.isValid(section_id)) + ? E('span', { + 'class': 'ifacebadge', + 'data-tooltip': _('Preshared key in use') + }, [ _('PSK', 'Label indicating that WireGuard peer uses a PSK') ]) : '' + ); + } - o = ss.option(form.Value, 'public_key', _('Public Key'), _('Required. Base64-encoded public key of peer.')); + return E([], desc); + }; + + function handleKeyChange(ev, section_id, value) { + var prv = this.section.getUIElement(section_id, 'private_key'), + btn = this.map.findElement('.btn.qr-code'); + + btn.disabled = (!prv.isValid() || !prv.getValue()); + } + + o = ss.option(form.Value, 'public_key', _('Public Key'), _('Required. Public key of the WireGuard peer.')); + o.modalonly = true; o.validate = validateBase64; - o.rmempty = false; + o.onchange = handleKeyChange; - o = ss.option(form.Value, 'preshared_key', _('Preshared Key'), _('Optional. Base64-encoded preshared key. Adds in an additional layer of symmetric-key cryptography for post-quantum resistance.')); + o = ss.option(form.Value, 'private_key', _('Private Key'), _('Optional. Private key of the WireGuard peer. The key is not required for establishing a connection but allows generating a peer configuration or QR code if available. It can be removed after the configuration has been exported.')); + o.modalonly = true; + o.validate = validateBase64; + o.onchange = handleKeyChange; o.password = true; + + o = ss.option(cbiKeyPairGenerate, '_gen_peer_keypair', ' '); + o.modalonly = true; + + o = ss.option(form.Value, 'preshared_key', _('Preshared Key'), _('Optional. Base64-encoded preshared key. Adds in an additional layer of symmetric-key cryptography for post-quantum resistance.')); + o.modalonly = true; o.validate = validateBase64; - o.optional = true; + o.password = true; + + o = ss.option(form.DummyValue, '_gen_psk', ' '); + o.modalonly = true; + o.cfgvalue = function(section_id, value) { + return E('button', { + 'class': 'btn', + 'click': ui.createHandlerFn(this, function(section_id, ev) { + var psk = this.section.getUIElement(section_id, 'preshared_key'), + map = this.map; + + if (psk.getValue() && !confirm(_('Do you want to replace the current PSK?'))) + return; + + return generatePsk().then(function(key) { + psk.setValue(key); + map.save(null, true); + }); + }, section_id) + }, [ _('Generate preshared key') ]); + }; - o = ss.option(form.DynamicList, 'allowed_ips', _('Allowed IPs'), _("Required. IP addresses and prefixes that this peer is allowed to use inside the tunnel. Usually the peer's tunnel IP addresses and the networks the peer routes through the tunnel.")); + o = ss.option(form.DynamicList, 'allowed_ips', _('Allowed IPs'), _("Optional. IP addresses and prefixes that this peer is allowed to use inside the tunnel. Usually the peer's tunnel IP addresses and the networks the peer routes through the tunnel.")); o.datatype = 'ipaddr'; - o.validate = function(section, value) { - var opt = this.map.lookupOption('allowed_ips', section); - var ips = opt[0].formvalue(section); - if (ips.length == 0) { - return _('Value must not be empty'); + o.textvalue = function(section_id) { + var ips = L.toArray(this.cfgvalue(section_id)), + list = []; + + for (var i = 0; i < ips.length; i++) { + if (i > 7) { + list.push(E('em', { + 'class': 'ifacebadge cbi-tooltip-container' + }, [ + _('+ %d more', 'Label indicating further amount of allowed ips').format(ips.length - i), + E('span', { + 'class': 'cbi-tooltip' + }, [ + E('ul', ips.map(function(ip) { + return E('li', [ + E('span', { 'class': 'ifacebadge' }, [ ip ]) + ]); + })) + ]) + ])); + + break; + } + + list.push(E('span', { 'class': 'ifacebadge' }, [ ips[i] ])); } - return true; + + if (!list.length) + list.push('*'); + + return E('span', { 'style': 'display:inline-flex;flex-wrap:wrap;gap:.125em' }, list); }; o = ss.option(form.Flag, 'route_allowed_ips', _('Route Allowed IPs'), _('Optional. Create routes for Allowed IPs for this peer.')); + o.modalonly = true; o = ss.option(form.Value, 'endpoint_host', _('Endpoint Host'), _('Optional. Host of peer. Names are resolved prior to bringing up the interface.')); o.placeholder = 'vpn.example.com'; o.datatype = 'host'; + o.textvalue = function(section_id) { + var host = this.cfgvalue(section_id), + port = this.section.cfgvalue(section_id, 'endpoint_port'); + + return (host && port) + ? '%h:%d'.format(host, port) + : (host + ? '%h:*'.format(host) + : (port + ? '*:%d'.format(port) + : '*')); + }; o = ss.option(form.Value, 'endpoint_port', _('Endpoint Port'), _('Optional. Port of peer.')); + o.modalonly = true; o.placeholder = '51820'; o.datatype = 'port'; o = ss.option(form.Value, 'persistent_keepalive', _('Persistent Keep Alive'), _('Optional. Seconds between keep alive messages. Default is 0 (disabled). Recommended value if this device is behind a NAT is 25.')); + o.modalonly = true; o.datatype = 'range(0,65535)'; o.placeholder = '0'; + + + + o = ss.option(form.DummyValue, '_keyops', _('Configuration Export'), + _('Generates a configuration suitable for import on a WireGuard peer')); + + o.modalonly = true; + + o.createPeerConfig = function(section_id, endpoint, ips) { + var pub = s.formvalue(s.section, 'public_key'), + port = s.formvalue(s.section, 'listen_port') || '51820', + prv = this.section.formvalue(section_id, 'private_key'), + psk = this.section.formvalue(section_id, 'preshared_key'), + eport = this.section.formvalue(section_id, 'endpoint_port'), + keep = this.section.formvalue(section_id, 'persistent_keepalive'); + + // If endpoint is IPv6 we must escape it with [] + if (endpoint.indexOf(':') > 0) { + endpoint = '['+endpoint+']'; + } + + return [ + '[Interface]', + 'PrivateKey = ' + prv, + eport ? 'ListenPort = ' + eport : '# ListenPort not defined', + '', + '[Peer]', + 'PublicKey = ' + pub, + psk ? 'PresharedKey = ' + psk : '# PresharedKey not used', + ips && ips.length ? 'AllowedIPs = ' + ips.join(', ') : '# AllowedIPs not defined', + endpoint ? 'Endpoint = ' + endpoint + ':' + port : '# Endpoint not defined', + keep ? 'PersistentKeepAlive = ' + keep : '# PersistentKeepAlive not defined' + ].join('\n'); + }; + + o.handleGenerateQR = function(section_id, ev) { + var mapNode = ss.getActiveModalMap(), + headNode = mapNode.parentNode.querySelector('h4'), + configGenerator = this.createPeerConfig.bind(this, section_id), + parent = this.map; + + return Promise.all([ + network.getWANNetworks(), + network.getWAN6Networks(), + L.resolveDefault(uci.load('ddns')), + L.resolveDefault(uci.load('system')), + parent.save(null, true) + ]).then(function(data) { + var hostnames = []; + + uci.sections('ddns', 'service', function(s) { + if (typeof(s.lookup_host) == 'string' && s.enabled == '1') + hostnames.push(s.lookup_host); + }); + + uci.sections('system', 'system', function(s) { + if (typeof(s.hostname) == 'string' && s.hostname.indexOf('.') > 0) + hostnames.push(s.hostname); + }); + + for (var i = 0; i < data[0].length; i++) + hostnames.push.apply(hostnames, data[0][i].getIPAddrs().map(function(ip) { return ip.split('/')[0] })); + + for (var i = 0; i < data[1].length; i++) + hostnames.push.apply(hostnames, data[1][i].getIP6Addrs().map(function(ip) { return ip.split('/')[0] })); + + var ips = [ '0.0.0.0/0', '::/0' ]; + + var qrm, qrs, qro; + + qrm = new form.JSONMap({ config: { endpoint: hostnames[0], allowed_ips: ips } }, null, _('The generated configuration can be imported into a WireGuard client application to set up a connection towards this device.')); + qrm.parent = parent; + + qrs = qrm.section(form.NamedSection, 'config'); + + function handleConfigChange(ev, section_id, value) { + var code = this.map.findElement('.qr-code'), + conf = this.map.findElement('.client-config'), + endpoint = this.section.getUIElement(section_id, 'endpoint'), + ips = this.section.getUIElement(section_id, 'allowed_ips'); + + if (this.isValid(section_id)) { + conf.firstChild.data = configGenerator(endpoint.getValue(), ips.getValue()); + code.style.opacity = '.5'; + + invokeQREncode(conf.firstChild.data, code); + } + }; + + qro = qrs.option(form.Value, 'endpoint', _('Connection endpoint'), _('The public hostname or IP address of this system the peer should connect to. This usually is a static public IP address, a static hostname or a DDNS domain.')); + qro.datatype = 'or(ipaddr,hostname)'; + hostnames.forEach(function(hostname) { qro.value(hostname) }); + qro.onchange = handleConfigChange; + + qro = qrs.option(form.DynamicList, 'allowed_ips', _('Allowed IPs'), _('IP addresses that are allowed inside the tunnel. The peer will accept tunnelled packets with source IP addresses matching this list and route back packets with matching destination IP.')); + qro.datatype = 'ipaddr'; + qro.default = ips; + ips.forEach(function(ip) { qro.value(ip) }); + qro.onchange = handleConfigChange; + + qro = qrs.option(form.DummyValue, 'output'); + qro.renderWidget = function() { + var peer_config = configGenerator(hostnames[0], ips); + + var node = E('div', { + 'style': 'display:flex;flex-wrap:wrap;align-items:center;gap:.5em;width:100%' + }, [ + E('div', { + 'class': 'qr-code', + 'style': 'width:320px;flex:0 1 320px;text-align:center' + }, [ + E('em', { 'class': 'spinning' }, [ _('Generating QR code…') ]) + ]), + E('pre', { + 'class': 'client-config', + 'style': 'flex:1;white-space:pre;overflow:auto', + 'click': function(ev) { + var sel = window.getSelection(), + range = document.createRange(); + + range.selectNodeContents(ev.currentTarget); + + sel.removeAllRanges(); + sel.addRange(range); + } + }, [ peer_config ]) + ]); + + invokeQREncode(peer_config, node.firstChild); + + return node; + }; + + return qrm.render().then(function(nodes) { + mapNode.classList.add('hidden'); + mapNode.nextElementSibling.classList.add('hidden'); + + headNode.appendChild(E('span', [ ' » ', _('Generate configuration') ])); + mapNode.parentNode.appendChild(E([], [ + nodes, + E('div', { + 'class': 'right' + }, [ + E('button', { + 'class': 'btn', + 'click': function() { + nodes.parentNode.removeChild(nodes.nextSibling); + nodes.parentNode.removeChild(nodes); + mapNode.classList.remove('hidden'); + mapNode.nextSibling.classList.remove('hidden'); + headNode.removeChild(headNode.lastChild); + } + }, [ _('Back to peer configuration') ]) + ]) + ])); + + if (!s.formvalue(s.section, 'listen_port')) { + nodes.appendChild(E('div', { 'class': 'alert-message' }, [ + E('p', [ + _('No fixed interface listening port defined, peers might not be able to initiate connections to this WireGuard instance!') + ]) + ])); + } + }); + }); + }; + + o.cfgvalue = function(section_id, value) { + var privkey = this.section.cfgvalue(section_id, 'private_key'); + + return E('button', { + 'class': 'btn qr-code', + 'style': 'display:inline-flex;align-items:center;gap:.5em', + 'click': ui.createHandlerFn(this, 'handleGenerateQR', section_id), + 'disabled': privkey ? null : '' + }, [ + Object.assign(E(qrIcon), { style: 'width:22px;height:22px' }), + _('Generate configuration…') + ]); + }; }, deleteConfiguration: function() { diff --git a/protocols/luci-proto-wireguard/htdocs/luci-static/resources/view/wireguard/status.js b/protocols/luci-proto-wireguard/htdocs/luci-static/resources/view/wireguard/status.js new file mode 100644 index 0000000000..4344c36739 --- /dev/null +++ b/protocols/luci-proto-wireguard/htdocs/luci-static/resources/view/wireguard/status.js @@ -0,0 +1,175 @@ +'use strict'; +'require view'; +'require rpc'; +'require poll'; +'require dom'; +'require ui'; + + +var callGetWgInstances = rpc.declare({ + object: 'luci.wireguard', + method: 'getWgInstances' +}); + +function timestampToStr(timestamp) { + if (timestamp < 1) + return _('Never', 'No WireGuard peer handshake yet'); + + var seconds = (Date.now() / 1000) - timestamp; + var ago; + + if (seconds < 60) + ago = _('%ds ago').format(seconds); + else if (seconds < 3600) + ago = _('%dm ago').format(seconds / 60); + else if (seconds < 86401) + ago = _('%dh ago').format(seconds / 3600); + else + ago = _('over a day ago'); + + return (new Date(timestamp * 1000)).toUTCString() + ' (' + ago + ')'; +} + +function handleInterfaceDetails(iface) { + ui.showModal(_('Instance Details'), [ + ui.itemlist(E([]), [ + _('Name'), iface.name, + _('Public Key'), E('code', [ iface.public_key ]), + _('Listen Port'), iface.listen_port, + _('Firewall Mark'), iface.fwmark != 'off' ? iface.fwmark : E('em', _('none')) + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, [ _('Dismiss') ]) + ]) + ]); +} + +function handlePeerDetails(peer) { + ui.showModal(_('Peer Details'), [ + ui.itemlist(E([]), [ + _('Description'), peer.name, + _('Public Key'), E('code', [ peer.public_key ]), + _('Endpoint'), peer.endpoint, + _('Allowed IPs'), (Array.isArray(peer.allowed_ips) && peer.allowed_ips.length) ? peer.allowed_ips.join(', ') : E('em', _('none')), + _('Received Data'), '%1024mB'.format(peer.transfer_rx), + _('Transmitted Data'), '%1024mB'.format(peer.transfer_tx), + _('Latest Handshake'), timestampToStr(+peer.latest_handshake), + _('Keep-Alive'), (peer.persistent_keepalive != 'off') ? _('every %ds', 'WireGuard keep alive interval').format(+peer.persistent_keepalive) : E('em', _('none')), + ]), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, [ _('Dismiss') ]) + ]) + ]); +} + +function renderPeerTable(instanceName, peers) { + var t = new L.ui.Table( + [ + _('Peer'), + _('Endpoint'), + _('Data Received'), + _('Data Transmitted'), + _('Latest Handshake') + ], + { + id: 'peers-' + instanceName + }, + E('em', [ + _('No peers connected') + ]) + ); + + t.update(peers.map(function(peer) { + return [ + [ + peer.name || '', + E('div', { + 'style': 'cursor:pointer', + 'click': ui.createHandlerFn(this, handlePeerDetails, peer) + }, [ + E('p', [ + peer.name ? E('span', [ peer.name ]) : E('em', [ _('Untitled peer') ]) + ]), + E('span', { + 'class': 'ifacebadge hide-sm', + 'data-tooltip': _('Public key: %h', 'Tooltip displaying full WireGuard peer public key').format(peer.public_key) + }, [ + E('code', [ peer.public_key.replace(/^(.{5}).+(.{6})$/, '$1…$2') ]) + ]) + ]) + ], + peer.endpoint, + [ +peer.transfer_rx, '%1024mB'.format(+peer.transfer_rx) ], + [ +peer.transfer_tx, '%1024mB'.format(+peer.transfer_tx) ], + [ +peer.latest_handshake, timestampToStr(+peer.latest_handshake) ] + ]; + })); + + return t.render(); +} + +return view.extend({ + renderIfaces: function(ifaces) { + var res = [ + E('h2', [ _('WireGuard Status') ]) + ]; + + for (var instanceName in ifaces) { + res.push( + E('h3', [ _('Instance "%h"', 'WireGuard instance heading').format(instanceName) ]), + E('p', { + 'style': 'cursor:pointer', + 'click': ui.createHandlerFn(this, handleInterfaceDetails, ifaces[instanceName]) + }, [ + E('span', { 'class': 'ifacebadge' }, [ + E('img', { 'src': L.resource('icons', 'tunnel.png') }), + '\xa0', + instanceName + ]), + E('span', { 'style': 'opacity:.8' }, [ + ' · ', + _('Port %d', 'WireGuard listen port').format(ifaces[instanceName].listen_port), + ' · ', + E('code', { 'click': '' }, [ ifaces[instanceName].public_key ]) + ]) + ]), + renderPeerTable(instanceName, ifaces[instanceName].peers) + ); + } + + if (res.length == 1) + res.push(E('p', { 'class': 'center', 'style': 'margin-top:5em' }, [ + E('em', [ _('No WireGuard interfaces configured.') ]) + ])); + + return E([], res); + }, + + render: function() { + poll.add(L.bind(function () { + return callGetWgInstances().then(L.bind(function(ifaces) { + dom.content( + document.querySelector('#view'), + this.renderIfaces(ifaces) + ); + }, this)); + }, this), 5); + + return E([], [ + E('h2', [ _('WireGuard Status') ]), + E('p', { 'class': 'center', 'style': 'margin-top:5em' }, [ + E('em', [ _('Loading data…') ]) + ]) + ]); + }, + + handleReset: null, + handleSaveApply: null, + handleSave: null +}); diff --git a/protocols/luci-proto-wireguard/root/usr/share/luci/menu.d/luci-proto-wireguard.json b/protocols/luci-proto-wireguard/root/usr/share/luci/menu.d/luci-proto-wireguard.json new file mode 100644 index 0000000000..06940ee7ef --- /dev/null +++ b/protocols/luci-proto-wireguard/root/usr/share/luci/menu.d/luci-proto-wireguard.json @@ -0,0 +1,14 @@ +{ + "admin/status/wireguard": { + "title": "WireGuard", + "order": 92, + "action": { + "type": "view", + "path": "wireguard/status" + }, + "depends": { + "acl": [ "luci-proto-wireguard" ], + "uci": { "network": true } + } + } +} diff --git a/protocols/luci-proto-wireguard/root/usr/share/rpcd/acl.d/luci-wireguard.json b/protocols/luci-proto-wireguard/root/usr/share/rpcd/acl.d/luci-wireguard.json new file mode 100644 index 0000000000..e7187c0e4f --- /dev/null +++ b/protocols/luci-proto-wireguard/root/usr/share/rpcd/acl.d/luci-wireguard.json @@ -0,0 +1,25 @@ +{ + "luci-proto-wireguard": { + "description": "Grant access to LuCI Wireguard procedures", + "read": { + "file": { + "/usr/bin/qrencode --inline --8bit --type=SVG --output=- -- *": [ "exec" ] + }, + "ubus": { + "luci.wireguard": [ + "getWgInstances" + ] + }, + "uci": [ "ddns", "system" ] + }, + "write": { + "ubus": { + "luci.wireguard": [ + "generateKeyPair", + "getPublicAndPrivateKeyFromPrivate", + "generatePsk" + ] + } + } + } +} diff --git a/protocols/luci-proto-wireguard/root/usr/share/rpcd/ucode/luci.wireguard b/protocols/luci-proto-wireguard/root/usr/share/rpcd/ucode/luci.wireguard new file mode 100644 index 0000000000..add810c8ae --- /dev/null +++ b/protocols/luci-proto-wireguard/root/usr/share/rpcd/ucode/luci.wireguard @@ -0,0 +1,107 @@ +// Copyright 2022 Jo-Philipp Wich <jo@mein.io> +// Licensed to the public under the Apache License 2.0. + +'use strict'; + +import { cursor } from 'uci'; +import { popen } from 'fs'; + + +function shellquote(s) { + return `'${replace(s ?? '', "'", "'\\''")}'`; +} + +function command(cmd) { + return trim(popen(cmd)?.read?.('all')); +} + + +const methods = { + generatePsk: { + call: function() { + return { psk: command('wg genpsk 2>/dev/null') }; + } + }, + + generateKeyPair: { + call: function() { + const priv = command('wg genkey 2>/dev/null'); + const pub = command(`echo ${shellquote(priv)} | wg pubkey 2>/dev/null`); + + return { keys: { priv, pub } }; + } + }, + + getPublicAndPrivateKeyFromPrivate: { + args: { privkey: "privkey" }, + call: function(req) { + const priv = req.args?.privkey; + const pub = command(`echo ${shellquote(priv)} | wg pubkey 2>/dev/null`); + + return { keys: { priv, pub } }; + } + }, + + getWgInstances: { + call: function() { + const data = {}; + let last_device; + let qr_pubkey = {}; + + const uci = cursor(); + const wg_dump = popen("wg show all dump 2>/dev/null"); + + if (wg_dump) { + uci.load("network"); + + for (let line = wg_dump.read('line'); length(line); line = wg_dump.read('line')) { + const record = split(rtrim(line, '\n'), '\t'); + + if (last_device != record[0]) { + last_device = record[0]; + data[last_device] = { + name: last_device, + public_key: record[2], + listen_port: record[3], + fwmark: record[4], + peers: [] + }; + + if (!length(record[2]) || record[2] == '(none)') + qr_pubkey[last_device] = ''; + else + qr_pubkey[last_device] = `PublicKey = ${record[2]}`; + } + else { + let peer_name; + + uci.foreach('network', `wireguard_${last_device}`, (s) => { + if (s.public_key == record[1]) + peer_name = s.description; + }); + + const peer = { + name: peer_name, + public_key: record[1], + endpoint: record[3], + allowed_ips: [], + latest_handshake: record[5], + transfer_rx: record[6], + transfer_tx: record[7], + persistent_keepalive: record[8] + }; + + if (record[3] != '(none)' && length(record[4])) + push(peer.allowed_ips, ...split(record[4], ',')); + + push(data[last_device].peers, peer); + } + } + } + + return data; + } + } +}; + +return { 'luci.wireguard': methods }; |