Skip to content

Recipes

Each recipe is a single working snippet. The YAML / JSON blocks describe the object you’d create — paste them in the corresponding UI page, or send as the request body to POST /api/v1/<kind> (or gwctl <kind> create -).

The CLI examples shown here use the JSON-CRUD pattern: gwctl <kind> create '<json-body>'. See the gwctl reference for the canonical interface.

A routing policy has a source (one source_type + source_value pair) and an action, expressed as a hops array. Valid source_type values: all, interface, vlan, subnet, device, device_group, destination, domain. Hop types: vpn, interface, proxy, mesh_peer. First match wins; lower priority number is checked first (conventional Linux ordering — same as ip rule). A specific policy should get a lower number than the catch-all.

Terminal window
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"
}'

Multiple vpn hops + splitting: true turns the hop list into a round-robin pool. The privacy level lives on the policy’s profile (set separately via /api/v1/profiles):

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "family-pool",
"priority": 90,
"enabled": true,
"source_type": "device_group",
"source_value": "family",
"hops": [
{"type":"vpn","target":"wg-eu-01"},
{"type":"vpn","target":"wg-eu-02"},
{"type":"vpn","target":"wg-asia-01"}
],
"splitting": true,
"fail_policy": "fail-closed"
}'

You don’t create routing policies for bridges by hand. Register the bridge with the relevant domains list (see Bridges for the create body); the bridge subsystem auto-generates one system-managed routing policy per domain at priority 200, with hops: [{type:"proxy", target:"socks5://127.0.0.1:<bridge-local-port>"}]. The list refreshes on every reconcile tick.

To add a domain after the fact, edit the bridge’s domains field and the policies are re-emitted automatically.

If you need to drive a SOCKS5 destination that isn’t a bridge — a commercial proxy, a custom relay — use a proxy hop directly, with a URL target:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "via-commercial-proxy",
"priority": 95,
"enabled": true,
"source_type": "domain",
"source_value": "*.example.com",
"hops": [{"type":"proxy","target":"socks5://198.51.100.20:1080"}],
"fail_policy": "fail-closed"
}'

proxy hop targets must start with socks5://, socks5h://, or http:// — anything else is rejected at the API boundary.

Blocks are expressed with a drop hop (not an empty hops array — that’s rejected with HTTP 400 “policy must have at least one hop”):

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "kids-no-tiktok",
"priority": 110,
"enabled": true,
"source_type": "domain",
"source_value": "*.tiktok.com",
"hops": [{"type":"drop"}],
"fail_policy": "fail-closed"
}'

For multi-pattern blocks (TikTok + tiktokcdn), repeat the policy with a different source_value and the same priority bucket.

Bypass the VPN for a single SaaS that allowlists by IP

Section titled “Bypass the VPN for a single SaaS that allowlists by IP”

direct egress is an interface hop pointing at the WAN:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-d '{
"name": "bank-direct",
"priority": 50,
"enabled": true,
"source_type": "domain",
"source_value": "*.bank.example",
"hops": [{"type":"interface","target":"eth0"}],
"fail_policy": "fail-open"
}'

Lower priority number than your default pool so it’s checked first and wins on match.

After any of these, push to the dataplane:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

Blocks and source-scoped rules live in /api/v1/dns/filter-rules (action ∈ {block,allow,rewrite}, match_type ∈ {exact,wildcard,regex,contains,suffix}, source_type ∈ {all,ip,subnet,interface,device,device_group}). Domain-to-IP rewrites have their own simpler endpoint /api/v1/dns/rewrites.

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"block-ads","priority":100,"enabled":true,
"action":"block","match_type":"exact","pattern":"ads.example.com",
"source_type":"all","source_value":"*"
}'
Terminal window
curl -X POST https://gateway.lan:8080/api/v1/dns/filter-rules \
-H "Authorization: Bearer $API_KEY" -d '{
"name":"kids-adult-content","priority":110,"enabled":true,
"action":"block","match_type":"wildcard","pattern":"*.adult-content.example",
"source_type":"device_group","source_value":"kids"
}'
Terminal window
curl -X POST https://gateway.lan:8080/api/v1/dns/rewrites \
-H "Authorization: Bearer $API_KEY" -d '{
"domain": "homeassistant.lan",
"answer": "10.0.0.6"
}'

