Reliable Wake-on-LAN with a HomeKit Switch, Behind a UniFi Firewall

Share this:

This is part of a small series on turning a headless Ubuntu NUC into a proper always-available server. The first post covers converting it from a Desktop install to headless and fixing a broken drive mount along the way. This one covers the actual goal: getting Wake-on-LAN to work reliably, and controlling it remotely as a HomeKit switch.

Goal

  • Wake reliably over the network (Wake-on-LAN).
  • Control power state remotely as a HomeKit switch, so I can suspend or wake it from my phone without SSHing in.

Enable Wake-on-LAN

First, find your network interface:

ip link

Check current WOL support and status:

sudo ethtool <interface> | grep Wake-on

Enable magic-packet wake for the current session:

sudo ethtool -s <interface> wol g

This setting resets on reboot unless you persist it. If NetworkManager manages your interface (common even on headless Ubuntu, if it started life as a Desktop install), persist the setting there instead. First find the connection profile name:

nmcli connection show

Find the entry whose DEVICE column matches your interface (e.g. eno1), and use its NAME value wherever you see <connection-name> below. Ubuntu autogenerates this name on many installs, often as something like netplan-eno1.

sudo nmcli connection modify "<connection-name>" 802-3-ethernet.wake-on-lan magic
sudo nmcli connection up "<connection-name>"
Confirming Wake-on-LAN is enabled and persisted via NetworkManager

A few more settings to unblock WOL

Energy-Efficient Ethernet (EEE)

Many NICs negotiate a low-power link state that can prevent the interface from ever seeing an incoming magic packet in the first place. Check and disable it:

sudo ethtool --show-eee <interface>
sudo ethtool --set-eee <interface> eee off

USB armed as an ACPI wakeup source

The machine would suspend, then wake itself again within seconds, for no obvious reason. Checking /proc/acpi/wakeup showed the USB controller (XHC) set as an active wakeup source. That meant any USB peripheral activity, like a mouse jiggle or wireless dongle chatter, could trigger a resume.

cat /proc/acpi/wakeup | grep XHC

Disable it (echoing the device name toggles its current state):

echo XHC | sudo tee /proc/acpi/wakeup
Checking which ACPI wakeup sources are active

Making both fixes persistent

Both of these settings reset on every reboot, so make them persistent with a systemd oneshot service. I wrote the ExecStart line as a single line rather than using backslash line-continuations, since some editors and CMSes silently strip those. Create the unit file:

sudo nano /etc/systemd/system/nuc-power-fixes.service

Paste this in:

# /etc/systemd/system/nuc-power-fixes.service
[Unit]
Description=Disable EEE and fix ACPI wakeup sources for reliable WOL
After=network.target

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'ethtool --set-eee <interface> eee off; grep -q "XHC.*enabled" /proc/acpi/wakeup && echo XHC > /proc/acpi/wakeup; exit 0'

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now nuc-power-fixes.service

EEE resets itself on every resume

A fourth issue only showed up after using this setup for a while: EEE resets itself on every resume. WOL worked fine right after setup, then started failing intermittently over the following days. Checking again after a suspend/resume cycle showed EEE back to enabled, even though the systemd service above had already run and disabled it. That service only runs once, at boot. The NIC driver reinitializes on resume from suspend, quietly re-enabling EEE each time. The other settings held fine across resumes, just not EEE.

The fix is a systemd sleep hook. It runs automatically both before suspend and after resume, rather than only at boot:

sudo nano /usr/lib/systemd/system-sleep/nuc-wol-fix.sh
#!/bin/bash
case "$1" in
    post)
        ethtool -s <interface> wol g
        ethtool --set-eee <interface> eee off
        grep -q "XHC.*enabled" /proc/acpi/wakeup && echo XHC > /proc/acpi/wakeup
        ;;
esac
exit 0
sudo chmod +x /usr/lib/systemd/system-sleep/nuc-wol-fix.sh

The post case runs after the system wakes, exactly when the driver would have reset EEE. Confirm it’s working: suspend the machine, wake it back up, and check immediately after:

sudo ethtool <interface> | grep Wake-on
sudo ethtool --show-eee <interface> | grep status

EEE status should now correctly show disabled right after resume, instead of drifting back to enabled. If your WOL setup works at first and then becomes unreliable after a day or two, check for this resume-drift behaviour before assuming it’s a network or router issue.

