Aller au contenu

API Reference

peers mesh domains

Every dashboard action has an HTTP API equivalent.

TunGuard provides an HTTP API for managing the VPN server, its peers, and the P2P mesh.

The API is available under:

http://YOUR_SERVER_IP:9000/api

Endpoint overview⚓

Endpoint Method Auth Purpose
/api/health GET None Liveness check
/api/status GET Either Server and peer status
/api/system GET Either Host metrics
/api/server_key GET Either Server public key and port
/api/configure POST Either Change key or listen port at runtime
/api/peers GET Either List peers
/api/peer/add POST Either Add an existing public key
/api/peer/remove POST Either Remove a peer
/api/peer/config POST Either Re-render a peer's configuration
/api/peer/generate-config POST Either Create a peer with a new key pair
/api/version GET Either Version and update check
/api/update POST Either Replace the binary and restart
/api/backup/download GET Either Download a data directory backup
/api/backup/restore POST Either Restore a backup
/api/peer-monitor/events GET Either Peer event history
/api/peer-monitor/state GET Either Latest peer metrics
/api/ws/ssh GET Either WebSocket SSH session
/api/auth/status GET Dashboard Current user, default-credential warning
/api/web/credentials POST Dashboard Change dashboard password
/api/key GET Dashboard Read the current API key
/api/key/regenerate POST Dashboard Issue a new API key
/api/mesh/* Various Either P2P mesh
/api/trp/* Various Either TCP relay proxies
/api/policy/* Various Either Policy groups
/api/domains GET Either List domain mappings and proxy status
/api/domains/status GET Either Domain proxy serving mode
/api/domain/add POST Either Add a domain mapping
/api/domain/update POST Either Update a domain mapping
/api/domain/remove POST Either Remove a domain mapping
/api/adguard GET Either AdGuard status
/api/adguard/start POST Either Start the managed AdGuard Home
/api/adguard/stop POST Either Stop the managed AdGuard Home
/api/adguard/config GET/POST Either Effective ADGUARD_* configuration
/adguard-ui/ GET Dashboard Reverse proxy to the AdGuard web UI

"Either" means the dashboard login or an API key. "Dashboard" means the login is required and an API key is rejected.

Authentication⚓

All /api/* endpoints require authentication except /api/health.

Most endpoints accept either the dashboard credentials or an API key. A small group of credential and key management endpoints accept only the dashboard login — see Credential and key management.

Endpoint group Accepted credentials
Most /api/*, all /api/mesh/*, all /api/trp/* Dashboard login or API key
/api/auth/status, /api/web/credentials, /api/key, /api/key/regenerate Dashboard login only
/api/health, /api/login, /api/logout None

Dashboard Authentication⚓

The browser dashboard signs in through an HTML form and a session cookie. For scripts, use HTTP Basic Authentication:

curl -u admin:PASSWORD http://localhost:9000/api/status

POST /api/login with a JSON {"username":...,"password":...} body establishes the same session the browser gets and returns the cookie, and POST /api/logout clears it. Neither is needed for the Basic-auth flow above. If the server is still on the shipped admin / tanguard login, signing in reports must_change: true and the dashboard forces you to set your own login before you can use it.

API Key⚓

Generate or rotate the API key from:

Settings → API Key

You can also generate one from the terminal using dashboard authentication:

curl -X POST \
  -u admin:PASSWORD \
  http://localhost:9000/api/key/regenerate

The response contains the new API key.

Store it securely:

API_KEY="REPLACE_WITH_YOUR_KEY"

Use the key with the X-API-Key header:

curl \
  -H "X-API-Key: $API_KEY" \
  http://localhost:9000/api/status

The API key can also be sent using:

Authorization: Bearer YOUR_API_KEY

Why the API key cannot manage itself

An API key is deliberately rejected by /api/key and /api/key/regenerate. A leaked key can therefore be rotated, but only by someone who also has the dashboard password. This is the difference between a key that can be neutralised and one that cannot.

Health⚓

GET /api/health⚓

Returns the health status of the TunGuard server.

This endpoint does not require authentication and exposes no sensitive information.

curl http://localhost:9000/api/health

This endpoint is suitable for uptime checks and load balancers.

Server Status⚓

GET /api/status⚓

Returns information about the TunGuard server and WireGuard status.

curl \
  -H "X-API-Key: $API_KEY" \
  http://localhost:9000/api/status

Server Public Key⚓

GET /api/server_key⚓

Returns the WireGuard server public key.

curl \
  -H "X-API-Key: $API_KEY" \
  http://localhost:9000/api/server_key

System Information⚓

GET /api/system⚓

Returns host metrics: hostname, OS, kernel, CPU model and core count, CPU utilisation, load averages, process counts, uptime, memory, disk usage for the data directory, and per-interface network counters.

curl \
  -H "X-API-Key: $API_KEY" \
  http://localhost:9000/api/system

Response:

{
  "hostname": "vpn-01",
  "os": "Debian GNU/Linux 12 (bookworm)",
  "kernel": "6.1.0-18-amd64",
  "cpu_model": "Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
  "cpu_cores": 4,
  "cpu_percent": 7.4,
  "load": [0.11, 0.08, 0.02],
  "uptime": 841203,
  "processes": { "total": 214, "running": 3 },
  "memory": { "total": 8123456, "used": 3910553, "percent": 48.1 },
  "disk": { "total": 41153856, "used": 12451840, "percent": 30.2 },
  "network": []
}

disk reflects the partition holding DATA_DIR, not the whole filesystem.

Runtime Configuration⚓

POST /api/configure⚓

Applies runtime changes without a restart. Both parameters are optional; send whichever you need.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "private_key": "REPLACE_WITH_64_CHAR_HEX_PRIVATE_KEY",
    "listen_port": 51820
  }' \
  http://localhost:9000/api/configure

Parameters⚓

Parameter Required Description
private_key No New WireGuard server private key. Must be valid hex
listen_port No New WireGuard listen port. Recorded, but a restart is required to apply it

If a new private_key is accepted, it is written to DATA_DIR/server_private.key with mode 0600, and the response includes the resulting server_public_key.

Rotating the server key invalidates every peer config

Peer configurations embed the server public key. After rotating, previously issued
client configurations stop working and must be regenerated with `/api/peer/config`.

Not available in mesh-only mode

With -mesh-only there is no WireGuard interface to reconfigure, so this endpoint must not be used. Use the mesh and configuration file endpoints instead.

List Peers⚓

GET /api/peers⚓

Returns the peers currently configured on the TunGuard server.

curl \
  -H "X-API-Key: $API_KEY" \
  http://localhost:9000/api/peers

Add Peer⚓

POST /api/peer/add⚓

Adds a peer using an existing WireGuard public key.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": "PEER_PUBKEY_HEX",
    "allowed_ip": "10.100.0.2/32",
    "device_name": "my-device"
  }' \
  http://localhost:9000/api/peer/add

Parameters⚓

Parameter Required Description
public_key Yes WireGuard public key of the peer. Must be valid hex
allowed_ip Yes VPN address assigned to the peer, in CIDR form. Must not already be assigned
device_name No Label used to identify the peer in the dashboard

Returns 409 if the public key is already registered, or if allowed_ip is already assigned to another peer.

Generate Peer Configuration⚓

POST /api/peer/generate-config⚓

Creates a new peer and generates its WireGuard client configuration.

TunGuard generates the client's WireGuard key pair. Nothing is required — every parameter has a default:

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "device_name": "my-phone",
    "server_host": "vpn.example.com"
  }' \
  http://localhost:9000/api/peer/generate-config

Parameters⚓

Parameter Required Default Description
device_name No — Label used to identify the peer
device_id No — Identifier stored with the peer record
server_host No Host of the request Public hostname or IP clients use to reach TunGuard
allowed_ip No Next free address VPN address to assign. Must not already be assigned
dns No 1.1.1.1 DNS server written into the generated configuration

Leaving server_host out uses the Host header of the request, so the generated endpoint matches however you reached the server — but set it explicitly if you want a different public hostname in the output.

The response contains the generated client configuration, including the peer's private key. Treat it as a secret.

Get Peer Configuration⚓

POST /api/peer/config⚓

Generates the complete WireGuard configuration for an existing peer.

This is useful for re-downloading or re-rendering a configuration for a peer created using /api/peer/generate-config.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": "PEER_PUBKEY_HEX",
    "server_host": "vpn.example.com"
  }' \
  http://localhost:9000/api/peer/config

Parameters⚓

Parameter Required Description
public_key Yes WireGuard public key of the peer
server_host Yes Public hostname or IP address clients use to reach TunGuard

For peers created using generate-config, the returned configuration can include the client's private key.

Treat generated configurations as sensitive.

Remove Peer⚓

POST /api/peer/remove⚓

Removes a peer from TunGuard.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": "PEER_PUBKEY_HEX"
  }' \
  http://localhost:9000/api/peer/remove

Parameters⚓

Parameter Required Description
public_key Yes WireGuard public key of the peer to remove

Credential and key management⚓

These four endpoints all require the dashboard login. An API key is rejected, so a leaked key can always be rotated by someone who holds the password.

GET /api/auth/status⚓

Returns the authenticated username and whether the server is still on default credentials. Used by the dashboard to decide whether to force a password change.

curl -u admin:PASSWORD http://localhost:9000/api/auth/status
{
  "authenticated": true,
  "username": "admin",
  "must_change": true
}

must_change is true only when no credentials have been stored yet and the configured username and password are still admin / tanguard.

POST /api/web/credentials⚓

Changes the dashboard username and password.

curl -X POST \
  -u admin:tanguard \
  -H "Content-Type: application/json" \
  -d '{
    "username": "alice",
    "password": "a-long-passphrase",
    "confirm_password": "a-long-passphrase"
  }' \
  http://localhost:9000/api/web/credentials

Parameters⚓

Parameter Required Description
username Yes New dashboard username. Whitespace is trimmed; cannot be empty
password Yes New password. Minimum 8 characters
confirm_password Yes Must match password exactly

Changing the password invalidates every existing session and API key, so update your clients at the same time.

GET /api/key⚓

Returns the current API key, if one exists.

curl -u admin:PASSWORD http://localhost:9000/api/key
{
  "exists": true,
  "key": "REPLACE_WITH_YOUR_KEY",
  "created_at": "2026-01-14T09:31:07Z"
}

When no key has been generated yet, exists is false and key is an empty string. created_at is only present when a key exists.

POST /api/key/regenerate⚓

Creates a new API key and invalidates the previous key.

Dashboard authentication is required for this endpoint.

curl -X POST \
  -u admin:PASSWORD \
  http://localhost:9000/api/key/regenerate

The response contains the new key and its creation timestamp. The previous key stops working immediately, so update any applications or automation that relied on it.

Version and updates⚓

GET /api/version⚓

Returns the running version, the latest published release, and update metadata. The version check runs in the background; results are cached.

curl -H "X-API-Key: $API_KEY" http://localhost:9000/api/version
{
  "current_version": "v1.2.0",
  "latest_version": "v1.3.0",
  "update_available": true,
  "download_url": "https://github.com/TunGuard/tanguard-binary/releases/download/v1.3.0/tanguard-linux-amd64",
  "release_notes": "https://api.github.com/repos/TunGuard/tanguard-binary/releases/latest",
  "release_url": "https://github.com/TunGuard/tanguard-binary/releases/tag/v1.3.0",
  "arch": "amd64",
  "last_checked": "2026-01-14T09:00:00Z"
}

Add ?refresh=1 to query GitHub immediately instead of returning the cached result:

curl -H "X-API-Key: $API_KEY" "http://localhost:9000/api/version?refresh=1"

POST /api/update⚓

Downloads a new binary, replaces the running executable, and restarts the process with the same arguments.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"download_url": "https://github.com/TunGuard/tanguard-binary/releases/download/v1.3.0/tanguard-linux-amd64"}' \
  http://localhost:9000/api/update

Parameters⚓

Parameter Required Description
download_url Yes URL of the replacement binary

This endpoint replaces the running binary

/api/update fetches download_url with no checksum or signature check, writes it over the running executable, and re-execs it. Anything able to call this endpoint can install arbitrary code as your server user.

Prefer downloading the release yourself, verifying it, and restarting through your process supervisor. If you do use this endpoint, only ever pass a download_url you obtained directly from the project's own releases, and never from a redirecting shortener or mirror.

The download is capped at 256 MB, and a payload under 100 KB is rejected. A second concurrent call returns 409 while an update is already in progress.

Backup and restore⚓

GET /api/backup/download⚓

Streams a .tar.gz backup of the data directory. The archive contains a manifest.json recording the tool, version, creation time, and data directory path.

curl \
  -H "X-API-Key: $API_KEY" \
  -o tanguard-backup.tar.gz \
  http://localhost:9000/api/backup/download

POST /api/backup/restore⚓

Restores a backup produced by /api/backup/download. Send the archive as a multipart form field named backup.

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -F "backup=@tanguard-backup.tar.gz" \
  http://localhost:9000/api/backup/restore

Back up before you restore

Restore overwrites the current data directory. Take a fresh download first, and do
not restore while peers are connected.

A backup contains peer private keys and the stored API key. Anyone holding the file
can impersonate your peers and read server credentials. Keep it encrypted at rest.

Peer monitoring⚓

These endpoints expose the peer handshakes the dashboard charts. They are registered only when the peer monitor is running, and are absent in -mesh-only mode, where the responses are 404.

GET /api/peer-monitor/events⚓

Returns recorded peer events as a JSON array.

curl -H "X-API-Key: $API_KEY" http://localhost:9000/api/peer-monitor/events
[
  { "t": "2026-01-14T09:31:07Z", "peer": "a1b2c3d4", "event": "handshake", "detail": "" },
  { "t": "2026-01-14T09:34:12Z", "peer": "e5f6a7b8", "event": "deferred", "detail": "handshake timeout" }
]

Query parameters⚓

Parameter Description
since RFC 3339 timestamp. Returns only events strictly after it. Invalid values are ignored
peer Public key prefix. Returns only events for that peer
curl -H "X-API-Key: $API_KEY" \
  "http://localhost:9000/api/peer-monitor/events?peer=a1b2c3d4&since=2026-01-14T09:00:00Z"

This is a polling endpoint, not a stream — it returns the current buffer and closes.

GET /api/peer-monitor/state⚓

Returns a snapshot of the most recent metrics per peer, keyed by shortened public key.

curl -H "X-API-Key: $API_KEY" http://localhost:9000/api/peer-monitor/state
{
  "a1b2c3d4": {
    "handshake": 1768382345,
    "endpoint": "198.51.100.24:4711",
    "txBytes": 184432,
    "rxBytes": 229104
  }
}

handshake is a Unix timestamp of the last completed handshake, or 0 if none has happened yet.

Web SSH⚓

GET /api/ws/ssh⚓

Upgrades the connection to a WebSocket and opens a pseudo-terminal onto the host, which is how the dashboard's SSH Gateway works. In a browser this must be requested as a WebSocket; it cannot be called with a plain curl request.

Credentials are taken from the Host header in the form user:password@host:port, and forwarded to the target host. A failure to connect is reported in-band over the socket rather than as an HTTP status.

Example: Automation⚓

A simple script can use an API key to retrieve peer information:

#!/bin/sh

API_KEY="REPLACE_WITH_YOUR_KEY"
API="http://localhost:9000"

curl \
  -H "X-API-Key: $API_KEY" \
  "$API/api/peers"

Example: Add a Peer⚓

#!/bin/sh

API_KEY="REPLACE_WITH_YOUR_KEY"
API="http://localhost:9000"

curl -X POST \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "public_key": "PEER_PUBKEY_HEX",
    "allowed_ip": "10.100.0.2/32",
    "device_name": "customer-device"
  }' \
  "$API/api/peer/add"

P2P Mesh⚓

The P2P mesh is driven by endpoints under /api/mesh/*. They use the same authentication as the rest of /api/*, and they answer whenever the control plane is running — in a normal server start and under -mesh-only alike.

Mutating calls are POST with a JSON body. A GET on one of those returns 405.

API_KEY="REPLACE_WITH_YOUR_KEY"
H="X-API-Key: $API_KEY"
JSON="Content-Type: application/json"
API="http://localhost:9000"

When the mesh is disabled

With MESH_ENABLED=false the control plane is never started and every endpoint below returns 503 tun control plane is disabled. Under -mesh-only the process refuses to start at all. See P2P Control Plane.

Nodes and Groups⚓

A node is a client allowed onto the mesh. Nodes that share a PSK form a group, and a group auto-meshes: every online member is punched to every other member. A node added without a PSK gets a unique one. Every node/add response returns the generated psk — save it, it is the device's join credential.

# Mesh state: enabled, node/group counts, listen addresses
curl -H "$H" "$API/api/mesh/status"

# List nodes. PSKs are masked; add ?show_psk=1 to reveal them
curl -H "$H" "$API/api/mesh/nodes"
curl -H "$H" "$API/api/mesh/nodes?show_psk=1"

# Devices bucketed by shared PSK, with per-group link counts
curl -H "$H" "$API/api/mesh/groups"

# Every direct and relayed link between nodes
curl -H "$H" "$API/api/mesh/links"

GET /api/mesh/nodes returns one object per node:

Field Description
id The node handle every other call needs
name Label you chose at enrollment
psk The group key (masked unless show_psk=1)
device_id The client's own stable identity, reported on connect
online Whether the control channel is currently up
control_ip Address the control channel came from
relay_ep Public UDP endpoint registered at the hub
relay_seen When that endpoint was last refreshed
peers Number of peers this node holds
direct_peers How many of those are direct
tested_peers How many of those answered a link test
connected_for How long the node has been connected

A node with a relay_ep but no direct_peers is the one to investigate: discovery worked and the hole punch did not.

GET /api/mesh/links returns one object per link:

Field Description
from / to Node ids of the two ends
online Whether the far node is connected
endpoint Public endpoint the punch is aimed at
direct A punch landed and the peer answered
tested A link test completed on this link
rtt_ms Round trip the client measured, in milliseconds
direct_since When the link first went direct

direct and tested answer different questions. direct is the punch: one packet arrived, once. tested is the client's own round trip to the peer — a small packet it sent that the peer echoed back — so direct with tested false is a link that punched and then went quiet, which is what the dashboard shows as no answer.

# Add a node. Omit "psk" to have one generated.
# A "name" is only a label - it is NOT a unique key, and duplicates are allowed.
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/node/add" \
  -d '{"name":"edge-a"}'

# Add a node into an existing group
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/node/add" \
  -d '{"name":"edge-b","psk":"shared-group-key"}'

# Remove a node entirely, or just reset it (clears its link state, keeps the record)
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/node/remove" \
  -d '{"id":"NODE_ID"}'
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/node/reset" \
  -d '{"id":"NODE_ID"}'

Removing a node leaves its proxies behind

node/remove does not clean up the node's TCP relays. Remove those with /api/trp/proxy/remove first, or they keep holding their listeners.

Direct Paths⚓

Nodes in the same group are punched to each other automatically, so these calls are for forcing a re-punch, forcing a group back onto the relay, or taking nodes off the mesh.

p2p/connect needs both nodes online — an offline or unknown id returns 400 node offline. Two nodes whose devices sit in different policy groups cannot be punched together and the call is refused with 400 policy groups are isolated: a direct path never passes through the server, so it is enforced at the point the link is created. Put both devices in one group with allow_inter_device on to link them.

# Force an immediate direct path between two nodes
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/p2p/connect" \
  -d '{"a":"NODE_ID_A","b":"NODE_ID_B"}'

# Re-punch a whole group. "group" is the PSK.
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/p2p/mesh" \
  -d '{"group":"shared-group-key"}'

# Clear a group's peer table and punch targets
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/p2p/mesh" \
  -d '{"group":"shared-group-key","enable":false}'

"enable": false clears the server-side peer table and tells every member to drop its punch targets. It is not a persistent "relay only" mode: each client re-learns the same peers from the next rendezvous reply within a few seconds and resumes punching.

Rendezvous Hub⚓

Attach a node to the relay hub, or detach it. Both need the node connected; an offline or unknown id returns 400 node offline.

curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/relay/join" \
  -d '{"id":"NODE_ID"}'
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/relay/leave" \
  -d '{"id":"NODE_ID"}'

Leaving drops only that node's P2P work. A full reset would also tear down its TCP relay sessions, which is not what leaving the mesh should do.

To manage the relay circuits by hand — pin a relayed path, or drop every link leaving a node:

curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/relay/link" \
  -d '{"src":"NODE_ID_A","dst":"NODE_ID_B"}'
curl -X POST -H "$H" -H "$JSON" "$API/api/mesh/relay/unlink" \
  -d '{"src":"NODE_ID_A"}'

These are for overriding the automatic behaviour. Left alone, the server keeps a relay circuit for every pair it attempts to punch, so a link that cannot go direct still carries traffic.

relay/unlink removes the fallback

Dropping a node's relay circuits leaves it with no path for any link that fails to go direct. Use it to isolate a node while debugging, not to pin traffic onto the relay — direct and relayed paths are maintained together, and there is no switch between them.

TCP Relay (TRP)⚓

A TRP proxy listens on the server and forwards each connection to target_ip:target_port on the target node over its mesh path.

Leave bind_port as an empty string to let the OS pick a free port — the chosen port comes back as bind_port and in public_url. "0" is rejected; only an empty string means auto-assign. target_port is always required and always numeric.

# List proxies, with live connection counts
curl -H "$H" "$API/api/trp/proxies"

# Auto-assigned listen port, forwarding to port 8022 on the node
curl -X POST -H "$H" -H "$JSON" "$API/api/trp/proxy/add" \
  -d '{"node_id":"NODE_ID","bind_port":"","target_port":"8022"}'

# Fixed listen port and an explicit target address
curl -X POST -H "$H" -H "$JSON" "$API/api/trp/proxy/add" \
  -d '{"node_id":"NODE_ID","bind_ip":"0.0.0.0","bind_port":"18022","target_ip":"127.0.0.1","target_port":"8022"}'

# Remove a proxy
curl -X POST -H "$H" -H "$JSON" "$API/api/trp/proxy/remove" \
  -d '{"id":"PROXY_ID"}'

Mesh Errors⚓

Failures are JSON with an error field, so scripts can branch on them:

Status Meaning
400 Bad request — id required, node_id required, unknown group, node offline, policy groups are isolated, port out of range, or malformed JSON
401 Missing or invalid dashboard login / API key
404 No such node, proxy, or binding
405 GET used on a POST-only route
503 Mesh control plane not running — set MESH_ENABLED=true (the default)

Mesh End to End⚓

#!/usr/bin/env bash
set -euo pipefail
API_KEY="REPLACE_WITH_YOUR_KEY"
API="http://localhost:9000"
field() { python3 -c "import json,sys;print(json.load(sys.stdin)['$1'])"; }

# Enroll a device and keep the id and PSK it returns
ADDED="$(curl -sS -X POST -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" "$API/api/mesh/node/add" \
  -d '{"name":"edge-a"}')"
NODE_ID="$(printf '%s' "$ADDED" | field id)"
PSK="$(printf '%s' "$ADDED" | field psk)"
echo "run on the device:  tun YOUR_SERVER_IP $PSK"

# Expose a service running on that node
PROXY="$(curl -sS -X POST -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" "$API/api/trp/proxy/add" \
  -d "{\"node_id\":\"$NODE_ID\",\"bind_port\":\"\",\"target_port\":\"8022\"}")"
echo "reachable at $(printf '%s' "$PROXY" | field public_url)"

# Tear down
curl -sS -X POST -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" "$API/api/trp/proxy/remove" \
  -d "{\"id\":\"$(printf '%s' "$PROXY" | field id)\"}"
curl -sS -X POST -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" "$API/api/mesh/node/remove" \
  -d "{\"id\":\"$NODE_ID\"}"

Policy Groups API⚓

A policy group is a set of devices with its own rules. Every device the server knows about is in exactly one group, and a device in no custom group is in default. The group decides what a device may reach:

Rule Allows
allow_inter_device Traffic to the other devices of the same group
allow_p2p_mesh P2P mesh participation
allow_trp TCP relay proxies onto the group
allow_wg_access Internet access out through the tunnel
API="http://localhost:9000"
H="X-API-Key: $API_KEY"
JSON="Content-Type: application/json"

# Every group with its members, every known device, and the packet-filter
# drop counters for inter-device and internet traffic
curl -H "$H" "$API/api/policy/groups"

# Create a group. Every omitted rule is OFF, so a new group denies by default
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/create" \
  -d '{"name":"office","allow_inter_device":true,"allow_wg_access":true}'

# Change a group. Omitted rules and an empty name keep their current value,
# so this turns on inter-device traffic without touching the other rules
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/update" \
  -d '{"id":"GROUP_ID","allow_inter_device":true}'

# Delete a group. Its devices fall back to the default group
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/delete" \
  -d '{"id":"GROUP_ID"}'

# Move WireGuard peers into a group, by public key
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/assign" \
  -d '{"id":"GROUP_ID","devices":["PEER_PUBKEY_HEX"]}'

# Move tun-client devices into a group, by device id
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/assign-device" \
  -d '{"id":"GROUP_ID","devices":["DEVICE_ID"]}'

# Send devices back to the default group
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/unassign" \
  -d '{"devices":["PEER_PUBKEY_HEX"]}'
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/group/unassign-device" \
  -d '{"devices":["DEVICE_ID"]}'

# Replace the whole policy in one call: groups, peer membership, and device
# membership. Rejected outright if any name names a device the server has
# never seen
curl -X POST -H "$H" -H "$JSON" "$API/api/policy/apply" \
  -d '{"groups":[{"id":"office","name":"Office","allow_inter_device":true,"allow_wg_access":true}],
       "assign":{"PEER_PUBKEY_HEX":"office"},
       "device_assign":{"DEVICE_ID":"office"}}'

A device is named by public key when it is a WireGuard peer, and by device id when it is a tun-client device with no peer of its own. Sending the wrong identity returns 400 unknown device. group/unassign and group/unassign-device do not need an id — the group id in their response is always default.

Groups are sealed from each other

allow_inter_device opens traffic to the other devices of that group only. It never grants reach into or out of another group, and it does not depend on the other group's own setting: one open side is enough, but only within one group. Before 2.4.2, an open group could reach into a closed one; that leak is fixed, so cross-group traffic now needs the devices to be in the same group — and /api/mesh/p2p/connect refuses such a pair with 400 policy groups are isolated.

Domains API⚓

These endpoints manage the HTTPS domain proxy — a public domain in front of a TRP mapping or a tunneled service, with automatic Let's Encrypt certificates. They use the same credentials as the rest of /api/*, and return 409 domain reverse proxy is disabled when DOMAIN_ENABLED=false.

API="http://localhost:9000"
H="X-API-Key: $API_KEY"
JSON="Content-Type: application/json"

# Every mapping, plus the proxy's serving status
curl -H "$H" "$API/api/domains"

# Proxy status alone: mode, webserver, ports
curl -H "$H" "$API/api/domains/status"

GET /api/domains⚓

Returns { domains: [...], status: {...} }. Each domain includes its id, domain name, backend, target, enabled flag, and timestamps.

GET /api/domains/status⚓

Returns the serving mode and the addresses in use:

Field Description
enabled Whether the proxy is enabled
mode builtin, external, idle, or blocked
webserver Detected webserver (nginx, apache, caddy, traefik)
owner_80 / owner_443 Process holding ports 80/443, if any
http_port / https_port Public HTTP/HTTPS ports
challenge_port Loopback ACME challenge port
count Number of configured mappings

POST /api/domain/add⚓

Create a mapping. Pass either a trp backend with proxy_ref, or a wg backend with target_ip and target_port:

# Behind an existing TRP mapping
curl -X POST -H "$H" -H "$JSON" "$API/api/domain/add" \
  -d '{"domain":"app.example.com","backend":"trp","proxy_ref":"3"}'

# Behind a tunneled WireGuard service
curl -X POST -H "$H" -H "$JSON" "$API/api/domain/add" \
  -d '{"domain":"api.example.com","backend":"wg","target_ip":"10.100.0.2","target_port":8080}'

enabled defaults to true. The response includes the generated domain id.

POST /api/domain/update⚓

Update a mapping by id. Send only the fields you want to change. enabled: false pauses the mapping without deleting it.

POST /api/domain/remove⚓

Remove a mapping by id. Returns 404 if it does not exist.

AdGuard API⚓

These endpoints manage the embedded AdGuard Home instance used for DNS filtering. They use the same credentials as the rest of /api/*.

# Status: enabled, running, and the web UI address
curl -H "$H" "$API/api/adguard"

The response is:

{
  "enabled": true,
  "running": true,
  "web": "http://127.0.0.1:1116"
}

POST /api/adguard/start / POST /api/adguard/stop⚓

Start or stop the managed AdGuard Home. Both rebuild the tunnel DNS redirect rules: starting AdGuard enables redirecting AdGuard-group peers to it, stopping it clears every redirect. Returns 409 when AdGuard is disabled.

GET/POST /api/adguard/config⚓

Reads the effective configuration — enabled, dns, web, upstream, family, adult — mirroring the ADGUARD_* environment variables.

/adguard-ui/ (dashboard login only)⚓

A reverse proxy to the AdGuard web UI on its loopback port. Requires the dashboard login (an API key is rejected) and returns 503 while AdGuard is not running:

http://YOUR_SERVER_IP:9000/adguard-ui/

API Security⚓

API keys provide access to protected TunGuard API endpoints. Keep them private and do not commit them to source control.

If a key is exposed, rotate it immediately from:

Settings → API Key

or:

curl -X POST \
  -u admin:PASSWORD \
  http://localhost:9000/api/key/regenerate

For production deployments, protect the dashboard and API with HTTPS when they are exposed over an untrusted network.

An API key is a broad credential: it can add and remove peers, read and restore backups, and replace the server binary via /api/update. Scope it like a password, and prefer issuing short-lived credentials from a provisioning job over a long-lived shared key.

Two endpoints deserve particular attention:

  • /api/update fetches an arbitrary URL and overwrites the running executable. Only point it at releases you obtained directly from the project's own release page.
  • /api/ws/ssh opens an interactive shell to the host. Anyone who can reach the API can reach the machine's SSH credentials, so keep the API off untrusted networks.

Both are reasons to terminate TLS in front of the API and to keep the default dashboard credentials from surviving first boot.