I had a Pi 4 sitting on a campus network behind a FortiGate doing aggressive deep packet inspection. The goal: make the Pi a Wi-Fi router for my own devices, hide everyone behind a single NAT, encrypt DNS, and get Tailscale working again so my home-lab monitoring (Beszel) could reach the Pi. By the end of the day all of it worked, but the journey through WARP, vanilla WireGuard, and finally AmneziaWG was a lot more interesting than the destination.
The Stack
Final architecture on the campus Pi:
- eth0 = WAN uplink (campus DHCP, behind FortiGate at
10.5.0.1) - wlan0 = AP, static
192.168.50.1/24, SSID "KN Pi" - hostapd broadcasting WPA2-PSK on 2.4 GHz channel 6
- AdGuard Home as DHCP + DNS (DoH upstream, ad blocking, DNSSEC)
- iptables doing NAT, TTL normalization to 64, and locking AdGuard to the wireless side only
- portal-watchdog systemd timer logging into the FortiGate captive portal every 5 min
- AmneziaWG tunnel to a Mikrotik-fronted Pi at home, full-tunnel (
AllowedIPs = 0.0.0.0/0) — all Wi-Fi client traffic exits via the home Pi, bypassing campus DPI entirely
Everything survives a reboot and recovers in ~2 minutes.
Stage 1 — Pi as a Router
I started with Ubuntu 24.04 on the Pi 4. Default behaviour: cloud-init manages networking via netplan, with wlan0 configured as a Wi-Fi client to the existing campus AP. I needed it the other way around.
# /etc/netplan/50-cloud-init.yaml
network:
version: 2
ethernets:
eth0:
dhcp4: true
optional: true
Cloud-init regenerates this file on reboot, so I dropped a kill-switch:
# /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
network: {config: disabled}
Then for wlan0 I bypassed netplan entirely and used systemd-networkd directly:
# /etc/systemd/network/10-wlan0.network [Match] Name=wlan0 [Network] Address=192.168.50.1/24 ConfigureWithoutCarrier=yes LinkLocalAddressing=no DHCP=no
hostapd.conf was straightforward — country_code=TH, hw_mode=g, channel=6, WPA2-PSK. Pi 4's built-in BCM43455 supports AP mode just fine. The tricky part was making sure wpa_supplicant@wlan0 was masked — otherwise it fights hostapd for the interface and you get weird intermittent failures.
For DHCP I initially used dnsmasq with port=0 so it served DHCP only, with no DNS:
interface=wlan0 bind-interfaces dhcp-range=192.168.50.10,192.168.50.100,255.255.255.0,24h dhcp-option=3,192.168.50.1 dhcp-option=6,192.168.50.1
NAT was the standard masquerade dance:
sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE sudo iptables -A FORWARD -i eth0 -o wlan0 -m state --state RELATED,ESTABLISHED -j ACCEPT sudo iptables -A FORWARD -i wlan0 -o eth0 -j ACCEPT sudo sysctl -w net.ipv4.ip_forward=1 sudo netfilter-persistent save
One thing I added later: TTL normalization. Forwarded packets normally have their TTL decremented, which is a dead giveaway that there's a router in between — campus IT departments love to flag this as tethering. Two mangle rules fix it:
sudo iptables -t mangle -A FORWARD -i wlan0 -j TTL --ttl-set 64 sudo iptables -t mangle -A POSTROUTING -o eth0 -j TTL --ttl-set 64
Now every packet leaving the Pi looks like it originated from a single OS sitting on eth0. Combined with MASQUERADE rewriting the source IP, the campus net sees one device, not "Pi plus seven clients behind it."
Stage 2 — The Captive Portal
The campus runs a FortiGate captive portal. The classic flow: any unauthenticated HTTP request gets hijacked into a JS redirect:
<script>window.location="http://10.5.0.1:1000/fgtauth?060e18de87099948";</script>
That hex string is the per-session "magic" token. The login form posts back to /?MAGIC with magic, username, password, and 4Tredir. On success it returns:
<script>window.location="http://10.5.0.1:1000/keepalive?000e04090b0e034a";</script>
That second URL keeps the session alive for ~12000 seconds (3.3 hours) if you periodically GET it.
I wrote a small watchdog script that runs every 5 min via a systemd timer. The first version had a subtle bug: it used curl http://cp.cloudflare.com to detect connectivity, but on boot, DNS isn't up yet (AdGuard hasn't started), so the watchdog would fail with code=000 and never get the magic token. I fixed it by going DNS-free:
body=$(curl -s --max-time 5 \
--resolve "cp.cloudflare.com:80:1.1.1.1" \
"http://cp.cloudflare.com/")
code=$(curl -s -o /dev/null --max-time 5 \
--resolve "cp.cloudflare.com:80:1.1.1.1" \
-w "%{http_code}" "http://cp.cloudflare.com/")
if [ "$code" = "204" ] && ! echo "$body" | grep -q "fgtauth"; then
log "online (204)"
ping_keepalive || true
exit 0
fi
The --resolve flag bypasses DNS entirely. cp.cloudflare.com returns 204 when authenticated; the captive portal returns 200 with HTML containing fgtauth. That two-condition check is bulletproof.
When offline, the script fetches any HTTP page (the portal hijacks it), extracts the magic token via regex, GETs /fgtauth?MAGIC to load the form state, then POSTs the login. It stores the returned keepalive URL in /run/portal-keepalive.url and pings it on every subsequent online check so the session never expires.
The boot ordering took two tries to get right:
# /etc/systemd/system/portal-watchdog.service [Unit] After=network-online.target systemd-networkd.service Wants=network-online.target Before=AdGuardHome.service [Service] Type=oneshot ExecStart=/usr/local/bin/portal-watchdog.sh TimeoutStartSec=30 SuccessExitStatus=0 1
And in /etc/systemd/system/AdGuardHome.service.d/wait-portal.conf:
[Unit] After=portal-watchdog.service Wants=portal-watchdog.service
The first version had Requires= instead of Wants= and RemainAfterExit=yes on the oneshot — and also had the script call systemctl restart AdGuardHome synchronously. That created a delicious three-way deadlock: the script blocks on the restart, the restart waits for the script's service unit to become inactive, and systemd's 30-second TimeoutStartSec eventually kills everything. The fix was --no-block on the restart call plus relaxing the dependency to Wants=. Also: drop RemainAfterExit=yes, otherwise the timer's "OnUnitActiveSec" skips because the service never becomes inactive.
Stage 3 — AdGuard Home + DNS-over-HTTPS
dnsmasq is fine but it can't do DoH and won't label clients by name. AdGuard Home does both, so I migrated.
systemd-resolved squats on port 53 even with its stub listener — I had to explicitly disable it:
# /etc/systemd/resolved.conf.d/adguard.conf [Resolve] DNSStubListener=no DNS=127.0.0.1
And /etc/resolv.conf had to be pinned to AdGuard so the Pi itself uses its own DNS (otherwise the Pi would resolve cleartext via the captive network):
echo "nameserver 192.168.50.1" | sudo tee /etc/resolv.conf sudo chattr +i /etc/resolv.conf
The chattr +i is important — without it, NetworkManager or networkd will overwrite the file on every interface change.
In the AdGuard admin UI:
- Listen interface = wlan0 /
192.168.50.1only (NOT "all interfaces" — that would expose the admin and DNS to the campus) - Upstream DNS:
https://dns.cloudflare.com/dns-query,https://dns.google/dns-query,https://dns.quad9.net/dns-query - Bootstrap DNS:
1.1.1.1 8.8.8.8 - DNSSEC: on
Belt and braces on the firewall:
sudo iptables -A INPUT -i eth0 -p tcp --dport 3333 -j DROP sudo iptables -A INPUT -i eth0 -p tcp --dport 53 -j DROP sudo iptables -A INPUT -i eth0 -p udp --dport 53 -j DROP
After this, I sniffed eth0 while running a few dig queries. The only port-53 packets visible were a couple of reverse-DNS PTR lookups for the LAN IP — every other DNS query left as HTTPS to Cloudflare, completely opaque to the campus DNS box. Big win.
Later I migrated DHCP from dnsmasq to AdGuard's built-in DHCP. The migration was a YAML edit:
dhcp:
enabled: true
interface_name: wlan0
local_domain_name: lan
dhcpv4:
gateway_ip: 192.168.50.1
subnet_mask: 255.255.255.0
range_start: 192.168.50.10
range_end: 192.168.50.100
lease_duration: 86400
The payoff: device hostnames now appear directly in the AdGuard Query Log, so I can see "iPhone-N: googleadservices.com → 0.0.0.0" instead of just "192.168.50.42." Per-client filtering rules become possible too.
Stage 4 — The WARP Detour That Didn't Work
I tried Cloudflare WARP first because it's free, easy, and routes everything through WireGuard. On macOS it worked instantly — connected, exit IP came back as Cloudflare's. On the Pi it never finished connecting.
The reason was visible in the curl output:
* TLSv1.3 (OUT), TLS alert, unknown CA (560): * SSL certificate problem: unable to get local issuer certificate
FortiGate is doing TLS MitM on outbound HTTPS to specific domains — including engage.cloudflareclient.com, which is what WARP hits during registration. The WARP client pins the real Cloudflare cert, the cert it sees is Fortinet's substitute, validation fails, and the client retries forever cycling through ports 500, 1701, 443, 2408. Mac WARP only worked because it had registered on a different network earlier and cached the session.
A failed install isn't free — WARP also altered iptables and routing tables. At one point my Mac (connected via the Pi's Wi-Fi) completely lost reachability to the Pi. Recovery: plug the Pi back into the Mac via USB Ethernet so it pulls DHCP from macOS Internet Sharing (bridge100 at 192.168.2.1), SSH in over that direct link, disable warp-svc, reboot.
That recovery turned up another lovely surprise — SSH from macOS to the Pi was hanging right after the protocol banner exchange. nc could connect, the banner came back, but ssh timed out on key exchange. Eventually I tracked it to IPQoS=0xb8 (AF31), which something on the path was dropping. The fix:
# ~/.ssh/config
Host 192.168.50.1 192.168.2.2
IPQoS none
I purged WARP, restored sudo password requirements that I'd briefly relaxed during debugging, and moved on.
Stage 5 — Tailscale Is Already Dead
The original goal of WARP was to fix Tailscale, which had stopped working because FortiGate's App Control fingerprints controlplane.tailscale.com at the TLS handshake. The Pi's tailscaled logs showed an infinite retry loop:
tlsdial: server cert seen while dialing "controlplane.tailscale.com"
looks like "Fortinet" equipment (could be blocking Tailscale)
Received error: fetch control key:
x509: certificate signed by unknown authority
Tailscale even has a built-in detector for this — the message names "Fortinet" specifically.
Interesting nuance: Tailscale on my phone and Mac still worked for data, because both had established their control-plane session on a clean network and kept it alive. The phone uses cellular as a fallback path, the Mac uses cached state. Only the Pi, with a single uplink behind FortiGate, hit the wall every restart.
A non-expiring auth key doesn't help here. The auth key only governs the initial login; the daemon still maintains a long-lived "map poll" connection to controlplane.tailscale.com for ACL updates, peer changes, and key rotation. When that connection drops and reconnects, it has to do a fresh TLS handshake — and that's what FortiGate kills.
So I needed an actual tunnel, not a cached session.
Interlude — What FortiGate Actually Does
Before the WireGuard attempt, it's worth pinning down exactly what's happening on the wire, because the failure modes only make sense once you can name the layers.
FortiGate's DPI stack on this campus is doing at least four independent things to outbound traffic:
- App Control signatures. A library of ~5000 protocol fingerprints (Fortinet ships them as
FGT-AppCtrlupdates) matched against the first N bytes of each new flow. Each signature is a small bytecode program — usually a regex on plaintext payload plus structural assertions on packet length, offset, and direction. WireGuard's signature is trivial to write: the very first UDP packet of a flow is exactly 148 bytes, byte 0 is0x01(message type = Handshake Initiation), and bytes 1–3 are reserved zeros. That's a four-byte structural match. Once App Control flags a flow, subsequent packets in the same 5-tuple get a verdict applied — usuallydropwith a counter increment, never any TCP RST or ICMP, which is why the symptom is "first handshake works, then silence." - SNI inspection on plain TLS. For TLS without MitM, FortiGate parses the
ClientHello, pulls theserver_nameextension, and consults its URL category database.controlplane.tailscale.comis categorized as "Proxy Avoidance"; the action is RST-on-handshake. This is why even a perfectly valid TLS to Tailscale's control plane never completes — the policy decision happens before any cert is exchanged. - Full TLS MitM for select domains. A short allowlist of domains gets a different treatment: instead of dropping, FortiGate terminates TLS at the firewall with a substituted leaf cert signed by its
Fortinet_CA_SSLroot, decrypts, inspects, then re-encrypts to the real server. WARP'sengage.cloudflareclient.comis on this list, as iscontrolplane.tailscale.comfor some session paths. Clients that pin certificates (WARP, Tailscale, the macOS keychain when you've installed a real cert) refuse the substitute and fail closed. Browsers, by contrast, just trust the Fortinet CA if it's already installed on the device and you see nothing. - Behavioral / flow heuristics. Even when no static signature matches, FortiGate looks at flow shape — packet-size histograms, inter-arrival timing, byte-direction symmetry, ratio of handshake to data bytes. A pure-UDP flow with ~1400-byte packets at steady cadence to an unknown port reads like "tunneling," and gets throttled or sampled regardless of payload. This is why moving WireGuard to UDP/53 didn't help — the flow shape was still tunnel-shaped, and UDP/53 has its own redirect policy on top.
The layer that broke each of my attempts:
| Attempt | Layer that killed it | Tell |
|---|---|---|
| WARP | TLS MitM on engage.cloudflareclient.com | SSL alert, unknown CA in curl |
| Tailscale | App Control signature + TLS MitM on control-plane SNI | looks like "Fortinet" equipment warning, x509 unknown authority |
| Vanilla WG/UDP9906 | App Control signature on the 148-byte init + zero-reserved-bytes structure | First handshake passes, subsequent transport-data silently dropped |
| Vanilla WG/UDP53 | Above, plus a DNS-redirect policy that NATed all UDP/53 to FortiGate's own resolver | No packets reached the upstream endpoint at all |
The asymmetry in the WG case is the diagnostic gold. App Control matches against the initial packet of a flow, but the verdict gets applied to the whole 5-tuple. So the very first packet sometimes sneaks past — the engine hasn't finished classifying yet, and the response packet is small enough to fall under the threshold — and then the moment real transport data starts moving, the verdict catches up and every packet drops. If your VPN appears to "work for a second and then die," you are almost certainly inside this window.
The reason AmneziaWG defeats this is mechanical, not magical. Three changes to the wire format break all the easy hooks:
Jc/Jmin/Jmaxsend a few junk UDP packets before the real handshake, of random sizes. Any signature that anchors on "first packet is 148 bytes" misses, because the first packet isn't the handshake anymore.H1/H2/H3/H4replace the four hardcoded packet-type bytes (1, 2, 3, 4 for Init/Response/Cookie/Data) with arbitrary 32-bit values. Any signature that anchors onbyte[0] == 0x01misses.S1/S2insert byte offsets in the handshake init and response, shifting all field positions. Any signature that asserts "byte 0x10 is the responder's static pubkey" misses.
What stays the same is the underlying Noise IK handshake — Curve25519, ChaCha20-Poly1305, BLAKE2s — so the security properties are identical to WireGuard. From FortiGate's perspective, it's just opaque UDP between two endpoints with no static fingerprint to bite into. The flow heuristics in layer 4 might eventually notice "this is tunnel-shaped" if you saturate the link for a long time, but for the bursty control-plane traffic Tailscale needs, you stay under the radar.
There's one detail that matters for parameter choice: the obfuscation parameters must match exactly on both ends, and H1–H4 must be unique within the configuration (no two equal). Mine — 1111111111, 2222222222, 3333333333, 4111111111 — are obviously not cryptographically interesting; the values aren't secret. They just have to be values that no static signature happens to look for. Any four distinct uint32s that aren't {1, 2, 3, 4} will do.
Stage 6 — Vanilla WireGuard via Mikrotik (Failed)
I have a Mikrotik at home with RouterOS 7, which has WireGuard built in, so the obvious first attempt was: Pi connects to Mikrotik directly, Pi routes 192.200.0.0/24 (Tailscale's announced range) through the tunnel, done.
Configuration was clean. On the Mikrotik:
/interface/wireguard/add name=wg-campus listen-port=9906 mtu=1420
/ip/address/add address=10.99.0.1/24 interface=wg-campus
/ip/firewall/filter/add chain=input action=accept \
protocol=udp dst-port=9906 comment="accept wg-campus" place-before=4
/interface/wireguard/peers/add interface=wg-campus \
public-key="<campus-pi-pub>" allowed-address=10.99.0.2/32 \
persistent-keepalive=25s
On the campus Pi:
[Interface] PrivateKey = ... Address = 10.99.0.2/24 MTU = 1380 [Peer] PublicKey = LqAD7an/f1UKHYFuWAF33tnhyYsgd69Z6AYnCOxkT2s= Endpoint = home.king-ppap.net:9906 AllowedIPs = 10.99.0.0/24, 192.200.0.0/24 PersistentKeepalive = 10
The first handshake went through — Mikrotik reported rx=148 tx=7.0KiB, the Pi reported latest handshake: Now. Then everything froze. The Pi kept sending data packets, the Mikrotik kept replying, but wg show on the Pi showed 92 B received and stayed there. Mikrotik's side was sending 50+ KiB. Every single transport-data packet from Mikrotik was being dropped.
I tried switching the port to UDP/53 (which is often whitelisted as "DNS"). It got worse — FortiGate doesn't just allow UDP/53, it actively redirects it to its own DNS resolver, so nothing reached the Mikrotik at all.
I had to accept it: FortiGate's App Control has a WireGuard signature. The first handshake might slip through because the response packet is small and the engine hadn't matched the flow yet, but once enough bytes accumulated, it flagged the flow and started dropping. Port number didn't matter.
Stage 7 — AmneziaWG: WireGuard Plus Camouflage
AmneziaWG is a fork of WireGuard that adds packet obfuscation. It does two things:
- Junk packets at handshake — the
Jc,Jmin,Jmaxparameters tell the client to send a configurable number of randomly-sized junk UDP packets before the real handshake, so the initial flow doesn't fingerprint cleanly. - Custom magic bytes —
H1,H2,H3,H4replace the protocol's hardcoded packet-type identifiers (1, 2, 3, 4) with values you choose, so the WireGuard signature can't match.
There's also S1 and S2 which add header offsets to the handshake init and response packets respectively.
The wire format change makes a stock WireGuard signature miss completely. As long as both ends use the same parameters, the cryptographic protocol underneath is identical to WireGuard.
Mikrotik doesn't support AmneziaWG, so the topology had to change. The Mikrotik became a dumb port-forwarder; the actual WG server moved to my home Pi (Pi CM5, Debian Bookworm).
Campus Pi (awg0 10.99.0.2) ──UDP:9906──► Mikrotik (dst-NAT) ──► Home Pi:9906 (awg0 10.99.0.1)
↓
internet (clean)
The new Mikrotik NAT rule:
/ip/firewall/nat/add chain=dstnat action=dst-nat protocol=udp \
in-interface=pppoe-out1 dst-port=9906 \
to-addresses=192.168.88.92 to-ports=9906 \
comment="awg-campus to home pi" place-before=0
Installation on both Pis. The AmneziaWG project has a Launchpad PPA but it's Ubuntu-only, so for the Debian home Pi I built from source:
sudo apt install -y build-essential git make libmnl-dev pkg-config git clone --depth=1 https://github.com/amnezia-vpn/amneziawg-tools cd amneziawg-tools/src && make && sudo make install
The userspace daemon amneziawg-go needs Go 1.23+ and Bookworm ships 1.19, so I grabbed the official tarball:
sudo rm -rf /usr/local/go
curl -sSL https://go.dev/dl/go1.23.4.linux-arm64.tar.gz \
| sudo tar -C /usr/local -xzf -
git clone --depth=1 https://github.com/amnezia-vpn/amneziawg-go
cd amneziawg-go && PATH=/usr/local/go/bin:$PATH make
sudo install -m 755 amneziawg-go /usr/bin/amneziawg-go
Same build on the campus Pi. The awg-quick tool automatically falls back to amneziawg-go if the kernel module isn't present, which is the easy path.
The server config on the home Pi:
[Interface] PrivateKey = ... Address = 10.99.0.1/24 ListenPort = 9906 MTU = 1380 Jc = 4 Jmin = 40 Jmax = 70 S1 = 50 S2 = 100 H1 = 1111111111 H2 = 2222222222 H3 = 3333333333 H4 = 4111111111 PostUp = sysctl -w net.ipv4.ip_forward=1 PostUp = iptables -A FORWARD -i awg0 -j ACCEPT PostUp = iptables -A FORWARD -o awg0 -j ACCEPT PostUp = iptables -t nat -A POSTROUTING -s 10.99.0.0/24 -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i awg0 -j ACCEPT PostDown = iptables -D FORWARD -o awg0 -j ACCEPT PostDown = iptables -t nat -D POSTROUTING -s 10.99.0.0/24 -o eth0 -j MASQUERADE [Peer] PublicKey = <campus-pub> AllowedIPs = 10.99.0.2/32 PersistentKeepalive = 10
The campus Pi config is the mirror image. AllowedIPs = 10.99.0.0/24, 192.200.0.0/24 keeps it split-tunnel — only Tailscale control-plane traffic flows through the tunnel; everything else takes the direct campus path. That keeps latency low for normal use and conserves home upload bandwidth.
One small landmine: my first attempt used H4 = 4444444444, which overflows uint32 (max is 4294967295). awg setconf failed with a cryptic Invalid argument. Easy fix once spotted: H4 = 4111111111.
awg-quick up awg0 came up clean and the difference was immediate. Within ten seconds of handshake completion:
- ping
10.99.0.1→ 10.7 ms, 0% loss - ping
192.200.0.110(a Tailscale control-plane IP) via the tunnel → 192 ms, 0% loss wg showshowed bidirectional KiB transfer, not the single 92-byte handshake-only reply from before
A sudo systemctl restart tailscaled later, the Pi was back in my tailnet:
100.89.94.109 pi-4 king-ppap@ linux idle; offers exit node
Beszel could see it again.
Making It Survive a Reboot
Persisting all of this through reboot was a layering exercise. The right boot order on the campus Pi is:
network-online.target
↓
portal-watchdog.service (FortiGate login)
↓
AdGuardHome.service (DNS up)
↓
awg-quick@awg0.service (tunnel up)
↓
ExecStartPost: systemctl try-restart tailscaled
The drop-in that wires this together:
# /etc/systemd/system/awg-quick@awg0.service.d/order.conf [Unit] After=portal-watchdog.service AdGuardHome.service Wants=portal-watchdog.service [Service] ExecStartPost=/bin/systemctl try-restart tailscaled.service
Both Pis have awg-quick@awg0.service enabled. A reboot test showed the campus Pi recovering its full stack — portal login, DNS, tunnel, Tailscale — within about 90 seconds.
What I Took Away
A few things I'd want to remember next time I touch a network like this:
- DPI fingerprints by behavior, not just port. Switching from 9906 to 53 made it worse because UDP/53 traffic gets intercepted as if it were DNS. The signature engine and the protocol-specific intercepts are independent layers.
- TLS pinning is what kills VPNs on hostile networks. WARP, Tailscale, and many other clients pin their certificates, so any MitM is fatal even if the IP and port are reachable. Anything that bypasses MitM at the transport layer (raw UDP, custom-magic WG) just works.
- The first handshake can succeed even when the protocol is doomed. I almost concluded WireGuard worked because
latest handshake: Nowshowed up inwg show. The actual test is whether transport-data packets flow afterwards. WantsnotRequires,--no-blocknot synchronous, and neverRemainAfterExiton a unit driven by a timer. Three different ways the same module — systemd ordering — bit me in the same hour.IPQoS=noneis the SSH config line that nobody mentions but you eventually need. When the path between you and the host involves a bridge, NAT, or QoS-aware switch, the default DSCP marking will silently break large packets and your connection will hang after the banner.
The final Pi setup hides everyone behind it, encrypts DNS, auto-recovers the captive portal, and gives Tailscale a clean path home. Total install footprint is small enough that the same recipe could be replayed on a Pi Zero 2W if I wanted a travel router version. I might.
Update (2026-07-10) — Full Wi-Fi Passthrough via AmneziaWG
The original setup was split-tunnel: only Tailscale's control-plane range (192.200.0.0/24) went through the AWG tunnel, and everything else went directly through the campus uplink. That means Wi-Fi clients were still subject to FortiGate's content filtering, SNI inspection, and DPI — just with encrypted DNS and a NAT hiding them. The Pi was clean; the clients weren't.
The fix is to make the tunnel full-tunnel and route all Wi-Fi client traffic through it.
Making the tunnel full-tunnel
Change AllowedIPs on the campus Pi's [Peer] block:
AllowedIPs = 0.0.0.0/0
awg-quick handles the routing loop problem automatically. With AllowedIPs = 0.0.0.0/0 it does not simply add a default route in the main table — it creates a separate routing table (51820) with the default via awg0, and inserts two ip rules:
5208: from all lookup main suppress_prefixlength 0 5209: not from all fwmark 0xca6c lookup 51820
Rule 5208 says: look in the main table, but only use routes with a prefix length greater than zero — i.e., suppress the default route. This means campus-specific routes (192.168.11.0/24, 10.5.0.0/x) still use eth0. Rule 5209 catches everything else (no match in main table) and routes it to awg0. The AWG endpoint itself gets a /32 host route added to the main table via the current gateway, preventing the obvious loop.
Natting Wi-Fi clients to the tunnel IP
The home Pi CM5 has the campus Pi configured as a peer with AllowedIPs = 10.99.0.2/32. To avoid changing anything on CM5, the campus Pi masquerades all Wi-Fi client traffic to its own tunnel IP before it enters awg0:
PostUp = iptables -t nat -A POSTROUTING -s 192.168.50.0/24 -o awg0 -j MASQUERADE PostUp = iptables -A FORWARD -i wlan0 -o awg0 -j ACCEPT PostUp = iptables -A FORWARD -i awg0 -o wlan0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT PreDown = iptables -t nat -D POSTROUTING -s 192.168.50.0/24 -o awg0 -j MASQUERADE PreDown = iptables -D FORWARD -i wlan0 -o awg0 -j ACCEPT PreDown = iptables -D FORWARD -i awg0 -o wlan0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
MASQUERADE -o awg0 rewrites the source to 10.99.0.2 — the Pi's tunnel address — before the packet enters the WireGuard kernel module. CM5 sees all of it as normal Pi traffic from 10.99.0.2/32. No changes to the home side at all.
The Tailscale conflict
After flipping to full-tunnel, the campus cameras on 192.168.11.0/24 went offline on the home NAS. The Pi advertises that subnet via Tailscale, and the NAS surveillance app routes to 192.168.11.0/24 through the Pi.
The problem was in the return path. The NAS's Tailscale IP is 100.118.27.7. When a camera responds to the NAS, the Pi needs to forward the packet to tailscale0. Tailscale stores its peer routes in a separate routing table — table 52 on this system — and inserts a low-priority rule to consult it:
5270: from all lookup 52
But awg-quick's full-tunnel rule fires first:
5209: not from all fwmark 0xca6c lookup 51820 → default via awg0
Rule 5209 has a lower number (higher priority) than 5270. For any packet not already marked with AWG's fwmark, it matches first and routes to awg0. Tailscale's table 52 — which has 100.118.27.7 dev tailscale0 — never gets consulted. The return traffic goes into the AWG tunnel and disappears.
The fix is a single ip rule inserted with a priority lower than 5209:
PostUp = ip rule add to 100.64.0.0/10 lookup 52 priority 5000 PreDown = ip rule del to 100.64.0.0/10 lookup 52 priority 5000
100.64.0.0/10 is the Tailscale address space (covering 100.64.0.0 – 100.127.255.255). Priority 5000 fires before 5208 and 5209. For any packet destined for a Tailscale peer, this rule short-circuits to table 52 → tailscale0 before AWG gets a chance to intercept it. Everything else falls through to the normal full-tunnel path. Cameras came back up immediately.
The complete PostUp/PreDown block in /etc/amnezia/amneziawg/awg0.conf:
PostUp = ip rule add to 100.64.0.0/10 lookup 52 priority 5000 PostUp = iptables -t nat -A POSTROUTING -s 192.168.50.0/24 -o awg0 -j MASQUERADE PostUp = iptables -A FORWARD -i wlan0 -o awg0 -j ACCEPT PostUp = iptables -A FORWARD -i awg0 -o wlan0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT PreDown = ip rule del to 100.64.0.0/10 lookup 52 priority 5000 PreDown = iptables -t nat -D POSTROUTING -s 192.168.50.0/24 -o awg0 -j MASQUERADE PreDown = iptables -D FORWARD -i wlan0 -o awg0 -j ACCEPT PreDown = iptables -D FORWARD -i awg0 -o wlan0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
Everything survives a reboot. The campus-specific routes (192.168.11.0/24, portal watchdog to 10.5.0.1) still use eth0 via suppress_prefixlength 0. Tailscale subnet routing is intact. Wi-Fi clients exit via the home Pi.