nuxx.net
Making, baking, and (un-)breaking things in Southeast Michigan.

Linode And GL MT3000 Dual Stack VPN 2026 Jul 22

/files/Linode_and_GL-MT3000_Dual_Stack_VPN_2026-Jul-22.md View raw

IPv6-over-WireGuard: Linode Gateway + GL.iNet AP — Setup Reference

Part 1: Linode Server

1. Provision

  • Spin up a Nanode (or similar), Ubuntu/Debian.
  • Note the Linode ID (visible on the Linode's overview page).

2. Allocate a routed IPv6 /64

Via API/CLI (the Cloud Manager GUI's "Add an IP Address" button doesn't exist on the newer Linode Interfaces networking model — this is the reliable path regardless of UI version):

linode-cli networking v6-range-create --linode_id <LINODE_ID> --prefix_length 64

Output gives you a range (your routed /64) and route_target (should match the Linode's existing SLAAC address). No interface-level config is needed on the VM for this to work — Linode's fabric already routes the range to the VM; you just need forwarding enabled (next step) and WireGuard's AllowedIPs to claim it.

3. Install WireGuard, generate keys

apt install wireguard
wg genkey | tee server_private.key | wg pubkey > server_public.key
wg genkey | tee client_private.key | wg pubkey > client_public.key

4. Enable IP forwarding — persistently

cat <<'EOF' > /etc/sysctl.d/99-wireguard-forwarding.conf
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF
sysctl --system

⚠️ Gotcha: this is easy to forget and won't produce an obvious error — traffic just silently doesn't forward. Always verify with sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding before troubleshooting anything else.

5. Server config — /etc/wireguard/wg0.conf

[Interface]
PrivateKey = <SERVER_PRIVKEY>
Address = 10.10.0.1/24, fd10:10:10::1/64
ListenPort = 51820

PostUp = iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o eth0 -j MASQUERADE
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = iptables -A FORWARD -o wg0 -j ACCEPT
PostUp = ip6tables -A FORWARD -i wg0 -j ACCEPT
PostUp = ip6tables -A FORWARD -o wg0 -j ACCEPT

PostDown = iptables -t nat -D POSTROUTING -s 10.10.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -D FORWARD -o wg0 -j ACCEPT
PostDown = ip6tables -D FORWARD -i wg0 -j ACCEPT
PostDown = ip6tables -D FORWARD -o wg0 -j ACCEPT

[Peer]
# <device name>
PublicKey = <CLIENT_PUBKEY>
AllowedIPs = 10.10.0.2/32, fd10:10:10::2/128, <YOUR_ROUTED_V6_PREFIX>

⚠️ Gotcha: don't add a manual ip -6 route add <prefix> dev wg0 line. wg-quick automatically installs a route for every prefix in a peer's AllowedIPs — a duplicate manual route-add fails with "File exists" and can abort the whole wg-quick up (PostUp failures are fatal).

6. Bring it up and enable at boot

systemctl enable --now wg-quick@wg0

active (exited) is the correct healthy status for this oneshot unit — not a failure.

7. Firewall

Open UDP 51820 inbound in the Linode Cloud Firewall (Cloud Manager → Linode → Firewalls tab).

8. Verify

wg show
ip -6 route show   # should show <routed prefix> dev wg0, added automatically

Part 2: GL.iNet Router (Stock Firmware — Client Side)

Applies to GL.iNet's own OpenWrt-based dashboard firmware. If a future device runs actual DD-WRT or vanilla OpenWrt instead, the WireGuard/UCI mechanics differ — check firmware type first via the Sysinfo/status page.

1. Confirm firmware type before doing anything else

Check for a GL.iNet-branded admin panel (192.168.8.1) vs. LuCI vs. DD-WRT GUI — determines which of this guide applies.

2. Add the WireGuard client config

GL.iNet dashboard → VPN → WireGuard Client → Import. Paste:

[Interface]
PrivateKey = <CLIENT_PRIVKEY>
Address = 10.10.0.2/24, fd10:10:10::2/64
DNS = 1.1.1.1, 2606:4700:4700::1111

[Peer]
PublicKey = <SERVER_PUBKEY>
Endpoint = <LINODE_PUBLIC_IPV4>:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25

Set Global Mode (not per-app/split), turn the client ON.

3. Configure LAN IPv6 to advertise the routed /64

Dashboard → NETWORK → IPv6:

  • Enabled IPv6: ON
  • LAN Mode: Static IPv6
  • IPv6 Address: <YOUR_ROUTED_V6_PREFIX> (e.g. 2600:3c06:e001:2ec::/64)
  • DNS acquisition: Automatic
  • Apply.

This single toggle handles RA/SLAAC advertisement to LAN clients — no manual radvd config needed on this firmware.

4. ⚠️ Critical gotcha — disable IPv6 NAT66 on the WireGuard zone

GL.iNet's firmware defaults every WireGuard client zone to masq6='1' (IPv6 masquerade), which breaks routed-/64 setups: it rewrites LAN clients' real global addresses to the tunnel's private link address, so return traffic has nowhere to go (symptom: outbound ping reaches the server, but replies never come back; conntrack shows replies addressed to the tunnel's fd... address instead of the client's real address).

Worse: GL.iNet's setup_instance_via.lua hardcodes masq and masq6 to always match each other — you can't just uncheck IPv6 NAT anywhere in the UI, and a plain uci set firewall.<zone>.masq6=0 gets silently overwritten. There's also a separate vendor script (/etc/firewall.nat6, registered with reload='1') that re-asserts the masquerade rule on every firewall reload, including the routine ones triggered by each interface coming up at boot.

The fix — a persistent include script that strips the rule after every reload:

cat <<'EOF' > /etc/firewall.<zone>_no_masq6
#!/bin/sh
while ip6tables -t nat -C zone_<zone>_postrouting -m comment --comment "!fw3" -j MASQUERADE 2>/dev/null; do
    ip6tables -t nat -D zone_<zone>_postrouting -m comment --comment "!fw3" -j MASQUERADE
done
exit 0
EOF
chmod +x /etc/firewall.<zone>_no_masq6

uci set firewall.<zone>_no_masq6=include
uci set firewall.<zone>_no_masq6.path="/etc/firewall.<zone>_no_masq6"
uci set firewall.<zone>_no_masq6.family='ipv6'
uci set firewall.<zone>_no_masq6.reload='1'   # <- easy to miss; without this it
                                               #    only runs once at initial boot,
                                               #    not on later interface-up reloads
uci commit firewall
service firewall restart

Replace <zone> with the actual WireGuard client zone name (check via uci show firewall | grep masq6 to find it — it'll match the network/interface name shown in ip a, e.g. wgclient1).

5. Verify, in this order

ip6tables -t nat -L zone_<zone>_postrouting -v -n   # confirm no MASQUERADE line

Then from a real LAN client (not the router itself — the router's own outbound traffic follows a different policy path and isn't a valid test):

ping -6 2606:4700:4700::1111

or visit test-ipv6.com.

Then reboot the router and re-check both — this setup has enough moving parts (vendor scripts, reload ordering, policy routing) that "works now" and "works after reboot" are genuinely different questions worth testing separately.


Known-good reference values from this build (for comparison, not to reuse)

  • Server: Linode Nanode, Chicago, Ubuntu 24.04, networking via systemd-networkd (not netplan, not /etc/network/interfaces — check /etc/systemd/network/*.network for actual live config on Ubuntu Linodes)
  • Client: GL-MT3000, GL.iNet firmware v4.8.1, OpenWrt 21.02-SNAPSHOT base

← Back to nuxx.net