answer may be an IPv4/IPv6 literal (A/AAAA rewrite) or a domain name (CNAME-style). Anything else is rejected at the API boundary.

Enable the windows, macos and ios presets under DNS → Presets — they’re curated blocklists that ship with the daemon.

Add a WireGuard tunnel from a provider config

Section titled “Add a WireGuard tunnel from a provider config”

The simplest path is the Tunnels page in the UI — paste a .conf file or fill in the fields. Via API:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/tunnels \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d @/tmp/provider-wg.json
gwctl tunnels up wg-provider-eu
Terminal window
gwctl tunnels down wg-eu-01 && gwctl tunnels up wg-eu-01

The Bridges page in the UI, or:

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/bridges \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "bridge-eu",
"address": "decoy.example.invalid:443",
"psk": "REDACTED-PSK-FROM-BRIDGE-NODE",
"transport": "quic",
"decoy_domain": "decoy.example.invalid",
"domains": ["youtube.com","*.youtube.com"],
"priority": 10,
"enabled": true,
"fail_policy": "fail-closed"
}'

Apply the paranoid class to a single device

Section titled “Apply the paranoid class to a single device”

Edit the device on the Devices page (one-field click), or via API. PUT /api/v1/devices/{id} replaces the row, so fetch the current device first, change the one field, and send the full body back. The {id} is the UUID assigned at create time, not the hostname:

Terminal window
# Look up the UUID by hostname
DEV_ID=$(curl -s https://gateway.lan:8080/api/v1/devices \
-H "Authorization: Bearer $API_KEY" \
| jq -r '.[] | select(.hostname=="laptop-alice") | .id')
# Round-trip the row with one field changed
curl -s https://gateway.lan:8080/api/v1/devices/$DEV_ID \
-H "Authorization: Bearer $API_KEY" \
| jq '.privacy_class = "paranoid"' \
| curl -X PUT https://gateway.lan:8080/api/v1/devices/$DEV_ID \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
--data @-

There is no PATCH for devices; the partial-update story is “GET, edit JSON, PUT”.

Terminal window
curl -X POST https://gateway.lan:8080/api/v1/privacy-classes \
-H "Authorization: Bearer $API_KEY" -d '{
"name": "travel",
"force_dns": true,
"ipv6_policy": "block",
"quic_policy": "block",
"block_webrtc": true,
"allow_direct_fallback": false
}'

DoH has its own toggle. DoT does notTCP/853 is dropped automatically when the DNS mode is set to paranoid:

Terminal window
# system-wide DoH block (by SNI against the bundled provider list)
curl -X POST https://gateway.lan:8080/api/v1/privacy/block-doh \
-H "Authorization: Bearer $API_KEY" -d '{"enabled": true}'
# DoT (and the rest of paranoid-mode protections)
curl -X PUT https://gateway.lan:8080/api/v1/privacy/dns-mode \
-H "Authorization: Bearer $API_KEY" -d '{"mode": "paranoid"}'

In the UI: Settings → Privacy → block_doh and DNS mode → paranoid.

Temporarily bypass the VPN for troubleshooting

Section titled “Temporarily bypass the VPN for troubleshooting”

Add an exception via the UI Exceptions page, or with a JSON CRUD call:

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

type{emergency-bypass, diagnostic-bypass, service-allowlist, temporary-direct, quarantine, captive-portal}. duration_min is minutes (max 24 h, defaults to 60). Auto-expires; nothing to remember to undo.

Treat each WAN as a “tunnel” of type interface

Section titled “Treat each WAN as a “tunnel” of type interface”
tunnels:
- { id: wan-fibre, type: interface, interface: eth0, gateway: "203.0.113.1", weight: 70 }
- { id: wan-lte, type: interface, interface: eth1, gateway: "198.51.100.1", weight: 30 }
profiles:
- name: dual-wan
action: pool
pool: [wan-fibre, wan-lte]
privacy_level: 1 # per-session round-robin
fail_policy: fail-open # this is balancing, not privacy
dns: gateway
policies:
- { name: lan, match: { source_interface: eth2 }, profile: dual-wan, priority: 100 }
policies:
- { name: bank-fibre,
match: { dest_domain: "*.bank.example" },
profile: { action: tunnel, tunnel: wan-fibre, fail_policy: fail-open },
priority: 110 }

