Skip to content

Anti-censorship family router

The flagship deployment. A single Raspberry Pi 4 in embedded mode, three SSIDs mapped to three VLANs, three device groups with three different posture profiles — and a kill-switch that means a stale handshake never produces a packet to the ISP.

A family of four lives in a country where YouTube and several messengers are intermittently blocked. They want:

  • Adults routed through a pool of three VPN tunnels with strong privacy guarantees.
  • Kids with content filtering and a fixed bridge for the few foreign sites they’re allowed to use.
  • IoT devices (TV, smart speakers, light bulbs) on direct egress — but with telemetry blocked.
  • Zero leakage if any tunnel goes down. Better no internet than the ISP seeing the connection.
  • Raspberry Pi 4 (4 GB), Ethernet to ISP router, USB-Ethernet for LAN
  • 64 GB A2 SD card with noatime and tmpfs logs
  • Install profile: rpi
Terminal window
sudo ./scripts/install-gateway.sh --profile rpi

Network mode: embedded. The ISP router stays in place; the Pi sits inline between it and the family LAN switch + WiFi AP.

ISP router ── eth0 ── [Pi 4] ── eth1 ── LAN switch
└── wlan0 (WiFi AP, 2.4+5 GHz)
├── kids' devices
├── adults' devices
└── IoT (separate SSID, isolated)

The WiFi AP advertises three SSIDs mapped to three VLANs:

  • Home → VLAN 10 (adults)
  • HomeKids → VLAN 20 (kids)
  • HomeIoT → VLAN 30 (IoT)
device_groups:
- { name: adults, members: [laptop-mom, laptop-dad, phone-mom, phone-dad] }
- { name: kids, members: [tablet-alice, tablet-ben] }
- { name: iot, members: [tv-livingroom, speaker-kitchen, hub-bulbs] }

The per-device toggles. System-wide block_doh is enabled separately under Settings → Privacy for the whole network; DoT block comes with switching DNS mode to paranoid. Field shape is the JSON the /api/v1/privacy-classes endpoint expects:

{ "name": "adults", "force_dns": true,
"ipv6_policy": "block", "quic_policy": "block",
"block_webrtc": true, "allow_direct_fallback": false }
{ "name": "kids", "force_dns": true,
"ipv6_policy": "block", "quic_policy": "allow",
"block_webrtc": true, "allow_direct_fallback": false }
{ "name": "iot", "force_dns": true,
"ipv6_policy": "block", "quic_policy": "block",
"block_webrtc": false, "allow_direct_fallback": true } // IoT just needs to work

POST each to https://gateway.lan:8080/api/v1/privacy-classes. The adults class plus allow_direct_fallback: false is what makes the fail-closed posture stick.

Three WireGuard tunnels to three different VPN regions: wg-eu-01, wg-eu-02, wg-asia-01. Each fetched from a commercial provider, with Endpoint, PublicKey, AllowedIPs=0.0.0.0/0,::/0, PersistentKeepalive=25.

One bridge bridge-eu on a small VPS the family rents, running the bridge-node binary on :443 with a static decoy site on /. Pre-shared key generated during bridge install.

From the Bridges UI page, or via API:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/bridges \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "bridge-eu",
"address": "decoy.example.invalid:443",
"psk": "REDACTED-PSK-FROM-BRIDGE-NODE",
"transport": "quic",
"decoy_domain": "decoy.example.invalid",
"domains": ["youtube.com","*.youtube.com","*.googlevideo.com","t.me","*.t.me","*.telegram.org"],
"priority": 10,
"enabled": true,
"fail_policy": "fail-closed"
}'

Each policy has one source_type + source_value. The kids’ split (censored-via-bridge, everything-else-via-tunnel) is two policies at different priorities; the bridge rule wins on domain match, the device_group rule catches the rest:

