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.
Source dimensions
Section titled “Source dimensions”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.
Hop types
Section titled “Hop types”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.
Build one
Section titled “Build one”# Alice's laptop → EU tunnel, fail-closedcurl -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"Route profiles
Section titled “Route profiles”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.
Exceptions with TTLs
Section titled “Exceptions with TTLs”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”:
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.
Evaluation tool
Section titled “Evaluation tool”gwctl evaluate hits POST /api/v1/policy/evaluate with {device_id} or {mac} and returns a RoutingDecision:
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.
API surface
Section titled “API surface”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 |
Next steps
Section titled “Next steps”After your first policy is firing:
- Verify it actually matches.
gwctl evaluate <mac>shows which policy a given device hits and via which match clause. Run it before assuming. - Render the compiled state.
gwctl renderprints the full nftables + ip rule output the policy compiled into. Useful when a behaviour surprises you. - Bring up the leak matrix —
gwctl leak-matrixshows the posture × tunnel-state grid so the kill-switch behaviour you intended is what you actually get. - Build a route profile once you have three or more policies that share the same action shape — DRY’s worth the indirection.
Related
Section titled “Related”- Concepts — device, device group, route profile and policy in one place.
- Privacy & kill-switch —
fail_policy: fail-closedis what engages the kill-switch. - Recipes — copy-paste policies for the most common shapes.
- gwctl CLI —
evaluate,apply,renderand friends.