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 <pepe.schlehofer@gmail.com>
(cherry picked from commit a29ca05b02)
This commit is contained in:
Josef Schlehofer committed 2026-10-05 16:21:27 +02:00
1 parent 2858f8d98b
commit be446e365c
10 files changed
+2414

No files matched your search

+13
View File
@@ -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 <pepe.schlehofer@gmail.com>
include ../../luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -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(); }) : []
};
}
});
@@ -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();
}
});
@@ -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));
}
});
@@ -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
});
@@ -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;
}
@@ -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 ""
@@ -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"
}
}
}
@@ -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"
]
}
}
}
}
@@ -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 };