From be446e365cfd2cfee549a0c32af884dfb2cced66 Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Mon, 28 Sep 2026 23:38:29 +0200 Subject: [PATCH] luci-app-weechat: add new application Add a LuCI web interface for the WeeChat IRC client. - Status page: service state and version, boot autostart toggle and start, stop and restart through the rc and service ubus objects, with read-only handling for users without write access - IRC Servers page: grid with modal tabs for connection, identity, authentication (none, NickServ, SASL PLAIN, SCRAM-SHA-256 and SCRAM-SHA-512) and auto-join channels with optional keys - Relay page: relay port and listen address, TLS, password, chat logging and a link to the hosted Glowing Bear web client. After a certificate is generated, a running WeeChat is restarted if the saved settings use it. - rpcd ucode plugin "weechat", which runs as root. It reports the daemon version, enables the service by committing only option enabled, and checks or generates a self-signed relay certificate with an EC key in /etc/weechat/tls, preferring openssl over px5g. The bundle is installed as root:weechat 0640, so the daemon can read but not replace it. Certificates at other paths are left to the user. - The forms refuse the values that the init script of the weechat package rejects. - ACL limited to the weechat UCI config and the methods above Signed-off-by: Josef Schlehofer (cherry picked from commit a29ca05b02073d9b4508265377f5688778e8f5c4) --- applications/luci-app-weechat/Makefile | 13 + .../luci-static/resources/tools/weechat.js | 27 + .../resources/view/weechat/relay.js | 412 +++++++++++ .../resources/view/weechat/servers.js | 376 ++++++++++ .../resources/view/weechat/status.js | 394 +++++++++++ .../resources/view/weechat/weechat.css | 65 ++ .../luci-app-weechat/po/templates/weechat.pot | 665 ++++++++++++++++++ .../share/luci/menu.d/luci-app-weechat.json | 37 + .../share/rpcd/acl.d/luci-app-weechat.json | 36 + .../root/usr/share/rpcd/ucode/weechat.uc | 389 ++++++++++ 10 files changed, 2414 insertions(+) create mode 100644 applications/luci-app-weechat/Makefile create mode 100644 applications/luci-app-weechat/htdocs/luci-static/resources/tools/weechat.js create mode 100644 applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js create mode 100644 applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js create mode 100644 applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js create mode 100644 applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/weechat.css create mode 100644 applications/luci-app-weechat/po/templates/weechat.pot create mode 100644 applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json create mode 100644 applications/luci-app-weechat/root/usr/share/rpcd/acl.d/luci-app-weechat.json create mode 100755 applications/luci-app-weechat/root/usr/share/rpcd/ucode/weechat.uc diff --git a/applications/luci-app-weechat/Makefile b/applications/luci-app-weechat/Makefile new file mode 100644 index 0000000000..6b06d28a8a --- /dev/null +++ b/applications/luci-app-weechat/Makefile @@ -0,0 +1,13 @@ +# This is free software, licensed under the Apache License, Version 2.0 . + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI support for WeeChat IRC client +LUCI_DEPENDS:=+luci-base +weechat +rpcd-mod-ucode + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=Josef Schlehofer + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-weechat/htdocs/luci-static/resources/tools/weechat.js b/applications/luci-app-weechat/htdocs/luci-static/resources/tools/weechat.js new file mode 100644 index 0000000000..7a3a218f17 --- /dev/null +++ b/applications/luci-app-weechat/htdocs/luci-static/resources/tools/weechat.js @@ -0,0 +1,27 @@ +'use strict'; +'require baseclass'; + +return baseclass.extend({ + parseAutoJoin: function(val) { + if (!val || !val.trim()) + return { channels: [], keys: [] }; + + var trimmed = val.trim(); + var spaceIdx = trimmed.search(/\s/); + if (spaceIdx === -1) { + return { + channels: trimmed.split(',').map(function(c) { return c.trim(); }).filter(Boolean), + keys: [] + }; + } + + var firstPart = trimmed.substring(0, spaceIdx).trim(); + var rest = trimmed.substring(spaceIdx + 1).trim(); + + /* Keys are positional, so keep empty ones to stay aligned with channels */ + return { + channels: firstPart.split(',').map(function(c) { return c.trim(); }).filter(Boolean), + keys: rest.length ? rest.split(',').map(function(k) { return k.trim(); }) : [] + }; + } +}); diff --git a/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js new file mode 100644 index 0000000000..c4ab274156 --- /dev/null +++ b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js @@ -0,0 +1,412 @@ +'use strict'; +'require view'; +'require form'; +'require rpc'; +'require uci'; +'require ui'; + +var callGetCertStatus = rpc.declare({ + object: 'weechat', + method: 'get_certificate_status', + params: [ 'path' ], + expect: { '': {} } +}); + +var callGenerateCert = rpc.declare({ + object: 'weechat', + method: 'generate_certificate', + params: [ 'cn', 'san', 'path' ], + expect: { '': {} } +}); + +var callServiceList = rpc.declare({ + object: 'service', + method: 'list', + params: [ 'name' ], + expect: { '': {} } +}); + +var callRcInit = rpc.declare({ + object: 'rc', + method: 'init', + params: [ 'name', 'action' ] +}); + +/* Mirrors check_path() in the init script, which refuses to start otherwise */ +function validatePath(value, allowPercent) { + if (!value) + return true; + if (value.charAt(0) !== '/' || /[\r\n]/.test(value) || /(^|\/)\.\.?(\/|$)/.test(value)) + return _('Must be an absolute path without . or .. components'); + if (value !== value.trim()) + return _('Must not start or end with a space'); + if (value.indexOf('${') !== -1) + return _('Must not contain "${"'); + if (!allowPercent && value.indexOf('%') !== -1) + return _('Must not contain "%"'); + return true; +} + +/* WeeChat only reads the certificate when it starts, so restart a running relay */ +function restartIfRunning() { + return callServiceList('weechat').then(function(res) { + var instances = (res && res.weechat && res.weechat.instances) || {}; + var running = Object.keys(instances).some(function(k) { return instances[k].running; }); + + if (!running) + return false; + + return callRcInit('weechat', 'restart').then(function(ret) { + if (ret) + throw new Error(_('Command failed with return code %d').format(ret)); + return true; + }); + }); +} + +return view.extend({ + load: function() { + return uci.load('weechat'); + }, + + render: function() { + var m, s, o; + var defaultPath = '/etc/weechat/tls/relay.pem'; + var refreshCertStatus = null; + + /* Only certificates in this directory are checked and generated here */ + function isManagedPath(path) { + return /^\/etc\/weechat\/tls\/[A-Za-z0-9._-]+\.pem$/.test(path) && path.indexOf('..') === -1; + } + + /* IPv6 literals come with brackets, which are not valid in a SAN */ + var hostname = (window.location.hostname || 'OpenWrt').replace(/^\[(.*)\]$/, '$1'); + var defaultSan = hostname; + + m = new form.Map('weechat', _('WeeChat - Relay'), + _('Configure the WeeChat relay and chat logging.')); + + /* Relay */ + s = m.section(form.NamedSection, 'weechat', 'weechat', _('Relay')); + + var optRelayEnabled = s.option(form.Flag, 'relay_enabled', _('Enable Relay Server')); + optRelayEnabled.default = optRelayEnabled.disabled; + optRelayEnabled.rmempty = false; + + o = s.option(form.Value, 'relay_address', _('Listen Address'), + _('IP address to bind (leave empty for all interfaces). The address must exist when WeeChat starts.')); + o.datatype = 'ipaddr("nomask")'; + o.placeholder = _('All interfaces'); + /* WeeChat cannot bind to it and the init script refuses it */ + o.validate = function(section_id, value) { + var m = (value || '').match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/); + if (m && m[1].split('.').some(function(p) { return p.length > 1 && p.charAt(0) === '0'; })) + return _('Leading zeros are not allowed in an IPv4 address'); + return true; + }; + o.rmempty = true; + o.depends('relay_enabled', '1'); + + o = s.option(form.Value, 'relay_port', _('Port'), + _('Port for relay connections (WebSocket).')); + o.datatype = 'and(port,min(1))'; + o.default = '9000'; + o.placeholder = '9000'; + o.depends('relay_enabled', '1'); + + /* Security */ + s = m.section(form.NamedSection, 'weechat', 'weechat', _('Security')); + + o = s.option(form.Flag, 'relay_tls', _('Enable TLS'), + _('Encrypt relay connections with TLS.')); + o.default = o.disabled; + + var optCertKey = s.option(form.Value, 'relay_cert_key', _('Certificate'), + _('PEM file containing the certificate and private key. It must be readable by the weechat user.')); + optCertKey.placeholder = defaultPath; + optCertKey.rmempty = true; + optCertKey.depends('relay_tls', '1'); + optCertKey.validate = function(section_id, value) { + return validatePath(value, true); + }; + optCertKey.onchange = function() { + if (refreshCertStatus) + refreshCertStatus(); + }; + + /* An empty field means the default path, as in the init script */ + function getActiveCertPath() { + var val = optCertKey.formvalue('weechat'); + if (val == null) + val = uci.get('weechat', 'weechat', 'relay_cert_key'); + return (val || '').trim() || defaultPath; + } + + /* Certificate status widget */ + var optCertStatus = s.option(form.DummyValue, '_cert_status', _('Certificate status')); + optCertStatus.depends('relay_tls', '1'); + + optCertStatus.renderWidget = function(section_id, option_index, cfgvalue) { + var container = E('div', { 'class': 'cbi-value-field' }); + var isReadonly = (this.readonly != null) ? this.readonly : this.map.readonly; + + function updateWidget(path) { + while (container.firstChild) + container.removeChild(container.firstChild); + + if (!isManagedPath(path)) { + container.appendChild(E('div', { 'class': 'alert-message notice' }, + _('This certificate is managed outside of LuCI, so it is not checked here. The service refuses to start if the file does not exist.'))); + return; + } + + container.appendChild(E('em', {}, _('Checking certificate status…'))); + + callGetCertStatus(path || defaultPath).then(function(res) { + while (container.firstChild) + container.removeChild(container.firstChild); + + var elements = []; + + if (res && res.exists && res.has_cert && res.has_key) { + elements.push(E('div', {}, [ + E('span', { 'class': 'label success' }, [ '● ', _('Certificate and key present') ]) + ])); + + elements.push(E('p', { 'class': 'cbi-value-description' }, [ + E('strong', {}, _('File: ')), + E('code', {}, res.path) + ])); + + elements.push(E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'disabled': isReadonly || null, + 'click': function(ev) { + ev.preventDefault(); + showGenerateDialog(getActiveCertPath(), res.generator_available); + } + }, _('Generate new certificate'))); + } else if (res && res.exists && (!res.has_cert || !res.has_key)) { + elements.push(E('div', { 'class': 'alert-message warning' }, + _('Certificate file exists but is incomplete (must contain both certificate and private key).'))); + + elements.push(E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'disabled': isReadonly || null, + 'click': function(ev) { + ev.preventDefault(); + showGenerateDialog(getActiveCertPath(), res.generator_available); + } + }, _('Generate self-signed certificate'))); + } else { + elements.push(E('div', { 'class': 'alert-message warning' }, + _('TLS is enabled but no certificate file was found.'))); + + elements.push(E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'type': 'button', + 'disabled': isReadonly || null, + 'click': function(ev) { + ev.preventDefault(); + showGenerateDialog(getActiveCertPath(), res && res.generator_available); + } + }, _('Generate self-signed certificate'))); + } + + if (res && res.generator_available === false) { + elements.push(E('div', { 'class': 'alert-message warning' }, + _('Certificate generation requires the %s or %s utility.').format('px5g', 'openssl'))); + } + + for (var i = 0; i < elements.length; i++) + container.appendChild(elements[i]); + }).catch(function(err) { + while (container.firstChild) + container.removeChild(container.firstChild); + container.appendChild(E('div', { 'class': 'alert-message error' }, + _('Unable to verify certificate status: %s').format(err.message || err))); + }); + } + + function showGenerateDialog(targetPath, generatorAvailable) { + var inputCn, inputSan, btnGenerate, btnCancel, msgBox; + + var modalContent = [ + E('div', { 'class': 'alert-message notice' }, [ + E('p', {}, + _('A self-signed certificate will be generated for WeeChat Relay at %s.').format(targetPath)), + E('p', {}, + E('strong', {}, _('Self-signed certificates provide encryption but are not trusted automatically by browsers.'))), + E('p', {}, + _('For Internet-facing deployments, use a certificate issued by a trusted CA.')) + ]), + + E('h5', {}, _('Certificate identity')), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Common name')), + E('div', { 'class': 'cbi-value-field' }, [ + inputCn = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'value': hostname, + 'placeholder': 'router.local' + }), + E('div', { 'class': 'cbi-value-description' }, + _('Host name identifying this router.')) + ]) + ]), + + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Subject alternative names')), + E('div', { 'class': 'cbi-value-field' }, [ + inputSan = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'value': defaultSan, + 'placeholder': 'router.local, 192.168.1.1' + }), + E('div', { 'class': 'cbi-value-description' }, + _('Comma-separated list of host names and IP addresses used to reach the relay.')) + ]) + ]), + + msgBox = E('div', {}), + + E('div', { 'class': 'right' }, [ + btnCancel = E('button', { + 'class': 'btn cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + btnGenerate = E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'click': function(ev) { + ev.preventDefault(); + var cnVal = inputCn.value.trim(); + var sanVal = inputSan.value.trim(); + + if (!cnVal) { + ui.addNotification(null, E('p', _('Please enter a valid Common Name.')), 'warning'); + return; + } + + btnGenerate.disabled = true; + btnCancel.disabled = true; + btnGenerate.textContent = _('Generating…'); + + while (msgBox.firstChild) + msgBox.removeChild(msgBox.firstChild); + msgBox.appendChild(E('p', { 'class': 'spinning' }, _('Generating self-signed certificate…'))); + + callGenerateCert(cnVal, sanVal, targetPath).then(function(res) { + if (res && res.success) { + ui.hideModal(); + updateWidget(getActiveCertPath()); + /* Only the saved relay settings decide which certificate WeeChat loads */ + (res.in_use ? restartIfRunning() : Promise.resolve(null)).then(function(restarted) { + var msg; + if (restarted === true) + msg = _('Self-signed certificate generated for %s. WeeChat was restarted to use it.'); + else if (restarted === false) + msg = _('Self-signed certificate generated for %s. WeeChat uses it from its next start.'); + else + msg = _('Self-signed certificate generated for %s. Save and apply TLS with this certificate to use it.'); + ui.addNotification(null, E('p', msg.format(cnVal)), 'info'); + }).catch(function(err) { + ui.addNotification(null, E('p', + _('Self-signed certificate generated for %s, but WeeChat could not be restarted: %s').format(cnVal, err.message || err)), + 'warning'); + }); + } else { + btnGenerate.disabled = false; + btnCancel.disabled = false; + btnGenerate.textContent = _('Generate'); + while (msgBox.firstChild) + msgBox.removeChild(msgBox.firstChild); + msgBox.appendChild(E('div', { 'class': 'alert-message error' }, + res && res.error ? res.error : _('Certificate generation failed.'))); + } + }).catch(function(err) { + btnGenerate.disabled = false; + btnCancel.disabled = false; + btnGenerate.textContent = _('Generate'); + while (msgBox.firstChild) + msgBox.removeChild(msgBox.firstChild); + msgBox.appendChild(E('div', { 'class': 'alert-message error' }, + err.message || err)); + }); + } + }, _('Generate')) + ]) + ]; + + if (generatorAvailable === false) { + btnGenerate.disabled = true; + msgBox.appendChild(E('div', { 'class': 'alert-message error' }, + _('Cannot generate certificate: neither %s nor %s is installed on this router.').format('px5g', 'openssl'))); + } + + ui.showModal(_('Generate certificate?'), modalContent); + } + + refreshCertStatus = function() { + updateWidget(getActiveCertPath()); + }; + + /* The field is not in the page yet while rendering, start from its stored value */ + updateWidget(String(optCertKey.cfgvalue(section_id) || '').trim() || defaultPath); + + return container; + }; + + o = s.option(form.Value, 'relay_password', _('Relay Password'), + _('Password required by clients to connect.')); + o.password = true; + o.validate = function(section_id, value) { + var formVal = optRelayEnabled ? optRelayEnabled.formvalue(section_id) : null; + var isEnabled = (formVal !== null) ? (formVal === '1') : (uci.get('weechat', section_id, 'relay_enabled') === '1'); + if (isEnabled && (!value || !value.trim())) { + return _('Relay password is required when relay server is enabled.'); + } + return true; + }; + + /* Logging */ + s = m.section(form.NamedSection, 'weechat', 'weechat', _('Logging')); + + o = s.option(form.Flag, 'log_enabled', _('Enable Logging'), + _('Record chat logs to disk. Disabled by default to protect flash memory.')); + o.default = o.disabled; + + o = s.option(form.Value, 'log_dir', _('Log Directory'), + _('Directory where WeeChat stores chat logs. The default /var/log/weechat is created automatically, any other directory must exist and be writable by the weechat user.')); + o.placeholder = '/var/log/weechat'; + o.depends('log_enabled', '1'); + o.validate = function(section_id, value) { + return validatePath(value, false); + }; + + /* Web Client */ + s = m.section(form.NamedSection, 'weechat', 'weechat', _('Web Client')); + + o = s.option(form.DummyValue, '_gb_info'); + o.cfgvalue = function() { + return E('div', {}, [ + E('p', { 'style': 'margin-bottom: 12px; line-height: 1.5;' }, + _('Glowing Bear is a web client that connects to WeeChat using the relay configured above.')), + E('a', { + 'class': 'btn cbi-button cbi-button-action', + 'href': 'https://www.glowing-bear.org/', + 'target': '_blank', + 'rel': 'noreferrer noopener', + 'style': 'margin-bottom: 6px; display: inline-block;' + }, [ _('Open Glowing Bear (Hosted)'), ' ↗' ]) + ]); + }; + + return m.render(); + } +}); diff --git a/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js new file mode 100644 index 0000000000..6d6482b58b --- /dev/null +++ b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js @@ -0,0 +1,376 @@ +'use strict'; +'require view'; +'require form'; +'require uci'; +'require ui'; +'require tools.weechat as weechatTools'; + +/* WeeChat evaluates these options, the init script refuses "${" in them */ +function validateNoExpr(section_id, value) { + if (value && value.indexOf('${') !== -1) + return _('Must not contain "${"'); + return true; +} + +function checkAutoJoinRows(rows) { + for (var i = 0; i < rows.length; i++) { + var chanInput = rows[i].querySelector('.chan-input'); + var keyInput = rows[i].querySelector('.key-input'); + var chan = (chanInput ? chanInput.value : '').trim(); + var key = (keyInput ? keyInput.value : '').trim(); + + if (!chan && key) + return _('Channel key cannot be specified without a channel name'); + if (chan && (chan.indexOf(',') !== -1 || /\s/.test(chan))) + return _('Channel name "%s" cannot contain commas or spaces').format(chan); + if (key && (key.indexOf(',') !== -1 || /\s/.test(key))) + return _('Channel key cannot contain commas or spaces'); + if (validateNoExpr(null, chan) !== true || validateNoExpr(null, key) !== true) + return _('Channels and keys must not contain "${"'); + } + return true; +} + +var CBIAutoJoin = form.Value.extend({ + validate: function(section_id, value) { + var el = document.getElementById(this.cbid(section_id)); + if (!el || !el.parentNode) + return true; + + var res = checkAutoJoinRows(el.parentNode.querySelectorAll('tr[data-channel-row]')); + + /* The modal shows no message for this widget, so show it here */ + var box = el.parentNode.querySelector('.autojoin-error'); + if (box) { + box.textContent = (res === true) ? '' : res; + box.style.display = (res === true) ? 'none' : ''; + } + + return res; + }, + + /* + * The value lives in a hidden field, which never runs validation on its + * own, so let parse() see the result of validate() through these. + */ + isValid: function(section_id) { + return this.validate(section_id, this.formvalue(section_id)) === true; + }, + + getValidationError: function(section_id) { + var res = this.validate(section_id, this.formvalue(section_id)); + return (res === true) ? '' : res; + }, + + textvalue: function(section_id) { + var val = this.cfgvalue(section_id); + var parsed = weechatTools.parseAutoJoin(val); + if (parsed.channels.length === 0) + return null; + + var nodes = []; + parsed.channels.forEach(function(chan, idx) { + if (idx > 0) + nodes.push(', '); + nodes.push(E('code', {}, chan)); + }); + return E('span', {}, nodes); + }, + + renderWidget: function(section_id, option_index, cfgvalue) { + var isReadonly = (this.readonly != null) ? this.readonly : this.map.readonly; + var hidden = new ui.Hiddenfield((cfgvalue != null) ? cfgvalue : '', { + id: this.cbid(section_id) + }); + + var table = E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr cbi-section-table-titles' }, [ + E('th', { 'class': 'th cbi-section-table-cell' }, _('Channel')), + E('th', { 'class': 'th cbi-section-table-cell' }, _('Key')), + E('th', { 'class': 'th cbi-section-table-cell cbi-section-actions' }, '') + ]) + ]); + + function updateValue() { + var rows = table.querySelectorAll('tr[data-channel-row]'); + var chansWithKeys = []; + var chansWithoutKeys = []; + + rows.forEach(function(row) { + var chanInput = row.querySelector('.chan-input'); + var keyInput = row.querySelector('.key-input'); + var chan = (chanInput ? chanInput.value : '').trim(); + var key = (keyInput ? keyInput.value : '').trim(); + + if (!chan) + return; + + if (key) + chansWithKeys.push({ chan: chan, key: key }); + else + chansWithoutKeys.push(chan); + }); + + var allChans = chansWithKeys.map(function(item) { return item.chan; }).concat(chansWithoutKeys); + var allKeys = chansWithKeys.map(function(item) { return item.key; }); + var result = allChans.join(','); + if (allKeys.length > 0) + result += ' ' + allKeys.join(','); + + hidden.setValue(result); + } + + function createRow(channel, key) { + var chanInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text chan-input', + 'placeholder': '#openwrt', + 'value': channel || '', + 'disabled': isReadonly || null + }); + + chanInput.addEventListener('input', function() { + if (chanInput.value.indexOf(',') !== -1 || /\s/.test(chanInput.value)) { + chanInput.classList.add('cbi-input-invalid'); + chanInput.title = _('Enter a single channel per row without commas or spaces'); + } else { + chanInput.classList.remove('cbi-input-invalid'); + chanInput.removeAttribute('title'); + } + updateValue(); + }); + + var keyInput = E('input', { + 'type': 'password', + 'class': 'cbi-input-text key-input', + 'placeholder': _('Optional key'), + 'value': key || '', + 'disabled': isReadonly || null + }); + + keyInput.addEventListener('input', function() { + if (keyInput.value.indexOf(',') !== -1 || /\s/.test(keyInput.value)) { + keyInput.classList.add('cbi-input-invalid'); + keyInput.title = _('Channel key cannot contain commas or spaces'); + } else { + keyInput.classList.remove('cbi-input-invalid'); + keyInput.removeAttribute('title'); + } + updateValue(); + }); + + var btnRemove = E('button', { + 'type': 'button', + 'class': 'btn cbi-button cbi-button-remove', + 'title': _('Remove'), + 'disabled': isReadonly || null + }, '✕'); + + var row = E('tr', { + 'class': 'tr cbi-section-table-row', + 'data-channel-row': '' + }, [ + E('td', { 'class': 'td cbi-section-table-cell' }, chanInput), + E('td', { 'class': 'td cbi-section-table-cell' }, keyInput), + E('td', { 'class': 'td cbi-section-table-cell cbi-section-actions' }, btnRemove) + ]); + + btnRemove.addEventListener('click', function() { + row.remove(); + updateValue(); + }); + + return row; + } + + var parsed = weechatTools.parseAutoJoin(cfgvalue != null ? cfgvalue : ''); + if (parsed.channels.length > 0) { + parsed.channels.forEach(function(chan, idx) { + table.appendChild(createRow(chan, parsed.keys[idx] || '')); + }); + } + else { + table.appendChild(createRow('', '')); + } + + var btnAdd = E('button', { + 'type': 'button', + 'class': 'btn cbi-button cbi-button-add', + 'disabled': isReadonly || null + }, _('Add channel')); + + btnAdd.addEventListener('click', function() { + var newRow = createRow('', ''); + table.appendChild(newRow); + var input = newRow.querySelector('.chan-input'); + if (input) + input.focus(); + }); + + return E('div', { 'class': 'cbi-value-field' }, [ + table, + btnAdd, + E('div', { 'class': 'alert-message error autojoin-error', 'style': 'display: none' }), + hidden.render() + ]); + } +}); + +return view.extend({ + render: function() { + var m, s, o; + + m = new form.Map('weechat', _('WeeChat - IRC Servers'), + _('Configure IRC server connections, authentication, and auto-join channels.')); + + s = m.section(form.GridSection, 'server', _('IRC Servers')); + s.addremove = true; + s.anonymous = false; + s.addbtntitle = _('Add Server'); + s.nodescriptions = true; + + s.sectiontitle = function(section_id) { + return section_id; + }; + + /* Define Tabs */ + s.tab('connection', _('Connection')); + s.tab('identity', _('Identity')); + s.tab('auth', _('Authentication')); + s.tab('channels', _('Channels')); + + /* Connection settings (table columns + modal Connection tab) */ + o = s.taboption('connection', form.Value, 'address', _('Server Hostname'), + _('Hostname or IP address of the IRC server.')); + o.placeholder = 'irc.oftc.net'; + o.datatype = 'host'; + o.rmempty = false; + + o = s.taboption('connection', form.Value, 'port', _('Port')); + o.datatype = 'and(port,min(1))'; + o.default = '6697'; + o.placeholder = '6697'; + + o = s.taboption('connection', form.Flag, 'ssl', _('Use TLS')); + o.default = o.enabled; + o.rmempty = false; + + o = s.taboption('connection', form.ListValue, 'ipv6', _('IPv6 Support'), + _('IPv6 connection mode for this IRC server.')); + o.value('auto', _('Auto')); + o.value('force', _('Force IPv6')); + o.value('disable', _('Disable IPv6')); + o.default = 'auto'; + o.textvalue = function(section_id) { + var val = this.cfgvalue(section_id) || this.default; + switch (val) { + case 'force': return _('Force IPv6'); + case 'disable': return _('Disabled'); + case 'auto': + default: + return _('Auto'); + } + }; + + /* Channels (table column + modal Channels tab) */ + o = s.taboption('channels', CBIAutoJoin, 'autojoin', _('Auto-join Channels'), + _('Channels to join automatically after connecting to this server.')); + o.rmempty = true; + + /* Auto-connect & Enabled flags (table columns + modal Connection tab) */ + o = s.taboption('connection', form.Flag, 'autoconnect', _('Auto-connect'), + _('Connect automatically when WeeChat starts.')); + o.default = o.enabled; + o.rmempty = false; + o.editable = true; + + o = s.taboption('connection', form.Flag, 'enabled', _('Enabled')); + o.default = o.enabled; + o.rmempty = false; + o.editable = true; + + /* Tab 2: Identity */ + o = s.taboption('identity', form.Value, 'nicks', _('Nicknames'), + _('Comma-separated list of nicknames to try.')); + o.placeholder = 'weechat,weechat_'; + o.modalonly = true; + o.validate = validateNoExpr; + + o = s.taboption('identity', form.Value, 'username', _('Username')); + o.placeholder = 'weechat'; + o.modalonly = true; + o.validate = validateNoExpr; + + o = s.taboption('identity', form.Value, 'realname', _('Real Name')); + o.placeholder = 'WeeChat User'; + o.modalonly = true; + o.validate = validateNoExpr; + + /* Tab 3: Authentication */ + o = s.taboption('auth', form.ListValue, 'auth_method', _('Authentication Method'), + _('Select the authentication method required by the IRC network.')); + o.value('none', _('None')); + o.value('nickserv', _('NickServ (/msg nickserv identify)')); + o.value('sasl_plain', _('SASL PLAIN')); + o.value('sasl_scram_sha_256', _('SASL SCRAM-SHA-256')); + o.value('sasl_scram_sha_512', _('SASL SCRAM-SHA-512')); + o.default = 'none'; + o.modalonly = true; + + o = s.taboption('auth', form.Value, 'sasl_username', _('SASL Username'), + _('Account username if different from nickname.')); + o.depends('auth_method', 'sasl_plain'); + o.depends('auth_method', 'sasl_scram_sha_256'); + o.depends('auth_method', 'sasl_scram_sha_512'); + o.modalonly = true; + /* Same default as the init script: the first nickname, then the username */ + o.validate = function(section_id, value) { + var nick = (this.section.formvalue(section_id, 'nicks') || '').split(',')[0]; + var account = value || nick || this.section.formvalue(section_id, 'username') || ''; + if (!account.trim()) + return _('Enter a SASL username or a nickname'); + return validateNoExpr(section_id, value); + }; + + o = s.taboption('auth', form.Value, 'password', _('Password')); + o.password = true; + o.depends('auth_method', 'nickserv'); + o.depends('auth_method', 'sasl_plain'); + o.depends('auth_method', 'sasl_scram_sha_256'); + o.depends('auth_method', 'sasl_scram_sha_512'); + o.modalonly = true; + o.validate = function(section_id, value) { + var method = this.section.formvalue(section_id, 'auth_method'); + + if (!method || method === 'none') + return true; + if (!value || !value.trim()) + return _('A password is required for this authentication method'); + /* WeeChat replaces these in the NickServ command after decoding the password */ + if (method === 'nickserv' && /\$(nick|channel|server)/.test(value)) + return _('A NickServ password cannot contain $nick, $channel or $server'); + return true; + }; + + return m.render().then(L.bind(function(nodes) { + var target = null; + var hashMatch = (window.location.hash || '').match(/#(?:edit|server)=([^&]+)/); + if (hashMatch) { + target = decodeURIComponent(hashMatch[1]); + } else { + var params = new URLSearchParams(window.location.search); + target = params.get('server') || params.get('edit'); + } + + if (target && (m.data.get('weechat', target) || uci.get('weechat', target))) { + if (window.history && window.history.replaceState) { + var cleanSearch = window.location.search ? window.location.search.replace(/[?&](?:server|edit)=[^&]*/g, '').replace(/^&/, '?') : ''; + window.history.replaceState(null, '', window.location.pathname + cleanSearch); + } + s.renderMoreOptionsModal(target); + } + + return nodes; + }, this)); + } +}); diff --git a/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js new file mode 100644 index 0000000000..65f18a7422 --- /dev/null +++ b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js @@ -0,0 +1,394 @@ +'use strict'; +'require view'; +'require rpc'; +'require uci'; +'require ui'; +'require poll'; +'require tools.weechat as weechatTools'; + +function renderEnabledState(isEnabled) { + return [ + isEnabled + ? E('span', { 'class': 'label success', 'style': 'margin-right: 8px;' }, _('Enabled')) + : E('span', { 'class': 'label danger', 'style': 'margin-right: 8px;' }, _('Disabled')), + E('span', { 'class': 'cbi-value-description' }, + isEnabled ? _('Service is allowed to start.') : _('Service is disabled in configuration.')) + ]; +} + +function getServiceStatus(serviceData) { + var isRunning = false, + pid = null; + + if (serviceData && serviceData.weechat && serviceData.weechat.instances) { + var instances = serviceData.weechat.instances; + for (var inst in instances) { + if (instances[inst].running) { + isRunning = true; + pid = instances[inst].pid; + break; + } + } + } + + return { running: isRunning, pid: pid }; +} + +return view.extend({ + callServiceList: rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { '': {} } + }), + + callRcList: rpc.declare({ + object: 'rc', + method: 'list', + expect: { '': {} } + }), + + callRcInit: rpc.declare({ + object: 'rc', + method: 'init', + params: ['name', 'action'] + }), + + callDaemonInfo: rpc.declare({ + object: 'weechat', + method: 'get_daemon_info', + expect: { '': {} } + }), + + callEnableService: rpc.declare({ + object: 'weechat', + method: 'enable_service', + expect: { success: false } + }), + + /* + * The init script only starts WeeChat with option enabled '1'. Let the + * backend commit just that option, uci.apply() would apply every change + * staged in this session, for any config. + */ + ensureEnabled: function() { + if (uci.get('weechat', 'weechat', 'enabled') === '1') + return Promise.resolve(); + + return this.callEnableService().then(function(success) { + if (!success) + throw new Error(_('Could not enable the service in /etc/config/weechat')); + + var cell = document.getElementById('weechat-service-enabled'); + if (cell) { + while (cell.firstChild) + cell.removeChild(cell.firstChild); + renderEnabledState(true).forEach(function(node) { cell.appendChild(node); }); + } + + uci.unload('weechat'); + return uci.load('weechat'); + }); + }, + + load: function() { + return Promise.all([ + uci.load('weechat'), + this.callServiceList('weechat'), + this.callRcList(), + L.resolveDefault(this.callDaemonInfo(), {}) + ]); + }, + + handleServiceAction: function(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, action) { + btnStart.disabled = true; + btnRestart.disabled = true; + btnStop.disabled = true; + + var ensureEnabled = (action === 'start') ? this.ensureEnabled() : Promise.resolve(); + + return ensureEnabled.then(L.bind(function() { + return this.callRcInit('weechat', action); + }, this)).then(L.bind(function(ret) { + if (ret) + throw new Error(_('Command failed with return code %d').format(ret)); + + return new Promise(function(resolve) { + window.setTimeout(resolve, 1000); + }); + }, this)).then(L.bind(function() { + return this.updateServiceStatus(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly); + }, this)).catch(function(e) { + ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/weechat %s": %s').format(action, e.message || e))); + }); + }, + + handleToggleAutostart: function(enable, autostartBadge, autostartBtn, isReadonly, ev) { + var action = enable ? 'enable' : 'disable'; + autostartBtn.disabled = true; + + var prepare = enable ? this.ensureEnabled() : Promise.resolve(); + + return prepare.then(L.bind(function() { + return this.callRcInit('weechat', action); + }, this)).then(L.bind(function(ret) { + if (ret) + throw new Error(_('Command failed with return code %d').format(ret)); + + autostartBadge.className = 'label ' + (enable ? 'success' : ''); + autostartBadge.textContent = enable ? _('Enabled') : _('Disabled'); + autostartBtn.textContent = enable ? _('Disable Autostart') : _('Enable Autostart'); + autostartBtn.disabled = isReadonly ? true : false; + autostartBtn.onclick = isReadonly ? null : ui.createHandlerFn(this, 'handleToggleAutostart', !enable, autostartBadge, autostartBtn, isReadonly); + }, this)).catch(function(e) { + autostartBtn.disabled = false; + ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/weechat %s": %s').format(action, e.message || e))); + }); + }, + + updateServiceStatus: function(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly) { + return this.callServiceList('weechat').then(function(res) { + var st = getServiceStatus(res); + var isRunning = st.running; + var pid = st.pid; + + statusBadge.className = 'label ' + (isRunning ? 'success' : 'danger'); + statusBadge.textContent = isRunning ? _('Running') : _('Stopped'); + pidLabel.textContent = (isRunning && pid) ? ('PID: ' + pid) : ''; + + btnStart.disabled = (isRunning || isReadonly); + btnRestart.disabled = (!isRunning || isReadonly); + btnStop.disabled = (!isRunning || isReadonly); + }); + }, + + render: function(data) { + var serviceData = data[1] || {}, + rcData = data[2] || {}, + daemonInfo = data[3] || {}, + isReadonly = !L.hasViewPermission(), + st = getServiceStatus(serviceData), + isRunning = st.running, + pid = st.pid, + isAutostart = !!(rcData && rcData.weechat && rcData.weechat.enabled), + isEnabled = (uci.get('weechat', 'weechat', 'enabled') === '1'), + version = (daemonInfo && daemonInfo.version) ? daemonInfo.version : '-'; + + var statusBadge = E('span', { + 'class': 'label ' + (isRunning ? 'success' : 'danger'), + 'style': 'padding: 4px 10px; font-size: 90%; font-weight: bold; border-radius: 4px;' + }, isRunning ? _('Running') : _('Stopped')); + + var pidLabel = E('span', { + 'class': 'cbi-value-description', + 'style': 'margin-left: 8px; font-weight: normal;' + }, (isRunning && pid) ? ('PID: ' + pid) : ''); + + var autostartBadge = E('span', { + 'class': 'label ' + (isAutostart ? 'success' : ''), + 'style': 'margin-right: 12px;' + }, isAutostart ? _('Enabled') : _('Disabled')); + + var autostartBtn = E('button', { + 'class': 'btn cbi-button cbi-button-action', + 'disabled': isReadonly ? 'disabled' : null + }, isAutostart ? _('Disable Autostart') : _('Enable Autostart')); + + if (!isReadonly) { + autostartBtn.onclick = ui.createHandlerFn(this, 'handleToggleAutostart', !isAutostart, autostartBadge, autostartBtn, isReadonly); + } + + var btnStart = E('button', { + 'class': 'btn cbi-button cbi-button-action weechat-btn-control', + 'disabled': (isRunning || isReadonly) ? 'disabled' : null + }, _('Start')); + + var btnRestart = E('button', { + 'class': 'btn cbi-button cbi-button-reload weechat-btn-control', + 'disabled': (!isRunning || isReadonly) ? 'disabled' : null + }, _('Restart')); + + var btnStop = E('button', { + 'class': 'btn cbi-button cbi-button-negative weechat-btn-control', + 'disabled': (!isRunning || isReadonly) ? 'disabled' : null + }, _('Stop')); + + if (!isReadonly) { + btnStart.addEventListener('click', + ui.createHandlerFn(this, 'handleServiceAction', + statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'start')); + btnRestart.addEventListener('click', + ui.createHandlerFn(this, 'handleServiceAction', + statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'restart')); + btnStop.addEventListener('click', + ui.createHandlerFn(this, 'handleServiceAction', + statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly, 'stop')); + } + + var relayEnabled = (uci.get('weechat', 'weechat', 'relay_enabled') === '1'), + relayPort = uci.get('weechat', 'weechat', 'relay_port') || '9000', + relayTls = (uci.get('weechat', 'weechat', 'relay_tls') === '1'); + + var servers = uci.sections('weechat', 'server'), + enabledCount = 0; + + servers.forEach(function(srv) { + if (srv.enabled !== '0' && srv.enabled !== false && srv.enabled !== 'off') + enabledCount++; + }); + + var relayStatusNode; + if (relayEnabled) { + var clientBtn = E('a', { + 'class': 'btn cbi-button cbi-button-neutral', + 'href': 'https://www.glowing-bear.org/', + 'target': '_blank', + 'rel': 'noreferrer noopener', + 'style': 'display: inline-block; margin-top: 6px;' + }, [ _('Open Web Client (Hosted)'), ' ↗' ]); + + relayStatusNode = E('div', {}, [ + E('div', {}, [ + E('span', { 'class': 'label success' }, _('Enabled')) + ]), + E('div', { 'class': 'cbi-value-description', 'style': 'margin-top: 4px;' }, + _('Port %s · TLS %s').format(relayPort, relayTls ? _('enabled') : _('disabled'))), + E('div', { 'style': 'margin-top: 6px;' }, clientBtn) + ]); + } else { + relayStatusNode = E('div', {}, [ + E('span', { 'class': 'label' }, _('Disabled')) + ]); + } + + var ircStatusNode = E('div', {}, [ + E('span', {}, + _('%d configured · %d enabled').format(servers.length, enabledCount)) + ]); + + var serverRows = []; + if (servers.length === 0) { + serverRows.push(E('tr', { 'class': 'tr cbi-section-table-row' }, [ + E('td', { 'colspan': 8, 'class': 'td cbi-section-table-cell center' }, + _('No IRC servers configured. Go to Servers tab to add one.')) + ])); + } else { + servers.forEach(function(s) { + var isSrvEnabled = (s.enabled !== '0' && s.enabled !== false && s.enabled !== 'off'); + var isSrvSsl = (s.ssl !== '0' && s.ssl !== false && s.ssl !== 'off'); + var parsed = weechatTools.parseAutoJoin(s.autojoin); + var chans = parsed.channels; + + var chanNodes = []; + if (chans.length > 0) { + chans.forEach(function(chan) { + chanNodes.push(E('code', { + 'style': 'margin: 2px 4px 2px 0; display: inline-block; padding: 2px 6px; border-radius: 3px;' + }, chan)); + }); + } else { + chanNodes.push(E('em', { 'class': 'cbi-value-description' }, _('no channels'))); + } + + serverRows.push(E('tr', { 'class': 'tr cbi-section-table-row' }, [ + E('td', { 'class': 'td cbi-section-table-cell' }, + isSrvEnabled + ? E('span', { 'class': 'label success' }, _('Enabled')) + : E('span', { 'class': 'label' }, _('Disabled'))), + E('td', { 'class': 'td cbi-section-table-cell' }, E('strong', {}, s['.name'])), + E('td', { 'class': 'td cbi-section-table-cell' }, s.address || '-'), + E('td', { 'class': 'td cbi-section-table-cell' }, s.port || '6697'), + E('td', { 'class': 'td cbi-section-table-cell' }, isSrvSsl ? _('Yes') : _('No')), + E('td', { 'class': 'td cbi-section-table-cell' }, s.nicks || s.username || '-'), + E('td', { 'class': 'td cbi-section-table-cell' }, chanNodes), + E('td', { 'class': 'td cbi-section-table-cell cbi-section-actions', 'style': 'text-align: right; white-space: nowrap;' }, [ + E('a', { + 'class': 'btn cbi-button cbi-button-edit', + 'href': L.url('admin/services/weechat/servers') + '?server=' + encodeURIComponent(s['.name']) + '#edit=' + encodeURIComponent(s['.name']), + 'title': _('Edit server'), + 'aria-label': _('Edit server') + }, '✎') + ]) + ])); + }); + } + + poll.add(L.bind(function() { + return this.updateServiceStatus(statusBadge, pidLabel, btnStart, btnRestart, btnStop, isReadonly); + }, this), 5); + + return E('div', { 'class': 'cbi-map' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('view/weechat/weechat.css') }), + + E('h2', {}, _('WeeChat - Status')), + E('div', { 'class': 'cbi-map-descr' }, + _('WeeChat runs as a headless daemon on your router. It keeps you connected to IRC networks and exposes a secure Relay API for web or mobile clients.')), + + /* Two cards grid */ + E('div', { 'class': 'weechat-status-grid' }, [ + /* Card 1: Service Status */ + E('div', { 'class': 'weechat-panel' }, [ + E('h3', {}, _('Service Status')), + E('table', {}, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left', 'style': 'width: 35%;' }, _('WeeChat Daemon')), + E('td', { 'class': 'td left' }, [ statusBadge, pidLabel ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Version')), + E('td', { 'class': 'td left' }, E('span', {}, version)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Service enabled')), + E('td', { 'class': 'td left', 'id': 'weechat-service-enabled' }, renderEnabledState(isEnabled)) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Autostart at boot')), + E('td', { 'class': 'td left' }, [ autostartBadge, autostartBtn ]) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('Service control')), + E('td', { 'class': 'td left' }, [ btnStart, btnRestart, btnStop ]) + ]) + ]) + ]), + + /* Card 2: Overview */ + E('div', { 'class': 'weechat-panel' }, [ + E('h3', {}, _('Overview')), + E('table', {}, [ + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left', 'style': 'width: 30%;' }, _('Relay server')), + E('td', { 'class': 'td left' }, relayStatusNode) + ]), + E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td left' }, _('IRC servers')), + E('td', { 'class': 'td left' }, ircStatusNode) + ]) + ]) + ]) + ]), + + /* Configured IRC Servers Table */ + E('div', { 'class': 'weechat-servers-panel' }, [ + E('h3', {}, _('Configured IRC Servers')), + E('table', { 'class': 'table cbi-section-table' }, [ + E('tr', { 'class': 'tr cbi-section-table-titles' }, [ + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 10%; white-space: nowrap;' }, _('Status')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 12%; white-space: nowrap;' }, _('Server')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 20%; white-space: nowrap;' }, _('Hostname')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 7%; white-space: nowrap;' }, _('Port')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 7%; white-space: nowrap;' }, _('TLS')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 13%; white-space: nowrap;' }, _('Nicknames')), + E('th', { 'class': 'th cbi-section-table-cell', 'style': 'width: 26%;' }, _('Channels')), + E('th', { 'class': 'th cbi-section-table-cell cbi-section-actions', 'style': 'width: 5%; text-align: right;' }, '') + ]) + ].concat(serverRows)) + ]) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/weechat.css b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/weechat.css new file mode 100644 index 0000000000..81363b545c --- /dev/null +++ b/applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/weechat.css @@ -0,0 +1,65 @@ +.weechat-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; + margin-bottom: 1.25rem; +} + +@media (max-width: 840px) { + .weechat-status-grid { + grid-template-columns: 1fr; + } +} + +.weechat-panel { + border: 1px solid rgba(128, 128, 128, 0.22); + border-radius: 6px; + padding: 16px 20px; + background: rgba(128, 128, 128, 0.02); + display: flex; + flex-direction: column; +} + +.weechat-panel h3 { + margin-top: 0; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); + font-size: 1.15rem; + font-weight: 600; +} + +.weechat-panel table { + width: 100%; + margin-bottom: 0; + border-collapse: collapse; + flex: 1; +} + +.weechat-panel td { + padding: 10px 4px; + vertical-align: middle; + border: none; +} + +.weechat-servers-panel { + border: 1px solid rgba(128, 128, 128, 0.22); + border-radius: 6px; + padding: 16px 20px; + background: rgba(128, 128, 128, 0.02); + margin-top: 0.5rem; +} + +.weechat-servers-panel h3 { + margin-top: 0; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(128, 128, 128, 0.15); + font-size: 1.15rem; + font-weight: 600; +} + +.weechat-btn-control { + min-width: 72px; + margin-right: 6px; +} diff --git a/applications/luci-app-weechat/po/templates/weechat.pot b/applications/luci-app-weechat/po/templates/weechat.pot new file mode 100644 index 0000000000..dfd0f7d0cd --- /dev/null +++ b/applications/luci-app-weechat/po/templates/weechat.pot @@ -0,0 +1,665 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:266 +msgid "%d configured · %d enabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:351 +msgid "A NickServ password cannot contain $nick, $channel or $server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:348 +msgid "A password is required for this authentication method" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:239 +msgid "A self-signed certificate will be generated for WeeChat Relay at %s." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:321 +msgid "Account username if different from nickname." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:229 +msgid "Add Server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:200 +msgid "Add channel" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:99 +msgid "All interfaces" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:239 +msgid "Authentication" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:310 +msgid "Authentication Method" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:260 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:271 +msgid "Auto" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:281 +msgid "Auto-connect" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:276 +msgid "Auto-join Channels" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:346 +msgid "Autostart at boot" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:282 +msgid "Cancel" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:349 +msgid "" +"Cannot generate certificate: neither %s nor %s is installed on this router." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:124 +msgid "Certificate" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:173 +msgid "Certificate and key present" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:192 +msgid "" +"Certificate file exists but is incomplete (must contain both certificate and " +"private key)." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:330 +msgid "Certificate generation failed." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:220 +msgid "Certificate generation requires the %s or %s utility." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:246 +msgid "Certificate identity" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:146 +msgid "Certificate status" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:88 +msgid "Channel" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:23 +msgid "Channel key cannot be specified without a channel name" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:27 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:154 +msgid "Channel key cannot contain commas or spaces" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:25 +msgid "Channel name \"%s\" cannot contain commas or spaces" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:240 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:383 +msgid "Channels" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:29 +msgid "Channels and keys must not contain \"${\"" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:277 +msgid "Channels to join automatically after connecting to this server." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:163 +msgid "Checking certificate status…" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:272 +msgid "" +"Comma-separated list of host names and IP addresses used to reach the relay." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:294 +msgid "Comma-separated list of nicknames to try." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:61 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:114 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:136 +msgid "Command failed with return code %d" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:249 +msgid "Common name" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:224 +msgid "" +"Configure IRC server connections, authentication, and auto-join channels." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:87 +msgid "Configure the WeeChat relay and chat logging." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:374 +msgid "Configured IRC Servers" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:282 +msgid "Connect automatically when WeeChat starts." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:237 +msgid "Connection" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:80 +msgid "Could not enable the service in /etc/config/weechat" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:385 +msgid "" +"Directory where WeeChat stores chat logs. The default /var/log/weechat is " +"created automatically, any other directory must exist and be writable by the " +"weechat user." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:140 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:195 +msgid "Disable Autostart" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:262 +msgid "Disable IPv6" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:268 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:13 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:139 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:190 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:260 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:297 +msgid "Disabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:308 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:309 +msgid "Edit server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:140 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:195 +msgid "Enable Autostart" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:380 +msgid "Enable Logging" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:92 +msgid "Enable Relay Server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:120 +msgid "Enable TLS" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:287 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:12 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:139 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:190 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:252 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:296 +msgid "Enabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:121 +msgid "Encrypt relay connections with TLS." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:331 +msgid "Enter a SASL username or a nickname" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:135 +msgid "Enter a single channel per row without commas or spaces" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:122 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:145 +msgid "Failed to execute \"/etc/init.d/weechat %s\": %s" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:177 +msgid "File:" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:243 +msgid "" +"For Internet-facing deployments, use a certificate issued by a trusted CA." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:261 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:267 +msgid "Force IPv6" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:326 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:335 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:342 +msgid "Generate" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:352 +msgid "Generate certificate?" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:189 +msgid "Generate new certificate" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:202 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:215 +msgid "Generate self-signed certificate" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:302 +msgid "Generating self-signed certificate…" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:298 +msgid "Generating…" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:399 +msgid "" +"Glowing Bear is a web client that connects to WeeChat using the relay " +"configured above." +msgstr "" + +#: applications/luci-app-weechat/root/usr/share/rpcd/acl.d/luci-app-weechat.json:3 +msgid "Grant access for luci-app-weechat" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:258 +msgid "Host name identifying this router." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:379 +msgid "Hostname" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:244 +msgid "Hostname or IP address of the IRC server." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:97 +msgid "" +"IP address to bind (leave empty for all interfaces). The address must exist " +"when WeeChat starts." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:258 +msgid "IPv6 Support" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:259 +msgid "IPv6 connection mode for this IRC server." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:226 +msgid "IRC Servers" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:365 +msgid "IRC servers" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:238 +msgid "Identity" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:89 +msgid "Key" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:104 +msgid "Leading zeros are not allowed in an IPv4 address" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:96 +msgid "Listen Address" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:384 +msgid "Log Directory" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:378 +msgid "Logging" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:40 +msgid "Must be an absolute path without . or .. components" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:44 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:11 +msgid "Must not contain \"${\"" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:46 +msgid "Must not contain \"%\"" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:42 +msgid "Must not start or end with a space" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:313 +msgid "NickServ (/msg nickserv identify)" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:293 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:382 +msgid "Nicknames" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:301 +msgid "No" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:273 +msgid "No IRC servers configured. Go to Servers tab to add one." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:312 +msgid "None" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:406 +msgid "Open Glowing Bear (Hosted)" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:248 +msgid "Open Web Client (Hosted)" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:146 +msgid "Optional key" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:358 +msgid "Overview" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:125 +msgid "" +"PEM file containing the certificate and private key. It must be readable by " +"the weechat user." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:335 +msgid "Password" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:366 +msgid "Password required by clients to connect." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:292 +msgid "Please enter a valid Common Name." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:110 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:249 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:380 +msgid "Port" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:255 +msgid "Port %s · TLS %s" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:111 +msgid "Port for relay connections (WebSocket)." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:304 +msgid "Real Name" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:381 +msgid "Record chat logs to disk. Disabled by default to protect flash memory." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:90 +#: applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json:30 +msgid "Relay" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:365 +msgid "Relay Password" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:372 +msgid "Relay password is required when relay server is enabled." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:361 +msgid "Relay server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:165 +msgid "Remove" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:209 +msgid "Restart" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:156 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:180 +msgid "Running" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:314 +msgid "SASL PLAIN" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:315 +msgid "SASL SCRAM-SHA-256" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:316 +msgid "SASL SCRAM-SHA-512" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:320 +msgid "SASL Username" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:118 +msgid "Security" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:311 +msgid "Select the authentication method required by the IRC network." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:320 +msgid "" +"Self-signed certificate generated for %s, but WeeChat could not be " +"restarted: %s" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:316 +msgid "" +"Self-signed certificate generated for %s. Save and apply TLS with this " +"certificate to use it." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:314 +msgid "" +"Self-signed certificate generated for %s. WeeChat uses it from its next " +"start." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:312 +msgid "" +"Self-signed certificate generated for %s. WeeChat was restarted to use it." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:241 +msgid "" +"Self-signed certificates provide encryption but are not trusted " +"automatically by browsers." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:378 +msgid "Server" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:243 +msgid "Server Hostname" +msgstr "" + +#: applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json:22 +msgid "Servers" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:331 +msgid "Service Status" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:350 +msgid "Service control" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:342 +msgid "Service enabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:15 +msgid "Service is allowed to start." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:15 +msgid "Service is disabled in configuration." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:204 +msgid "Start" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:377 +#: applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json:14 +msgid "Status" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:214 +msgid "Stop" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:156 +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:180 +msgid "Stopped" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:263 +msgid "Subject alternative names" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:381 +msgid "TLS" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:205 +msgid "TLS is enabled but no certificate file was found." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:159 +msgid "" +"This certificate is managed outside of LuCI, so it is not checked here. The " +"service refuses to start if the file does not exist." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:229 +msgid "Unable to verify certificate status: %s" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:254 +msgid "Use TLS" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:299 +msgid "Username" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:338 +msgid "Version" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:393 +msgid "Web Client" +msgstr "" + +#: applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json:3 +msgid "WeeChat" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/servers.js:223 +msgid "WeeChat - IRC Servers" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/relay.js:86 +msgid "WeeChat - Relay" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:323 +msgid "WeeChat - Status" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:334 +msgid "WeeChat Daemon" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:325 +msgid "" +"WeeChat runs as a headless daemon on your router. It keeps you connected to " +"IRC networks and exposes a secure Relay API for web or mobile clients." +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:301 +msgid "Yes" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:255 +msgid "disabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:255 +msgid "enabled" +msgstr "" + +#: applications/luci-app-weechat/htdocs/luci-static/resources/view/weechat/status.js:290 +msgid "no channels" +msgstr "" diff --git a/applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json b/applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json new file mode 100644 index 0000000000..fb887e6e00 --- /dev/null +++ b/applications/luci-app-weechat/root/usr/share/luci/menu.d/luci-app-weechat.json @@ -0,0 +1,37 @@ +{ + "admin/services/weechat": { + "title": "WeeChat", + "order": 60, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-weechat" ], + "uci": { "weechat": true } + } + }, + "admin/services/weechat/status": { + "title": "Status", + "order": 10, + "action": { + "type": "view", + "path": "weechat/status" + } + }, + "admin/services/weechat/servers": { + "title": "Servers", + "order": 20, + "action": { + "type": "view", + "path": "weechat/servers" + } + }, + "admin/services/weechat/relay": { + "title": "Relay", + "order": 30, + "action": { + "type": "view", + "path": "weechat/relay" + } + } +} diff --git a/applications/luci-app-weechat/root/usr/share/rpcd/acl.d/luci-app-weechat.json b/applications/luci-app-weechat/root/usr/share/rpcd/acl.d/luci-app-weechat.json new file mode 100644 index 0000000000..1df8c03855 --- /dev/null +++ b/applications/luci-app-weechat/root/usr/share/rpcd/acl.d/luci-app-weechat.json @@ -0,0 +1,36 @@ +{ + "luci-app-weechat": { + "description": "Grant access for luci-app-weechat", + "read": { + "uci": [ + "weechat" + ], + "ubus": { + "service": [ + "list" + ], + "rc": [ + "list" + ], + "weechat": [ + "get_certificate_status", + "get_daemon_info" + ] + } + }, + "write": { + "uci": [ + "weechat" + ], + "ubus": { + "rc": [ + "init" + ], + "weechat": [ + "enable_service", + "generate_certificate" + ] + } + } + } +} diff --git a/applications/luci-app-weechat/root/usr/share/rpcd/ucode/weechat.uc b/applications/luci-app-weechat/root/usr/share/rpcd/ucode/weechat.uc new file mode 100755 index 0000000000..9f17ae3718 --- /dev/null +++ b/applications/luci-app-weechat/root/usr/share/rpcd/ucode/weechat.uc @@ -0,0 +1,389 @@ +#!/usr/bin/env ucode +'use strict'; + +import { access, lstat, readfile, unlink, rename, chmod, chown, mkdir, mkdtemp, open, popen } from 'fs'; +import { cursor } from 'uci'; + +/* + * The weechat init script keeps /etc/weechat and /etc/weechat/tls owned by + * root and not writable by the daemon. Certificates are only written there, + * so the daemon cannot redirect any of the paths used below. + */ +const BASE_DIR = '/etc/weechat'; +const TLS_DIR = BASE_DIR + '/tls'; +const DEFAULT_CERT = TLS_DIR + '/relay.pem'; + +function find_px5g() { + if (access('/usr/sbin/px5g', 'x')) { + return '/usr/sbin/px5g'; + } + if (access('/usr/bin/px5g', 'x')) { + return '/usr/bin/px5g'; + } + return null; +} + +function find_openssl() { + if (access('/usr/bin/openssl', 'x')) { + return '/usr/bin/openssl'; + } + return null; +} + +function px5g_supports_addext(px5g_bin) { + if (!px5g_bin) + return false; + let p = popen(px5g_bin + ' 2>&1', 'r'); + let out = p ? p.read('all') : ''; + if (p) + p.close(); + // px5g-wolfssl outputs: "PX5G X.509 Certificate Generator Utilit using WolfSSL" + // and does not support -addext + if (index(out, 'WolfSSL') >= 0) + return false; + return true; +} + +/* Without the s flag, ^ and $ also match at line breaks, so reject them first */ +function single_line(val) { + return type(val) == 'string' && index(val, '\n') < 0 && index(val, '\r') < 0; +} + +function valid_cert_path(path) { + return single_line(path) && + match(path, /^\/etc\/weechat\/tls\/[a-zA-Z0-9._-]+\.pem$/) && + index(path, '..') < 0; +} + +function root_owned_dir(path) { + let st = lstat(path); + return !!(st && st.type == 'directory' && st.uid == 0 && !(st.mode & 0022)); +} + +function normalize_san(entry) { + if (!single_line(entry)) + return null; + + let item = trim(entry); + if (!length(item)) + return null; + + let kind = null; + if (match(item, /^IP:/i)) { + kind = 'IP'; + item = trim(substr(item, 3)); + } else if (match(item, /^DNS:/i)) { + kind = 'DNS'; + item = trim(substr(item, 4)); + } + + let ip = (kind != 'DNS') ? iptoarr(item) : null; + if (ip) + return { type: 'IP', value: arrtoip(ip), bytes: ip }; + if (kind == 'IP') + return null; + + if (match(item, /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/)) + return { type: 'DNS', value: item }; + + return null; +} + +/* DER encoding of a SAN entry, GeneralName [7] for IP and [2] for DNS */ +function san_der(san) { + let bytes = (san.type == 'IP') ? san.bytes : map(split(san.value, ''), (c) => ord(c)); + let tag = (san.type == 'IP') ? 0x87 : 0x82; + let hdr = (length(bytes) < 128) ? [ tag, length(bytes) ] : [ tag, 0x81, length(bytes) ]; + return chr(...hdr, ...bytes); +} + +/* + * px5g-mbedtls up to PKG_RELEASE 11 writes every IP entry with a length of + * 16 bytes, so an IPv4 entry turns into a wrong IPv6 address, and other px5g + * builds might ignore -addext. Look for each SAN entry in the certificate. + */ +function cert_has_sans(cert_pem, sans) { + let der = b64dec(replace(cert_pem, /-----[A-Z ]+-----|[ \t\r\n]/g, '')); + if (der == null) + return false; + + for (let san in sans) + if (index(der, san_der(san)) < 0) + return false; + return true; +} + +function cleanup_temp_dir(tmp_dir) { + if (tmp_dir && match(tmp_dir, /^\/tmp\/weechat-cert\.[A-Za-z0-9]+$/)) { + system(['rm', '-rf', tmp_dir]); + } +} + +function failure(msg) { + return { success: false, error: msg }; +} + +/* Whether the saved config makes the relay use this certificate */ +function cert_in_use(path) { + let ctx = cursor(); + return ctx.get('weechat', 'weechat', 'relay_enabled') == '1' && + ctx.get('weechat', 'weechat', 'relay_tls') == '1' && + (ctx.get('weechat', 'weechat', 'relay_cert_key') || DEFAULT_CERT) == path; +} + +const methods = { + /* + * The init script only starts WeeChat with option enabled '1'. Commit + * just that option, LuCI itself can only apply all staged changes. + */ + enable_service: { + call: function(req) { + let ctx = cursor(); + if (!ctx.get('weechat', 'weechat')) + ctx.set('weechat', 'weechat', 'weechat'); + ctx.set('weechat', 'weechat', 'enabled', '1'); + return { success: !!ctx.commit('weechat') }; + } + }, + get_daemon_info: { + call: function(req) { + let version = null; + let p = popen('/usr/bin/weechat-headless -v 2>/dev/null', 'r'); + if (p) { + version = trim(p.read('all')); + p.close(); + } + if (!length(version)) { + p = popen('/usr/bin/weechat -v 2>/dev/null', 'r'); + if (p) { + version = trim(p.read('all')); + p.close(); + } + } + return { + version: length(version) ? version : null + }; + } + }, + get_certificate_status: { + args: { path: 'path' }, + call: function(req) { + let path = req?.args?.path || DEFAULT_CERT; + if (!valid_cert_path(path)) { + return { + exists: false, + error: 'Invalid path. Must be located under /etc/weechat/tls/.' + }; + } + + let tool_available = !!(find_px5g() || find_openssl()); + + let st = lstat(path); + if (!st || st.type != 'file' || st.size == 0) { + return { + exists: false, + path: path, + generator_available: tool_available + }; + } + + let content = readfile(path); + if (!content) { + return { + exists: true, + path: path, + readable: false, + generator_available: tool_available + }; + } + + return { + exists: true, + path: path, + readable: true, + has_key: !!match(content, /-----BEGIN ([A-Z0-9]+ )?PRIVATE KEY-----/), + has_cert: !!match(content, /-----BEGIN CERTIFICATE-----/), + generator_available: tool_available + }; + } + }, + + generate_certificate: { + args: { cn: 'cn', san: 'san', path: 'path' }, + call: function(req) { + let args = req?.args || {}; + let path = args.path || DEFAULT_CERT; + + if (!valid_cert_path(path)) + return failure('Invalid target path. Must be located under /etc/weechat/tls/.'); + + let cn = (type(args.cn) == 'string') ? trim(args.cn) : ''; + if (!length(cn)) { + cn = 'OpenWrt'; + } + /* ':' allows an IPv6 address, which the dialog pre-fills over IPv6 */ + if (!single_line(cn) || !match(cn, /^[a-zA-Z0-9.:_-]+$/)) + return failure('Invalid Common Name format.'); + + let san_raw = args.san; + let san_list = []; + if (type(san_raw) == 'array') { + san_list = san_raw; + } else if (type(san_raw) == 'string') { + san_list = split(san_raw, ','); + } + + let sans = []; + for (let entry in san_list) { + if (type(entry) != 'string' || !length(trim(entry))) + continue; + let san = normalize_san(entry); + if (!san) + return failure('Invalid Subject Alternative Name (SAN): ' + trim(entry)); + push(sans, san); + } + + let px5g_bin = find_px5g(); + let openssl_bin = find_openssl(); + + if (!px5g_bin && !openssl_bin) + return failure('Neither px5g nor openssl certificate generator is installed.'); + + /* + * A certificate needs at least one SAN, default to the CN. px5g-wolfssl + * cannot add any, it puts the CN in as a DNS name itself. + */ + let with_san = !!(openssl_bin || px5g_supports_addext(px5g_bin)); + if (!length(sans) && with_san) { + let san = normalize_san(cn); + if (!san) + return failure('Please enter a Subject Alternative Name.'); + push(sans, san); + } + + if (!root_owned_dir(BASE_DIR)) + return failure(BASE_DIR + ' must be a directory owned by root. Restart the WeeChat service to fix its permissions.'); + + if (!lstat(TLS_DIR)) { + if (!mkdir(TLS_DIR, 0755) || !chown(TLS_DIR, 0, 0) || !chmod(TLS_DIR, 0755)) + return failure('Failed to create directory: ' + TLS_DIR); + } + if (!root_owned_dir(TLS_DIR)) + return failure(TLS_DIR + ' must be a directory owned by root.'); + + let st_path = lstat(path); + if (st_path && st_path.type != 'file') + return failure('Target exists and is not a regular file: ' + path); + + let tmp_dir = mkdtemp('/tmp/weechat-cert.XXXXXX'); + if (!tmp_dir) + return failure('Failed to create secure temporary directory.'); + + let key_file = tmp_dir + '/key.pem'; + let cert_file = tmp_dir + '/cert.pem'; + let subj = sprintf('/C=ZZ/ST=Somewhere/L=Unknown/O=OpenWrt/CN=%s', cn); + /* + * rpcd waits for this call, so use an EC key: generating an RSA + * key can block it for a long time on slow routers. + */ + let key_opts = [ '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256' ]; + let used_px5g = false; + let cmd; + + if (openssl_bin) { + let san_str = join(',', map(sans, (san) => san.type + ':' + san.value)); + + cmd = [ + openssl_bin, 'req', '-x509', '-nodes', + '-days', '397', + ...key_opts, + '-keyout', key_file, + '-out', cert_file, + '-subj', subj, + '-addext', 'extendedKeyUsage=serverAuth', + '-addext', 'subjectAltName=' + san_str + ]; + } else if (px5g_supports_addext(px5g_bin)) { + cmd = [ + px5g_bin, 'selfsigned', + '-days', '397', + ...key_opts, + '-keyout', key_file, + '-out', cert_file, + '-subj', subj, + '-addext', 'extendedKeyUsage=serverAuth' + ]; + // Keep the subjectAltName options last, older px5g-mbedtls + // hangs when any other option follows one of them. + for (let san in sans) + push(cmd, '-addext', 'subjectAltName=' + san.type + ':' + san.value); + used_px5g = true; + } else { + if (length(sans) > 0) { + cleanup_temp_dir(tmp_dir); + return failure('The installed px5g (WolfSSL) does not support Subject Alternative Names (-addext). Please install openssl-util or px5g-mbedtls.'); + } + /* It would put the address in as a DNS name, which no client accepts */ + if (iptoarr(cn)) { + cleanup_temp_dir(tmp_dir); + return failure('The installed px5g (WolfSSL) cannot put an IP address into a certificate. Please install openssl-util or px5g-mbedtls, or use a host name as Common Name.'); + } + cmd = [ + px5g_bin, 'selfsigned', + '-days', '397', + ...key_opts, + '-keyout', key_file, + '-out', cert_file, + '-subj', subj + ]; + } + + let ret = system(cmd); + let key_content = (ret == 0) ? readfile(key_file) : null; + let cert_content = (ret == 0) ? readfile(cert_file) : null; + cleanup_temp_dir(tmp_dir); + + if (!key_content || !cert_content) + return failure('Certificate generation failed.'); + + if (used_px5g && !cert_has_sans(cert_content, sans)) { + if (length(filter(sans, (san) => san.type == 'IP'))) + return failure('The installed px5g writes invalid IP addresses into certificates. Please install openssl-util, update px5g-mbedtls or use host names only.'); + return failure('The installed px5g did not write the Subject Alternative Names. Please install openssl-util or px5g-mbedtls.'); + } + + let combined = key_content; + if (substr(combined, -1) != '\n') { + combined += '\n'; + } + combined += cert_content; + + // Readable by the daemon, but only root can replace it + let tmp_file = path + '.tmp'; + unlink(tmp_file); + + let f = open(tmp_file, 'wx', 0640); + if (!f) + return failure('Failed to write certificate bundle.'); + let written = f.write(combined); + f.close(); + + if (written != length(combined) || + !chown(tmp_file, 'root', 'weechat') || + !chmod(tmp_file, 0640) || + !rename(tmp_file, path)) { + unlink(tmp_file); + return failure('Failed to install certificate to: ' + path); + } + + return { + success: true, + path: path, + in_use: cert_in_use(path), + message: 'Certificate generated successfully.' + }; + } + } +}; + +return { 'weechat': methods };