Privacy & kill-switch
The privacy subsystem is what makes The Gateway fail-closed: when a tunnel goes down, traffic for policies bound to that tunnel stops, instead of silently falling back to your ISP.
It has three pieces:
- Kill-switch — reference-counted nftables drops that engage and disengage cleanly across overlapping policies.
- Privacy classes — per-device posture controlling IPv6, QUIC, WebRTC, DNS, etc.
- Leak checker — active probes that compare what’s leaving the box against what should be leaving.
The kill-switch
Section titled “The kill-switch”A naïve kill-switch is a single nftables rule. The problem: when two policies both protect against the same tunnel going down, a naïve implementation either double-installs (so disengaging policy A wrongly unblocks traffic for policy B) or loses state (so reinstalling policy A leaves a window of leakage).
The Gateway’s kill-switch is reference-counted:
- Each policy that needs fail-closed behaviour increments a counter for its (tunnel, interface) pair.
- The drop rule is installed when the counter first goes above zero.
- The drop rule is removed only when the counter returns to zero.
- A daemon restart re-derives counters from policy state — there are no leftover “phantom” engagements.
The kill-switch lives at prerouting priority -1, so it fires before the routing engine. There is no decision the kernel can make that bypasses it.
Privacy classes
Section titled “Privacy classes”A privacy class is a named posture you attach to a device or device group. The struct itself is small and focused; system-wide DNS / WebRTC / DoH protections are managed separately and combine with the class.
Privacy class fields
Section titled “Privacy class fields”| Field | Type | What it does |
|---|---|---|
force_dns |
bool | DNAT all UDP/53 from this device to the gateway resolver, even if the device hard-codes 8.8.8.8. |
ipv6_policy |
allow / block / tunnel |
Drop IPv6, allow it, or require it to go through the tunnel. |
quic_policy |
allow / block |
Drop UDP/443 to force browsers back to TCP/443 (HTTP/2 instead of HTTP/3). |
block_webrtc |
bool | Drop UDP traffic to known STUN endpoints. Stops WebRTC IP leakage. |
allow_direct_fallback |
bool | If the policy’s tunnel goes down and no fallback exists, allow direct egress? Default false (fail-closed). |
logging_level |
enum | How verbose to log this device’s policy decisions. |
observability_level |
enum | What metrics to collect for this device (anomaly detection, leak monitor, …). |
Three built-in classes ship: balanced, private, paranoid. You can define your own.
curl -X POST https://gateway.lan:8080/api/v1/privacy-classes \ -H "Authorization: Bearer $API_KEY" -d '{ "name": "paranoid", "force_dns": true, "ipv6_policy": "block", "quic_policy": "block", "block_webrtc": true, "allow_direct_fallback": false, "logging_level": "verbose", "observability_level": "full" }'Valid enums: ipv6_policy ∈ {allow, block, tunnel}, quic_policy ∈ {allow, block}, logging_level ∈ {none, minimal, standard, verbose}, observability_level ∈ {none, basic, standard, full}.
System-wide DNS / DoH / DoT protections
Section titled “System-wide DNS / DoH / DoT protections”The following are not privacy-class fields — they’re system-wide protections managed under Settings → Privacy (or the corresponding API endpoints):
| Setting | API endpoint | What it does |
|---|---|---|
block_doh |
POST /privacy/block-doh |
Drop traffic to known DoH endpoints (by SNI and well-known port). |
block_ipv6 |
POST /privacy/block-ipv6 |
Drop all IPv6 system-wide. Useful when no tunnel supports v6. |
block_webrtc |
POST /privacy/block-webrtc |
Drop UDP STUN traffic system-wide. |
force_dns |
POST /privacy/force-dns |
Force every DNS query in the network through the integrated resolver. |
| DoT block (TCP/853) | via DNS mode | No standalone toggle — comes with the paranoid DNS mode (set via PUT /privacy/dns-mode). |
They combine with class-level toggles: the stricter setting wins. A device whose class allows IPv6 still sees it blocked if the system-wide block_ipv6 is on.
Leak checker
Section titled “Leak checker”POST /api/v1/privacy/leak-check (or gwctl leak-check) runs nine probes against the local network and produces a LeakReport with per-test results:
| Test name | What it verifies |
|---|---|
dns_leak |
A query through the gateway resolver actually exits via the configured upstream — not via a sideband path. |
dns_bypass |
A device hard-coding 8.8.8.8 is DNAT-redirected back to the gateway resolver. |
dns_forcing |
force_dns is actually engaged on the firewall, not just configured. |
dns_upstream_route |
The DNS upstream query egresses through the expected tunnel interface. |
dnssec |
DNSSEC validation is reachable end-to-end. |
doh_blocked |
A test connection to a known DoH provider is dropped when block_doh is on. |
ipv6_leak |
IPv6 connectivity is either disabled or routes through the tunnel. |
ipv4_fallback |
No accidental WAN-IP egress when the tunnel goes down. |
webrtc_stun |
UDP STUN to known endpoints is dropped when block_webrtc is on. |
Each result is { test, passed, details }. The overall report has all_passed: bool. Any failing test trips the built-in leak-detected rule (severity critical), which fires through the configured webhook + Telegram. Use the Privacy → Leak monitor UI page (or re-run gwctl leak-check and jq for the failed entry) to see which test failed.
Failure-mode matrix
Section titled “Failure-mode matrix”Two related matrices answer different questions:
gwctl leak-matrix — a vector → mitigation → residual-risk table for the policy + privacy posture currently in place. Columns: VECTOR | MITIGATION | RISK | VALIDATION. Useful for compliance review (a regulator asks “what defends against split-routing mistakes?” — you point at the row).
gwctl fail-matrix — a per-profile breakdown of what happens when each egress type fails. Columns: PROFILE | VPN FAIL | PROXY FAIL | DNS FAIL | CAPTIVE. This is the closer answer to “what does this device actually do if its tunnel goes down?”.
Both are computed from the same policy graph the dataplane enforces, so the rows reflect live state — not aspirational config.
Captive-portal detection
Section titled “Captive-portal detection”For deployments that occasionally land on networks with a captive portal (travel routers, mobile installs, conference WiFi), POST /api/v1/captive/check runs three standard probes in parallel:
http://connectivitycheck.gstatic.com/generate_204— expects HTTP204with empty bodyhttp://detectportal.firefox.com/canonical.html— expects<meta http-equivin the bodyhttp://www.msftconnecttest.com/connecttest.txt— expectsMicrosoft Connect Testin the body
Any of three signals trip the detector: a 3xx redirect, a non-204 from the Google probe, or a body that doesn’t match the expected fingerprint on the others. Redirects are not followed — the redirect target is what the operator typically needs to open in a browser.
$ gwctl captiveDetected: YesProbe URL: http://detectportal.firefox.com/canonical.htmlStatus Code: 302Redirect To: https://hotel-portal.example.invalid/loginThe detector does not auto-modify routing policy or relax the kill-switch — captive bypass is intentionally explicit. The standard pattern (covered in the travel-router use case) is to add a short-lived direct exception for the captive-detection target plus the portal hostname, complete the portal flow, and let the TTL expire.
API surface
Section titled “API surface”| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/privacy/status |
Overall privacy posture snapshot |
GET/PUT |
/api/v1/privacy/dns-mode |
Force-DNS mode (off / device / system) |
POST |
/api/v1/privacy/force-dns |
Apply force-DNS to a device |
POST |
/api/v1/privacy/block-doh |
Toggle DoH block (system-wide) |
POST |
/api/v1/privacy/block-webrtc |
Toggle WebRTC STUN block |
POST |
/api/v1/privacy/block-ipv6 |
Toggle system-wide IPv6 block |
GET/PUT |
/api/v1/privacy/leak-monitor |
Leak-monitor configuration |
POST |
/api/v1/privacy/leak-check |
Trigger an on-demand leak check |
GET/POST/PUT/DELETE |
/api/v1/privacy-classes |
Privacy-class CRUD (top-level resource) |
POST |
/api/v1/captive/check |
Run the captive-portal detector once |
Next steps
Section titled “Next steps”Once your privacy classes are assigned:
- Run the leak checker once per network. Nine probes, ~5 seconds — it’s the only way to know your posture actually holds, not just that it’s configured.
- Wire the
leak-detectedalert to a paging channel. A failing probe is not an event you want to learn about from a billing surprise. - Read the threat model so the class choices match the threats they’re meant to defend against.
- Set a
fail_policy: fail-closedpolicy for at least one sensitive workload. Privacy classes alone don’t engage the kill-switch — the policy’sfail_policydoes.
Related
Section titled “Related”- Routing policies —
fail_policyis what hands a policy off to the kill-switch. - DNS plane —
force_dnsand DoH/DoT blocks combine with system-wide DNS protections. - Threat model — what the kill-switch actually defends against (and what it doesn’t).
- Recipes → Privacy — privacy classes, exceptions, DoH/DoT toggles.
- Alerts & monitoring — the built-in
leak-detectedrule fires when any leak-check test fails.