Skip to content

Architecture

┌────────────────────────────────┐
│ 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 ifaces

Everything 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.

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:

  1. inet killswitch — at prerouting priority -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 from inet gateway so the kill-switch survives even if the gateway table is being rebuilt.

  2. split-mark chain (in inet gateway) — at prerouting priority mangle - 1. The splitting engine selects an exit endpoint using numgen mod N, where N is the total weight across the policy’s endpoints. The result becomes an fwmark on the packet.

  3. policy-mark chain — at prerouting priority -150. Per-policy fwmark assignment for non-splitting policies (single-tunnel, bridge, direct).

  4. ip rule fwmark 0xNNN lookup TTTT — the kernel routes the packet using table TTTT instead of main. Each policy gets its own routing table.

  5. ip route in table TTTTdefault 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 a veth pair, which isolates per-tunnel sidecars (redsocks, openvpn) from each other and from the rest of the dataplane.

  6. forward / postrouting chains (in inet 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).

  7. tc qdisc — at egress, HTB shapes bandwidth (privacy levels 3+) and netem injects 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.

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 rule
32007: from all fwmark 0x701 lookup 1001
32008: from all fwmark 0x702 lookup 1002
32009: from all fwmark 0x703 lookup 1003
# ip route table 1001 / 1002 / 1003
default dev wg-eu-01
default dev wg-eu-02
default dev wg-eu-03

A 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.

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-mark chain, DNS leak blocks and ip rule entries against the desired state derived from kv_settings and re-applies anything missing;
  • cleans up orphaned redsocks processes, 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.

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.

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.

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).

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.

  • 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 numgen vmap shown above.