Skip to content

Self-hosted travel router

A Pi 4 you carry. Connect it to whatever WiFi the hotel hands you; expose your own AP to your devices; route everything through a pool of three VPN exits in different jurisdictions. When the hotel WiFi drops, the kill-switch engages — devices see “no internet” instead of fumbling onto the airport open network across the road.

An operator travels for work. They want every device they carry — laptop, phone, e-reader — to:

  • Connect to a single trusted AP they bring with them, regardless of which hotel / café WiFi they’re on.
  • Route all outbound traffic through a pool of three VPN tunnels.
  • Never leak through the upstream WiFi, even briefly, when tunnels cycle.
  • Work in countries with active DPI — pluggable transports as a fallback.
  • Raspberry Pi 4 (4 GB) with the official PoE+ HAT or a USB battery
  • Single internal WiFi (wlan0) — used as the AP (downstream).
  • Single USB WiFi dongle (wlan1) — used as the client (upstream to hotel WiFi).
  • Optional: an LTE USB modem (wwan0) as a fallback upstream.

Install profile: rpi. Network mode: portable.

Terminal window
sudo ./scripts/install-gateway.sh --profile rpi

Then in the bootstrap config (/var/lib/gateway/config.yaml):

network:
mode: portable
wan_iface: wlan1 # upstream — USB dongle
lan_iface: wlan0 # AP — internal radio

Portable mode tells gatewayd:

  • No DHCP relay on WAN — the upstream gives the gateway an address; downstream the gateway runs its own DHCP for the AP.
  • No SLAAC / RA acceptance on WAN — don’t trust the upstream’s IPv6 advertisement (and probably block IPv6 outbound anyway).
  • AP-first DNS — the AP’s DHCP hands out the gateway as the DNS server; the upstream DNS is never consulted directly by clients.

The Interfaces page in the UI shows nearby SSIDs on wlan1 — select one, paste the password, and the daemon brings up the link. All client policies bound to wlan0 continue to apply, so changing the upstream is transparent to clients.

Behind the scenes the daemon writes a wpa_supplicant config for wlan1 and brings the interface up; you can do the same manually if you prefer (wpa_passphrasewpa_supplicant -i wlan1 -c …), then leave it to the reconciler to keep the rest of the dataplane consistent.

Three WireGuard tunnels with different exit countries:

  • wg-eu-01 (Frankfurt)
  • wg-us-01 (NYC)
  • wg-asia-01 (Singapore)

Plus one bridge bridge-eu for countries with active DPI.

Single class applied to every connected device. POST to https://gateway.lan:8080/api/v1/privacy-classes:

{
"name": "travel",
"force_dns": true,
"ipv6_policy": "block",
"quic_policy": "block",
"block_webrtc": true,
"allow_direct_fallback": false
}

ipv6_policy: block so the hotel’s IPv6 isn’t trusted. quic_policy: block neutralises captive-portal hijacks that ride QUIC. allow_direct_fallback: false is what guarantees the kill-switch — clients never fall back to hotel WiFi when tunnels are down.

Plus, system-wide under Settings → Privacy, turn on block_doh and switch DNS mode to paranoid (which is what drops TCP/853 DoT) so even apps that hard-code their own encrypted-DNS endpoints can’t bypass the resolver.

One policy on the AP interface catches every client. No per-device config needed when travelling. Multi-vpn-hop + splitting: true produces the 3-way round-robin pool, with the fail-closed kill-switch on the policy itself:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name": "travel",
"priority": 100,
"enabled": true,
"source_type": "interface",
"source_value": "wlan0",
"hops": [
{"type":"vpn","target":"wg-eu-01"},
{"type":"vpn","target":"wg-us-01"},
{"type":"vpn","target":"wg-asia-01"}
],
"splitting": true,
"fail_policy": "fail-closed"
}'
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

Privacy level 4 (jitter + padding + cover traffic + rotation) lives on the policy’s route profile — set via PUT /api/v1/profiles/{id}. See Traffic splitting.

Most hotel networks require accepting a captive portal page before granting internet. Run gwctl captive to detect the redirect:

Terminal window
gwctl captive
# Detected: Yes
# Probe URL: http://detectportal.example/
# Status Code: 302
# Redirect To: https://hotel-portal.example/login

Once detected, the operator typically:

  1. Adds a short-lived exception routing the captive-detection target (and the portal hostname) direct with a 10-minute TTL.
  2. Opens a browser, accepts the portal terms.
  3. Waits for the exception to auto-expire — the kill-switch and full policy resume.

There is no global “captive mode” toggle; the daemon prefers explicit, time-bounded exceptions so you never forget to re-enable fail-closed.

For trips where the hotel WiFi is unreliable but a phone tether always works: pair the operator’s phone as a Bluetooth PAN peer once, and let the daemon failover to it automatically when the primary uplink goes down.

Terminal window
# one-time pairing on the box
gwctl bluetooth enable
gwctl bluetooth scan
gwctl bluetooth pair <phone-MAC>

Then configure the failover policy:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/bluetooth/failover \
-H "Authorization: Bearer $API_KEY" \
-d '{
"enabled": true,
"phone_mac": "AA:BB:CC:DD:EE:FF",
"delay_seconds": 30,
"auto_connect": true
}'

How it behaves:

  • The daemon polls the primary WAN every 10 s. After delay_seconds / 10 consecutive failures (minimum 3), it connects to the phone over Bluetooth PAN and brings up bnep0 as a WAN uplink.
  • bnep0 is treated as untrusted WAN, exactly like the hotel WiFi was. Traffic still has to go through your VPN pool — the kill-switch will never let bnep0 itself act as a tunnel.
  • When the primary returns and stays up for one poll cycle, the daemon disconnects PAN automatically so the phone stops carrying traffic.

This is for emergencies — Bluetooth PAN sits at 1-2 Mbit/s in practice. Web works, video doesn’t.

Or: serve other devices over Bluetooth (NAP host mode)

Section titled “Or: serve other devices over Bluetooth (NAP host mode)”

The inverse of failover. When WiFi is jammed or you’re in a no-WiFi-radio zone (some museums, certain offices), the gateway can act as a Bluetooth Network Access Point — other devices pair with the Pi as a PANU client and get internet through it:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/bluetooth/nap/start \
-H "Authorization: Bearer $API_KEY" \
-d '{"subnet":"10.44.0.1/24"}'

This creates a bt-pan0 bridge interface, runs DHCP (range .10-.50, 12 h leases) on it, and registers org.bluez.NetworkServer1 for nap. The Pi advertises itself for 120 seconds (discoverable timeout — keeps the attack window narrow), then accepts paired clients silently.

NAP and failover are mutually exclusive on the radio. Run failover when the Pi needs an uplink; run NAP when other devices need one. Switch with POST /api/v1/bluetooth/nap/stop before re-arming failover.

Scenario Result
Connected to hotel WiFi, all tunnels up All client traffic exits via a 3-way split. Hotel WiFi sees only WireGuard handshakes.
Hotel WiFi drops Tunnels go down. Kill-switch engages. Clients see “no internet” — no fallback to the open WiFi at the airport across the road.
Hotel WiFi drops, BT failover armed After ~30 s of WAN-down, the daemon auto-connects PAN to the paired phone; tunnels re-handshake over bnep0; clients stay online at 1-2 Mbit/s. Switch back is automatic.
Hotel uses DPI to block WireGuard Bring up bridge-eu and add a policy with dest_domain: * over the bridge for affected destinations.
Battery / no AC The PoE HAT runs the Pi off a battery bank for hours. The AP keeps serving.

Why this beats “just use a VPN client on the laptop”

Section titled “Why this beats “just use a VPN client on the laptop””
  • A laptop VPN client only protects the laptop. Your phone, your e-reader, and any guest device sharing your AP all leak.
  • A laptop VPN client doesn’t fail-closed cleanly on every OS. The Gateway always does.
  • A laptop VPN client doesn’t give you split tunnels across regions for jurisdictional diversity.
  • A laptop VPN client doesn’t run a bridge if WireGuard is blocked.

You move one device — the Pi — and every device behind it gets the same posture.

Before you travel:

  1. Turn on the dead-man’s switch with hours: 24. If the Pi is left behind in a hotel room and you don’t return, sensitive tables auto-wipe.
  2. Enable LUKS storage for forensic resistance on a powered-off device. Accept the SD-wear cost — it’s a travel box.
  3. Pair your phone for Bluetooth WAN failover so an unreliable hotel uplink doesn’t leave you offline.
  4. Test the leak checker at every new networkgwctl leak-check confirms posture in 5 seconds.