Gaming on the fly with Azure Relay, a Raspberry Pi and Parsec
Waking a desktop at home from anywhere, without a static IP or a single inbound port, using Wake-on-LAN relayed through a Raspberry Pi.
While travelling recently I wanted to reach the desktop at home for gaming, since my laptop is not powerful enough on its own. Two problems stood in the way:
- Boot the PC remotely
- Take control of it once it was up
Remote boot
Modern motherboards support Wake-on-LAN. The network card keeps running in a low-power mode even while the machine is shut down, as long as it still has power, and it scans incoming traffic for a magic byte sequence. If an Ethernet frame arrives containing that sequence anywhere in its payload, the card triggers a boot.
That is the mechanism. The remaining problem is getting those bytes onto the home network in the first place.
A Raspberry Pi already running there solved it. It is on 24x7 and sits on the same network as the PC, so if I could reach the Pi remotely, I could have it send the magic packet.
Reaching the Pi
Getting to the Pi is the hard part. A home router leans heavily on NAT and drops unsolicited inbound traffic, and the address the ISP hands out can change at any time unless you pay for a static IP. Even with one, opening a port inbound is its own can of worms.
What is needed is an outbound connection the Pi establishes itself.
There are a few options here. ngrok and Cloudflare Tunnel both do this, though they generally want a custom domain or username and password authentication to be secured properly. Azure Relay is another. It is built on Service Bus and supports Entra ID authentication alongside ordinary connection strings. I already had an Azure subscription, so that is the route I took.
Relay is driven by a CLI tool called azbridge (opens in a new tab). With Relay configured and azbridge running on the Pi, the Pi becomes reachable from outside the network without anything being exposed inbound.
Sending the packet
The last piece is something on the Pi to receive the request and broadcast the
magic packet on the local network. A small Flask server does the job, with a
/wake endpoint and a /devices endpoint for finding MAC addresses.
With azbridge running on the laptop too, the Relay surfaces as a local address there, so a request to localhost comes out on the Pi:
curl -X POST 127.1.0.2:1234/wake \
-H "Content-Type: application/json" \
-d '{"mac": "REDACTED"}'The PC’s MAC address comes from Windows network settings.
Remote desktop
The rest is straightforward. Parsec is built for high-bandwidth remote desktop, and it is set to start automatically on boot. My laptop is signed in to the same account, so the machine appears there as soon as it comes up, without the two being on the same local network.
flowchart TB
subgraph away["Away from home"]
laptop["Laptop"]
end
subgraph cloud["Azure"]
relay["Relay"]
end
subgraph lan["Home LAN: behind NAT"]
pi["Raspberry Pi"]
pc["Desktop"]
end
laptop -->|"POST /wake"| relay
pi -.->|"dials out"| relay
relay -->|"wake request"| pi
pi ==>|"magic packet"| pc
laptop <-->|"Parsec"| pcThat is the whole setup. Boot and control a machine at home from anywhere with an internet connection, in a matter of seconds, and with nothing inside the house ever accepting a connection it did not make itself.
Appendix: pywol.py
The Flask server from earlier, in full. Note that it binds to localhost rather than to every interface, so nothing on the home network can reach it directly. Requests only arrive through azbridge, which means Relay handles the authentication before anything gets this far.
import re
import socket
import subprocess
from flask import Flask, jsonify, request
app = Flask(__name__)
def send_magic_packet(mac_address, broadcast_ip="192.168.1.255", port=9):
mac_address = mac_address.replace(":", "").replace("-", "")
if len(mac_address) != 12:
raise ValueError("Invalid MAC address format")
magic_packet = bytes.fromhex("FF" * 6 + mac_address * 16)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(magic_packet, (broadcast_ip, port))
@app.route("/wake", methods=["POST"])
def wake():
data = request.get_json()
mac = data.get("mac")
broadcast = data.get("broadcast", "192.168.1.255")
try:
send_magic_packet(mac, broadcast)
return jsonify({"status": "sent", "mac": mac, "broadcast": broadcast})
except Exception as e:
return jsonify({"status": "error", "error": str(e)}), 400
@app.route("/devices", methods=["GET"])
def list_devices():
# Adjust subnet based on your LAN range
subnet = "192.168.1.0/24"
try:
output = subprocess.check_output(["nmap", "-sn", subnet], text=True)
devices = []
ip = mac = vendor = None
for line in output.splitlines():
if line.startswith("Nmap scan report for"):
ip = line.split()[-1]
elif "MAC Address" in line:
mac_info = re.search(r"MAC Address: ([\w:]+) \((.+)\)", line)
if mac_info:
mac, vendor = mac_info.groups()
devices.append({"ip": ip, "mac": mac, "vendor": vendor})
ip = mac = vendor = None # Reset for next device
return jsonify(devices)
except subprocess.CalledProcessError as e:
return jsonify({"status": "error", "output": e.output, "error": str(e)}), 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=1234)