Troubleshooting
import { Aside } from ‘@astrojs/starlight/components’;
This is a playbook, not a manual. Each section starts with the symptom you’d actually notice, then walks through what to check, in order, from cheapest to most invasive.
The dataplane in 90 seconds
Section titled “The dataplane in 90 seconds”When something is wrong, the first three commands are almost always:
gwctl status # daemon up? subsystems ok?gwctl observer # which subsystem is unhappy?journalctl -u gatewayd --since '5 min ago' -p warn # the last few warningsAlmost every diagnostic below assumes you’ve run those first.
Symptom: a device suddenly has no internet
Section titled “Symptom: a device suddenly has no internet”-
Is the device matched by a fail-closed policy whose tunnel is down?
Terminal window gwctl evaluate <device-id-or-mac>gwctl tunnels # check the named tunnel's handshake ageIf the policy’s tunnel is down and
fail_policy: fail-closed, this is working as designed — the kill-switch is doing its job. Either bring the tunnel up or add a fallback policy with lower priority. -
Is the device’s DHCP lease expired?
Terminal window gwctl dhcp leases # MAC, IP, hostname, lease expirygwctl devices # confirm the device is registered (ID, name, MAC, segment, profile)No lease → no L3 → no traffic. Check the DHCP server.
-
Did a recent policy push break routing?
Terminal window curl -s https://gateway.lan:8080/api/v1/audit -H "Authorization: Bearer $API_KEY" | jq 'map(select(.resource_type == "routing_policy"))'gwctl snapshots # list availablegwctl rollback <snapshot> # back to a known-good state
Symptom: the kill-switch won’t disengage even though tunnels are up
Section titled “Symptom: the kill-switch won’t disengage even though tunnels are up”The kill-switch is reference-counted per (tunnel, interface) pair. If the counter is stuck above zero, the drop rule stays installed.
-
Inspect the counter:
Terminal window gwctl privacy-classes # which classes reference the tunnel?curl -s https://gw.local:8080/api/v1/privacy/kill-switch | jq -
Force an enforcement re-apply (the trigger is on
gwctl enforce;gwctl reconcileonly prints status):Terminal window gwctl enforce reconcile -
If the counter is wrong, the daemon will rebuild it from policy state on restart:
Terminal window sudo systemctl restart gatewayd
Symptom: traffic isn’t using the tunnel I expect
Section titled “Symptom: traffic isn’t using the tunnel I expect”-
Run the evaluator — what does the daemon think the policy is?
Terminal window gwctl evaluate <device-id-or-mac> -
Compare against compiled state:
Terminal window gwctl render # full compiled nftables / ip-rule output -
Look for a higher-priority policy that’s stealing the match. Open the Routing policies page and sort by priority, or query the API:
Terminal window curl -s https://gw.lan:8080/api/v1/routing/policies \-H "Authorization: Bearer $API_KEY" \| jq 'sort_by(-.priority)'First match wins; a wildcard
dest_domain: *policy at priority 100 swallows everything below it. -
Check exceptions: a TTL’d override may be active.
Terminal window gwctl exceptions
Symptom: DNS queries leak to the ISP
Section titled “Symptom: DNS queries leak to the ISP”-
Confirm the device is going through the gateway resolver. Open the DNS log page in the UI, or query the cache via the REST API and filter by device — there’s no
gwctlwrapper for the cache:Terminal window curl -s https://gateway.lan:8080/api/v1/dns/cache \-H "Authorization: Bearer $API_KEY" \| jq 'map(select(.client_ip=="10.0.10.50"))'If you see nothing for that device, it isn’t using the resolver at all.
-
Is
force_dnsenabled for the device’s privacy class? Without it, a hard-coded8.8.8.8bypasses the integrated resolver. -
Is DoH being blocked? Check Settings → Privacy → block_doh. A browser using DoH won’t show up in the DNS log at all.
-
Run the leak checker — there’s no per-device flag; the probe runs at network scope and the report tells you which test failed and where:
Terminal window gwctl leak-checkResult is a
LeakReportJSON with one entry per probe (dns_leak,dns_bypass,dns_forcing,dns_upstream_route,dnssec,doh_blocked,ipv6_leak,ipv4_fallback,webrtc_stun) — see Privacy → leak checker.
Symptom: bridge is unhealthy
Section titled “Symptom: bridge is unhealthy”-
Reachability: there’s no
gwctlwrapper for bridges; use the REST API:Terminal window curl https://gateway.lan:8080/api/v1/bridges \-H "Authorization: Bearer $API_KEY" # list with health columncurl -X POST https://gateway.lan:8080/api/v1/bridges/<bridge-id>/test \-H "Authorization: Bearer $API_KEY" # one-shot probe -
Check the bridge-node logs on the VPS:
Terminal window ssh vps "journalctl -u bridge-node -n 200"Common: certificate expired, PSK mismatch, decoy directory not readable.
-
Decoy SNI mismatch: if the SNI on the wire doesn’t match the bridge-node’s TLS cert, the censor’s gear sees a TLS error and your client too. Confirm the bridge’s
decoy_domainis a real hostname on the bridge-node’s cert.
Symptom: throughput is much lower than the link
Section titled “Symptom: throughput is much lower than the link”-
Privacy level at fault?
Terminal window gwctl splitting # shows per-policy level + slot mapLevel 3+ adds HTB shaping. The shape rate is the ceiling.
-
MTU mismatch. Check the tunnel’s MTU on the Tunnels page or via
gwctl tunnels. If MTU is 1500 but the tunnel’s effective MTU is 1380, you’ll see fragmentation and retransmits. Set the tunnel’s MTU explicitly. -
CPU-bound WireGuard: the Pi 4 caps at ~300 Mbit per core for WireGuard. Splitting across multiple tunnels helps spread load.
-
Cover traffic competing with real traffic? Cover lives on its own HTB class so this shouldn’t happen — but if
splitting.cover_traffic_max_ppswas raised manually, it can.
Symptom: the reconciler keeps re-applying state
Section titled “Symptom: the reconciler keeps re-applying state”-
Something else is editing nftables /
ip rule:Terminal window gwctl driftEach drift entry includes the diff. If you see the same rule being added by drift over and over, find what’s removing it (typical suspects: Docker’s
iptables, a strayufwenable, a sibling daemon). -
Quiet mode: raise
daemon.reconcile_interval_secto reduce churn while you investigate. Don’t lower the kill-switch’s coverage to make the alerts stop.
Symptom: federation isn’t syncing
Section titled “Symptom: federation isn’t syncing”-
Tunnel between peers up?
Terminal window gwctl federation peersIf the peer’s WireGuard handshake is stale, fix the tunnel first.
-
Per-peer sync allowlists set the way you intend?
Terminal window gwctl federation-roles # labelling taxonomy (descriptive only)curl https://gateway.lan:8080/api/v1/nodes \-H "Authorization: Bearer $API_KEY" # see sync_policy + sync_resources + allowed_receive_resources per peerIf the receiving peer’s
allowed_receive_resourcesdoesn’t include the kind being pushed, the push is silently dropped. Update both sides viaPUT /api/v1/nodes/{id}— see Federation. -
Recent events + drift: open the Federation page (or
GET /api/v1/federation/recent-eventsand/federation/overview). Every overwrite from an incoming push is logged, and drifting peers are surfaced via thepeer_drift_summaryrollup. Conflicts are resolved last-writer-wins; the overwritten row appears in the event log for inspection.
Capture targeted traffic
Section titled “Capture targeted traffic”When a symptom doesn’t fit a chapter above, capture the raw packets. The daemon wraps tcpdump with policy context, capture presets and per-device filters — no shell needed.
Quick captures from the CLI
Section titled “Quick captures from the CLI”gwctl capture start --iface wg-eu-01 --filter "port 53" --count 200 # 200 DNS packetsgwctl capture list # active + recentgwctl capture get <id> # JSON with parsed summariesgwctl capture stop <id> # end earlyPresets
Section titled “Presets”The daemon ships fourteen named BPF filters so you don’t have to remember tcp[tcpflags] & ... for common scenarios:
| Preset key | Filter | Use for |
|---|---|---|
dns |
port 53 |
DNS queries + answers |
http |
tcp port 80 or tcp port 443 |
Web egress |
icmp |
icmp or icmp6 |
Ping, traceroute, unreachables |
dhcp |
port 67 or port 68 |
DHCP discovery + leases |
arp |
arp |
L2 address resolution |
tcp_syn |
tcp-syn only |
New connection attempts |
tcp_rst |
tcp-rst / tcp-fin |
Failures and teardowns |
udp_no_dns |
udp and not port 53 |
UDP minus DNS noise |
wireguard |
udp port 51820 |
WireGuard handshakes |
tailscale |
tcp port 443 or udp port 3478 or udp port 41641 |
Tailscale / DERP |
ntp |
udp port 123 |
Time sync |
ssh |
tcp port 22 |
SSH |
broadcast |
ether broadcast or ether multicast |
Noisy LAN frames |
large_pkt |
greater 1400 |
MTU / fragmentation |
Reference a preset via the REST API:
curl -X POST https://gateway.lan:8080/api/v1/diagnostics/capture \ -H "Authorization: Bearer $API_KEY" \ -d '{"interface":"wg-eu-01","preset":"dns","device_ip":"10.0.10.50","count":200,"duration_sec":60}'device_mac or device_ip narrows the capture to one LAN client — useful when one device is misbehaving in a crowd.
Limits
Section titled “Limits”The capture engine is intentionally bounded:
- max 3 concurrent captures
- max 20 retained sessions
- max 50 000 packets per session
- max 3600 s per session
Hit a limit, the API rejects with 429 or 400; stop an old session first.
Stream live
Section titled “Stream live”curl -N https://gateway.lan:8080/api/v1/diagnostics/capture/<id>/stream \ -H "Authorization: Bearer $API_KEY"NDJSON, one parsed packet per line. The Capture page in the UI consumes the same stream.
When all else fails
Section titled “When all else fails”Capture a manual diagnostic bundle:
mkdir -p /tmp/gw-bundle && cd /tmp/gw-bundlejournalctl -u gatewayd --since '24 hours ago' > logs.txtgwctl status > status.txtgwctl observer > observer.txtgwctl tunnels > tunnels.txtcurl -s https://gateway.lan:8080/api/v1/bridges -H "Authorization: Bearer $API_KEY" > bridges.txtcurl -s https://gateway.lan:8080/api/v1/routing/policies -H "Authorization: Bearer $API_KEY" > policies.txtgwctl drift > drift.txtgwctl audit > audit.txtsudo nft list table inet gateway > nftables.txtip rule list > ip-rule.txtip -all route show table all > ip-route.txtcd .. && tar czf gw-bundle.tar.gz gw-bundle/Inspect before sharing — the bundle includes device hostnames, tunnel names and policy structure (no keys or backup contents).
Related
Section titled “Related”- Architecture — how the dataplane fits together; useful when reading diagnostics.
- Concepts — vocabulary.
- FAQ — the questions that aren’t symptoms.