A HomeKit switch to control it remotely

For remote control, I used Homebridge with a plugin that maps a HomeKit switch to shell commands. I first tried homebridge-cmdaccessory, but its maintainer hasn’t updated it for Homebridge v2’s breaking API changes, and it crashed on startup after Homebridge removed updateReachability(). I switched to the actively maintained homebridge-cmd4 instead.

Homebridge Cmd4 installed and running

The Cmd4 control script

Cmd4 calls a single script with Get/Set arguments, rather than using three separate command strings. This script also avoids line-continuation backslashes in the SSH call, for the same reason as above. Create it on the machine running Homebridge, not the server itself. It calls etherwake to wake the server, so that needs to be installed on the Homebridge machine too. It’s not installed by default, so grab it first if you haven’t needed it before:

sudo apt install etherwake

Then create the script:

sudo nano /var/lib/homebridge/cmd4-server.sh

Paste this in:

#!/bin/bash
# Cmd4 control script for a power switch
# Called by Homebridge as:
#   Get <name> On
#   Set <name> On 1|0
#
# $1 = ACTION   (Get | Set)
# $2 = NAME
# $3 = CHARACTERISTIC
# $4 = VALUE    (Cmd4 passes 1/0, not the strings "true"/"false")

ACTION="$1"
VALUE="$4"

MAC="<your-mac-address>"
IP="<your-server-ip>"
SSH_KEY="/home/homebridge/.ssh/<key-name>"
SSH_USER="<your-server-username>"

if [ "$ACTION" == "Get" ]; then
    if ping -c 1 -W 1 "$IP" > /dev/null 2>&1; then
        echo "TRUE"
    else
        echo "FALSE"
    fi

elif [ "$ACTION" == "Set" ]; then
    if [ "$VALUE" == "1" ]; then
        etherwake -i wlan0 "$MAC"
    else
        ssh -i "$SSH_KEY" -o ConnectTimeout=5 -o StrictHostKeyChecking=no "$SSH_USER@$IP" 'sudo systemctl suspend'
    fi
fi
sudo chmod +x /var/lib/homebridge/cmd4-server.sh

Two things worth calling out explicitly:

  • Cmd4 passes 1/0 for boolean values, not the strings "true"/"false". My first version checked for "true", which never matched. That meant toggling the switch “on” silently ran the suspend branch instead of etherwake, since the condition fell through to else. Turning the switch off worked fine by coincidence, since that’s the branch that kept firing. Turning it on threw an SSH timeout instead, since it was trying to SSH into a sleeping machine. Check what your plugin actually sends on the wire (enable debug logging) rather than assuming a convention.
  • Homebridge runs as its own dedicated service user, not whatever user you’re logged in as when you set things up. Any SSH key a script references needs to live under that service user’s home directory (e.g. /home/homebridge/.ssh/). Generate and permission the key as that user, not your own login.
Homebridge logs confirming the Cmd4 Set commands are firing correctly
sudo -u homebridge ssh-keygen -t ed25519 -N "" -f /home/homebridge/.ssh/<key-name>
sudo -u homebridge ssh-copy-id -i /home/homebridge/.ssh/<key-name>.pub <user>@<server-ip>
sudo chown -R homebridge:homebridge /home/homebridge/.ssh
sudo chmod 700 /home/homebridge/.ssh
sudo chmod 600 /home/homebridge/.ssh/<key-name>

Always reference the key by absolute path in the script. ~ expands to the wrong directory when the Homebridge process invokes the script, rather than an interactive shell.

Test the script directly as the service user before trusting HomeKit to exercise it:

sudo -u homebridge /path/to/cmd4-script.sh Set <name> On 1
sudo -u homebridge /path/to/cmd4-script.sh Set <name> On 0

Adding the switch to Homebridge’s config

Add this as a new entry in the platforms array of Homebridge’s main config file, usually /var/lib/homebridge/config.json. If you’re not comfortable hand-editing JSON that other plugins also share, use the Homebridge UI’s Config editor instead:

