Skip to content

Instantly share code, notes, and snippets.

@strfry
Created December 23, 2019 20:45
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save strfry/d7e081cb16581477d40ae4a86a28c5ab to your computer and use it in GitHub Desktop.
Save strfry/d7e081cb16581477d40ae4a86a28c5ab to your computer and use it in GitHub Desktop.

How to create a Wake-on-LAN API Endpoint with OpenWRT LuCI

Disclaimer

I'm stuck on OpenWRT 18.06, and these info may soon be obsoleted by newer versions, and/or the more detailed ACL scheme in LuCI2.

Motivation

The motivation So i have this server at home, that serves http://hq.strfry.org , which i sometimes switch off at night. Several when a wanted to access it from remote, i wished for a remote interface to do that. Since my router is running OpenWRT, i thought it might be a good idea to base this functionality of the OpenWRT Lua configuration interface (LuCI).

Base Functionality in luci-app-wol

opkg install luci-app-wol brings us a WoL menu that is already very close to what we want. To find out how this is made, we change directory to the luci basedir, in this case /usr/lib/lua/luci

Let's have a look how luci-app-wol installs it's routes (/admin/services/wol)

    module("luci.controller.wol", package.seeall)

    function index()
            entry({"admin", "services", "wol"}, form("wol"), _("Wake on LAN"), 90)
            entry({"mini", "services", "wol"}, form("wol"), _("Wake on LAN"), 90)
    end

The existing entries place themselves under a parent node that will enforce authentication (/admin, /mini, ...)

If we add our page directly at the root, we get anonymous access to the WoL Interface:

entry({"wol"}, form("wol"), _("Wake on LAN"), 90)

Post Security

When trying to submit a WoL request as an unauthenticated web user, we get an error related to a missing token. This is the Cross-Site Request Forgery migitation, and we can't get around it in the form("wol"), without doing a proper login.

Also the unauthenticated form leaks some internal network info that isn't necessary for the job, like ames and MAC addresses of other hosts. Nonetheless we shall explore some options to realize a password-based authentication for a lesser user:

Use of JsonRpc API

https://github.com/openwrt/luci/wiki/JsonRpcHowTo

opkg install luci-mod-rpc
/etc/init.d/uhttpd restart

Now we have a new HTTP Endpoint behind http://172.16.42.1/cgi-bin/luci/rpc

An authentification token can be acquired like this:

curl http://172.16.42.1/cgi-bin/luci/rpc/auth -d '
{
    "id": 1,
    "method": "login",
    "params": [
        "username",
        "password"
    ]
}'

Unfortunately i couldn't figure out how to do something useful from there, and if i could call the existing WoL code.

Adding a route with different authentication to LuCI

To protect the page properly, we can so we better add proper authentication. Also that seems to be a condition to get a CSRF Token and be authorized to send POST requests. To inform LuCI that we desire authentication, we configure the accepted users on the page:

local page = entry({"wol"}, form("wol"), _("Wake on LAN"), 90)
page.sysauth = {"root", "wol"}
page.sysauth_authenticator = "htmlauth"

This will present us with a login form that accepts other usernames than root, unlike the normal login screen. The "htmlauth" authenticators role is to actually show us the login form, and give a web browser user the chance to login. Maybe it can be left out, since we're looking for a way to call this in a automated way.

Adding a user to the system

The htmlauth authenticator authenticates against system users. Creating a user is not so straightforward in OpenWRT, because it doesn't ship tools like adduser/useradd. Those are hidden in the package shadow-useradd:

opkg update
opkg install shadow-useradd
useradd wol
passwd wol

Login through API

The documentation to the previous login method mentions that we can use a cookie store, instead of the explicit token authentication. Unfortunately the RPC API seems to be unrelated to what is going on with LuCI otherwise, so we can't use that.

So just using the Web API, we do it in 2 calls with curl, and a ./cookiejar to store the auth cookie:

curl -X POST -c ./cookiejar \
    http://172.16.42.1/cgi-bin/luci/wol \
    -F luci_username=wol -F luci_password=wol

curl -X POST -b ./cookiejar http://172.16.42.1/cgi-bin/luci/wol -F luci_username=wol -F luci_password=wol -F cbid.wol.1.iface=br-lan -F cbid.wol.1.mac=00:E0:61:13:0C:39 -F token=a6da536ce841ddf2a0100c07b44c1b10 -F cbid.wol.1.broadcast=0 -F cbi.submit=1

The -F field of curl comes really handy to auto-format form-queries, but unfortunately, the token= field must be filled with a magic value scraped from the previous HTML response. Unfortunately, this behaviour is an important migitation against Cross Site Request Forgery.

Implementing an unauthenticated, limited Endpoint

Searching for the "wol" implementation behind the form, we find model/cbi/wol.lua.

The idea is to find the routine to call directly with the right parameters from the controller. The main logic happens in host.write(), which wraps the WoL operation in a write access to a virtual host variable, as represented by the CBI Form, which transports various parameters like the MAC address.

The function has some logic to select between available binaries, but i just went with etherwake, which i manually installed on the system. In the end, it's a simple system call through io.popen(). So we can get rid of the whole dependency on the luci-app-wol:

io.popen("/usr/bin/etherwake 14:02:EC:31:3D:20")

Wrapping things up in a new controller

Also I realized that the call() would trigger our function on GET requests, which violates HTTP conventions. post() is perfect to filter the method.

The new controller/hq.lua now implements this independently, with space for other HQ APIs to be added in the future. I also created a different API endpoint group /hq, which can wake the guardian by POST to /hq/wake.

module("luci.controller.hq", package.seeall)

function index()
        entry({"hq"}, nil, _{"HQ API"}, 90)
        entry({"hq", "wake"}, post("wake"), _("Wake HQ Guardian"), 90)
end

function wake()
        --print("HELLO, WORLD")
        local cmd = "/usr/bin/etherwake 14:02:EC:31:3D:20"
        io.popen(cmd)
end

In the end,

Can i now hack you and turn on your PC at night?

Unfortunately, public traffic to the Webinterface is still firewalled on my OpenWRT router. Heck, I didn't even configure HTTPS for LuCI yet! Though it is available through a wireguarded backdoor, reachable from my VPN.

So, if you're in the friendly zone and have IPv6, you might be able to annoy me at night with:

curl -v -X POST "http://[2001:470:5082::]"/cgi-bin/luci/hq/wake

This can be executed on my webserver, and start building a remote home control panel :D A satisfying result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment