PoC: WireGuard pair with the connection ladder¶
Date: 2026-10-25. Five OpenWrt 25.12.6 arm64 VMs on a Mac (firmware/scripts/lab.sh):
a fake internet router, an ISP box running miniupnpd (UPnP IGD v2 + NAT-PMP/PCP,
NAT44, default-deny IPv6 firewall), the home router behind it, the travel
router on a "hotel" uplink, and a laptop on the travel router's guest port.
Result¶
Only WireGuard and VXLAN (both in the kernel), no third-party service:
| Rung | Setup | Result |
|---|---|---|
| 1. IPv6 + pinhole | Home asks the ISP box for an IPv6 pinhole with UPnP IGD v2; travel connects to home's IPv6 | Blocked before the pinhole, handshake after; 3 ms |
| 2. IPv4 + port mapping | Travel without IPv6; home asks for a UDP 51820 mapping with UPnP; travel connects to the box's public IPv4 | Blocked before the mapping, handshake after; 2.5 ms |
| L2 extension | VXLAN inside WireGuard; home bridges it with its uplink, travel with its guest port | Laptop got 192.168.50.102 and a home IPv6 address from the ISP box; home learned the laptop's MAC on its VXLAN port |
| Path switching | wg set … endpoint between the IPv6 and IPv4 paths while the laptop pings |
14/14 pings answered, no restart |
Not tested yet: NAT-PMP/PCP (only UPnP was used), hole punching, the relay, throughput on real hardware.
Automated, multi-path (agent 0.3, same lab, no manual step on the routers)¶
Staff pairs the two routers in the admin; everything else is the agents:
- Each router applies the tunnel through the confirm-or-rollback guard; both changes were confirmed (control plane still reachable).
- Home discovers and opens its endpoints for each path port: IPv6 pinhole (UPnP IGD v2) and IPv4 mapping (UPnP) on 51820 and 51821, and reports them.
- The control plane pushes them to travel (desired version bump + wake-up).
- Travel dials each path with the rungs in a different order:
jumo0over IPv6,jumo1over the IPv4 mapping. Both stay up. - The path watcher routes VXLAN over
jumo0; the laptop gets a home IPv4 and IPv6 address.
| Test: travel loses IPv6 while the laptop pings once per second | Laptop outage |
|---|---|
| Single path (agent 0.2): stale-handshake detection, then re-dial | 146 s (73 pings lost) |
Two paths + path watcher (agent 0.3): failover to jumo1 |
~3 s (2 pings lost) |
Finding: a dialed endpoint must be judged by a reply through its interface, not by a fresh handshake; an established WireGuard session does not renegotiate for two minutes, so a working path looked dead.
Findings that shape the agent¶
- Allow SSDP replies on the uplink. UPnP discovery is a multicast query answered by unicast; OpenWrt's WAN firewall drops the answer as unsolicited. The agent adds a rule: UDP from source port 1900 on the uplink.
- IPv6 discovery must use the uplink interface explicitly (
upnpc -6 -m <uplink>). - A pinhole is only granted to the address that asks for it (miniupnpd answers 606 "Action not authorized" otherwise). The agent requests it for the address the kernel uses as source towards the box.
- Use a stable IPv6 address. Reconfiguring the uplink changed the DHCPv6
address (
::231→::230) and broke the pinhole and the advertised endpoint. The home router setswan6.reqaddress=noneand keeps the SLAAC address derived from its MAC. Addresses can still change (ISP prefix change): the agent watches them, renews the pinhole or mapping and reports the new endpoint, which the control plane pushes to the travel router. - Reserved or shared IPv4 is refused by miniupnpd ("Public IP address is required"). Real boxes on shared IPv4 behave the same. The agent compares the box's reported external IP with the address the control plane sees and moves to the next rung when they differ.
- Boxes may run IGD v1, which has no IPv6 pinholes (OpenWrt's miniupnpd defaults to v1). Then: PCP, else ask the customer to allow incoming IPv6 for the router (documented per ISP box), else IPv4 rungs.
- Never restart the tunnel to change the endpoint:
wg setswitches it live without dropping traffic. - No
tunlinkon the VXLAN interface. Withtunlink=wireguard, a WireGuard restart destroyed the VXLAN device while netifd still reported it up. Without it, VXLAN rides the tunnel through routing and survives restarts. - Check bridge membership after every change and repair it: a recreated port is not always put back into its bridge.
- Install packages before netifd starts (they are in the image): a protocol
handler installed at runtime needs a full
network restart.
Manual configuration used (what the agent will generate)¶
# both: WireGuard (keys generated on the router; only public keys leave it)
uci set network.jumo=interface; uci set network.jumo.proto=wireguard
uci set network.jumo.private_key="$(cat /etc/jumo/wg.key)"
uci add_list network.jumo.addresses=10.99.0.1/30 # .2 on travel
uci set network.jumo.listen_port=51820 # home only
uci set network.peer=wireguard_jumo; uci set network.peer.public_key=<other side>
uci add_list network.peer.allowed_ips=10.99.0.2/32 # .1 on travel
# travel only: endpoint chosen by the ladder, keepalive for NATs
uci set network.peer.endpoint_host=<home address>; uci set network.peer.endpoint_port=51820
uci set network.peer.persistent_keepalive=25
# both: L2 over the tunnel, no tunlink
uci set network.vx=interface; uci set network.vx.proto=vxlan
uci set network.vx.ipaddr=10.99.0.1; uci set network.vx.peeraddr=10.99.0.2 # swapped on travel
uci set network.vx.port=4789; uci set network.vx.vid=42
# home: uplink + VXLAN in one bridge, router keeps DHCP on it, stable IPv6
uci set network.br_isp=device; uci set network.br_isp.type=bridge; uci set network.br_isp.name=br-isp
uci add_list network.br_isp.ports=eth1; uci add_list network.br_isp.ports=vx
uci set network.wan.device=br-isp; uci set network.wan6.device=br-isp; uci set network.wan6.reqaddress=none
# travel: guest port + VXLAN in one bridge, no IP
uci set network.br_guest=device; uci set network.br_guest.type=bridge; uci set network.br_guest.name=br-guest
uci add_list network.br_guest.ports=eth2; uci add_list network.br_guest.ports=vx
uci set network.guest=interface; uci set network.guest.proto=none; uci set network.guest.device=br-guest
# home: pinhole / mapping requests (rung 1 / rung 2)
upnpc -6 -m br-isp -A "" 0 <home stable IPv6> 51820 UDP 3600
upnpc -m br-isp -e jumo -a <home LAN IPv4> 51820 51820 UDP 3600