Recently I encountered a need to allow users from my intranet to wake computers that are sleeping / powered off. The simplest and most reliable solution I could think of was having such function available directly on the root router. There is already a decent package with interface for WOL, however, it is necessary to be authorized to use it, and I did not want to give access to the router to other people, only to this one particular function.
So, I implemented a tiny public interface using uhttpd
+ lua
and wanted to share my solution. This topic was a very good starting point, however it lacked several essential functions, which was relatively uneasy to dig out and integrate, so I believe this example might further help somebody.
The project consists of 3 files, but relies on the list of other built-in resources (like static images) to save space:
> cat /www/pwol/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<link rel="icon" type="image/png" href="/luci-static/bootstrap/favicon.png"/>
<title>Public Wake-On-LAN Interface</title>
<style>
body { text-align: center; }
table { border-collapse: collapse; }
th, td { padding: 1px 5px 1px 5px; border: 1px solid black; }
button { margin: 1px 2px 1px 2px; }
.red { color: #f00; } .blue { color: #00f; } .text-center { text-align: center } .w-100 { width: 100% } .w-80 { width: 80% } .elem-center { margin-left: auto; margin-right: auto; }
</style>
</head>
<body>
<h1>Public Wake-On-LAN Interface</h1>
<div id="err_cont" class="center red" hidden>error text</div>
<table id="dev_list" class="elem-center">
<tr><th>Hostname</th><th>MAC Addr</th><th>IP Addr</th><th>Status</th><th>Action</th></tr>
</table>
<script src="script.js"></script>
</body>
</html>
> cat /www/pwol/script.js
'use strict';
const {error} = console; // const log = function () {}; // = console.log;
const devices = [];
const api_ep = "/cgi-bin/pwol-ctl";
const srr = "/luci-static/resources/"; // static resources root dir
Object.defineProperties(Date, { now_sec: { enumerable: true, get() { return (Date.now()/1000) >>> 0; }, } });
class NwkDevice {
static status_icon_path = {
renewing: srr+"icons/loading.gif",
online: srr+"cbi/save.gif",
waking: srr+"cbi/apply.gif",
offline: srr+"cbi/reset.gif",
error: srr+"cbi/help.gif",
connected: srr+"icons/port_up.png",
disconnected: srr+"icons/port_down.png",
};
static auto_update = {
default: { interval_sec: 10 },
quick: { interval_sec: 3, duration_max_sec: 60, },
};
name = "";
info = { ip: "", mac: "", };
auto_update = { en: false, qmode: false, timeout_ts: 0 };
h_intv = null;
root_tbl = { elem: null, row_idx: NaN, };
elem = { tr: null, sts: null, chk: null, wake: null, };
state_prev;
constructor(root_tbl_elem, name, info, auto_update_en = false) {
this.name = name;
Object.assign(this.info, info);
this.root_tbl.elem = root_tbl_elem;
this.root_tbl.row_idx = root_tbl_elem.rows.length;
this.elem_create();
this.chk_sts();
if (auto_update_en) { this.auto_update.en = true; this.update_interval = this.constructor.auto_update.default.interval_sec; };
};
set update_interval(val) {
if (this.h_intv) { clearInterval(this.h_intv); this.h_intv = null; };
if (!(val > 1)) { return; };
this.h_intv = setInterval(this.chk_sts.bind(this), val*1000);
};
get state() { return this.elem.sts.alt; };
set state(sts) {
// log("Dev['%s']: setting state '%s'", this.name, sts);
this.elem.chk.disabled = this.elem.wake.disabled = (sts === 'renewing');
const src = this.constructor.status_icon_path[sts];
if (!src) { error("Dev['%s']: Could not set state '%s': invalid value!", this.name, sts); return; };
if (!this.elem.chk.disabled) { this.state_prev = this.state; };
Object.assign(this.elem.sts, { src, alt: sts, title: sts, })
};
elem_create() {
let td,el;
const tr = this.elem.tr = this.root_tbl.elem.insertRow();
td = tr.insertCell(); Object.assign(td, { innerText: this.name, style: "text-transform: uppercase;" });
td = tr.insertCell(); Object.assign(td, { innerText: this.info.mac });
td = tr.insertCell(); Object.assign(td, { innerText: this.info.ip });
td = tr.insertCell(); // Object.assign(td, { innerHTML: '<img src="/luci-static/resources/icons/loading.gif" height="16px"><img src="/luci-static/resources/cbi/save.gif" height="16px"><img src="/luci-static/resources/cbi/reset.gif" height="16px">' });
el = this.elem.sts = document.createElement('img'); Object.assign(el, { src: this.constructor.status_icon_path.renewing, height: 16 });
td.appendChild(el);
td = tr.insertCell(); // Object.assign(td, { innerHTML: });
el = this.elem.wake = document.createElement('button'); Object.assign(el, { innerText: "Wake", disabled: false }); el.addEventListener('click', this.wake.bind(this));
td.appendChild(el);
if (!this.auto_update.en) {
el = this.elem.chk = document.createElement('button'); Object.assign(el, { innerText: "Check", disabled: false }); el.addEventListener('click', this.chk_sts.bind(this));
td.appendChild(el);
};
};
wake(evt) {
// log("Dev['%s'].wake button clicked", this.name);
if (this.elem.wake.disabled) { error("Cannot invoke waking until current operation is finished."); return; };
this.state = 'renewing';
api_get({ cmd: "hw", host: this.name }).then((resp) => {
do {
if (!resp?.ok) { error("Dev['%s']: Received fail-resp:", this.name, resp); break; };
if (!resp.data[this.name]?.hasOwnProperty('wol_ok')) { error("Dev['%s']: invalid resp structure:", this.name, resp); break; };
if (!resp.data[this.name].wol_ok) { error("Dev['%s']: failed sending WOL!", this.name); break; };
this.state = 'waking';
this.auto_update.qmode = true;
this.auto_update.timeout_ts = Date.now_sec + this.constructor.auto_update.quick.duration_max_sec;
this.update_interval = this.constructor.auto_update.quick.interval_sec;
return;
} while (0);
this.state = 'error';
}).catch((err) => {
error("Dev['%s']: Failed receiving status update:", this.name, err);
this.state = 'error';
});
};
chk_sts(evt) {
//log("Dev['%s'].chk_sts button clicked", this.name);
if (this.elem.chk.disabled) { error("Cannot initiate new state check until current operation is finished."); return; };
this.state = 'renewing';
const au = this.auto_update;
if (au.qmode) {
const now = Date.now_sec;
if (au.timeout_ts < now) {
au.qmode = false; this.update_interval = (au.en)?(this.constructor.auto_update.default.interval_sec):(0);
this.state = 'error';
this.elem.sts.title = "The host did not wake up within expected time, manual check required.";
return;
};
};
api_get({ cmd: "hs", host: this.name }).then((resp) => {
do {
if (!resp?.ok) { error("Dev['%s']: Received fail-resp:", this.name, resp); break; };
if (!resp.data[this.name]?.hasOwnProperty('online')) { error("Dev['%s']: invalid resp structure:", this.name, resp); };
if (resp.data[this.name].online) {
this.state = 'online';
if (au.qmode) { au.qmode = false; this.update_interval = (au.en)?(this.constructor.auto_update.default.interval_sec):(0); };
} else {
this.state = 'offline';
};
return;
} while (0);
this.state = 'error';
}).catch((err) => {
error("Dev['%s']: Failed receiving status update:", this.name, err);
this.state = 'error';
});
};
};
function api_get(req) { const q = new URLSearchParams(req); return fetch(api_ep + '?' + q).then(data => data.json()); };
function show_error(msg, err) {
const el = document.getElementById('err_cont');
Object.assign(el, { innerText: msg, hidden: false, })
};
function create_dev_table(resp) {
// log("resp:", resp);
const root_elem = document.getElementById("dev_list");
if (!resp?.ok || !resp.data) { show_error("Invalid device list received:", resp); return; };
function dev_val(dev) { return Number(resp.data[dev].ip.split('.')[3]); };
const devs_sorted = Object.keys(resp.data).sort((a, b) => (dev_val(a)-dev_val(b)));
for (const dev of devs_sorted) { devices.push(new NwkDevice(root_elem, dev, resp.data[dev])); };
};
window.addEventListener('DOMContentLoaded', function main(evt) {
api_get({ cmd: "hl" }).then(create_dev_table).catch(show_error.bind(this, "ERROR: Failed to read states of devices"));
});
> ln -s /www/pwol/api.lua /www/cgi-bin/pwol-ctl
> cat /www/pwol/api.lua
#!/usr/bin/lua
require("uci");
require("nixio");
require("luci.jsonc");
require("luci.http");
require("luci.ip");
local dev_list_path = "/www/pwol/dev_list.txt";
function contains(arr, val) local k,v; for k,v in ipairs(arr) do if (v == val) then return true; end; end; return false; end;
function to_bool(val) if ((val == 0) or (val == nil) or (val == "")) then return false; end; return true; end;
function is_online(ip)
local ret = os.execute(string.format("ping -4 -W 1 -c 1 '%s' > /dev/null", ip));
return not to_bool(ret);
end;
function read_pub_hosts_list(file_path)
local ret = {};
local hfile = io.open(file_path, 'r');
if (not hfile) then return nil; end;
for line in hfile:lines() do
if (to_bool(line)) then table.insert(ret, line); end;
end;
hfile:close();
return ret;
end;
local host_info = {}; -- { ['dev'] = { ['mac'] = '00:11:22:33:44:55', ['ip'] = "192.168.1.2" } };
local host_ctl = {
['hl'] = function () -- host list
return host_info;
end,
['hs'] = function (host) -- host state
local ret = {};
if (host) then -- if value of 'host' is empty, return statuses of all known hosts
local info = host_info[host];
if (not info) then return nil; end;
ret[host] = info;
ret[host].online = is_online(info.ip);
else
for host,info in pairs(host_info) do
ret[host] = info;
ret[host].online = is_online(info.ip);
end;
end;
return ret;
end,
['hw'] = function (host) -- host wake
if (not host) then return nil; end;
local info = host_info[host];
if (not info) then return nil; end;
local ret = { [host] = host_info[host] };
ret[host].wol_ok = not to_bool(os.execute(string.format("etherwake -b -i br-lan '%s' > /dev/null", info.mac)));
return ret;
end,
};
local args = luci.http.urldecode_params(nixio.getenv('QUERY_STRING') or arg[1] or "cmd=hl"); -- NOTE: final val is for debugging, when script executed from terminal without args, e.g. `lua api.lua 'cmd=hs&host=computer1'`
local ret = {
['ok'] = false,
-- ['args'] = args, -- DEBUG ONLY
};
repeat -- NOTE: fake loop allows shortcuts using 'break'
local public_hosts = read_pub_hosts_list(dev_list_path); -- { 'dev1', 'dev2', ... }; // NOTE: cwd for FastCGI is '/www'
if (not public_hosts) then ret.err = "Failed reading list of available hosts"; break; end;
for k,v in pairs(uci.get_all('dhcp')) do
-- NOTE: current behaviour is such that only *fully* matching device host names will end up in 'host_info', others will be completely ignored
if (contains(public_hosts, v.name)) then host_info[v.name] = { mac = v.mac, ip = v.ip }; end;
end;
local cmd = host_ctl[args.cmd];
if (not cmd) then ret.err = "Unknown command"; break; end;
local data = cmd(args.host);
if (not data) then ret.err = "Unknown host"; break; end;
ret.ok = true;
ret.data = data;
until (true);
print("Status: 200 OK");
print("Content-type: application/json\n");
print(luci.jsonc.stringify(ret, '\t'));
> cat /www/pwol/dev_list.txt
computer1
computer2
computer3
laptop
To make it work, list publicly visible computers via their local hostnames inside /www/pwol/dev_list.txt
, one device name per line. No further configurations are required. This page will be available to anyone, who visits http://192.168.1.1/pwol
without authentication.