Skip to content

Routing policies

A routing policy answers the question: “given this packet, which tunnel (or bridge, or direct path) should it leave through?”

Policies are matched in priority order. The first matching policy wins. Each policy declares one source (what traffic to match) and an ordered hops array (where the traffic goes). A fail_policy decides whether to drop or fall back when the first hop is unreachable.

Priority direction: lower priority number wins. Policies are loaded ORDER BY priority ASC and the resulting nft rules are appended to the policy-mark chain in load order, so the policy with the lowest number is checked first. Conventional Linux ordering — same direction as ip rule and bridge priority. A “more specific” or “more sensitive” policy should get a lower number; the catch-all wildcard should be the highest number in the set so it only matches when nothing more specific does.

A policy matches on exactly one dimension — the source_type field — with a value supplied in source_value. Combine dimensions by stacking policies at different priorities, not by ANDing inside one rule.

source_type source_value example Compiles to
all * match everything (use as catch-all)
interface wlan0 iifname "wlan0"
vlan 20 iifname "eth0.20"
subnet 10.0.10.0/24 ip saddr 10.0.10.0/24
device <device-id> resolved to the device’s MAC + IP
device_group <group-id> resolved to each member’s MAC + IP
destination 1.1.1.1 or 198.51.100.0/24 ip daddr ...
domain *.youtube.com resolved to IPs at apply-time → ip daddr { … }

Anything outside this set is rejected at the API boundary with HTTP 400. The combined match is compiled into nftables expressions, so evaluation is at kernel speed.

The hops array describes the egress path. Each hop is {type, target}. Validated at the API boundary — unknown types and empty hops arrays are rejected (per routing.go validateHopTargets):

type target example Meaning
vpn wg0 or a tunnel interface name A WireGuard / OpenVPN tunnel. List several with splitting: true for a pool.
interface eth0 Raw network interface — equivalent to “direct egress” out of a WAN.
proxy socks5://127.0.0.1:18080 A SOCKS5 / HTTP proxy URL. Must start with socks5://, socks5h://, or http://.
mesh_peer vps-london Send through a mesh peer by its mesh name.
drop (no target) Black-hole the matched traffic. Use this for explicit blocks.

Block traffic with a drop hop, not an empty hops array. Zero-hop policies are rejected with HTTP 400 (“policy must have at least one hop”) because the renderer would emit no rules and the policy would silently no-op.

Bridges aren’t a hop target. Register a bridge with its domains list and the daemon auto-creates the routing policies that point at the bridge’s local SOCKS5 port. You don’t write proxy → bridge-eu by hand; the bridge subsystem maintains those rules for you.

Terminal window
# Alice's laptop → EU tunnel, fail-closed
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "alice-via-eu",
"priority": 100,
"enabled": true,
"source_type": "device",
"source_value": "laptop-alice",
"hops": [{"type":"vpn","target":"wg-eu-01"}],
"fail_policy": "fail-closed"
}'
# After any create/update/delete, push to the dataplane:
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

Profiles are a separate top-level resource (/api/v1/profiles) that hold the non-routing knobs — privacy level, DNS source, default chain — that a policy doesn’t carry inline. They live alongside policies, not embedded in them. See the routing recipes for combined examples.

The exception manager lets you create time-bounded overrides that auto-expire. Useful for “let this device bypass the VPN for 30 minutes while I troubleshoot”:

Terminal window
gwctl exceptions create '{
"type": "temporary-direct",
"device_id": "laptop-alice",
"allow_direct": true,
"duration_min": 30,
"reason": "Zoom troubleshooting"
}'

Required: type (one of emergency-bypass, diagnostic-bypass, service-allowlist, temporary-direct, quarantine, captive-portal) and reason. duration_min is minutes — defaults to 60, capped at 24 h. allow_direct: true is what actually grants the bypass; without it, temporary-direct is just a label. The exception is logged to the audit trail with the operator who created it, expires automatically, and disappears from the active policy set.

gwctl evaluate hits POST /api/v1/policy/evaluate with {device_id} or {mac} and returns a RoutingDecision:

Terminal window
gwctl evaluate <device-id>

The response JSON is:

{
"decision": {
"device_id": "laptop-alice",
"device_name": "Alice's MacBook",
"profile_name": "high-privacy",
"chain_name": "vpn-eu-only",
"fail_policy": "fail-closed",
"dns_policy": "gateway",
"privacy_class": "private",
"action": "tunnel",
"tunnel_id": "wg-eu-01",
"reason": "device_group=adults → profile high-privacy → chain vpn-eu-only"
},
"_note": "This evaluates route_profiles/traffic_chains, not live routing_policies. Results may differ from actual enforcement."
}

Important: read the _note. The evaluator runs against the legacy route_profiles / traffic_chains model, not the live routing_policies table that the dataplane actually enforces. For the live state, use gwctl render (or GET /api/v1/render) — it prints the compiled nftables + ip rule output, which is the source of truth for what’s actually happening to packets.

Routing policies live under /routing/. Profiles, exceptions and privacy classes are top-level resources.

Method Path Purpose
GET /api/v1/routing/policies List policies in priority order
POST /api/v1/routing/policies Create
PUT /api/v1/routing/policies/{id} Update
DELETE /api/v1/routing/policies/{id} Delete
POST /api/v1/routing/policies/{id}/toggle Enable / disable
POST /api/v1/routing/policies/apply Compile + push to dataplane
POST /api/v1/policy/evaluate Dry-run the evaluator
POST /api/v1/policy/conflicts Detect conflicting policies
GET /api/v1/policy/leak-matrix Posture × tunnel-state grid
GET /api/v1/policy/fail-matrix Per-policy fail-policy summary
GET /api/v1/render Compiled dataplane state
GET/POST/PUT/DELETE /api/v1/profiles Route-profile CRUD
GET/POST/DELETE /api/v1/exceptions Time-bounded overrides
GET /api/v1/exceptions/active Only currently-active exceptions

After your first policy is firing:

  1. Verify it actually matches. gwctl evaluate <mac> shows which policy a given device hits and via which match clause. Run it before assuming.
  2. Render the compiled state. gwctl render prints the full nftables + ip rule output the policy compiled into. Useful when a behaviour surprises you.
  3. Bring up the leak matrixgwctl leak-matrix shows the posture × tunnel-state grid so the kill-switch behaviour you intended is what you actually get.
  4. Build a route profile once you have three or more policies that share the same action shape — DRY’s worth the indirection.
  • Conceptsdevice, device group, route profile and policy in one place.
  • Privacy & kill-switchfail_policy: fail-closed is what engages the kill-switch.
  • Recipes — copy-paste policies for the most common shapes.
  • gwctl CLIevaluate, apply, render and friends.