luci-app-wol: replace fs.stat/exec with safe RPC backend, fix ACLs
authormdevolde <[email protected]>
Mon, 24 Nov 2025 15:12:23 +0000 (16:12 +0100)
committerPaul Donald <[email protected]>
Wed, 26 Nov 2025 23:07:15 +0000 (00:07 +0100)
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 <[email protected]>
applications/luci-app-wol/htdocs/luci-static/resources/view/wol.js
applications/luci-app-wol/root/usr/share/rpcd/acl.d/luci-app-wol.json
applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol [new file with mode: 0644]

index 29b0d305aa2fbdb5caf0557d5f45137b52724cd4..f67e9d1ccf6ca53b87278e0b7b2841f99010989e 100644 (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', {
index c679fe321315cfbaeedad38798b045e8db385a70..b2bece606c47b7039b0b373192e3379f379e75bc 100644 (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" ]
                        }
                }
        }
diff --git a/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol b/applications/luci-app-wol/root/usr/share/rpcd/ucode/luci.wol
new file mode 100644 (file)
index 0000000..9d1e26f
--- /dev/null
@@ -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 };