PWOL: Public Wake-On-Lan Interface Example

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.

PWOL Interface Screenshot

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.

7 Likes

Thank you very much for your work. I altered this script, so it would use manual list of devices instead using DHCP, which wouldnt work always:

/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";
local dev_list_json_path = "/www/pwol/dev_list.json";

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/nul", ip));
	
	return not to_bool(ret);
end;

function readAll(file)

    local f = assert(io.open(file, "rb"));
    local content = f:read("*all");
	
    f:close();
	
    return content;
end

--local host_info = {};  -- { ['dev'] = { ['mac'] = '00:11:22:33:44:55', ['ip'] = "192.168.1.2" } };
--local host_info = require 'dev_list';

local host_info = luci.jsonc.parse(readAll(dev_list_json_path));

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;
			
			--print(host); -- debug
			
			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/nul", 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 --disabled
	
		-- NOTE: current behaviour is such that only *fully* matching device host names will end up in 'host_info', others will be completely ignored
		
		-- print(v.name); --debug
		
		--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: " .. args.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'));

and put devices into /www/pwol/dev_list.json.

{
	"PC1": {
	  "mac": "11:11:11:11:11:11",
	  "ip": "192.168.8.12"
	},
	"PC2": {
	  "mac": "22:22:22:22:22:22",
	  "ip": "192.168.8.166"
	},
	"PC3": {
	  "mac": "33:33:33:33:33:33",
	  "ip": "192.168.8.10"
	}
}

You also need to do btw if using symlink:

chmod +x /www/pwol/api.lua

1 Like