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.
How the CLI is shaped
Section titled “How the CLI is shaped”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.
Connection & auth
Section titled “Connection & auth”gwctl connects to http://localhost:8080 by default. Override with:
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: trueAND you’re only running read commands. Theauth.local_net(default127.0.0.0/8) bypass elevatesGET/HEAD/OPTIONSonly;POST/PUT/PATCH/DELETEfromgwctlwill 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.
Body input
Section titled “Body input”create and update accept the JSON body three ways:
gwctl exceptions create '{"type":"temporary-direct","device_id":"laptop-alice","allow_direct":true,"duration_min":30,"reason":"Zoom"}' # literal arggwctl exceptions create /tmp/exc.json # path (auto-detected)gwctl exceptions create < /tmp/exc.json # stdincat exc.json | gwctl exceptions create # also stdinCommand groups
Section titled “Command groups”Status & health
Section titled “Status & health”| 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 |
Devices
Section titled “Devices”| 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 |
Tunnels and bridges
Section titled “Tunnels and bridges”| 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 |
Policy
Section titled “Policy”| 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.
Privacy
Section titled “Privacy”| 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 |
Mesh and federation
Section titled “Mesh and federation”| 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) |
Monitoring
Section titled “Monitoring”| Command | Effect |
|---|---|
gwctl audit |
Audit log query |
gwctl alerts |
List alerts (active + resolved) |
gwctl anomalies |
Recent anomalies |
Config and lifecycle
Section titled “Config and lifecycle”| 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 |
Secrets and AI
Section titled “Secrets and AI”| 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) |
Test and debug
Section titled “Test and debug”| 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 |
Output formats
Section titled “Output formats”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:
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.
Common workflows
Section titled “Common workflows”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”# 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"Give a device a temporary VPN bypass
Section titled “Give a device a temporary VPN bypass”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.
Push splitting onto a tunnel pool
Section titled “Push splitting onto a tunnel pool”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:
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.
Take a backup before a risky change
Section titled “Take a backup before a risky change”# 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:
gwctl snapshots # list availablegwctl rollback before-vlan-cleanup # apply a named snapshotVerify the leak posture is what you think
Section titled “Verify the leak posture is what you think”gwctl leak-matrix # posture × tunnel-state gridgwctl leak-check # one-shot leak probe across all devicesAudit who changed what
Section titled “Audit who changed what”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:
# all routing-policy mutationscurl -s https://gateway.lan:8080/api/v1/audit \ -H "Authorization: Bearer $API_KEY" \ | jq 'map(select(.resource_type == "routing_policy"))'
# everything one operator didcurl -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:
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 nowsync_resources excludes tunnels, so each peer’s local tunnel config stays its own.
Related
Section titled “Related”- REST API — the same surface, scriptable from anywhere.
- Settings reference — every
kv_settingskey. - Concepts — the vocabulary every command above uses.