Architecture
The big picture
Section titled “The big picture” ┌────────────────────────────────┐ │ gatewayd │ │ ───────────────────────── │ Web UI ───TLS───►│ HTTP / REST API (:8080) │ gwctl ───sock──►│ Unix socket │ │ │ │ SQLite (kv_settings, │ │ policies, devices) │ │ │ │ Subsystems (in-process): │ │ dnsplane / netplane / │ │ transport / meshplane / │ │ bridge / splitting / │ │ privacy / observe / │ │ alert / policy / backup │ └────────────┬───────────────────┘ │ owns ┌──────────────────┼──────────────────┐ ▼ ▼ ▼ nftables ip rule / WireGuard / table inet ip route OpenVPN / gateway tables SOCKS5 ifacesEverything dataplane-related is in-process. There is no sidecar, no broker, no message bus. The daemon owns every kernel object it touches and considers them its property.
Packet flow
Section titled “Packet flow”A device on the LAN makes an outbound TCP connection. The daemon owns two nftables tables — inet killswitch (priority -1) and inet gateway (multiple chains at prerouting priorities -150 through mangle - 1). Here’s what happens, in order:
-
inet killswitch— atpreroutingpriority-1, before any routing decision. Established and LAN-internal traffic is accepted; anything else is dropped if the kill-switch is engaged for a matching policy. This is a separate table frominet gatewayso the kill-switch survives even if the gateway table is being rebuilt. -
split-markchain (ininet gateway) — atpreroutingprioritymangle - 1. The splitting engine selects an exit endpoint usingnumgen mod N, whereNis the total weight across the policy’s endpoints. The result becomes anfwmarkon the packet. -
policy-markchain — atpreroutingpriority-150. Per-policy fwmark assignment for non-splitting policies (single-tunnel, bridge, direct). -
ip rule fwmark 0xNNN lookup TTTT— the kernel routes the packet using tableTTTTinstead ofmain. Each policy gets its own routing table. -
ip routein tableTTTT—default via GATEWAY dev wgN. The packet leaves through a specific tunnel interface. The interface lives in its own network namespace; the root namespace reaches it via avethpair, which isolates per-tunnel sidecars (redsocks, openvpn) from each other and from the rest of the dataplane. -
forward/postroutingchains (ininet gateway) — MSS clamping (1340 on tunnel egress, 1360 in split context), DNS-leak blocks (any UDP/53 not going to the integrated resolver is dropped), DoH / DoT blocks when enabled, segment isolation, NAT (masquerade on the tunnel interface). -
tcqdisc — at egress, HTB shapes bandwidth (privacy levels 3+) andneteminjects jitter on the cover-traffic class.
Reply packets follow the reverse path: tunnel → de-NAT → device. Because the connection has state, return packets match the established conntrack entry without re-running policy evaluation.
Worked example
Section titled “Worked example”Policy alice-private, profile high-privacy: action pool over wg-eu-01, wg-eu-02, wg-eu-03, privacy level 4, fail-closed.
Compiled state:
# nftables (excerpt)table inet gateway { chain split-mark-p7 { type filter hook prerouting priority mangle - 1 ; policy accept ; meta mark 0 ip saddr 10.0.10.50 \ numgen inc mod 3 vmap { 0 : mark set 0x701, 1 : mark set 0x702, 2 : mark set 0x703, } }}
table inet killswitch { # separate table, priority -1 chain protect { type filter hook prerouting priority -1 ; policy accept ; meta mark { 0x701, 0x702, 0x703 } \ oifname != { wg-eu-01, wg-eu-02, wg-eu-03 } drop }}
# ip rule32007: from all fwmark 0x701 lookup 100132008: from all fwmark 0x702 lookup 100232009: from all fwmark 0x703 lookup 1003
# ip route table 1001 / 1002 / 1003default dev wg-eu-01default dev wg-eu-02default dev wg-eu-03A new TCP SYN from 10.0.10.50 hits split-mark-p7, gets fwmark=0x702 (slot 1, this time round), is routed via table 1002, exits through wg-eu-02. The kill-switch will drop any subsequent packets bearing those marks if their tunnel is down — no leak to the default route.
Why a reconciler
Section titled “Why a reconciler”Anything in the kernel can be mutated out of band: a sibling script, an over-eager iptables invocation, a partial daemon restart. The Gateway treats this as drift and re-applies state.
The reconciler runs on a single configurable cadence (default 60 s, minimum 5 s). On each tick it:
- diffs the
split-markchain, DNS leak blocks andip ruleentries against the desired state derived fromkv_settingsand re-applies anything missing; - cleans up orphaned
redsocksprocesses, stale tunnel interfaces, expired exception TTLs and leftover routing tables from deleted policies.
Drift events are surfaced on the Reconciliation page and counted as a metric, so silent self-healing is still observable.
The splitting engine
Section titled “The splitting engine”Six privacy levels, parameterised by P ∈ [0, 1]:
| Level | Name | Behaviour | Overhead |
|---|---|---|---|
| 0 | Performance | Lowest-latency endpoint; no jitter, padding, cover or rotation. | ~0% |
| 1 | Light | Weighted round-robin; 10 ms jitter; 30-min rotation shuffle. | ~0% |
| 2 | Balanced | Weighted round-robin; 50 ms jitter; 10-min rotation. | ~5% |
| 3 | Private | Crypto-random selection; 200 ms jitter; 512-byte padding; cover 1 pps; HTB 20 Mbit; 5-min rotation. | ~30% |
| 4 | Strong | Crypto-random; 500 ms jitter; 1400-byte padding; cover 5 pps; HTB 10 Mbit; 2-min rotation. | ~60% |
| 5 | Maximum | Crypto-random; 1000 ms jitter; 1400-byte padding; cover 10 pps; HTB 5 Mbit; 1-min rotation. | ~100% |
Rotation reshuffles endpoint order on the level’s cadence (Fisher-Yates with crypto/rand throughout — no PRNG fast-path).
Cover traffic uses a Poisson process: inter-arrival times are exponentially distributed, so an observer can’t infer real-traffic timing from packet rate alone. An EMA tracks real traffic rate and adapts the cover rate to stay proportionate.
Subsystem boundaries
Section titled “Subsystem boundaries”Each subsystem under internal/ owns its piece of the world:
| Subsystem | Owns |
|---|---|
dnsplane |
The DNS listener, filter chain, cache, upstream pool |
netplane |
Network interfaces (state, IPs, MTU, VLANs) |
transport |
WireGuard / OpenVPN tunnels, SOCKS5 endpoints |
meshplane |
Tailscale / Headscale peer state |
bridge |
Bridge-node clients, transport (TLS, obfs4, others planned), redsocks |
splitting |
split-mark chain, numgen slots, cover-traffic generator |
privacy |
Kill-switch state machine, leak checker, leak blocks |
policy |
Routing policies, exceptions, evaluator |
observe |
Subsystem health, drift detection, anomaly heuristics |
alert |
Alert rules, dispatch (webhook, Telegram), persistence |
backup |
AES-256-GCM export / import, schedule, remote SCP / SFTP push |
auth |
JWT issuing, API keys, RBAC |
store |
SQLite access, goose migrations |
Cross-subsystem calls go through narrow Go interfaces. The HTTP layer is the only place that knows about all of them at once.
Web UI
Section titled “Web UI”A single-page React app served from the daemon’s HTTP listener. The UI talks to the same REST API as gwctl, so anything you can click you can script.
The UI is ~50 pages organised by domain: networking (interfaces, tunnels, bridges, mesh), routing (policies, chains, splitting, segments), security (privacy classes, trust classes, firewall, certificates), DNS (resolver, log, telemetry), monitoring (alerts, anomalies, live logs, audit), management (settings, backup, reconciliation, federation), and advanced (AI assistant, map, capture).
Federation
Section titled “Federation”Multiple gateway nodes can be peered into a federation. Each peer keeps its own SQLite, but configuration objects can be pushed or pulled across the mesh (or a REST connection). Sync is gated per peer, per direction: each peer relationship has a sync_policy (none/push/pull/bidirectional) plus separate outbound (sync_resources) and inbound (allowed_receive_resources) allowlists. The kind catalog covers bridges, routing, dns, devices, profiles, tunnels, segments, settings, and nodes. See Federation for the full model.
Related
Section titled “Related”- Concepts — every term in this page defined in one place.
- Configuration — bootstrap vs runtime; what touches the kernel before SQLite is ready.
- Traffic splitting — the engine that compiles policy into the
numgenvmap shown above.