Skip to content

gwctl CLI

gwctl is the command-line client for gatewayd. It speaks the same REST API as the web UI, so anything you can click you can script.

gwctl is intentionally minimal: it wraps the REST API with a small set of verbs per object kind. The general shape is:

gwctl <kind> <verb> [arg...]

The verbs follow the same CRUD pattern across all kinds:

Verb Effect
list List all (the default if no verb)
get <id> Fetch one
create <json> Create — body is a JSON document on stdin or as a quoted arg
update <id> <json> Patch
delete <id> Remove

Plus a handful of object-specific verbs (e.g. tunnels up, tunnels down, backup export, leak-check, evaluate).

If you’re tempted to look for a flag like --name, --device or --key-file, the answer is almost always: there isn’t one — pipe a JSON body instead, or use the UI for first-time setup.

gwctl connects to http://localhost:8080 by default. Override with:

Terminal window
export GATEWAY_ADDR="https://gateway.lan:8080"

gwctl itself does not carry an API token. It assumes one of:

  • The daemon has auth.enabled: false — the install default. All requests are admin regardless of source.
  • It’s running on the daemon host with auth.enabled: true AND you’re only running read commands. The auth.local_net (default 127.0.0.0/8) bypass elevates GET/HEAD/OPTIONS only; POST/PUT/PATCH/DELETE from gwctl will return 401 under enforced auth.

For state-changing commands under enforced auth, or for scripted access from another host, use curl against the REST API directly with an Authorization: Bearer <key> header, where <key> is configured under auth.api_keys in the bootstrap config.

create and update accept the JSON body three ways:

Terminal window
gwctl exceptions create '{"type":"temporary-direct","device_id":"laptop-alice","allow_direct":true,"duration_min":30,"reason":"Zoom"}' # literal arg
gwctl exceptions create /tmp/exc.json # path (auto-detected)
gwctl exceptions create < /tmp/exc.json # stdin
cat exc.json | gwctl exceptions create # also stdin
Command What it shows
gwctl status Version, deployment mode, ready bool, interface count
gwctl health /health rollup — overall status + per-component health
gwctl observer Per-subsystem health snapshot
gwctl drift Recent drift items (type, resource, field, expected vs actual)
gwctl platform Hardware info, RAM/disk usage, upgrade readiness
gwctl conflicts Run policy-conflict detection across current routing policies
Command Effect
gwctl devices List registered devices
gwctl devices create '<json>' Register a device (name is required; ID is an auto-assigned UUID)
gwctl devices get | update | delete <id> Per-device CRUD; <id> is the UUID returned at create
gwctl device-groups List / CRUD device groups
gwctl trust-classes List / CRUD trust classes
Command Effect
gwctl tunnels List tunnels (ID, name, type, status, interface)
gwctl tunnels up <id> Bring up
gwctl tunnels down <id> Bring down
gwctl proxy List SOCKS5 proxies (ID, name, listen, status, conn count, TX/RX)
gwctl proxy create '<json>' Register a SOCKS5 proxy endpoint
gwctl proxy delete <id> Remove a SOCKS5 proxy endpoint

Tunnel CRUD (create / update / delete), bridges, and the splitting engine have no top-level gwctl subcommand — go through the REST API (POST /api/v1/tunnels, POST /api/v1/tunnels/upload-ovpn, POST /api/v1/bridges, etc.) or the corresponding UI pages.

Command Effect
gwctl dns Resolver status (listener, filter state, query / blocked totals)
gwctl dns block <domain> ... Add domains to the blocklist
gwctl dns unblock <domain> ... Remove domains from the blocklist

Cache, filter-rule CRUD, zones and records have no gwctl wrapper — use the REST API (/api/v1/dns/cache, /dns/filter-rules, /dns/zones, /dns/records). | gwctl pihole | Pi-hole backend status (connected? version, queries today) | | gwctl pihole enable / disable | Toggle Pi-hole filtering remotely | | gwctl captive | Detect whether a captive portal is intercepting |