Terminal window
# Adults pool — splitting across 3 tunnels, fail-closed
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"adults","priority":100,"enabled":true,
"source_type":"device_group","source_value":"adults",
"hops":[
{"type":"vpn","target":"wg-eu-01"},
{"type":"vpn","target":"wg-eu-02"},
{"type":"vpn","target":"wg-asia-01"}
],
"splitting":true,"fail_policy":"fail-closed"
}'
# Kids' censored domains go through the bridge automatically — the bridge subsystem
# generates a routing policy per entry in the bridge's `domains` list, pointing at
# the bridge's local SOCKS5 port. Don't create those policies by hand; the bridge
# auto-creates them at priority 200 (system-managed) and refreshes them on every
# reconcile tick. You only manage the bridge's `domains` field.
# Kids: everything else through the EU tunnel, fail-closed
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"kids","priority":90,"enabled":true,
"source_type":"device_group","source_value":"kids",
"hops":[{"type":"vpn","target":"wg-eu-01"}],
"fail_policy":"fail-closed"
}'
# IoT direct out the WAN
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"iot","priority":80,"enabled":true,
"source_type":"device_group","source_value":"iot",
"hops":[{"type":"interface","target":"eth0"}],
"fail_policy":"fail-open"
}'
# Push to the dataplane
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

Privacy level (per the splitting engine) lives on the policy’s route profile — set adults’ profile to level 4 and the default kids profile to level 2 via PUT /api/v1/profiles/{id}.

DNS filter rules are pattern + match_type + action, scoped by source. The real schema accepts match_type ∈ {exact, wildcard, regex, contains, suffix} and action ∈ {block, allow, rewrite}:

Terminal window
# Global ad blocking — applies to every device
curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"ads-doubleclick","priority":100,"enabled":true,
"action":"block","match_type":"wildcard","pattern":"*.doubleclick.net",
"source_type":"all","source_value":"*"
}'
# Kids-only adult-content block
curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"kids-adult","priority":110,"enabled":true,
"action":"block","match_type":"wildcard","pattern":"*.adult-content.example",
"source_type":"device_group","source_value":"kids"
}'
# Telemetry block, global
curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"ms-telemetry","priority":100,"enabled":true,
"action":"block","match_type":"exact","pattern":"telemetry.microsoft.com",
"source_type":"all","source_value":"*"
}'

Plus the windows, macos, ios telemetry presets enabled in one click from the Telemetry page.

Scenario Result
All tunnels up, bridge healthy Adults split across wg-eu-01/02 and wg-asia-01 at privacy level 4. Kids’ censored domains go via bridge-eu; the rest go via wg-eu-01 at level 2. IoT direct.
wg-eu-02 handshake stale Adults’ new connections re-balance to the remaining two tunnels. No leakage.
bridge-eu down Kids’ censored domains stop (fail-closed on the bridge policy). Kids’ other traffic keeps flowing through wg-eu-01.
wg-eu-01 and wg-eu-02 and wg-asia-01 all down Adults’ traffic stops (fail-closed). Kids’ non-censored traffic also stops (its tunnel is gone). The bridge still works for censored domains if it has a non-pool path. IoT direct unaffected.
ISP DNS hijack at upstream The Gateway uses its own forwarders; ECS stripped; DoH/DoT blocked → no hijack possible.

The threat model isn’t “annoying ads”. It’s: an active observer who learns from an IP-level leak that the family is using a foreign VPN at all. Fail-closed means a stale handshake never produces a single packet to the ISP. The trade-off is brief outages when tunnels cycle — acceptable for the comfort of knowing nothing leaked.

After this baseline works:

  1. Schedule encrypted backups. Three policies, four privacy classes and a bridge are a lot to lose. See Backup.
  2. Turn on the leak monitor so any breach of the fail-closed promise surfaces as a paging alert rather than a quiet incident.
  3. Watch the kids’ DNS log. The first month tells you which preset blocks are too aggressive and which categories slipped through. See DNS plane.
  4. Pair a second gateway at a grandparent’s house with the same kids’ DNS profile via federation — they get the same posture at granny’s WiFi.