Not to be confused with WireGuard relay mode, a separate relay-side design where the Hyper-DERP server speaks WG directly.
hdwgis a client-side daemon that uses the existing HD relay as a signaling channel (and optional fallback transport) for otherwise-vanillawireguard.kotunnels.
hdwg sits next to wireguard.ko and:
- Connects to an HD relay (TLS, HMAC-authenticated).
- Discovers other enrolled peers via
PeerInfo. - Exchanges WG public keys with each peer via a small
MeshData message (
WGEXmagic). - Tries to set up direct peer-to-peer UDP — wg.ko talks straight to the peer's public endpoint.
- Falls back to relayed — WG packets wrap in HD MeshData and flow through the HD relay — when direct fails or isn't possible.
The WG protocol itself is untouched: the kernel module
does all crypto. hdwg only controls who the peer is
(netlink SET_PEER) and where its UDP goes.
sudo hdwg \
--relay-host relay.example.com \
--relay-port 3341 \
--relay-key $(cat relay.hmac.hex) \
--wg-key $(wg genkey | tee wg.priv) \
--tunnel 10.99.0.1/24Or with a YAML config:
# /etc/hdwg.yaml
relay:
host: relay.example.com
port: 3341
key: "aabbcc..." # HMAC shared secret
wireguard:
private_key: "eeff00..." # 32-byte hex
interface: wg0
listen_port: 51820
tunnel:
cidr: 10.99.0.1/24
proxy:
port: 51821 # local UDP bridge
keepalive: 25
force_relay: false # skip ICE, always use relaysudo hdwg --config /etc/hdwg.yamlDirect path (happy case):
wg0 (kernel) -- UDP --> peer_ip:51820
Relay path (force_relay, sym-NAT, or direct dies):
wg0 -- UDP --> 127.0.0.1:51821 (hdwg proxy)
-- MeshData over TLS --> HD relay
-- MeshData over TLS --> peer's hdwg proxy
-- UDP --> peer's wg0
The proxy binds 127.0.0.1:<proxy-port> and is added as
wg.ko's endpoint only for peers in the relayed state.
Direct peers have wg.ko's endpoint set to the peer's
real ip:port.
PeerInfo WGEX candidates handshake on direct
kNew ---------------> kWgexSent --> kWgexDone -----> kIceChecking -----------> kDirect
| | |
| 500ms timeout | 5s timeout (no fresh | rx-stall 15s
| (no candidates | handshake) or iptables | (WG sees no
| — peer in | block | reply)
v force-relay) v v
kRelayed <---------- kRelayed <---- FALL signal from peer
Transitions:
kNew → kWgexSent: receivedPeerInfofor this peer, sent ourWGEX+CAND.kWgexSent → kWgexDone: received the peer'sWGEX. Wait for their candidates.kWgexDone → kIceChecking: receivedCAND; configure wg.ko with the direct endpoint, capture handshake time as a baseline.kIceChecking → kDirect: wg.ko reports a fresh handshake after the endpoint switch — direct works.kIceChecking → kRelayed: 5s elapsed without a fresh handshake.kWgexDone → kRelayed: 500ms without any candidates (peer is inforce_relaymode, or candidates were lost).kDirect → kRelayed: health check every 2s observestx_bytesgrowing whilerx_byteshas been static for 15s. Removes + re-adds the WG peer (wipes the stale session) and sends aFALLMeshData to the remote so it resets too.
--force-relay or force_relay: true skips ICE entirely:
- We never send CAND.
- We ignore any CAND we receive.
- On receiving WGEX, the
kWgexDone → kRelayed500ms timer fires, wg.ko gets the proxy endpoint, done.
Use this when direct paths are known-unavailable (CGNAT on both ends, corporate firewall, etc.) or when HD is explicitly the intended transport.
All over HD MeshData. First 4 bytes identify the kind.
| Magic | Size | Meaning |
|---|---|---|
WGEX |
40 B | 32-byte WG pubkey + 4-byte tunnel IP |
CAND |
6–12 B | 1–2 [ip:4][port:2] candidate tuples |
FALL |
4 B | "I fell back to relay, please reset your session" |
All other MeshData payloads are opaque WG UDP bytes — forwarded between the proxy socket and wg.ko.
hdwg [options]
--config PATH YAML config file
--relay-host HOST Relay IP
--relay-port PORT Relay port (3341)
--relay-key HEX HMAC shared secret
--wg-key HEX WireGuard private key
--wg-interface NAME WG interface (wg0)
--wg-port PORT WG listen port (51820)
--tunnel CIDR Tunnel address (e.g. 10.99.0.1/24)
--proxy-port PORT UDP proxy port (51821)
--stun SERVER STUN server (host:port)
--keepalive SECS WG persistent keepalive (25)
--force-relay Always tunnel through HD (no ICE)
--help
Needs CAP_NET_ADMIN (netlink WG config + interface up).
| Path | RTT |
|---|---|
| Direct | 0.6 ms |
| Relayed (force-relay) | 1.5 ms |
| Relayed (ICE fell back) | 1.5 ms |
| Direct → runtime fallback | ~15 s detection window, then 1.5 ms |
Relay path RTT is tuned — the naive SO_RCVTIMEO was
adding ~100ms per hop; current loop only blocks on poll,
and only re-enters HdClientRecvFrame when the internal
buffer has more bytes.
- Direct-path promotion can race with the 5s ICE window when both peers start simultaneously. WG handshake requires outbound traffic on at least one side, so the happy path is most reliable when something is pinging.
- Runtime direct → relay fallback has a ~15s detection window (intentional hysteresis) and drops packets during the transition (~20% loss over that window in the VM test).
- The proxy listens on
127.0.0.1only; both wg.ko and the proxy must run in the same network namespace.