'use strict'; 'require fs'; 'require dom'; 'require view'; 'require form'; 'require ui'; 'require tools.widgets as widgets'; var domparser = new DOMParser(); var QRCODE_VARIABLES = ['KEY_BASE64', 'KEY', 'HMAC_KEY_BASE64', 'HMAC_KEY']; var INVALID_KEYS = ['__CHANGEME__', 'CHANGEME']; function setOptionValue(map, section_id, option, value) { var option = L.toArray(map.lookupOption(option, section_id))[0]; var uiEl = option ? option.getUIElement(section_id) : null; if (uiEl) uiEl.setValue(value); } function lines(content) { return content.split(/\r?\n/); } function parseLine(rawLine) { if (rawLine[0] != '#' && rawLine[0] != ';') { var line = rawLine.split(/ ([^;]*)/, 2); if (line.length == 2) { var key = line[0].trim(); var value = line[1].trim(); if (key && value) return [key, value]; } } return null; } function parseKeys(content) { var l = lines(content); var keys = {}; for (var i = 0; i < l.length; i++) { var p = l[i].split(/:(.*)/, 2); if (p.length == 2) keys[p[0].trim()] = p[1].trim(); } return keys; } var KeyTypeValue = form.ListValue.extend({ __init__: function() { this.super('__init__', arguments); this.hidden = false; }, cfgvalue: function(section_id) { for (var i = 0; i < this.keylist.length; i++) { var value = this.map.data.get( this.uciconfig || this.section.uciconfig || this.map.config, this.ucisection || section_id, this.keylist[i] ); if (value) return this.keylist[i]; } return this.keylist[0]; }, render: function(section_id, option_index, cfgvalue) { return this.super('render', arguments) .then(L.bind(function(el) { // Use direct style to hide, because class .hidden // is used by this.isActive(). We want full functionality, // but hidden field if (this.hidden) el.style.display = 'none'; return el; }, this)); }, remove: function() { // Ignore }, write: function() { // Ignore }, }); var YNValue = form.Flag.extend({ __init__: function() { this.super('__init__', arguments); this.enabled = 'Y'; this.disabled = 'N'; this.default = 'N'; }, cfgvalue: function(section_id) { var value = this.super('cfgvalue', arguments); return value ? String(value).toUpperCase() : value; }, parse: function(section_id) { var active = this.isActive(section_id), cval = this.cfgvalue(section_id), fval = active ? this.formvalue(section_id) : null; if (String(fval).toUpperCase() != cval) { if (fval == 'Y') return Promise.resolve(this.write(section_id, fval)); else if (cval !== undefined) return Promise.resolve(this.remove(section_id)); } }, }); var QrCodeValue = form.DummyValue.extend({ __init__: function() { this.super('__init__', arguments); this.needsRefresh = {}; this.components = []; QRCODE_VARIABLES.forEach(L.bind(function(option) { this.components.push(option); var dep = {}; dep[option] = /.+/; this.depends(dep); }, this)); }, cfgQrCode: function(section_id) { var qr = []; for (var i = 0; i < this.components.length; i++) { var value = this.map.data.get( this.uciconfig || this.section.uciconfig || this.map.config, this.ucisection || section_id, this.components[i] ); if (value) qr.push(this.components[i] + ':' + value); } return qr ? qr.join(' ') : null; }, formQrCode: function(section_id) { var qr = []; for (var i = 0; i < this.components.length; i++) { var value = null; var uiEl = L.toArray(this.map.lookupOption(this.components[i], section_id))[0]; if (uiEl) { if (uiEl.isActive(section_id)) value = uiEl.formvalue(section_id); } if (value) qr.push(this.components[i] + ':' + value); } return qr ? qr.join(' ') : null; }, onchange: function(ev, section_id) { if (this.needsRefresh[section_id] !== undefined) this.needsRefresh[section_id] = true; else { this.refresh(section_id); } }, refresh: function(section_id) { var qrcode = this.formQrCode(section_id); var formvalue = this.formvalue(section_id); if (formvalue != qrcode) { this.getUIElement(section_id).setValue(qrcode); var uiEl = document.getElementById(this.cbid(section_id)); if (uiEl) { var contentEl = uiEl.nextSibling; if (contentEl.childNodes.length == 1) { dom.append(contentEl, E('em', { 'class': 'spinning', }, [ _('Loading…') ])); } this.needsRefresh[section_id] = false; // Render QR code return this.renderSvg(qrcode) .then(L.bind(function(svgEl) { dom.content(contentEl, svgEl || E('div')); var needsAnotherRefresh = this.needsRefresh[section_id]; delete this.needsRefresh[section_id]; if (needsAnotherRefresh) { this.refresh(section_id); } }, this)).finally(L.bind(function() { if (this.needsRefresh[section_id] === undefined) { if (contentEl.childNodes.length == 2) contentEl.removeChild(contentEl.lastChild); delete this.needsRefresh[section_id]; } }, this)).catch(L.error); } } // Nothing to render return Promise.resolve(null); }, renderWidget: function(section_id) { var qrcode = this.cfgQrCode(section_id); return this.renderSvg(qrcode) .then(L.bind(function(svgEl) { var uiEl = new ui.Hiddenfield(qrcode, { id: this.cbid(section_id) }); return E([ uiEl.render(), E('div', {}, svgEl || E('div')) ]); }, this)); }, qrEncodeSvg: function(qrcode) { return fs.exec('/usr/bin/qrencode', ['--type', 'svg', '--inline', '-o', '-', qrcode]) .then(function(response) { return response.stdout; }); }, renderSvg: function(qrcode) { if (qrcode) return this.qrEncodeSvg(qrcode) .then(function(rawsvg) { return domparser.parseFromString(rawsvg, 'image/svg+xml') .querySelector('svg'); }); else return Promise.resolve(null); }, }); var GenerateButton = form.Button.extend({ __init__: function() { this.super('__init__', arguments); this.onclick = L.bind(this.generateKeys, this); this.keytypes = {}; }, keytype: function(key, regex) { this.keytypes[key] = regex; }, qrcode: function(option) { this.qrcode = option; }, generateKeys: function(ev, section_id) { return fs.exec('/usr/sbin/fwknopd', ['--key-gen']) .then(function(response) { return parseKeys(response.stdout); }) .then(L.bind(this.applyKeys, this, section_id)) .catch(L.error); }, applyKeys: function(section_id, keys) { for (var key in keys) { setOptionValue(this.map, section_id, key, keys[key]); for (var type in this.keytypes) { if (this.keytypes[type].test(key)) setOptionValue(this.map, section_id, type, key); } } // Force update of dependencies (element visibility) this.map.checkDepends(); // Refresh QR code var option = L.toArray(this.map.lookupOption(this.qrcode, section_id))[0]; if (option) return option.refresh(section_id); else return Promise.resolve(null); }, }); var ParseButton = form.Button.extend({ __init__: function() { this.super('__init__', arguments); this.onclick = L.bind(this.parseAccessConf, this); }, parseAccessConf: function() { this.stanzas = []; var ctx = { processLine: L.bind(this.processAccessLine, this), remainingLines: [], stanzas: { last: {}, all: [] } }; return fs.read('/etc/fwknop/access.conf') .then(L.bind(this.parseFile, this, ctx)) .then(L.bind(function() { if (ctx.stanzas.all.length > 0) return this.renderStanzas(ctx.stanzas.all) .then(function(topEl) { var dlg = ui.showModal(_('Firewall Knock Operator Daemon'), [ topEl, E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': ui.hideModal }, _('Close')) ], 'cbi-modal'); dlg.querySelector('button').focus(); dlg.parentNode.scrollTop = 0; }); else { var dlg = ui.showModal(_('Firewall Knock Operator Daemon'), [ E('p', _("No stanza found.")), E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': ui.hideModal }, _('Close')) ]); dlg.querySelector('button').focus(); } }, this)) .catch(function(err) { L.error(err); }); }, parseFile: function(ctx, content) { ctx.remainingLines.unshift.apply(ctx.remainingLines, lines(content)); return this.parseLines(ctx); }, parseFolder: function(ctx, folder, entries) { // Parse and process files in order var parseJobs = []; var parsedLines = []; entries.sort(function(el1, el2) { return (el1.name > el2.name) ? 1 : (el1.name < el2.name) ? -1 : 0; }); entries.forEach(L.bind(function(entry) { var ctxLines = []; parsedLines.unshift(ctxLines); parseJobs.push(fs.read(folder + '/' + entry.name) .then(function(content) { ctxLines.push.apply(ctxLines, lines(content)); })); }, this)); return Promise.all(parseJobs) .then(L.bind(function(ctx) { parsedLines.forEach(function(lines) { ctx.remainingLines.unshift.apply(ctx.remainingLines, lines); }); }, this, ctx)) .then(L.bind(this.parseLines, this, ctx)); }, parseLines: function(ctx) { while (ctx.remainingLines.length > 0) { var line = parseLine(ctx.remainingLines.shift()); if (line) { var result = ctx.processLine.call(this, ctx, line[0], line[1]); if (result) return result; } } }, processAccessLine: function(ctx, key, value) { if (key.endsWith(':')) { key = key.slice(0, -1); } if (key == "%include") { return fs.read(value) .then(L.bind(this.parseFile, this, ctx)); } else if (key == "%include_folder") { return fs.list(value) .then(L.bind(this.parseFolder, this, ctx, value)); } else if (key == "%include_keys") { var keysCtx = { processLine: L.bind(this.processKeysLine, this), remainingLines: [], stanzas: ctx.stanzas }; return fs.read(value) .then(L.bind(this.parseFile, this, keysCtx)) .then(L.bind(this.parseLines, this, ctx)); } else { if (key == 'SOURCE') { ctx.stanzas.last = {}; ctx.stanzas.all.push(ctx.stanzas.last); } ctx.stanzas.last[key] = value; } }, processKeysLine: function(ctx, key, value) { // Simplification - accept only KEY arguments if (ctx.stanzas.last && key.match(/KEY/)) ctx.stanzas.last[key] = value; }, renderStanzas: function(stanzas) { var svgJobs = []; var config = {}; config.access = stanzas; var m, s, o; var accessSection; var sourceValue; m = new form.JSONMap(config, null, _('Custom configuration read from /etc/fwknop/access.conf.')); m.readonly = true; // set the access.conf settings accessSection = s = m.section(form.TypedSection, 'access', _('access.conf stanzas')); s.anonymous = true; var qrCode = s.option(QrCodeValue, 'qr', _('QR code'), ('QR code to configure fwknopd Android application.')); sourceValue = s.option(form.Value, 'SOURCE', 'SOURCE'); s.option(form.Value, 'DESTINATION', 'DESTINATION'); o = s.option(form.Value, 'KEY', 'KEY'); o.depends('keytype', 'KEY'); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The symmetric key has to be specified.'); } o = s.option(form.Value, 'KEY_BASE64', 'KEY_BASE64'); o.depends('keytype', 'KEY_BASE64'); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The symmetric key has to be specified.'); } o = s.option(KeyTypeValue, 'keytype'); o.value('KEY', _('Normal key')); o.value('KEY_BASE64', _('Base64 key')); o.hidden = true; o = s.option(form.Value, 'HMAC_KEY', 'HMAC_KEY'); o.depends('hkeytype', 'HMAC_KEY'); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The HMAC authentication key has to be specified.'); } o = s.option(form.Value, 'HMAC_KEY_BASE64', 'HMAC_KEY_BASE64'); o.depends('hkeytype', 'HMAC_KEY_BASE64'); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The HMAC authentication key has to be specified.'); } o = s.option(KeyTypeValue, 'hkeytype'); o.value('HMAC_KEY', _('Normal key')); o.value('HMAC_KEY_BASE64', _('Base64 key')); o.hidden = true; return m.load() .then(L.bind(m.render, m)); } }); return view.extend({ load: function() { return Promise.all([ L.resolveDefault(fs.stat('/etc/fwknop/access.conf')) ]); }, render: function(results) { var has_access_conf = results[0]; var m, s, o; m = new form.Map('fwknopd', _('Firewall Knock Operator Daemon')); s = m.section(form.TypedSection, 'global', _('Enable Uci/Luci control')); s.anonymous = true; s.option(form.Flag, 'uci_enabled', _('Enable config overwrite'), _('When unchecked, the config files in /etc/fwknopd will be used as is, ignoring any settings here.')); if ( has_access_conf ) { o = s.option(ParseButton, 'parse', _('Custom configuration'), _('Parses the /etc/fwknop/access.conf file (and \ included files/folders/keys) and generates QR codes for all found \ stanzas. Handles only files in /etc/fwknop folder due to access rights \ restrictions.')); o.inputtitle = _("Show access.conf QR codes"); } s = m.section(form.TypedSection, 'network', _('Network configuration')); s.anonymous = true; o = s.option(widgets.NetworkSelect, 'network', _('Network'), _('The network on which the daemon listens. The daemon \ is automatically started when the network is up-and-running. This option \ has precedence over “PCAP_INTF” option.')); o.unpecified = true; o.nocreate = true; o.rmempty = true; // set the access.conf settings s = m.section(form.TypedSection, 'access', _('access.conf stanzas')); s.anonymous = true; s.addremove = true; var qrCode = s.option(QrCodeValue, 'qr', _('QR code'), ('QR code to configure fwknopd Android application.')); o = s.option(form.Value, 'SOURCE', 'SOURCE', _('The source address from which the SPA packet will be accepted. The string “ANY” is \ also accepted if a valid SPA packet should be honored from any source IP. \ Networks should be specified in CIDR notation (e.g. “192.168.10.0/24”), \ and individual IP addresses can be specified as well. Multiple entries \ are comma-separated.')); o.validate = function(section_id, value) { return String(value).length > 0 ? true : _('The source address has to be specified.'); } s.option(form.Value, 'DESTINATION', 'DESTINATION', _('The destination address for which the SPA packet will be accepted. The \ string “ANY” is also accepted if a valid SPA packet should be honored to any \ destination IP. Networks should be specified in CIDR notation \ (e.g. “192.168.10.0/24”), and individual IP addresses can be specified as well. \ Multiple entries are comma-separated.')); o = s.option(GenerateButton, 'keys', _('Generate keys'), _('Generates the symmetric key used for decrypting an incoming \ SPA packet, that is encrypted by the fwknop client with Rijndael block cipher, \ and HMAC authentication key used to verify the authenticity of the incoming SPA \ packet before the packet is decrypted.')); o.inputtitle = _("Generate Keys"); o.keytype('keytype', /^KEY/); o.keytype('hkeytype', /^HMAC_KEY/); o.qrcode('qr'); o = s.option(form.Value, 'KEY', 'KEY', _('Define the symmetric key used for decrypting an incoming SPA \ packet that is encrypted by the fwknop client with Rijndael.')); o.depends('keytype', 'KEY'); o.onchange = L.bind(qrCode.onchange, qrCode); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The symmetric key has to be specified.'); } o = s.option(form.Value, 'KEY_BASE64', 'KEY_BASE64', _('Define the symmetric key (in Base64 encoding) used for \ decrypting an incoming SPA packet that is encrypted by the fwknop client \ with Rijndael.')); o.depends('keytype', 'KEY_BASE64'); o.onchange = L.bind(qrCode.onchange, qrCode); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The symmetric key has to be specified.'); } o = s.option(KeyTypeValue, 'keytype', _('Key type')); o.value('KEY', _('Normal key')); o.value('KEY_BASE64', _('Base64 key')); o.onchange = L.bind(qrCode.onchange, qrCode); o = s.option(form.Value, 'HMAC_KEY', 'HMAC_KEY', _('Define the HMAC authentication key used for verifying \ the authenticity of the SPA packet before the packet is decrypted.')); o.depends('hkeytype', 'HMAC_KEY'); o.onchange = L.bind(qrCode.onchange, qrCode); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The HMAC authentication key has to be specified.'); } o = s.option(form.Value, 'HMAC_KEY_BASE64', 'HMAC_KEY_BASE64', _('Define the HMAC authentication key \ (in Base64 encoding) used for verifying the authenticity of the SPA \ packet before the packet is decrypted.')); o.depends('hkeytype', 'HMAC_KEY_BASE64'); o.onchange = L.bind(qrCode.onchange, qrCode); o.validate = function(section_id, value) { return (String(value).length > 0 && !INVALID_KEYS.includes(value)) ? true : _('The HMAC authentication key has to be specified.'); } o = s.option(KeyTypeValue, 'hkeytype', _('HMAC key type')); o.value('HMAC_KEY', _('Normal key')); o.value('HMAC_KEY_BASE64', _('Base64 key')); o.onchange = L.bind(qrCode.onchange, qrCode); o = s.option(form.Value, 'OPEN_PORTS', 'OPEN_PORTS', _('Define a set of ports and protocols (tcp or udp) that will be opened if a valid knock sequence is seen. \ If this entry is not set, fwknopd will attempt to honor any proto/port request specified in the SPA data \ (unless of it matches any “RESTRICT_PORTS” entries). Multiple entries are comma-separated.')); o.placeholder = "protocol/port,..."; o = s.option(form.Value, 'RESTRICT_PORTS', 'RESTRICT_PORTS', _('Define a set of ports and protocols (tcp or udp) that are explicitly not allowed \ regardless of the validity of the incoming SPA packet. Multiple entries are comma-separated.')); o.placeholder = "protocol/port,..."; o = s.option(form.Value, 'FW_ACCESS_TIMEOUT', 'FW_ACCESS_TIMEOUT', _('Define the length of time access will be granted by fwknopd through the firewall after a \ valid knock sequence from a source IP address. If “FW_ACCESS_TIMEOUT” is not set then the default \ timeout of 30 seconds will automatically be set.')); o.placeholder = "30"; s.option(YNValue, 'REQUIRE_SOURCE_ADDRESS', 'REQUIRE_SOURCE_ADDRESS', _('Force all SPA packets to contain a real IP address within the encrypted data. \ This makes it impossible to use the -s command line argument on the fwknop client command line, so either -R \ has to be used to automatically resolve the external address (if the client behind a NAT) or the client must \ know the external IP and set it via the -a argument.')); s = m.section(form.TypedSection, 'config', _('fwknopd.conf config options')); s.anonymous=true; s.option(form.Value, 'MAX_SPA_PACKET_AGE', 'MAX_SPA_PACKET_AGE', _('Maximum age in seconds that an SPA packet will be accepted. Defaults to 120 seconds.')); s.option(form.Value, 'PCAP_INTF', 'PCAP_INTF', _('Specify the ethernet interface on which fwknopd will sniff packets.')); s.option(YNValue, 'ENABLE_IPT_FORWARDING', 'ENABLE_IPT_FORWARDING', _('Allow SPA clients to request access to services through an iptables firewall instead of just to it.')); s.option(YNValue, 'ENABLE_NAT_DNS', 'ENABLE_NAT_DNS', _('Allow SPA clients to request forwarding destination by DNS name.')); return m.render(); } });