{
    "platform": "Cmd4",
    "name": "Cmd4",
    "accessories": [
        {
            "type": "Switch",
            "displayName": "Server",
            "on": "FALSE",
            "name": "Server",
            "state_cmd": "/path/to/cmd4-server.sh",
            "polling": [
                { "characteristic": "on", "interval": 600, "timeout": 8000 }
            ]
        }
    ],
    "_bridge": {
        "username": "<generated-mac-style-id>",
        "port": 30839,
        "name": "Homebridge Cmd4"
    }
}
The Cmd4 platform entry in the Homebridge config

The polling interval (in seconds) controls how often Cmd4 calls Get to refresh the switch’s state in the Home app on its own. This is separate from any manual toggle. Cmd4 also calls Get a few seconds after every Set, to confirm the change actually took effect rather than assuming it worked.

Passwordless suspend via sudoers

For the remote suspend command to work without hanging on a password prompt, set up a narrow sudoers rule. Grant passwordless systemctl suspend specifically, rather than broad sudo access. Run this on the server, not the Homebridge machine:

sudo visudo -f /etc/sudoers.d/allow-suspend

Add this single line:

<user> ALL=(ALL) NOPASSWD: /usr/bin/systemctl suspend

visudo validates the syntax on save. It won’t let you write a broken sudoers file that could lock you out of sudo entirely.

Adding the bridge to the Home app

Once the child bridge is running (check the Homebridge UI shows it as “Running”, not stopped/errored):

  • In the Home app: + then Add Accessory then More options…
  • Select the new bridge from the list of nearby accessories.
  • Enter the setup PIN, found either in the Homebridge UI’s child bridge settings or in the startup logs.
Adding the new bridge in the Home app via More options
The finished HomeKit switch tile

If your Homebridge instance is on a different VLAN/subnet than your phone, HomeKit discovery, which relies on local network mDNS/Bonjour, may not find it. Same underlying issue as the firewall/VLAN topic below.

UniFi firewall rules for a Homebridge host on a separate VLAN

If your Homebridge instance lives on a separate VLAN from your server (in my case, an isolated DMZ network for a Raspberry Pi, which is good security practice), you’ll need narrow firewall rules. UniFi blocks inter-VLAN traffic by default, so only the specific traffic below needs an explicit allow rule between the two zones:

  • SSH: protocol TCP, destination port 22.
  • Wake-on-LAN: protocol UDP, destination port 9.
  • Ping/health-check: protocol ICMP. Note that ICMP doesn’t use a port at all. In theory, UniFi’s newer rule builder lets you select ICMP as a protocol type, sometimes under a “Custom” option specifying IP protocol number 1.

Creating the rules in UniFi

In the UniFi rule builder, for each rule: set Source Zone to your isolated VLAN (e.g. DMZ), and switch from “Any” to the specific source IP. Set Action to Allow. Set Destination Zone to your main network, and again switch from “Any” to the specific server IP. Then set the Protocol and Port fields as above. Scope each rule as narrowly as possible: specific source IP to specific destination IP, rather than “Any” to “Any” within the zones. That way the isolated network stays isolated from everything else.

Creating a narrow firewall rule between the isolated VLAN and the main network in UniFi

If the traffic still isn’t passing through, try setting the rule’s Protocol to All instead. That’s what got it working for me, so it’s worth trying first if the ICMP-specific option doesn’t seem to take effect.

WOL across subnets and from a WiFi controller

Also remember that Wake-on-LAN’s broadcast packet doesn’t cross subnets on its own. You need to send a directed packet to the target’s actual IP:

wakeonlan -i <target-ip> <mac-address>

I found etherwake to be more reliable than wakeonlan for sending a directed packet across subnets, especially from a controller on WiFi. Same idea, different tool:

etherwake -i wlan0 <mac-address>

(Same install as noted earlier: sudo apt install etherwake.)

The device sending the WOL packet doesn’t need any special wireless capability. If your controller (e.g. a Raspberry Pi running Homebridge) is on WiFi, that’s fine, since it’s just sending a normal UDP packet like any other network traffic. WiFi-related wake limitations (WoWLAN) only matter for the device that itself needs to wake up while asleep. In my case the NUC is wired, which is the reliable configuration for the receiving end of a magic packet.

Result

WOL now works reliably, both immediately after setup and days later, and I can wake or suspend the server from a HomeKit switch on my phone without SSHing in. The other post in this series covers making sure the machine doesn’t suspend itself out from under an active file transfer or SSH session in the first place.



If this post has been useful, support me by buying me a latte or two 🙂
Buy Me A Coffee
Share this:

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.