The tunnel-down built-in rule is enabled by default. Wire the webhook destination through the dedicated endpoint:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/alerts/webhook \
-H "Authorization: Bearer $API_KEY" \
-d '{ "webhook_url": "https://hooks.example.invalid/oncall" }'

leak-detected fires on any failing leak-monitor test. Same endpoint handles all three alert-channel keys; webhook + Telegram receive every fire:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/alerts/webhook \
-H "Authorization: Bearer $API_KEY" \
-d '{
"telegram_bot_token": "REDACTED",
"telegram_chat_id": "@my-bot"
}'

After saving, fire a synthetic event through the channel to prove it works: POST /api/v1/alerts/webhook/test.

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/alerts/rules/new-device \
-H "Authorization: Bearer $API_KEY" \
-d '{"enabled": false}'

gwctl backup export writes the plaintext sanitized JSON (no encryption, secrets stripped). For the AES-256-GCM-encrypted variant with secrets, use the REST API:

Terminal window
# Sanitized plaintext (no passphrase)
gwctl backup export > /var/backups/gateway-$(date +%F).json
# Encrypted with secrets (AES-256-GCM + PBKDF2-HMAC-SHA256, 100k iters)
curl -X POST https://gateway.lan:8080/api/v1/backup/export-encrypted \
-H "Authorization: Bearer $API_KEY" \
-d '{"passphrase":"REDACTED"}' \
--output /var/backups/gateway-$(date +%F).enc

The daemon never logs or stores the passphrase. Lose it, lose the encrypted backup.

The scheduler accepts three fields — enabled, interval (hours), and a single SCP target string. Rotation and timing are interval-based, not cron-shaped:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/backup/config \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true,
"interval_hours": 24,
"remote_target": "gateway-backup@backup.example.invalid:/srv/backups/gw01/"
}'

interval_hours is clamped to 1–720 (one hour to one month). remote_target looks like user@host:/path and is passed to scp — the daemon refuses shell metacharacters and --prefixed targets to block CVE-2020-15778-class injection.

The encrypted-export passphrase is not part of this config. The scheduler produces sanitized plaintext exports automatically. For encrypted backups, supply the passphrase per-call in the POST /api/v1/backup/export-encrypted body. Nothing persists it.

gwctl backup import <file> takes a plaintext JSON file as a positional argument (not stdin). For an encrypted file, decrypt externally first — there’s no encrypted-import endpoint today:

Terminal window
gwctl backup import /var/backups/gateway-2026-06-26.json

The simplest path is the Federation → Peers UI page (which walks key exchange + approval). Via API:

Terminal window
# Register the peer on this node
curl -X POST https://gw-here.lan:8080/api/v1/nodes \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"vps-london","address":"https://vps.mesh:8080"}'
# Exchange signing keys + approve (see /features/federation/ for the full flow)

Sync gating is per-peer, not “per kind globally”. Each peer relationship has a direction (sync_policy) plus two allowlists (sync_resources out, allowed_receive_resources in). The kind catalog is bridges, routing, dns, devices, profiles, tunnels, segments, settings, nodes.

Terminal window
# On the office Pi — push routing+dns to the VPS, don't accept anything back
curl -X PUT https://gateway.lan:8080/api/v1/nodes/<vps-id> \
-H "Authorization: Bearer $API_KEY" \
-d '{
"sync_policy": "push",
"sync_resources": "routing,dns",
"allowed_receive_resources": ""
}'
# On the VPS — pull from the office Pi, accept only routing+dns (no tunnels)
curl -X PUT https://gateway.lan:8080/api/v1/nodes/<office-id> \
-H "Authorization: Bearer $API_KEY" \
-d '{
"sync_policy": "pull",
"sync_resources": "",
"allowed_receive_resources": "routing,dns"
}'

Empty allowed_receive_resources is “accept everything” — legacy behaviour. Set it explicitly even when you mean “accept all” so an upgraded peer can’t suddenly start writing kinds you didn’t anticipate.