Command Effect
gwctl profiles Route profile CRUD
gwctl chains Traffic-chain CRUD (ordered stages of dns/vpn/nat/…; referenced by route profiles)
gwctl exceptions Exception CRUD
gwctl exceptions create '<json>' Add a TTL’d exception
gwctl evaluate <device> Dry-run the policy evaluator
gwctl apply (reads plan from stdin or file) Apply a declarative apply.Plan JSON via POST /api/v1/apply (Terraform-style multi-resource bundle)
gwctl validate (reads plan from stdin or file) Dry-run validation of the same plan shape via POST /api/v1/validate
gwctl render Print the compiled nftables / routes

Note: to recompile + push the current routing_policies rows to the dataplane after a CRUD change, the operator-facing endpoint is POST /api/v1/routing/policies/apply (no body). There’s no direct gwctl wrapper for it — use curl. | gwctl conflicts | Detect policy conflicts |

Routing policies themselves don’t have a dedicated gwctl subcommand — manage them via the REST API (/api/v1/routing/policies) or the Routing policies UI page.

Command Effect
gwctl privacy-classes Privacy class CRUD
gwctl leak-matrix Vector × mitigation × residual-risk table (compliance view)
gwctl fail-matrix Per-profile failure-mode breakdown (vpn / proxy / dns / captive)
gwctl fail-matrix Per-policy fail behaviour matrix
gwctl leak-check Trigger an on-demand leak check
Command Effect
gwctl mesh Mesh status
gwctl nodes List mesh peers
gwctl gateway-nodes List federated gateway nodes
gwctl federation Federation status snapshot
gwctl federation peers List federation peers
gwctl federation sync --peer <url> Force a sync with one peer (URL is required)
gwctl federation-roles Federation-roles labelling CRUD (top-level command, hyphenated)
Command Effect
gwctl audit Audit log query
gwctl alerts List alerts (active + resolved)
gwctl anomalies Recent anomalies
Command Effect
gwctl presets Preset bundles
gwctl segments Network segments
gwctl vlan VLAN management
gwctl dhcp DHCP settings
gwctl backup export Plaintext sanitized JSON (secrets stripped) to stdout
gwctl backup import <file> Restore from a plaintext JSON file (positional arg, not stdin)
gwctl snapshots Local snapshot stack
gwctl rollback <snapshot> Roll back to a snapshot
Command Effect
gwctl secrets Secret material inventory (incl. API keys, mesh keys, backup keys)
gwctl ai status Ollama availability + loaded model list
gwctl ai query <text> Ask the assistant to suggest a policy action (does not apply it)
Command Effect
gwctl capture start --iface <if> [--filter <bpf>] [--count <n>] Start a tcpdump-backed capture
gwctl capture list List active and recent captures
gwctl capture get <id> Parsed packet summaries for a capture
gwctl capture stop <id> Stop a running capture
gwctl verify Run self-tests
gwctl reconcile Show reconciler status (running? last run? drift count?)
gwctl enforce List compiled enforcement rules (nft tables, chains, rules)
gwctl enforce reconcile Trigger an immediate enforcement re-apply (POST /api/v1/enforce/reconcile)
gwctl test-definitions Manage custom network tests
gwctl version Daemon version and build info

gwctl is mixed-output by design: list-style commands (devices, tunnels, audit, secrets, alerts active, mesh peers, dhcp leases, etc.) print a human-readable tab-separated table; single-object commands (status, capture, federation) print JSON. There is no --output json flag.

For scripted filtering, go to the REST API directly — it always returns JSON, and the response shape matches what gwctl would have shown as a row:

Terminal window
curl -s https://gateway.lan:8080/api/v1/tunnels \
-H "Authorization: Bearer $API_KEY" \
| jq '.[] | select(.healthy == false)'
curl -s https://gateway.lan:8080/api/v1/audit \
-H "Authorization: Bearer $API_KEY" \
| jq 'map(select(.action == "create_routing_policy"))'

The audit entry shape is {timestamp, actor, action, resource_type, resource_id, details}action is the verb (create_routing_policy, delete_device, …), resource_type is the noun.

All examples below use the JSON-CRUD pattern (gwctl <kind> create '<json>'). For first-time setup you’ll usually want the UI; these snippets are for scripting and automation.

Add a device and route it through a specific tunnel

