⛄ Sync 2026-04-23 20:32:35

This commit is contained in:
github-actions[bot] committed 2026-04-23 20:32:35 +08:00
1 parent 17738c6f79
commit fe51d2b304
206 files changed
+725671

No files matched your search

@@ -0,0 +1,36 @@
'use strict';
'require view';
'require fs';
'require ui';
var isReadonlyView = !L.hasViewPermission() || null;
return view.extend({
load: function() {
return L.resolveDefault(fs.read('/etc/crontabs/root'), '');
},
handleSave: function(ev) {
var value = (document.querySelector('textarea').value || '').trim().replace(/\r\n/g, '\n') + '\n';
return fs.write('/etc/crontabs/root', value).then(function(rc) {
document.querySelector('textarea').value = value;
ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
return fs.exec('/etc/init.d/cron', [ 'reload' ]);
}).catch(function(e) {
ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
});
},
render: function(crontab) {
return E([
E('h2', _('Scheduled Tasks')),
E('p', { 'class': 'cbi-section-descr' }, _('This is the system crontab in which scheduled tasks can be defined.')),
E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 25, 'disabled': isReadonlyView }, [ crontab != null ? crontab : '' ]))
]);
},
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,53 @@
'use strict';
'require view';
'require form';
'require tools.widgets as widgets';
return view.extend({
render: function() {
var m, s, o;
m = new form.Map('dropbear', _('SSH Access'), _('Dropbear offers <abbr title="Secure Shell">SSH</abbr> network shell access and an integrated <abbr title="Secure Copy">SCP</abbr> server'));
s = m.section(form.TypedSection, 'dropbear', _('Dropbear Instance'));
s.anonymous = true;
s.addremove = true;
s.addbtntitle = _('Add instance');
o = s.option(form.Flag, 'enable', _('Enable Instance'), _('Enable <abbr title="Secure Shell">SSH</abbr> service instance'));
o.default = o.enabled;
o = s.option(form.Flag, '_direct', _('Bind to Interface'));
o.default = o.disabled;
o = s.option(widgets.NetworkSelect, 'DirectInterface', _('Interface'), _('Listen only on the given interface or, if unspecified, on all'));
o.nocreate = true;
o.depends('_direct', '1');
o = s.option(widgets.NetworkSelect, 'Interface', _('Interface'), _('Listen on up to 10 IPs on the given interface or, if unspecified, on all interfaces'));
o.nocreate = true;
o.depends('_direct', '0');
o = s.option(form.Value, 'Port', _('Port'));
o.datatype = 'port';
o.placeholder = 22;
o = s.option(form.Flag, 'PasswordAuth', _('Password authentication'), _('Allow <abbr title="Secure Shell">SSH</abbr> password authentication'));
o.enabled = 'on';
o.disabled = 'off';
o.default = o.enabled;
o.rmempty = false;
o = s.option(form.Flag, 'RootPasswordAuth', _('Allow root logins with password'), _('Allow the <em>root</em> user to log in with password'));
o.enabled = 'on';
o.disabled = 'off';
o.default = o.enabled;
o = s.option(form.Flag, 'GatewayPorts', _('Gateway Ports'), _('Allow remote hosts to connect to local SSH forwarded ports'));
o.enabled = 'on';
o.disabled = 'off';
o.default = o.disabled;
return m.render();
}
});
@@ -0,0 +1,492 @@
'use strict';
'require view';
'require dom';
'require form';
'require rpc';
'require fs';
'require ui';
const isReadonlyView = !L.hasViewPermission();
const callSystemValidateFirmwareImage = rpc.declare({
object: 'system',
method: 'validate_firmware_image',
params: [ 'path' ],
expect: { '': { valid: false, forcable: true } }
});
function findStorageSize(procmtd, procpart) {
let kernsize = 0, rootsize = 0, wholesize = 0;
procmtd.split(/\n/).forEach(function(ln) {
const match = ln.match(/^mtd\d+: ([0-9a-f]+) [0-9a-f]+ "(.+)"$/);
const size = match ? parseInt(match[1], 16) : 0;
switch (match ? match[2] : '') {
case 'linux':
case 'firmware':
if (size > wholesize)
wholesize = size;
break;
case 'kernel':
case 'kernel0':
kernsize = size;
break;
case 'rootfs':
case 'rootfs0':
case 'ubi':
case 'ubi0':
rootsize = size;
break;
}
});
if (wholesize > 0)
return wholesize;
else if (kernsize > 0 && rootsize > kernsize)
return kernsize + rootsize;
procpart.split(/\n/).forEach(function(ln) {
const match = ln.match(/^\s*\d+\s+\d+\s+(\d+)\s+(\S+)$/);
if (match) {
const size = parseInt(match[1], 10);
if (!match[2].match(/\d/) && size > 2048 && wholesize == 0)
wholesize = size * 1024;
}
});
return wholesize;
}
const mapdata = { actions: {}, config: {} };
return view.extend({
load() {
const tasks = [
L.resolveDefault(fs.stat('/lib/upgrade/platform.sh'), {}),
fs.trimmed('/proc/sys/kernel/hostname'),
fs.trimmed('/proc/mtd'),
fs.trimmed('/proc/partitions'),
fs.trimmed('/proc/mounts')
];
return Promise.all(tasks);
},
handleBackup(ev) {
const form = E('form', {
method: 'post',
action: L.env.cgi_base + '/cgi-backup',
enctype: 'application/x-www-form-urlencoded'
}, E('input', { type: 'hidden', name: 'sessionid', value: rpc.getSessionID() }));
ev.currentTarget.parentNode.appendChild(form);
form.submit();
form.parentNode.removeChild(form);
},
handleFirstboot(ev) {
if (!confirm(_('Do you really want to erase all settings?')))
return;
ui.showModal(_('Erasing...'), [
E('p', { 'class': 'spinning' }, _('The system is erasing the configuration partition now and will reboot itself when finished.'))
]);
/* Currently the sysupgrade rpc call will not return, hence no promise handling */
fs.exec('/sbin/firstboot', [ '-r', '-y' ]);
ui.awaitReconnect('10.0.0.1', 'kwrt.lan');
},
handleRestore(ev) {
return ui.uploadFile('/tmp/backup.tar.gz', ev.target)
.then(L.bind(function(btn, res) {
btn.firstChild.data = _('Checking archive…');
return fs.exec('/bin/tar', [ '-tzf', '/tmp/backup.tar.gz' ]);
}, this, ev.target))
.then(L.bind(function(btn, res) {
if (res.code != 0) {
ui.addNotification(null, E('p', _('The uploaded backup archive is not readable')));
return fs.remove('/tmp/backup.tar.gz');
}
ui.showModal(_('Apply backup?'), [
E('p', _('The uploaded backup archive appears to be valid and contains the files listed below. Press "Continue" to restore the backup and reboot, or "Cancel" to abort the operation.')),
E('pre', {}, [ res.stdout ]),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.createHandlerFn(this, function(ev) {
return fs.remove('/tmp/backup.tar.gz').finally(ui.hideModal);
})
}, [ _('Cancel') ]), ' ',
E('button', {
'class': 'btn cbi-button-action important',
'click': ui.createHandlerFn(this, 'handleRestoreConfirm', btn)
}, [ _('Continue') ])
])
]);
}, this, ev.target))
.catch(function(e) { ui.addNotification(null, E('p', e.message)) })
.finally(L.bind(function(btn, input) {
btn.firstChild.data = _('Upload archive...');
}, this, ev.target));
},
handleRestoreConfirm(btn, ev) {
return fs.exec('/sbin/sysupgrade', [ '--restore-backup', '/tmp/backup.tar.gz' ])
.then(L.bind(function(btn, res) {
if (res.code != 0) {
ui.addNotification(null, [
E('p', _('The restore command failed with code %d').format(res.code)),
res.stderr ? E('pre', {}, [ res.stderr ]) : ''
]);
L.raise('Error', 'Unpack failed');
}
btn.firstChild.data = _('Rebooting…');
return fs.exec('/sbin/reboot');
}, this, ev.target))
.then(L.bind(function(res) {
if (res.code != 0) {
ui.addNotification(null, E('p', _('The reboot command failed with code %d').format(res.code)));
L.raise('Error', 'Reboot failed');
}
ui.showModal(_('Rebooting…'), [
E('p', { 'class': 'spinning' }, _('The system is rebooting now. If the restored configuration changed the current LAN IP address, you might need to reconnect manually.'))
]);
ui.awaitReconnect(window.location.host, '10.0.0.1', 'kwrt.lan');
}, this))
.catch(function(e) { ui.addNotification(null, E('p', e.message)) })
.finally(function() { btn.firstChild.data = _('Upload archive...') });
},
handleBlock(hostname, ev) {
const mtdblock = dom.parent(ev.target, '.cbi-section').querySelector('[data-name="mtdselect"] select');
const mtdnumber = mtdblock.value;
const mtdname = mtdblock.selectedOptions[0].text.replace(/([^a-zA-Z0-9]+)/g, '-');
const form = E('form', {
'method': 'post',
'action': L.env.cgi_base + '/cgi-download',
'enctype': 'application/x-www-form-urlencoded'
}, [
E('input', { 'type': 'hidden', 'name': 'sessionid', 'value': rpc.getSessionID() }),
E('input', { 'type': 'hidden', 'name': 'path', 'value': '/dev/mtdblock%d'.format(mtdnumber) }),
E('input', { 'type': 'hidden', 'name': 'filename', 'value': '%s.mtd%d.%s.bin'.format(hostname, mtdnumber, mtdname) })
]);
ev.currentTarget.parentNode.appendChild(form);
form.submit();
form.parentNode.removeChild(form);
},
handleSysupgrade(storage_size, has_rootfs_data, ev) {
return ui.uploadFile('/tmp/firmware.bin', ev.target.firstChild)
.then(L.bind(function(btn, reply) {
btn.firstChild.data = _('Checking image…');
ui.showModal(_('Checking image…'), [
E('span', { 'class': 'spinning' }, _('Verifying the uploaded image file.'))
]);
return callSystemValidateFirmwareImage('/tmp/firmware.bin')
.then(function(res) { return [ reply, res ]; });
}, this, ev.target))
.then(L.bind(function(btn, reply) {
return fs.exec('/sbin/sysupgrade', [ '--test', '/tmp/firmware.bin' ])
.then(function(res) { reply.push(res); return reply; });
}, this, ev.target))
.then(L.bind(function(btn, res) {
/* sysupgrade opts table [0]:checkbox element [1]:check condition [2]:args to pass */
const opts = {
keep : [ E('input', { type: 'checkbox' }), false, '-n' ],
force : [ E('input', { type: 'checkbox' }), true, '--force' ],
skip_orig : [ E('input', { type: 'checkbox' }), true, '-u' ],
backup_pkgs : [ E('input', { type: 'checkbox' }), true, '-k' ],
},
is_valid = res[1].valid,
is_forceable = res[1].forceable,
allow_backup = res[1].allow_backup,
is_too_big = (storage_size > 0 && res[0].size > storage_size),
body = [];
body.push(E('p', _("The flash image was uploaded. Below is the checksum and file size listed, compare them with the original file to ensure data integrity. <br /> Click 'Continue' below to start the flash procedure.")));
body.push(E('ul', {}, [
res[0].size ? E('li', {}, '%s: %1024.2mB'.format(_('Size'), res[0].size)) : '',
res[0].checksum ? E('li', {}, '%s: %s'.format(_('MD5'), res[0].checksum)) : '',
res[0].sha256sum ? E('li', {}, '%s: %s'.format(_('SHA256'), res[0].sha256sum)) : ''
]));
body.push(E('p', {}, E('label', { 'class': 'btn' }, [
opts.keep[0], ' ', _('Keep settings and retain the current configuration')
])));
if (!is_valid || is_too_big)
body.push(E('hr'));
if (is_too_big)
body.push(E('p', { 'class': 'alert-message' }, [
_('It appears that you are trying to flash an image that does not fit into the flash memory, please verify the image file!')
]));
if (!is_valid)
body.push(E('p', { 'class': 'alert-message' }, [
res[2].stderr ? res[2].stderr : '',
res[2].stderr ? E('br') : '',
res[2].stderr ? E('br') : '',
_('The uploaded image file does not contain a supported format. Make sure that you choose the generic image format for your platform.')
]));
if (!allow_backup) {
body.push(E('p', { 'class': 'alert-message' }, [
_('The uploaded firmware does not allow keeping current configuration.')
]));
opts.keep[0].disabled = true;
} else {
opts.keep[0].checked = true;
if (has_rootfs_data) {
body.push(E('p', {}, E('label', { 'class': 'btn' }, [
opts.skip_orig[0], ' ', _('Skip from backup files that are equal to those in /rom')
])));
}
body.push(E('p', {}, E('label', { 'class': 'btn' }, [
opts.backup_pkgs[0], ' ', _('Include in backup a list of current installed packages at /etc/backup/installed_packages.txt')
])));
opts.backup_pkgs[0].checked = true;
};
const cntbtn = E('button', {
'class': 'btn cbi-button-action important',
'click': ui.createHandlerFn(this, 'handleSysupgradeConfirm', btn, opts),
}, [ _('Continue') ]);
if (res[2].code != 0) {
body.push(E('p', { 'class': 'alert-message danger' }, E('label', {}, [
_('Image check failed:'),
E('br'), E('br'),
res[2].stderr
])));
};
if ((!is_valid || is_too_big || res[2].code != 0) && is_forceable) {
body.push(E('p', {}, E('label', { 'class': 'btn alert-message danger' }, [
opts.force[0], ' ', _('Force upgrade'),
E('br'), E('br'),
_('Select \'Force upgrade\' to flash the image even if the image format check fails. Use only if you are sure that the firmware is correct and meant for your device!')
])));
cntbtn.disabled = true;
};
body.push(E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.createHandlerFn(this, function(ev) {
return fs.remove('/tmp/firmware.bin').finally(ui.hideModal);
})
}, [ _('Cancel') ]), ' ', cntbtn
]));
opts.force[0].addEventListener('change', function(ev) {
cntbtn.disabled = !ev.target.checked;
});
opts.keep[0].addEventListener('change', function(ev) {
opts.skip_orig[0].disabled = !ev.target.checked;
opts.backup_pkgs[0].disabled = !ev.target.checked;
if (ev.target.checked == false){
opts.skip_orig[0].checked =false
opts.backup_pkgs[0].checked =false
}
});
ui.showModal(_('Flash image?'), body);
}, this, ev.target))
.catch(function(e) { ui.addNotification(null, E('p', e.message)) })
.finally(L.bind(function(btn) {
btn.firstChild.data = _('Flash image...');
}, this, ev.target));
},
handleSysupgradeConfirm(btn, opts, ev) {
btn.firstChild.data = _('Flashing…');
ui.showModal(_('Flashing…'), [
E('p', { 'class': 'spinning' }, _('The system is flashing now.<br /> DO NOT POWER OFF THE DEVICE!<br /> Wait a few minutes before you try to reconnect. It might be necessary to renew the address of your computer to reach the device again, depending on your settings.'))
]);
const args = [];
for (let key in opts)
/* if checkbox == condition add args to sysupgrade */
if (opts[key][0].checked == opts[key][1])
args.push(opts[key][2]);
args.push('/tmp/firmware.bin');
/* Currently the sysupgrade rpc call will not return, hence no promise handling */
fs.exec('/sbin/sysupgrade', args);
if (opts['keep'][0].checked)
ui.awaitReconnect(window.location.host);
else
ui.awaitReconnect('10.0.0.1', 'kwrt.lan');
},
handleBackupList(ev) {
return fs.exec('/sbin/sysupgrade', [ '--list-backup' ]).then(function(res) {
if (res.code != 0) {
ui.addNotification(null, [
E('p', _('The sysupgrade command failed with code %d').format(res.code)),
res.stderr ? E('pre', {}, [ res.stderr ]) : ''
]);
L.raise('Error', 'Sysupgrade failed');
}
ui.showModal(_('Backup file list'), [
E('p', _('Below is the determined list of files to backup. It consists of changed configuration files marked by opkg, essential base files and the user defined backup patterns.')),
E('ul', {}, (res.stdout || '').trim().split(/\n/).map(function(ln) { return E('li', {}, ln) })),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, [ _('Dismiss') ])
])
], 'cbi-modal');
});
},
handleBackupSave(m, ev) {
return m.save(function() {
return fs.write('/etc/sysupgrade.conf', mapdata.config.editlist.trim().replace(/\r\n/g, '\n') + '\n');
}).then(function() {
ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
}).catch(function(e) {
ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e)));
});
},
render([p_fstat, hostname, procmtd, procpart, procmounts]) {
const has_sysupgrade = (p_fstat.type == 'file');
const has_rootfs_data = (procmtd.match(/"rootfs_data"/) != null) || (procmounts.match("overlayfs:/overlay / ") != null);
const storage_size = findStorageSize(procmtd, procpart);
let m, s, o, ss;
m = new form.JSONMap(mapdata, _('Flash operations'));
m.tabbed = true;
m.readonly = isReadonlyView;
s = m.section(form.NamedSection, 'actions', _('Actions'));
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Backup'), _('Click "Generate archive" to download a tar archive of the current configuration files.'));
ss = o.subsection;
o = ss.option(form.Button, 'dl_backup', _('Download backup'));
o.inputstyle = 'action important';
o.inputtitle = _('Generate archive');
o.onclick = this.handleBackup;
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Restore'), _('To restore configuration files, you can upload a previously generated backup archive here. To reset the firmware to its initial state, click "Perform reset" (only possible with squashfs images).'));
ss = o.subsection;
if (has_rootfs_data) {
o = ss.option(form.Button, 'reset', _('Reset to defaults'));
o.inputstyle = 'negative important';
o.inputtitle = _('Perform reset');
o.onclick = this.handleFirstboot;
}
o = ss.option(form.Button, 'restore', _('Restore backup'), _('Custom files (certificates, scripts) may remain on the system. To prevent this, perform a factory-reset first.'));
o.inputstyle = 'action important';
o.inputtitle = _('Upload archive...');
o.onclick = L.bind(this.handleRestore, this);
const mtdblocks = [];
procmtd.split(/\n/).forEach(function(ln) {
const match = ln.match(/^mtd(\d+): .+ "(.+?)"$/);
if (match)
mtdblocks.push(match[1], match[2]);
});
if (mtdblocks.length) {
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Save mtdblock contents'), _('Click "Save mtdblock" to download specified mtdblock file. (NOTE: THIS FEATURE IS FOR PROFESSIONALS! )'));
ss = o.subsection;
o = ss.option(form.ListValue, 'mtdselect', _('Choose mtdblock'));
for (var i = 0; i < mtdblocks.length; i += 2)
o.value(mtdblocks[i], mtdblocks[i+1]);
o = ss.option(form.Button, 'mtddownload', _('Download mtdblock'));
o.inputstyle = 'action important';
o.inputtitle = _('Save mtdblock');
o.onclick = L.bind(this.handleBlock, this, hostname);
}
o = s.option(form.SectionValue, 'actions', form.NamedSection, 'actions', 'actions', _('Flash new firmware image'),
has_sysupgrade
? _('Upload a sysupgrade-compatible image here to replace the running firmware.')
: _('Sorry, there is no sysupgrade support present; a new firmware image must be flashed manually. Please refer to the wiki for device specific install instructions.'));
ss = o.subsection;
if (has_sysupgrade) {
o = ss.option(form.Button, 'sysupgrade', _('Image'));
o.inputstyle = 'action important';
o.inputtitle = _('Flash image...');
o.onclick = L.bind(this.handleSysupgrade, this, storage_size, has_rootfs_data);
}
s = m.section(form.NamedSection, 'config', 'config', _('Configuration'), _('This is a list of shell glob patterns for matching files and directories to include during sysupgrade. Modified files in /etc/config/ and certain other configurations are automatically preserved.'));
s.render = L.bind(function(view /*, ... */) {
return form.NamedSection.prototype.render.apply(this, this.varargs(arguments, 1))
.then(L.bind(function(node) {
node.appendChild(E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'cbi-button cbi-button-save',
'click': ui.createHandlerFn(view, 'handleBackupSave', this.map),
'disabled': isReadonlyView || null
}, [ _('Save') ])
]));
return node;
}, this));
}, s, this);
o = s.option(form.Button, 'showlist', _('Show current backup file list'));
o.inputstyle = 'action';
o.inputtitle = _('Open list...');
o.onclick = L.bind(this.handleBackupList, this);
o = s.option(form.TextValue, 'editlist');
o.forcewrite = true;
o.rows = 30;
o.load = function(section_id) {
return L.resolveDefault(fs.read('/etc/sysupgrade.conf'), '');
};
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,9 @@
'use strict';
'require baseclass';
return baseclass.extend({
trigger: _('Always on (kernel: default-on)'),
description: _('The LED is always in default state on.'),
kernel: true,
addFormOptions: function(s) {}
});
@@ -0,0 +1,19 @@
'use strict';
'require baseclass';
'require form';
return baseclass.extend({
trigger: _('Heartbeat interval (kernel: heartbeat)'),
description: _('The LED flashes to simulate actual heart beat.') +
_('The frequency is in direct proportion to 1-minute average CPU load.'),
kernel: true,
addFormOptions: function(s) {
var o;
o = s.option(form.Flag, 'inverted', _('Invert blinking'),
_('When inverted, the LED is continuously lit and flickers instead of it being off by default and blinking on system activity.'));
o.rmempty = true;
o.modalonly = true;
o.depends('trigger', 'heartbeat');
}
});
@@ -0,0 +1,36 @@
'use strict';
'require baseclass';
'require form';
'require tools.widgets as widgets';
return baseclass.extend({
trigger: _("Network device activity (kernel: netdev)"),
description: _('The LED flashes with link status and activity on the configured interface.'),
kernel: true,
addFormOptions: function(s) {
var o;
o = s.option(widgets.DeviceSelect, '_net_dev', _('Device'));
o.rmempty = true;
o.ucioption = 'dev';
o.modalonly = true;
o.noaliases = true;
o.depends('trigger', 'netdev');
o = s.option(form.MultiValue, 'mode', _('Trigger Mode'));
o.rmempty = true;
o.modalonly = true;
o.depends('trigger', 'netdev');
o.value('link', _('Link On'));
o.value('link_10', _('Link 10M On'));
o.value('link_100', _('Link 100M On'));
o.value('link_1000', _('Link 1G On'));
o.value('link_2500', _('Link 2.5G On'));
o.value('link_5000', _('Link 5G On'));
o.value('link_10000', _('Link 10G On'));
o.value('half_duplex', _('Half Duplex'));
o.value('full_duplex', _('Full Duplex'));
o.value('tx', _('Transmit'));
o.value('rx', _('Receive'));
}
});
@@ -0,0 +1,22 @@
'use strict';
'require form';
'require baseclass';
return baseclass.extend({
trigger: _('Always off (kernel: none)'),
description: _('The LED is always in default state off.'),
kernel: true,
addFormOptions: function(s) {
var o;
o = s.option(form.Flag, 'default', _('Default state'));
o.rmempty = false;
o.depends('trigger', 'none');
o.textvalue = function(section_id) {
var cval = this.cfgvalue(section_id);
if (cval == null)
cval = this.default;
return (cval == this.enabled) ? _('On') : _('Off');
};
}
});
@@ -0,0 +1,24 @@
'use strict';
'require baseclass';
'require form';
return baseclass.extend({
trigger: _('Custom flash interval (kernel: timer)'),
description: _('The LED blinks with the configured on/off frequency'),
kernel: true,
addFormOptions: function(s) {
var o;
o = s.option(form.Value, 'delayon', _('On-State Delay'),
_('How long (in milliseconds) the LED should be on')
);
o.modalonly = true;
o.depends('trigger', 'timer');
o = s.option(form.Value, 'delayoff', _('Off-State Delay'),
_('How long (in milliseconds) the LED should be off')
);
o.modalonly = true;
o.depends('trigger', 'timer');
}
});
@@ -0,0 +1,133 @@
'use strict';
'require view';
'require uci';
'require rpc';
'require form';
'require fs';
const callLeds = rpc.declare({
object: 'luci',
method: 'getLEDs',
expect: { '': {} }
});
return view.extend({
load() {
return Promise.all([
callLeds(),
L.resolveDefault(fs.list('/www' + L.resource('view/system/led-trigger')), [])
]).then(function([leds, plugins]) {
const tasks = [];
for (let p of plugins) {
const m = p.name.match(/^(.+)\.js$/);
if (p.type != 'file' || m == null)
continue;
tasks.push(L.require('view.system.led-trigger.' + m[1]).then(L.bind(function(name){
return L.resolveDefault(L.require('view.system.led-trigger.' + name)).then(function(form) {
return {
name: name,
form: form,
};
});
}, this, m[1])));
}
return Promise.all(tasks).then(function(plugins) {
return [leds, plugins];
});
});
},
render([leds, plugins]) {
let m, s, o;
const triggers = [];
for (let k in leds)
for (let t of leds[k].triggers)
triggers.push(t);
m = new form.Map('system',
_('<abbr title="Light Emitting Diode">LED</abbr> Configuration'),
_('Customizes the behaviour of the device <abbr title="Light Emitting Diode">LED</abbr>s if possible.'));
s = m.section(form.GridSection, 'led', '');
s.anonymous = true;
s.addremove = true;
s.sortable = true;
s.addbtntitle = _('Add LED action');
s.nodescriptions = true;
s.option(form.Value, 'name', _('Name'));
o = s.option(form.ListValue, 'sysfs', _('<abbr title="Light Emitting Diode">LED</abbr> Name'));
Object.keys(leds).sort().forEach(function(name) {
o.value(name)
});
o = s.option(form.ListValue, 'trigger', _('Trigger'));
for (let plugin of plugins) {
if ( plugin.form.kernel == false ) {
o.value(plugin.name, plugin.form.trigger);
}
else {
if (triggers.indexOf(plugin.name) >= 0)
o.value(plugin.name, plugin.form.trigger);
}
}
o.onchange = function(ev, section, value) {
const nes = this.map.findElement('id', 'cbid.system.%s.trigger'.format(section)).nextElementSibling;
for (let plugin of plugins) {
if ( plugin.name === value && nes )
nes.innerText = plugin.form.description || '';
}
}
o.load = function(section_id) {
const trigger = uci.get('system', section_id, 'trigger');
for (let plugin of plugins) {
if ( plugin.name === trigger)
this.description = plugin.form.description || ' ';
}
return trigger;
};
s.addModalOptions = function(s) {
for (let plugin of plugins) {
plugin.form.addFormOptions(s);
}
const opts = s.getOption();
const removeIfNoneActive = function(original_remove_fn, section_id) {
let isAnyActive = false;
for (let optname in opts) {
if (opts[optname].ucioption != this.ucioption)
continue;
if (!opts[optname].isActive(section_id))
continue;
isAnyActive = true;
break;
}
if (!isAnyActive)
original_remove_fn.call(this, section_id);
};
for (let optname in opts) {
if (!opts[optname].ucioption || optname == opts[optname].ucioption)
continue;
opts[optname].remove = removeIfNoneActive.bind(opts[optname], opts[optname].remove);
}
};
o = s.option(form.Value, 'interval', _('Interval'), _('milliseconds'));
o.placeholder = '50';
return m.render();
}
});
@@ -0,0 +1,386 @@
'use strict';
'require view';
'require fs';
'require ui';
'require uci';
'require rpc';
'require form';
const callBlockDevices = rpc.declare({
object: 'luci',
method: 'getBlockDevices',
expect: { '': {} }
});
const callMountPoints = rpc.declare({
object: 'luci',
method: 'getMountPoints',
expect: { result: [] }
});
const callBlockDetect = rpc.declare({
object: 'luci',
method: 'setBlockDetect',
expect: { result: false }
});
function device_textvalue(devices, section_id) {
let v = (uci.get('fstab', section_id, 'uuid') || '').toLowerCase();
let e = Object.keys(devices).filter(function(dev) { return (devices[dev].uuid || '-').toLowerCase() == v })[0];
if (v) {
this.section.devices[section_id] = devices[e];
if (e && devices[e].size)
return E('span', 'UUID: %h (%s, %1024.2mB)'.format(v, devices[e].dev, devices[e].size));
else if (e)
return E('span', 'UUID: %h (%s)'.format(v, devices[e].dev));
else
return E('span', 'UUID: %h (<em>%s</em>)'.format(v, _('not present')));
}
v = uci.get('fstab', section_id, 'label');
e = Object.keys(devices).filter(function(dev) { return devices[dev].label == v })[0];
if (v) {
this.section.devices[section_id] = this.section.devices[section_id] || devices[e];
if (e && devices[e].size)
return E('span', 'Label: %h (%s, %1024.2mB)'.format(v, devices[e].dev, devices[e].size));
else if (e)
return E('span', 'Label: %h (%s)'.format(v, devices[e].dev));
else
return E('span', 'Label: %h (<em>%s</em>)'.format(v, _('not present')));
}
v = uci.get('fstab', section_id, 'device');
e = Object.keys(devices).filter(function(dev) { return devices[dev].dev == v })[0];
if (v) {
this.section.devices[section_id] = this.section.devices[section_id] || devices[e];
if (e && devices[e].size)
return E('span', '%h (%1024.2mB)'.format(v, devices[e].size));
else if (e)
return E('span', '%h'.format(v));
else
return E('span', '%h (<em>%s</em>)'.format(v, _('not present')));
}
}
return view.extend({
handleDetect(m, ev) {
return callBlockDetect()
.then(L.bind(uci.unload, uci, 'fstab'))
.then(L.bind(m.render, m));
},
handleMountAll(m, ev) {
return fs.exec('/sbin/block', ['mount'])
.then(function(res) {
if (res.code != 0)
ui.addNotification(null, E('p', _('The <em>block mount</em> command failed with code %d').format(res.code)));
})
.then(L.bind(uci.unload, uci, 'fstab'))
.then(L.bind(m.render, m));
},
handleUmount(m, path, ev) {
return fs.exec('/bin/umount', [path])
.then(L.bind(uci.unload, uci, 'fstab'))
.then(L.bind(m.render, m))
.catch(function(e) { ui.addNotification(null, E('p', e.message)) });
},
load() {
return Promise.all([
callBlockDevices(),
fs.lines('/proc/filesystems'),
fs.lines('/etc/filesystems'),
uci.load('fstab'),
]);
},
render([devices, procfs, etcfs]) {
let m, s, o;
let filesystems = {};
for (let p of procfs)
if (p.match(/\S/) && !p.match(/^nodev\t/))
filesystems[p.trim()] = true;
for (let e of etcfs)
if (e.match(/\S/))
filesystems[e.trim()] = true;
filesystems = Object.keys(filesystems).sort();
if (!uci.sections('fstab', 'global').length)
uci.add('fstab', 'global');
m = new form.Map('fstab', _('Mount Points'));
s = m.section(form.TypedSection, 'global', _('Global Settings'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.Button, '_detect', _('Generate Config'), _('Find all currently attached filesystems and swap and replace configuration with defaults based on what was detected'));
o.onclick = this.handleDetect.bind(this, m);
o.inputstyle = 'reload';
o = s.option(form.Button, '_mountall', _('Mount attached devices'), _('Attempt to enable configured mount points for attached devices'));
o.onclick = this.handleMountAll.bind(this, m);
o.inputstyle = 'reload';
o = s.option(form.Flag, 'anon_swap', _('Anonymous Swap'), _('Mount swap not specifically configured'));
o.default = o.disabled;
o.rmempty = false;
o = s.option(form.Flag, 'anon_mount', _('Anonymous Mount'), _('Mount filesystems not specifically configured'));
o.default = o.disabled;
o.rmempty = false;
o = s.option(form.Flag, 'auto_swap', _('Automount Swap'), _('Automatically mount swap on hotplug'));
o.default = o.enabled;
o.rmempty = false;
o = s.option(form.Flag, 'auto_mount', _('Automount Filesystem'), _('Automatically mount filesystems on hotplug'));
o.default = o.enabled;
o.rmempty = false;
o = s.option(form.Flag, 'check_fs', _('Check filesystems before mount'), _('Automatically check filesystem for errors before mounting'));
o.default = o.disabled;
o.rmempty = false;
// Mount status table
o = s.option(form.DummyValue, '_mtab');
o.load = function(section_id) {
return callMountPoints().then(L.bind(function(mounts) {
this.mounts = mounts;
}, this));
};
o.render = L.bind(function(view, section_id) {
const table = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Filesystem')),
E('th', { 'class': 'th' }, _('Mount Point')),
E('th', { 'class': 'th center' }, _('Available')),
E('th', { 'class': 'th center' }, _('Used')),
E('th', { 'class': 'th' }, _('Unmount'))
])
]);
const rows = [];
for (let tm of this.mounts) {
var used = tm.size - tm.free,
umount = true;
if (/^\/(overlay|rom|tmp(?:\/.+)?|dev(?:\/.+)?|)$/.test(tm.mount))
umount = false;
rows.push([
tm.device,
tm.mount,
'%1024.2mB / %1024.2mB'.format(tm.avail, tm.size),
'%.2f%% (%1024.2mB)'.format(100 / tm.size * used, used),
umount ? E('button', {
'class': 'btn cbi-button-remove',
'click': ui.createHandlerFn(view, 'handleUmount', m, tm.mount),
'disabled': this.map.readonly || null
}, [ _('Unmount') ]) : '-'
]);
}
cbi_update_table(table, rows, E('em', _('Unable to obtain mount information')));
return E([], [ E('h3', _('Mounted file systems')), table ]);
}, o, this);
// Mounts
s = m.section(form.GridSection, 'mount', _('Mount Points'), _('Mount Points define at which point a memory device will be attached to the filesystem'));
s.modaltitle = _('Mount Points - Mount Entry');
s.anonymous = true;
s.addremove = true;
s.sortable = true;
s.devices = {};
s.renderHeaderRows = function(/* ... */) {
var trEls = form.GridSection.prototype.renderHeaderRows.apply(this, arguments);
return trEls.childNodes[0];
}
s.tab('general', _('General Settings'));
s.tab('advanced', _('Advanced Settings'));
o = s.taboption('general', form.Flag, 'enabled', _('Enabled'));
o.rmempty = false;
o.default = true;
o.editable = true;
o = s.taboption('general', form.DummyValue, '_device', _('Device'));
o.rawhtml = true;
o.modalonly = false;
o.write = function() {};
o.remove = function() {};
o.textvalue = device_textvalue.bind(o, devices);
o = s.taboption('general', form.Value, 'uuid', _('UUID'), _('If specified, mount the device by its UUID instead of a fixed device node'));
o.modalonly = true;
o.value('', _('-- match by uuid --'));
const devs = Object.keys(devices).sort();
for (let d of devs) {
const dev = devices[d];
if (dev.uuid && dev.size)
o.value(dev.uuid, '%s (%s, %1024.2mB)'.format(dev.uuid, dev.dev, dev.size));
else if (dev.uuid)
o.value(dev.uuid, '%s (%s)'.format(dev.uuid, dev.dev));
}
o = s.taboption('general', form.Value, 'label', _('Label'), _('If specified, mount the device by the partition label instead of a fixed device node'));
o.modalonly = true;
o.depends('uuid', '');
o.value('', _('-- match by label --'));
for (let d of devs) {
const dev = devices[d];
if (dev.label && dev.size)
o.value(dev.label, '%s (%s, %1024.2mB)'.format(dev.label, dev.dev, dev.size));
else if (dev.label)
o.value(dev.label, '%s (%s)'.format(dev.label, dev.dev));
}
o = s.taboption('general', form.Value, 'device', _('Device'), _('The device file of the memory or partition (<abbr title="for example">e.g.</abbr> <code>/dev/sda1</code>)'));
o.modalonly = true;
o.depends({ uuid: '', label: '' });
for (let d of devs) {
const dev = devices[d];
if (dev.size)
o.value(dev.dev, '%s (%1024.2mB)'.format(dev.dev, dev.size));
else
o.value(dev.dev);
}
o = s.taboption('general', form.Value, 'target', _('Mount point'), _('Specifies the directory the device is attached to'));
o.value('/', _('Use as root filesystem (/)'));
o.value('/overlay', _('Use as external overlay (/overlay)'));
o.rmempty = false;
o = s.taboption('general', form.DummyValue, '__notice', _('Root preparation'));
o.depends('target', '/');
o.modalonly = true;
o.rawhtml = true;
o.default = '' +
'<p>%s</p>'.format(_('Make sure to clone the root filesystem using something like the commands below:')) +
'<pre>' +
'mkdir -p /tmp/introot\n' +
'mkdir -p /tmp/extroot\n' +
'mount --bind / /tmp/introot\n' +
'mount /dev/sda1 /tmp/extroot\n' +
'tar -C /tmp/introot -cvf - . | tar -C /tmp/extroot -xf -\n' +
'umount /tmp/introot\n' +
'umount /tmp/extroot\n' +
'</pre>'
;
o = s.taboption('advanced', form.ListValue, 'fstype', _('Filesystem'));
o.textvalue = function(section_id) {
const dev = this.section.devices[section_id];
let text = this.cfgvalue(section_id) || 'auto';
if (dev && dev.type && dev.type != text)
text += ' (%s)'.format(dev.type);
return text;
};
o.value('', 'auto');
for (let fs of filesystems)
o.value(fs);
o = s.taboption('advanced', form.Value, 'options', _('Mount options'), _('See "mount" manpage for details'));
o.textvalue = function(section_id) { return this.cfgvalue(section_id) || 'defaults' };
o.placeholder = 'defaults';
// Swaps
s = m.section(form.GridSection, 'swap', _('SWAP'), _('If your physical memory is insufficient unused data can be temporarily swapped to a swap-device resulting in a higher amount of usable <abbr title="Random Access Memory">RAM</abbr>. Be aware that swapping data is a very slow process as the swap-device cannot be accessed with the high datarates of the <abbr title="Random Access Memory">RAM</abbr>.'));
s.modaltitle = _('Mount Points - Swap Entry');
s.anonymous = true;
s.addremove = true;
s.sortable = true;
s.devices = {};
s.renderHeaderRows = function(/* ... */) {
var trEls = form.GridSection.prototype.renderHeaderRows.apply(this, arguments);
trEls.childNodes[0].childNodes[1].style.width = '90%';
return trEls.childNodes[0];
}
o = s.option(form.Flag, 'enabled', _('Enabled'));
o.rmempty = false;
o.editable = true;
o = s.option(form.DummyValue, '_device', _('Device'));
o.modalonly = false;
o.textvalue = device_textvalue.bind(o, devices);
o = s.option(form.Value, 'uuid', _('UUID'), _('If specified, mount the device by its UUID instead of a fixed device node'));
o.modalonly = true;
o.value('', _('-- match by uuid --'));
for (let d of devs) {
const dev = devices[d];
if (dev.dev.match(/^\/dev\/(mtdblock|ubi|ubiblock)\d/))
continue;
if (dev.uuid && dev.size)
o.value(dev.uuid, '%s (%s, %1024.2mB)'.format(dev.uuid, dev.dev, dev.size));
else if (dev.uuid)
o.value(dev.uuid, '%s (%s)'.format(dev.uuid, dev.dev));
}
o = s.option(form.Value, 'label', _('Label'), _('If specified, mount the device by the partition label instead of a fixed device node'));
o.modalonly = true;
o.depends('uuid', '');
o.value('', _('-- match by label --'));
for (let d of devs) {
const dev = devices[d];
if (dev.dev.match(/^\/dev\/(mtdblock|ubi|ubiblock)\d/))
continue;
if (dev.label && dev.size)
o.value(dev.label, '%s (%s, %1024.2mB)'.format(dev.label, dev.dev, dev.size));
else if (dev.label)
o.value(dev.label, '%s (%s)'.format(dev.label, dev.dev));
}
o = s.option(form.Value, 'device', _('Device'), _('The device file of the memory or partition (<abbr title="for example">e.g.</abbr> <code>/dev/sda1</code>)'));
o.modalonly = true;
o.depends({ uuid: '', label: '' });
for (let d of devs) {
const dev = devices[d];
if (dev.dev.match(/^\/dev\/(mtdblock|ubi|ubiblock)\d/))
continue;
if (dev.size)
o.value(dev.dev, '%s (%1024.2mB)'.format(dev.dev, dev.size));
else
o.value(dev.dev);
}
return m.render();
}
});
@@ -0,0 +1,99 @@
'use strict';
'require view';
'require dom';
'require ui';
'require form';
'require rpc';
var formData = {
password: {
pw1: null,
pw2: null
}
};
var callSetPassword = rpc.declare({
object: 'luci',
method: 'setPassword',
params: [ 'username', 'password' ],
expect: { result: false }
});
return view.extend({
checkPassword: function(section_id, value) {
var strength = document.querySelector('.cbi-value-description'),
strongRegex = new RegExp("^(?=.{8,})(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*\\W).*$", "g"),
mediumRegex = new RegExp("^(?=.{7,})(((?=.*[A-Z])(?=.*[a-z]))|((?=.*[A-Z])(?=.*[0-9]))|((?=.*[a-z])(?=.*[0-9]))).*$", "g"),
enoughRegex = new RegExp("(?=.{6,}).*", "g");
if (strength && value.length) {
if (false == enoughRegex.test(value))
strength.innerHTML = '%s: <span style="color:red">%s</span>'.format(_('Password strength'), _('More Characters'));
else if (strongRegex.test(value))
strength.innerHTML = '%s: <span style="color:green">%s</span>'.format(_('Password strength'), _('Strong'));
else if (mediumRegex.test(value))
strength.innerHTML = '%s: <span style="color:orange">%s</span>'.format(_('Password strength'), _('Medium'));
else
strength.innerHTML = '%s: <span style="color:red">%s</span>'.format(_('Password strength'), _('Weak'));
}
return true;
},
render: function() {
var m, s, o;
m = new form.JSONMap(formData, _('Router Password'), _('Changes the administrator password for accessing the device'));
m.readonly = !L.hasViewPermission();
s = m.section(form.NamedSection, 'password', 'password');
o = s.option(form.Value, 'pw1', _('Password'));
o.password = true;
o.validate = this.checkPassword;
o = s.option(form.Value, 'pw2', _('Confirmation'), ' ');
o.password = true;
o.renderWidget = function(/* ... */) {
var node = form.Value.prototype.renderWidget.apply(this, arguments);
node.querySelector('input').addEventListener('keydown', function(ev) {
if (ev.keyCode == 13 && !ev.currentTarget.classList.contains('cbi-input-invalid'))
document.querySelector('.cbi-button-save').click();
});
return node;
};
return m.render();
},
handleSave: function() {
var map = document.querySelector('.cbi-map');
return dom.callClassMethod(map, 'save').then(function() {
if (formData.password.pw1 == null || formData.password.pw1.length == 0)
return;
if (formData.password.pw1 != formData.password.pw2) {
ui.addNotification(null, E('p', _('Given password confirmation did not match, password not changed!')), 'danger');
return;
}
return callSetPassword('root', formData.password.pw1).then(function(success) {
if (success)
ui.addNotification(null, E('p', _('The system password has been successfully changed.')), 'info');
else
ui.addNotification(null, E('p', _('Failed to change the system password.')), 'danger');
formData.password.pw1 = null;
formData.password.pw2 = null;
dom.callClassMethod(map, 'render');
});
});
},
handleSaveApply: null,
handleReset: null
});
@@ -0,0 +1,65 @@
'use strict';
'require view';
'require rpc';
'require ui';
'require uci';
var callReboot = rpc.declare({
object: 'system',
method: 'reboot',
expect: { result: 0 }
});
return view.extend({
load: function() {
return uci.changes();
},
render: function(changes) {
var body = E([
E('h2', _('Reboot')),
E('p', {}, _('Reboots the operating system of your device'))
]);
for (var config in (changes || {})) {
body.appendChild(E('p', { 'class': 'alert-message warning' },
_('Warning: There are unsaved changes that will get lost on reboot!')));
break;
}
body.appendChild(E('hr'));
body.appendChild(E('button', {
'class': 'cbi-button cbi-button-action important',
'click': ui.createHandlerFn(this, 'handleReboot')
}, _('Perform reboot')));
return body;
},
handleReboot: function(ev) {
return callReboot().then(function(res) {
if (res != 0) {
L.ui.addNotification(null, E('p', _('The reboot command failed with code %d').format(res)));
L.raise('Error', 'Reboot failed');
}
L.ui.showModal(_('Rebooting…'), [
E('p', { 'class': 'spinning' }, _('Waiting for device...'))
]);
window.setTimeout(function() {
L.ui.showModal(_('Rebooting…'), [
E('p', { 'class': 'spinning alert-message warning' },
_('Device unreachable! Still waiting for device...'))
]);
}, 150000);
L.ui.awaitReconnect();
})
.catch(function(e) { L.ui.addNotification(null, E('p', e.message)) });
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,313 @@
'use strict';
'require baseclass';
'require fs';
'require form';
'require ui';
'require view';
const APK_DIR = '/etc/apk/keys/';
const OPKG_DIR = '/etc/opkg/keys/';
const isReadonlyView = !L.hasViewPermission() || null;
let KEYDIR = null;
let KEYEXT = null;
/* This safeList is not bullet-proof, but should prevent users
accidentally deleting official repo keys */
const safeList = [
'd310c6f2833e97f7', // 24.10 release usign key
'openwrt-snapshots.pem', // main snapshots EC pub key
];
function isFileInSafeList(file){
for (let name of safeList) {
if (file === name)
return true;
if (file.toLocaleLowerCase().replace(/^openwrt-[0-9]+\.[0-9]+/i, '') !== file)
return true;
if (file.toLocaleLowerCase().replace(/^openwrt-snapshots/i, '') !== file)
return true;
}
return false;
}
function normalizeKey(s) {
return s?.replace(/\s+/g, ' ')?.trim();
}
function determineKeyEnv() {
return fs.stat(APK_DIR).then(() => {
KEYDIR = APK_DIR;
KEYEXT = '.pem'; // not strictly necessary - apk allows any extension
}).catch(() => {
KEYDIR = OPKG_DIR;
KEYEXT = null; // opkg requires key filenames without an extension
});
}
function listKeyFiles() {
return fs.list(KEYDIR).then(entries =>
Promise.all(entries.map(entry =>
fs.read(KEYDIR + entry.name).then(content => ({
filename: entry.name,
key: content
}))
))
);
}
function saveKeyFile(keyContent, file, fileContent) {
const ts = Date.now();
// Note: opkg can only verify against a key with filename that matches its key hash
// generate a file name in case key content was pasted
const filename = file ? file?.name?.split('.')?.[0] + (KEYEXT || '') : null;
const noname = 'key_' + ts + (KEYEXT || '');
return fs.write(KEYDIR + (filename ?? noname), fileContent ?? keyContent, 384 /* 0600 */);
}
function removeKey(ev, key) {
L.showModal(_('Delete key'), [
E('div', _('Really delete the following software repository public key?')),
E('pre', [ key.filename ]),
E('div', { class: 'right' }, [
E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
' ',
E('div', {
class: 'btn danger',
click: function() {
fs.remove(KEYDIR + key.filename)
.then(() => window.location.reload())
.catch(e => ui.addNotification(null, E('p', e.message)))
.finally(() => ui.hideModal());
}
}, _('Delete key'))
])
]);
}
function isPemFormat(content) {
return (/-BEGIN ([A-Z ]+)?PUBLIC KEY-/.test(content));
}
function keyEnvironmentCheck(key) {
const isPem = isPemFormat(key);
// Reject PEM in OPKG; reject non-PEM in APK
if (KEYDIR === OPKG_DIR && isPem)
return _('This key appears to be in PEM format, which is not supported in an opkg environment.');
if (KEYDIR === APK_DIR && !isPem)
return _('This key does not appear to be in PEM format, which is required in an apk environment.');
return null;
}
function addKey(ev, file, fileContent) {
const input = document.getElementById('key-input');
const key = (fileContent ?? input?.value?.trim());
if (!key || !key.length)
return;
// Handle remote URL paste
if (/^https?:\/\/\S+$/i.test(key) && !fileContent) {
ui.addTimeLimitedNotification(_('Fetching key from URL…'), [], 5000, 'info');
L.Request.request(key, { method: 'GET' }).then(res => {
if (res.status !== 200) {
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
E('p', _('HTTP error %d').format(res.status)),
], 7000, 'warning');
return;
}
const fetched = res.responseText?.trim();
if (!fetched || fetched.length > 8192) {
ui.addTimeLimitedNotification(_('Key file too large'), [
E('p', _('Fetched content seems too long. Maximum 8192 bytes.')),
], 7000, 'warning');
return;
}
if (!fetched || fetched.length < 32) {
ui.addTimeLimitedNotification(_('Invalid or empty key file'), [
E('p', _('Fetched content seems empty or too short.')),
], 7000, 'warning');
return;
}
const filename = res?.url?.split('/').pop().split('?')[0].split('#')[0];
// Remove extension if any (we'll re-add based on environment)
const file = {name: filename.replace(/\.[^.]+$/, '') };
addKey(ev, file, fetched);
}).catch(err => {
ui.addTimeLimitedNotification(_('Failed to fetch key'), [
E('p', err.message),
], 7000, 'warning');
});
return;
}
// From here on, key content (either pasted, fetched, or dropped)
const formatError = keyEnvironmentCheck(key);
if (formatError) {
ui.addTimeLimitedNotification(_('Invalid key format'), [
E('p', formatError)
], 7000, 'warning');
return;
}
// Prevent duplicates
listKeyFiles().then(existingKeys => {
if (existingKeys.some(k => normalizeKey(k.key) === normalizeKey(key))) {
ui.addTimeLimitedNotification(_('Add key'), [
E('div', _('The given software repository public key is already present.')),
], 7000, 'notice');
return;
}
// Save and refresh the UI
input.value = '';
saveKeyFile(key, file, fileContent)
.then(() => window.location.reload())
.catch(e => ui.addNotification(null, E('p', e.message)));
});
}
function dragKey(ev) {
ev.stopPropagation();
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
}
function dropKey(ev) {
ev.preventDefault();
ev.stopPropagation();
const input = document.getElementById('key-input');
if (!input)
return;
for (const file of ev.dataTransfer.files) {
const reader = new FileReader();
reader.onload = rev => {
input.value = rev.target.result;
addKey(ev, file, rev.target.result);
input.value = '';
};
reader.readAsText(file);
}
}
function handleWindowDragDropIgnore(ev) {
ev.preventDefault();
}
return view.extend({
load() {
return Promise.all([
determineKeyEnv().then(listKeyFiles),
]);
},
render([keys]) {
const m = new form.JSONMap({
keys: keys,
fup: {},
},
_('Repository Public Keys'), _(
_('Each software repository public key (from official or third party repositories) allows packages in lists signed by it to be installed by the package manager.') + '<br/>' +
_('Each key is stored as a file in %s.').format(`<code>${KEYDIR}</code>`)
));
m.submit = false;
m.reset = false;
m.readonly = isReadonlyView;
let s, o;
s = m.section(form.TableSection, 'keys');
s.anonymous = true;
s.nodescriptions = true;
o = s.option(form.DummyValue, 'filename', _('Name'));
o.width = '20%';
o = s.option(form.TextValue, 'key', _('Key'));
o.readonly = true;
o.monospace = true;
o.cols = 85;
o.rows = 5;
s.renderRowActions = function (section_id) {
const key = this.map.data.get(this.map.config, section_id);
const isReservedKey = isFileInSafeList(key.filename);
const btns = [
E('button', {
'class': 'cbi-button cbi-button-negative remove',
'click': ui.createHandlerFn(this, this.handleRemove, key),
'disabled': isReservedKey ? true : null,
}, [_('Delete')]),
];
return E('td', { 'class': 'td middle cbi-section-actions' }, E('div', btns));
};
s.handleRemove = function(key, ev) {
if (isFileInSafeList(key.filename)) {
ui.addTimeLimitedNotification(null, E('p', _('This key is protected and cannot be deleted.')), 3000, 'warning');
return;
}
return removeKey(ev, key)
};
s = m.section(form.NamedSection, 'fup');
o = s.option(form.DummyValue, '_newkey');
o.cfgvalue = function(/* section_id*/) {
const addInput = E('textarea', {
id: 'key-input',
'aria-label': _('Paste or drag repository public key'),
class: 'cbi-input-text',
type: 'text',
style: 'width: 100%; min-height: 120px;',
placeholder: _('Paste content of a file, or a URL to a key file, or drag and drop here to upload a software repository public key…'),
keydown: function(ev) { if (ev.keyCode === 13 && (ev.ctrlKey || ev.metaKey)) addKey(ev); },
disabled: isReadonlyView
});
addInput.addEventListener('dragover', handleWindowDragDropIgnore);
addInput.addEventListener('drop', handleWindowDragDropIgnore);
const addBtn = E('button', {
class: 'cbi-button',
click: ui.createHandlerFn(this, addKey),
disabled: isReadonlyView
}, _('Add key'));
return E('div', {
class: 'cbi-section-node',
dragover: isReadonlyView ? null : dragKey,
drop: isReadonlyView ? null : dropKey
}, [
E('div', { class: 'cbi-section-descr' }, _('Add new repository public key by pasting its content, a file, or a URL.')),
E('div', {
'style': 'height: 20px',
}, [' ']),
addInput,
E('div', { class: 'right' }, [ addBtn ])
]);
};
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,298 @@
'use strict';
'require baseclass';
'require view';
'require fs';
'require ui';
var isReadonlyView = !L.hasViewPermission() || null;
var SSHPubkeyDecoder = baseclass.singleton({
lengthDecode: function(s, off)
{
var l = (s.charCodeAt(off++) << 24) |
(s.charCodeAt(off++) << 16) |
(s.charCodeAt(off++) << 8) |
s.charCodeAt(off++);
if (l < 0 || (off + l) > s.length)
return -1;
return l;
},
decode: function(s)
{
var parts = s.trim().match(/^((?:(?:^|,)[^ =,]+(?:=(?:[^ ",]+|"(?:[^"\\]|\\.)*"))?)+ +)?(ssh-dss|ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp[0-9]+|sk-ecdsa-sha2-nistp256@openssh\.com|sk-ssh-ed25519@openssh\.com) +([^ ]+)( +.*)?$/);
if (!parts)
return null;
var key = null;
try { key = atob(parts[3]); } catch(e) {}
if (!key)
return null;
var off, len;
off = 0;
len = this.lengthDecode(key, off);
if (len <= 0)
return null;
var type = key.substr(off + 4, len);
if (type !== parts[2])
return null;
off += 4 + len;
var len1 = off < key.length ? this.lengthDecode(key, off) : 0;
if (len1 <= 0)
return null;
var curve = null;
if (type.indexOf('ecdsa-sha2-') === 0) {
curve = key.substr(off + 4, len1);
if (!len1 || type.substr(11) !== curve)
return null;
type = 'ecdsa-sha2';
curve = curve.replace(/^nistp(\d+)$/, 'NIST P-$1');
}
off += 4 + len1;
var len2 = off < key.length ? this.lengthDecode(key, off) : 0;
if (len2 < 0)
return null;
if (len1 & 1)
len1--;
if (len2 & 1)
len2--;
var comment = (parts[4] || '').trim(),
fprint = parts[3].length > 68 ? parts[3].substr(0, 33) + '…' + parts[3].substr(-34) : parts[3];
var options = null;
(parts[1] || '').trim().replace(/(?:^|,)([^ =,]+)(?:=(?:([^ ",]+)|"((?:[^"\\]|\\.)*)"))?/g, function(m, k, p, q) {
options = options || {};
if (options.hasOwnProperty(k))
options[k] += ',' + (q || p || true);
else
options[k] = (q || p || true);
});
switch (type)
{
case 'ssh-rsa':
return { type: 'RSA', bits: len2 * 8, comment: comment, options: options, fprint: fprint, src: s };
case 'ssh-dss':
return { type: 'DSA', bits: len1 * 8, comment: comment, options: options, fprint: fprint, src: s };
case 'ssh-ed25519':
return { type: 'EdDSA', curve: 'Curve25519', comment: comment, options: options, fprint: fprint, src: s };
case 'ecdsa-sha2':
return { type: 'ECDSA', curve: curve, comment: comment, options: options, fprint: fprint, src: s };
case 'sk-ecdsa-sha2-nistp256@openssh.com':
return { type: 'ECDSA-SK', curve: 'NIST P-256', comment: comment, options: options, fprint: fprint, src: s };
case 'sk-ssh-ed25519@openssh.com':
return { type: 'EdDSA-SK', curve: 'Curve25519', comment: comment, options: options, fprint: fprint, src: s };
default:
return null;
}
}
});
function renderKeyItem(pubkey) {
return E('div', {
class: 'item',
click: isReadonlyView ? null : removeKey,
'data-key': pubkey.src
}, [
E('strong', {}, [ pubkey.comment || _('Unnamed key') ]), E('br'),
E('small', {}, [
'%s, %s'.format(pubkey.type, pubkey.curve || _('%d Bit').format(pubkey.bits)),
pubkey.options ? E([], {}, [
' / ', _('Options:'), ' ',
E('code', {}, [Object.keys(pubkey.options).sort().join(', ')])
]) : '',
E('br'), E('code', {}, [pubkey.fprint])
])
]);
}
function renderKeys(keys) {
var list = document.querySelector('.cbi-dynlist');
while (!matchesElem(list.firstElementChild, '.add-item'))
list.removeChild(list.firstElementChild);
keys.forEach(function(key) {
var pubkey = SSHPubkeyDecoder.decode(key);
if (pubkey)
list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
});
if (list.firstElementChild === list.lastElementChild)
list.insertBefore(E('p', _('No public keys present yet.')), list.lastElementChild);
}
function saveKeys(keys) {
return fs.write('/etc/dropbear/authorized_keys', keys.join('\n') + '\n', 384 /* 0600 */)
.then(renderKeys.bind(this, keys))
.catch(function(e) { ui.addNotification(null, E('p', e.message)) })
.finally(ui.hideModal);
}
function addKey(ev) {
var list = findParent(ev.target, '.cbi-dynlist'),
input = list.querySelector('input[type="text"]'),
key = input.value.trim(),
pubkey = SSHPubkeyDecoder.decode(key),
keys = [];
if (!key.length)
return;
list.querySelectorAll('.item').forEach(function(item) {
keys.push(item.getAttribute('data-key'));
});
if (keys.indexOf(key) !== -1) {
ui.showModal(_('Add key'), [
E('div', { class: 'alert-message warning' }, _('The given SSH public key has already been added.')),
E('div', { class: 'right' }, E('div', { class: 'btn', click: L.hideModal }, _('Close')))
]);
}
else if (!pubkey) {
ui.showModal(_('Add key'), [
E('div', { class: 'alert-message warning' }, _('The given SSH public key is invalid. Please supply proper public RSA, ED25519 or ECDSA keys.')),
E('div', { class: 'right' }, E('div', { class: 'btn', click: L.hideModal }, _('Close')))
]);
}
else {
keys.push(key);
input.value = '';
return saveKeys(keys).then(function() {
var added = list.querySelector('[data-key="%s"]'.format(key.replace(/["\\]/g, '\\$&')));
if (added)
added.classList.add('flash');
});
}
}
function removeKey(ev) {
var list = findParent(ev.target, '.cbi-dynlist'),
delkey = ev.target.getAttribute('data-key'),
keys = [];
list.querySelectorAll('.item').forEach(function(item) {
var key = item.getAttribute('data-key');
if (key !== delkey)
keys.push(key);
});
L.showModal(_('Delete key'), [
E('div', _('Do you really want to delete the following SSH key?')),
E('pre', [ delkey ]),
E('div', { class: 'right' }, [
E('div', { class: 'btn', click: L.hideModal }, _('Cancel')),
' ',
E('div', { class: 'btn danger', click: ui.createHandlerFn(this, saveKeys, keys) }, _('Delete key')),
])
]);
}
function dragKey(ev) {
ev.stopPropagation();
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
}
function dropKey(ev) {
var file = ev.dataTransfer.files[0],
input = ev.currentTarget.querySelector('input[type="text"]'),
reader = new FileReader();
if (file) {
reader.onload = function(rev) {
input.value = rev.target.result.trim();
addKey(ev);
input.value = '';
};
reader.readAsText(file);
}
ev.stopPropagation();
ev.preventDefault();
}
function handleWindowDragDropIgnore(ev) {
ev.preventDefault()
}
return view.extend({
load: function() {
return fs.lines('/etc/dropbear/authorized_keys').then(function(lines) {
return lines.map(function(line) {
return SSHPubkeyDecoder.decode(line);
}).filter(function(line) {
return line != null;
});
});
},
render: function(keys) {
var list = E('div', {
'class': 'cbi-dynlist',
'dragover': isReadonlyView ? null : dragKey,
'drop': isReadonlyView ? null : dropKey
}, [
E('div', { 'class': 'add-item' }, [
E('input', {
'class': 'cbi-input-text',
'type': 'text',
'placeholder': _('Paste or drag SSH key file…') ,
'keydown': function(ev) { if (ev.keyCode === 13) addKey(ev) },
'disabled': isReadonlyView
}),
E('button', {
'class': 'cbi-button',
'click': ui.createHandlerFn(this, addKey),
'disabled': isReadonlyView
}, _('Add key'))
])
]);
keys.forEach(L.bind(function(pubkey) {
list.insertBefore(renderKeyItem(pubkey), list.lastElementChild);
}, this));
if (list.firstElementChild === list.lastElementChild)
list.insertBefore(E('p', _('No public keys present yet.')), list.lastElementChild);
window.addEventListener('dragover', handleWindowDragDropIgnore);
window.addEventListener('drop', handleWindowDragDropIgnore);
return E('div', {}, [
E('h2', _('SSH-Keys')),
E('div', { 'class': 'cbi-section-descr' }, _('Public keys allow for passwordless SSH logins with higher security than plain passwords. In order to upload a new key to the device, paste an OpenSSH-compatible public key or drag a <code>.pub</code> file into the input field.')),
E('div', { 'class': 'cbi-section-node' }, list)
]);
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,138 @@
'use strict';
'require view';
'require rpc';
'require fs';
'require ui';
var isReadonlyView = !L.hasViewPermission() || null;
return view.extend({
callRcList: rpc.declare({
object: 'rc',
method: 'list',
params: [ 'skip_running_check' ],
expect: { '': {} }
}),
callRcInit: rpc.declare({
object: 'rc',
method: 'init',
params: [ 'name', 'action' ],
}),
load: function() {
return Promise.all([
L.resolveDefault(fs.read('/etc/rc.local'), ''),
this.callRcList(true)
]);
},
handleAction: function(name, action, ev) {
return this.callRcInit(name, action).then(function(ret) {
if (ret)
throw _('Command failed');
return true;
}).catch(function(e) {
ui.addNotification(null, E('p', _('Failed to execute "/etc/init.d/%s %s" action: %s').format(name, action, e)));
});
},
handleEnableDisable: function(name, isEnabled, ev) {
return this.handleAction(name, isEnabled ? 'disable' : 'enable', ev).then(L.bind(function(name, isEnabled, btn) {
btn.parentNode.replaceChild(this.renderEnableDisable({
name: name,
enabled: isEnabled
}), btn);
}, this, name, !isEnabled, ev.currentTarget));
},
handleRcLocalSave: function(ev) {
var value = (document.querySelector('textarea').value || '').trim().replace(/\r\n/g, '\n') + '\n';
return fs.write('/etc/rc.local', value).then(function() {
document.querySelector('textarea').value = value;
ui.addNotification(null, E('p', _('Contents have been saved.')), 'info');
}).catch(function(e) {
ui.addNotification(null, E('p', _('Unable to save contents: %s').format(e.message)));
});
},
renderEnableDisable: function(init) {
return E('button', {
class: 'btn cbi-button-%s'.format(init.enabled ? 'positive' : 'negative'),
click: ui.createHandlerFn(this, 'handleEnableDisable', init.name, init.enabled),
disabled: isReadonlyView
}, init.enabled ? _('Enabled') : _('Disabled'));
},
render: function(data) {
var rcLocal = data[0],
initList = data[1],
rows = [], list = [];
var table = E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Start priority')),
E('th', { 'class': 'th' }, _('Initscript')),
E('th', { 'class': 'th nowrap cbi-section-actions' })
])
]);
for (var init in initList)
if (initList[init].start < 100)
list.push(Object.assign({ name: init }, initList[init]));
list.sort(function(a, b) {
if (a.start != b.start)
return a.start - b.start
return a.name > b.name;
});
for (var i = 0; i < list.length; i++) {
rows.push([
'%02d'.format(list[i].start),
list[i].name,
E('div', [
this.renderEnableDisable(list[i]),
E('button', { 'class': 'btn cbi-button-action', 'click': ui.createHandlerFn(this, 'handleAction', list[i].name, 'start'), 'disabled': isReadonlyView }, _('Start', 'daemon start action')),
E('button', { 'class': 'btn cbi-button-action', 'click': ui.createHandlerFn(this, 'handleAction', list[i].name, 'restart'), 'disabled': isReadonlyView }, _('Restart', 'daemon restart action')),
E('button', { 'class': 'btn cbi-button-action', 'click': ui.createHandlerFn(this, 'handleAction', list[i].name, 'reload'), 'disabled': isReadonlyView }, _('Reload', 'daemon reload action')),
E('button', { 'class': 'btn cbi-button-action', 'click': ui.createHandlerFn(this, 'handleAction', list[i].name, 'stop'), 'disabled': isReadonlyView }, _('Stop', 'daemon stop action'))
])
]);
}
cbi_update_table(table, rows);
var view = E('div', {}, [
E('h2', _('Startup')),
E('div', {}, [
E('div', { 'data-tab': 'init', 'data-tab-title': _('Initscripts') }, [
E('p', {}, _('You can enable or disable installed init scripts here. Changes will be applied after a device reboot.<br /><strong>Warning: If you disable essential init scripts like "network", your device might become inaccessible!</strong>')),
table
]),
E('div', { 'data-tab': 'rc', 'data-tab-title': _('Local Startup') }, [
E('p', {}, _('This is the content of /etc/rc.local. Insert your own commands here (in front of \'exit 0\') to execute them at the end of the boot process.')),
E('p', {}, E('textarea', { 'style': 'width:100%', 'rows': 20, 'disabled': isReadonlyView }, [ (rcLocal != null ? rcLocal : '') ])),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button-save',
'click': ui.createHandlerFn(this, 'handleRcLocalSave'),
'disabled': isReadonlyView
}, _('Save'))
])
])
])
]);
ui.tabs.initTabGroup(view.lastElementChild.childNodes);
return view;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,390 @@
'use strict';
'require view';
'require fs';
'require poll';
'require ui';
'require uci';
'require rpc';
'require form';
'require tools.widgets as widgets';
const callRcList = rpc.declare({
object: 'rc',
method: 'list',
params: [ 'name' ],
expect: { '': {} },
filter(res) {
for (var k in res)
return +res[k].enabled;
return null;
}
});
const callRcInit = rpc.declare({
object: 'rc',
method: 'init',
params: [ 'name', 'action' ],
expect: { result: false }
});
const callGetUnixtime = rpc.declare({
object: 'luci',
method: 'getUnixtime',
expect: { result: 0 }
});
const callSetLocaltime = rpc.declare({
object: 'luci',
method: 'setLocaltime',
params: [ 'localtime' ],
expect: { result: 0 }
});
const callTimezone = rpc.declare({
object: 'luci',
method: 'getTimezones',
expect: { '': {} }
});
function formatTime(epoch) {
const date = new Date(epoch * 1000);
const zn = uci.get('system', '@system[0]', 'zonename')?.replaceAll(' ', '_') || 'UTC';
const ts = uci.get('system', '@system[0]', 'clock_timestyle') || 0;
const hc = uci.get('system', '@system[0]', 'clock_hourcycle') || 0;
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: (ts == 0) ? 'long' : 'full',
hourCycle: (hc == 0) ? undefined : hc,
timeZone: zn
}).format(date);
}
const CBILocalTime = form.DummyValue.extend({
renderWidget(section_id, option_id, cfgvalue) {
return E([], [
E('input', {
'id': 'localtime',
'type': 'text',
'readonly': true,
'value': formatTime(cfgvalue)
}),
E('br'),
E('span', { 'class': 'control-group' }, [
E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, function() {
return callSetLocaltime(Math.floor(Date.now() / 1000));
}),
'disabled': (this.readonly != null) ? this.readonly : this.map.readonly
}, _('Sync with browser')),
' ',
this.ntpd_support ? E('button', {
'class': 'cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, function() {
return callRcInit('sysntpd', 'restart');
}),
'disabled': (this.readonly != null) ? this.readonly : this.map.readonly
}, _('Sync with NTP-Server')) : ''
])
]);
},
});
return view.extend({
load() {
return Promise.all([
callRcList('sysntpd'),
callTimezone(),
callGetUnixtime(),
uci.load('luci'),
uci.load('system'),
L.hasSystemFeature('zram')
? L.resolveDefault(fs.read('/sys/block/zram0/comp_algorithm'), '')
: ''
]);
},
render([ntpd_enabled, timezones, unixtime, _luci, _system, zram_algorithms]) {
let m, s, o;
const system_section = uci.sections('system', 'system')[0];
m = new form.Map('system',
_('System'),
_('Here you can configure the basic aspects of your device like its hostname or the timezone.'));
this.map = m;
this.systemSid = system_section?.['.name'];
m.chain('luci');
s = m.section(form.TypedSection, 'system', _('System Properties'));
s.anonymous = true;
s.addremove = false;
s.tab('general', _('General Settings'));
s.tab('logging', _('Logging'));
s.tab('timesync', _('Time Synchronization'));
s.tab('language', _('Language and Style'));
/*
* System Properties
*/
o = s.taboption('general', CBILocalTime, '_systime', _('Local Time'));
o.cfgvalue = function() { return unixtime };
o.ntpd_support = ntpd_enabled;
o = s.taboption('general', form.Value, 'hostname', _('Hostname'));
o.datatype = 'hostname';
/* could be used also as a default for LLDP, SNMP "system description" in the future */
o = s.taboption('general', form.Value, 'description', _('Description'), _('An optional, short description for this device'));
o.optional = true;
o = s.taboption('general', form.TextValue, 'notes', _('Notes'), _('Optional, free-form notes about this device'));
o.optional = true;
o = s.taboption('general', form.ListValue, 'zonename', _('Timezone'));
o.value('UTC');
const zones = Object.keys(timezones || {}).sort();
for (let zone of zones)
o.value(zone);
o.write = function(section_id, formvalue) {
const tz = timezones[formvalue] ? timezones[formvalue].tzstring : null;
uci.set('system', section_id, 'zonename', formvalue);
uci.set('system', section_id, 'timezone', tz);
};
o = s.taboption('general', form.Flag, 'clock_timestyle', _('Full TimeZone Name'), _('Unchecked means the timezone offset (E.g. GMT+1) is displayed'));
o = s.taboption('general', form.ListValue, 'clock_hourcycle', _('Time Format'));
o.value('', _('Default'));
o.value('h12', _('12-Hour Clock'));
o.value('h23', _('24-Hour Clock'));
/*
* Logging
*/
o = s.taboption('logging', form.Value, 'log_size', _('System log buffer size'), "kiB");
o.optional = true;
o.placeholder = 128;
o.datatype = 'uinteger';
o = s.taboption('logging', form.Value, 'log_ip', _('External system log server'));
o.optional = true;
o.placeholder = '0.0.0.0';
o.datatype = 'host';
o = s.taboption('logging', form.Value, 'log_port', _('External system log server port'));
o.optional = true;
o.placeholder = 514;
o.datatype = 'port';
o = s.taboption('logging', form.ListValue, 'log_proto', _('External system log server protocol'));
o.value('udp', 'UDP');
o.value('tcp', 'TCP');
o = s.taboption('logging', form.Value, 'log_file', _('Write system log to file'));
o.optional = true;
o.placeholder = '/tmp/system.log';
o = s.taboption('logging', form.ListValue, 'conloglevel', _('Log output level'), _('Only affects dmesg kernel log'));
o.value(8, _('Debug'));
o.value(7, _('Info'));
o.value(6, _('Notice'));
o.value(5, _('Warning'));
o.value(4, _('Error'));
o.value(3, _('Critical'));
o.value(2, _('Alert'));
o.value(1, _('Emergency'));
o = s.taboption('logging', form.ListValue, 'cronloglevel', _('Cron Log Level'));
o.default = 7;
o.value(7, _('Normal'));
o.value(9, _('Disabled'));
o.value(5, _('Debug'));
/*
* Zram Properties
*/
if (L.hasSystemFeature('zram')) {
s.tab('zram', _('ZRam Settings'));
o = s.taboption('zram', form.Flag, 'zram_enabled', _('Enable'));
o.enabled = '1';
o.disabled = '0';
o.default = '1';
o.rmempty = false;
o.cfgvalue = function(section_id) {
return String(uci.get('system', section_id, 'zram_enabled') ?? '1');
};
o = s.taboption('zram', form.Value, 'zram_size_mb', _('ZRam Size'), _('Size of the ZRam device in megabytes'));
o.optional = true;
o.placeholder = 16;
o.datatype = 'uinteger';
o.depends('zram_enabled', '1');
const supported_algorithms = String(zram_algorithms || '')
.trim()
.split(/\s+/)
.map(algo => algo.replace(/[\[\]]/g, ''))
.filter((algo, index, list) => algo && list.indexOf(algo) === index);
const current_algorithm = uci.get('system', '@system[0]', 'zram_comp_algo');
o = s.taboption('zram', form.ListValue, 'zram_comp_algo', _('ZRam Compression Algorithm'));
o.optional = true;
o.default = current_algorithm || supported_algorithms[0] || 'lzo';
o.depends('zram_enabled', '1');
if (!supported_algorithms.length) {
[ 'lzo', 'lz4', 'zstd' ].forEach(algo => o.value(algo, algo));
}
else {
supported_algorithms.forEach(algo => o.value(algo, algo));
if (current_algorithm && supported_algorithms.indexOf(current_algorithm) < 0)
o.value(current_algorithm, '%s (%s)'.format(current_algorithm, _('unsupported')));
}
}
/*
* Language & Style
*/
o = s.taboption('language', form.ListValue, '_lang', _('Language'))
o.uciconfig = 'luci';
o.ucisection = 'main';
o.ucioption = 'lang';
o.value('auto', _('auto'));
const l = Object.assign({ en: 'English' }, uci.get('luci', 'languages'));
const keys = Object.keys(l).sort();
for (let k of keys)
if (k.charAt(0) != '.')
o.value(k, l[k]);
o = s.taboption('language', form.ListValue, '_mediaurlbase', _('Design'))
o.uciconfig = 'luci';
o.ucisection = 'main';
o.ucioption = 'mediaurlbase';
const th = Object.keys(uci.get('luci', 'themes') || {}).sort();
for (let t of th)
if (t.charAt(0) != '.')
o.value(uci.get('luci', 'themes', t), t);
o = s.taboption('language', form.Flag, '_tablefilters', _('Table Filters'));
o.default = o.disabled;
o.uciconfig = 'luci';
o.ucisection = 'main';
o.ucioption = 'tablefilters';
/*
* NTP
*/
if (L.hasSystemFeature('sysntpd')) {
var default_servers = [
'0.openwrt.pool.ntp.org', '1.openwrt.pool.ntp.org',
'2.openwrt.pool.ntp.org', '3.openwrt.pool.ntp.org'
];
o = s.taboption('timesync', form.Flag, 'enabled', _('Enable NTP client'));
o.rmempty = false;
o.ucisection = 'ntp';
o.default = o.disabled;
o.write = function(section_id, value) {
ntpd_enabled = +value;
if (ntpd_enabled && !uci.get('system', 'ntp')) {
uci.add('system', 'timeserver', 'ntp');
uci.set('system', 'ntp', 'server', default_servers);
}
if (!ntpd_enabled)
uci.set('system', 'ntp', 'enabled', 0);
else
uci.unset('system', 'ntp', 'enabled');
return callRcInit('sysntpd', 'enable');
};
o.load = function(section_id) {
return (ntpd_enabled == 1 &&
uci.get('system', 'ntp') != null &&
uci.get('system', 'ntp', 'enabled') != 0) ? '1' : '0';
};
o = s.taboption('timesync', form.Flag, 'enable_server', _('Provide NTP server'));
o.ucisection = 'ntp';
o.depends('enabled', '1');
o = s.taboption('timesync', widgets.NetworkSelect, 'interface',
_('Bind NTP server'),
_('Provide the NTP server to the selected interface or, if unspecified, to all interfaces'));
o.ucisection = 'ntp';
o.depends('enable_server', '1');
o.multiple = false;
o.nocreate = true;
o.optional = true;
o = s.taboption('timesync', form.Flag, 'use_dhcp', _('Use DHCP advertised servers'));
o.ucisection = 'ntp';
o.default = o.enabled;
o.depends('enabled', '1');
o = s.taboption('timesync', form.DynamicList, 'server', _('NTP server candidates'),
_('List of upstream NTP server candidates with which to synchronize.'));
o.datatype = 'host(0)';
o.ucisection = 'ntp';
o.depends('enabled', '1');
o.load = function(section_id) {
return uci.get('system', 'ntp', 'server');
};
}
return m.render().then(function(mapEl) {
poll.add(function() {
return callGetUnixtime().then(function(t) {
mapEl.querySelector('#localtime').value = formatTime(t);
});
});
return mapEl;
});
},
isZramChanged() {
if (!L.hasSystemFeature('zram') || !this.map || !this.systemSid)
return false;
const enabled = this.map.lookupOption('zram_enabled', this.systemSid);
const size = this.map.lookupOption('zram_size_mb', this.systemSid);
const algo = this.map.lookupOption('zram_comp_algo', this.systemSid);
const current_enabled = String(uci.get('system', '@system[0]', 'zram_enabled') ?? '1');
const current_size = String(uci.get('system', '@system[0]', 'zram_size_mb') ?? '');
const current_algo = String(uci.get('system', '@system[0]', 'zram_comp_algo') ?? '');
const form_enabled = String(enabled ? (enabled[0].formvalue(enabled[1]) ?? '') : current_enabled);
const form_size = String(size ? (size[0].formvalue(size[1]) ?? '') : current_size);
const form_algo = String(algo ? (algo[0].formvalue(algo[1]) ?? '') : current_algo);
return (form_enabled !== current_enabled || form_size !== current_size || form_algo !== current_algo);
},
handleSaveApply(ev, mode) {
if (this.isZramChanged()) {
const fn = L.bind(() => {
callRcInit('zram', 'restart');
document.removeEventListener('uci-applied', fn);
});
document.addEventListener('uci-applied', fn);
}
return this.super('handleSaveApply', [ev, mode]);
}
});
@@ -0,0 +1,19 @@
'use strict';
'require view';
'require form';
return view.extend({
render: function() {
var m, s, o;
m = new form.Map('uhttpd', _('HTTP(S) Access'), _('uHTTPd offers <abbr title="Hypertext Transfer Protocol">HTTP</abbr> or <abbr title="Hypertext Transfer Protocol Secure">HTTPS</abbr> network access.'));
s = m.section(form.NamedSection, 'main', 'uhttpd', _('Settings'));
s.addremove = false;
o = s.option(form.Flag, 'redirect_https', _('Redirect to HTTPS'), _('Enable automatic redirection of <abbr title="Hypertext Transfer Protocol">HTTP</abbr> requests to <abbr title="Hypertext Transfer Protocol Secure">HTTPS</abbr> port.'));
o.rmempty = false;
return m.render();
}
});