luci-app-wol: replace fs.stat/exec with safe RPC backend, fix ACLs

fs.stat() and fs.exec() require broad rpcd permissions (ubus file.*) which
prevent luci-app-wol from working for restricted users and expose more
access than necessary.

This introduces a dedicated RPC backend (luci.wol) providing safe stat/exec
wrappers for etherwake and wakeonlan, and simplifies ACLs to only allow
these two RPC calls.

Also adds the missing 'getNetworkDevices' ACL required by DeviceSelect.

Signed-off-by: Martin Devolder <martin.devolder2@gmail.com>
This commit is contained in:
mdevolde
2025-11-24 16:12:23 +01:00
committed by Paul Donald
parent cf3d8cdb27
commit bf7d15af13
3 changed files with 94 additions and 21 deletions

View File

@@ -8,9 +8,26 @@
'require form';
'require tools.widgets as widgets';
const ETHERWAKE_BIN = '/usr/bin/etherwake';
const WAKEONLAN_BIN = '/usr/bin/wakeonlan';
return view.extend({
formdata: { wol: {} },
callStat: rpc.declare({
object: 'luci.wol',
method: 'stat',
params: [ ],
expect: { }
}),
callExec: rpc.declare({
object: 'luci.wol',
method: 'exec',
params: [ 'name', 'args' ],
expect: { }
}),
callHostHints: rpc.declare({
object: 'luci-rpc',
method: 'getHostHints',
@@ -19,17 +36,15 @@ return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(fs.stat('/usr/bin/etherwake')),
L.resolveDefault(fs.stat('/usr/bin/wol')),
L.resolveDefault(this.callStat()),
this.callHostHints(),
uci.load('etherwake')
]);
},
render: function(data) {
var has_ewk = data[0],
has_wol = data[1],
hosts = data[2],
render([stat, hosts]) {
var has_ewk = stat && stat.etherwake,
has_wol = stat && stat.wakeonlan,
m, s, o;
this.formdata.has_ewk = has_ewk;
@@ -44,8 +59,8 @@ return view.extend({
o = s.option(form.ListValue, 'executable', _('WoL program'),
_('Sometimes only one of the two tools works. If one fails, try the other one'));
o.value('/usr/bin/etherwake', 'Etherwake');
o.value('/usr/bin/wol', 'WoL');
o.value(ETHERWAKE_BIN, 'Etherwake');
o.value(WAKEONLAN_BIN, 'Wakeonlan');
}
if (has_ewk) {
@@ -67,7 +82,7 @@ return view.extend({
});
if (has_wol)
o.depends('executable', '/usr/bin/etherwake');
o.depends('executable', ETHERWAKE_BIN);
}
o = s.option(form.Value, 'mac', _('Host to wake up'),
@@ -88,7 +103,7 @@ return view.extend({
o = s.option(form.Flag, 'broadcast', _('Send to broadcast address'));
if (has_wol)
o.depends('executable', '/usr/bin/etherwake');
o.depends('executable', ETHERWAKE_BIN);
}
return m.render();
@@ -96,16 +111,17 @@ return view.extend({
handleWakeup: function(ev) {
var map = document.querySelector('#maincontent .cbi-map'),
data = this.formdata;
data = this.formdata,
self = this;
return dom.callClassMethod(map, 'save').then(function() {
if (!data.wol.mac)
return alert(_('No target host specified!'));
var bin = data.executable || (data.has_ewk ? '/usr/bin/etherwake' : '/usr/bin/wol'),
var bin = data.wol.executable || (data.has_ewk ? ETHERWAKE_BIN : WAKEONLAN_BIN),
args = [];
if (bin == '/usr/bin/etherwake') {
if (bin == ETHERWAKE_BIN) {
args.push('-D', '-i', data.wol.iface);
if (data.wol.broadcast == '1')
@@ -114,16 +130,16 @@ return view.extend({
args.push(data.wol.mac);
}
else {
args.push('-v', data.wol.mac);
args.push(data.wol.mac);
}
ui.showModal(_('Waking host'), [
E('p', { 'class': 'spinning' }, [ _('Starting WoL utility…') ])
]);
return fs.exec(bin, args).then(function(res) {
return self.callExec(bin, args).then(function(res) {
ui.showModal(_('Waking host'), [
res.stderr ? E('p', [ res.stdout ]) : '',
res.stdout ? E('p', [ res.stdout ]) : '',
res.stderr ? E('pre', [ res.stderr ]) : '',
E('div', { 'class': 'right' }, [
E('button', {

View File

@@ -3,14 +3,14 @@
"description": "Grant access to wake-on-lan executables",
"read": {
"ubus": {
"luci-rpc": [ "getHostHints" ]
"luci.wol": [ "stat" ],
"luci-rpc": [ "getHostHints", "getNetworkDevices" ]
},
"uci": [ "etherwake" ]
},
"write": {
"file": {
"/usr/bin/etherwake": [ "exec" ],
"/usr/bin/wol": [ "exec" ]
"ubus": {
"luci.wol": [ "exec" ]
}
}
}

View File

@@ -0,0 +1,57 @@
#!/usr/bin/ucode
'use strict';
import { access, stat, popen } from 'fs';
const etherwake = '/usr/bin/etherwake';
const wakeonlan = '/usr/bin/wakeonlan';
function shellquote(s) {
return "'" + replace(s, "'", "'\\''") + "'";
}
const methods = {
stat: {
call: function(request) {
const result = {};
result.etherwake = false;
result.wakeonlan = false;
if (access(etherwake, "x")) {
result.etherwake = true;
}
if (access(wakeonlan, "x")) {
result.wakeonlan = true;
}
return result;
}
},
exec: {
args: { name: 'string', args: [] },
call: function(request) {
const result = {};
if (request.args.name == etherwake || request.args.name == wakeonlan) {
parts = map(request.args.args, shellquote);
const fd = popen(request.args.name + ' ' + join(' ', parts));
result.stdout = fd.read('all');
result.stderr = '';
result.code = 0;
} else {
result.stdout = '';
result.stderr = 'disallowed';
result.code = 1;
}
return result;
}
}
};
return { "luci.wol": methods };