Section titled “Add a device and route it through a specific tunnel”
Terminal window
# 1. Register the device — `name` is required; ID is auto-assigned UUID.
# Capture the returned id for the policy in step 3.
DEV_ID=$(gwctl devices create '{
"name": "Alice MacBook",
"mac": "aa:bb:cc:11:22:33",
"hostname": "laptop-alice",
"trust_class": "operator",
"privacy_class": "balanced"
}' | jq -r '.id')
# 2. Bring up the tunnel (if not already)
gwctl tunnels up wg-eu-01
# 3. Create the policy. source_value is the device's UUID, not its hostname —
# the daemon resolves device → MAC+IP via `WHERE id=?`. No gwctl wrapper
# for routing-policies; go through the REST API.
curl -X POST https://gateway.lan:8080/api/v1/routing/policies \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"alice-via-eu\",
\"priority\": 100,
\"enabled\": true,
\"source_type\": \"device\",
\"source_value\": \"$DEV_ID\",
\"hops\": [{\"type\":\"vpn\",\"target\":\"wg-eu-01\"}],
\"fail_policy\": \"fail-closed\"
}"
# 4. Push to the dataplane + verify (no gwctl wrapper for compile-and-push)
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"
gwctl evaluate "$DEV_ID"
Terminal window
gwctl exceptions create '{
"type": "temporary-direct",
"device_id": "laptop-alice",
"allow_direct": true,
"duration_min": 30,
"reason": "Zoom troubleshooting"
}'

type is required; valid values are emergency-bypass, diagnostic-bypass, service-allowlist, temporary-direct, quarantine, captive-portal. duration_min is minutes (defaults to 60 if omitted, capped at 24 h).

The exception auto-expires; nothing to remember to undo.

A routing policy with multiple vpn hops and splitting: true round-robins new connections across the pool. No gwctl wrapper for routing-policies CRUD — use the REST API:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/routing/policies/<policy-id> \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "alice-via-eu",
"priority": 100,
"enabled": true,
"source_type": "device",
"source_value": "<device-uuid>",
"hops": [
{"type":"vpn","target":"wg-eu-01"},
{"type":"vpn","target":"wg-eu-02"},
{"type":"vpn","target":"wg-us-01"}
],
"splitting": true,
"fail_policy": "fail-closed"
}'
curl -X POST https://gateway.lan:8080/api/v1/routing/policies/apply \
-H "Authorization: Bearer $API_KEY"

Privacy level lives on the policy’s route profile — set separately via gwctl profiles update.

Terminal window
# Encrypted off-box backup (key comes from daemon config)
gwctl backup export > /var/backups/gateway-$(date +%F).json # sanitized plaintext (secrets stripped)

Roll back via snapshots:

Terminal window
gwctl snapshots # list available
gwctl rollback before-vlan-cleanup # apply a named snapshot
Terminal window
gwctl leak-matrix # posture × tunnel-state grid
gwctl leak-check # one-shot leak probe across all devices

gwctl audit prints a human-readable table. For filtering, hit the REST API and pipe through jq. Real audit fields are timestamp, actor, action, resource_type, resource_id, details:

Terminal window
# all routing-policy mutations
curl -s https://gateway.lan:8080/api/v1/audit \
-H "Authorization: Bearer $API_KEY" \
| jq 'map(select(.resource_type == "routing_policy"))'
# everything one operator did
curl -s https://gateway.lan:8080/api/v1/audit \
-H "Authorization: Bearer $API_KEY" \
| jq 'map(select(.actor == "alice@example.invalid"))'

Federation: push routing + dns to a peer, keep tunnels local

Section titled “Federation: push routing + dns to a peer, keep tunnels local”

Per-peer allowlists live on the nodes resource; gwctl wraps gwctl federation sync --peer <url> but peer updates themselves go through the REST API:

Terminal window
curl -X PUT https://gateway.lan:8080/api/v1/nodes/<peer-id> \
-H "Authorization: Bearer $API_KEY" \
-d '{
"sync_policy": "push",
"sync_resources": "routing,dns,devices",
"allowed_receive_resources": ""
}'
gwctl federation sync --peer https://peer.mesh:8080 # force a push right now

sync_resources excludes tunnels, so each peer’s local tunnel config stays its own.