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.
Hardware & install
Section titled “Hardware & install”- Raspberry Pi 4 (4 GB), Ethernet to ISP router, USB-Ethernet for LAN
- 64 GB A2 SD card with
noatimeand tmpfs logs - Install profile:
rpi
sudo ./scripts/install-gateway.sh --profile rpiNetwork mode: embedded. The ISP router stays in place; the Pi sits inline between it and the family LAN switch + WiFi AP.
Topology
Section titled “Topology” 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)
Devices and groups
Section titled “Devices and groups”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] }Privacy classes
Section titled “Privacy classes”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 workPOST 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.
Tunnels
Section titled “Tunnels”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.
Bridges
Section titled “Bridges”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:
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" }'Policies
Section titled “Policies”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:
# Adults pool — splitting across 3 tunnels, fail-closedcurl -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-closedcurl -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 WANcurl -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 dataplanecurl -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 rules
Section titled “DNS rules”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}:
# Global ad blocking — applies to every devicecurl -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 blockcurl -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, globalcurl -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.
What happens when
Section titled “What happens when”| 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. |
Why fail-closed for adults
Section titled “Why fail-closed for adults”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.
Next steps
Section titled “Next steps”After this baseline works:
- Schedule encrypted backups. Three policies, four privacy classes and a bridge are a lot to lose. See Backup.
- Turn on the leak monitor so any breach of the fail-closed promise surfaces as a paging alert rather than a quiet incident.
- Watch the kids’ DNS log. The first month tells you which preset blocks are too aggressive and which categories slipped through. See DNS plane.
- 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.
Related
Section titled “Related”- Privacy & kill-switch — the reference-counted machinery the
fail_policy: fail-closedhere triggers. - Traffic splitting — what level 4 actually does behind the adults’ pool.
- Bridge proxies — how the kids’ censored-domain bridge works in detail.
- Troubleshooting — what to check when one of these policies misbehaves.