WireGuard / Secure Gateway¶
WireGuard via Headscale: users, nodes, auth keys, ACL rules, routes.
All endpoints below are mounted under /api. Authentication: Authorization: Bearer <token> unless noted as public.
Endpoint count: 34
Endpoints¶
| Method | Path | Handler | Guards |
|---|---|---|---|
GET |
/api/wireguard-vpn/acl |
hsGetACL |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/acl |
hsSaveACL |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/advertise-routes |
hsAdvertiseRoutes |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/apikeys |
hsListAPIKeys |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/apikeys |
hsCreateAPIKey |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/install |
hsInstall |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/ipsec/ |
ipsecListTunnels |
- |
POST |
/api/wireguard-vpn/ipsec/ |
ipsecCreateTunnel |
- |
DELETE |
/api/wireguard-vpn/ipsec/:id |
ipsecDeleteTunnel |
- |
PUT |
/api/wireguard-vpn/ipsec/:id |
ipsecUpdateTunnel |
- |
POST |
/api/wireguard-vpn/ipsec/:id/restart |
ipsecRestartTunnel |
- |
POST |
/api/wireguard-vpn/ipsec/:id/toggle |
ipsecToggleTunnel |
- |
GET |
/api/wireguard-vpn/ipsec/generate-secret |
ipsecGenSecret |
- |
POST |
/api/wireguard-vpn/ipsec/install |
ipsecInstall |
- |
GET |
/api/wireguard-vpn/ipsec/local-ip |
ipsecLocalIP |
- |
GET |
/api/wireguard-vpn/ipsec/status |
ipsecStatus |
- |
GET |
/api/wireguard-vpn/nodes |
hsListNodes |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
DELETE |
/api/wireguard-vpn/nodes/:id |
hsDeleteNode |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/nodes/:id/expire |
hsExpireNode |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
PUT |
/api/wireguard-vpn/nodes/:id/ip |
hsEditNodeIP |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/preauthkeys |
hsListPreAuthKeys |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/preauthkeys |
hsCreatePreAuthKey |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/preauthkeys/expire |
hsExpirePreAuthKey |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/routes |
hsListRoutes |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/routes/:id/disable |
hsDisableRoute |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/routes/:id/enable |
hsEnableRoute |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/service/:action |
hsServiceAction |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/settings |
hsGetSettings |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/settings |
hsSaveSettings |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/settings/subnet |
hsSaveSubnet |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/status |
hsStatus |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
GET |
/api/wireguard-vpn/users |
hsListUsers |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
POST |
/api/wireguard-vpn/users |
hsCreateUser |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
DELETE |
/api/wireguard-vpn/users/:name |
hsDeleteUser |
RequireSuperuser(), RequirePlugin(db, "wireguard-vpn") |
Source¶
Handlers for this section live in api/routes/handlers_*.go in the StarkOne source. Each handler binds its request body with gin.ShouldBindJSON(&req) and responds with the standard envelope:
On error:
Reference¶
GET /api/wireguard-vpn/acl¶
Returns the Headscale ACL policy (groups, ACL rules, auto-approvers) parsed from /etc/headscale/acl.hujson; when the file is absent it returns a default allow-all policy.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
{
"data": {
"groups": { "group:admins": ["user1"] },
"acls": [{ "action": "accept", "src": ["*"], "dst": ["*:*"] }],
"autoApprovers": { "routes": {}, "exitNode": [] }
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
parse_error |
The ACL HuJSON file could not be parsed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/acl \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/acl¶
Saves the Headscale ACL policy: sanitizes group members against existing users, injects auto-approvers for the exit node and enabled IPsec subnets, ensures an autogroup:internet rule exists, writes acl.hujson, updates config.yaml, and restarts headscale.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
{
"groups": { "group:admins": ["user1"] },
"acls": [{ "action": "accept", "src": ["*"], "dst": ["*:*"] }]
}
| Field | Type | Required | Description |
|---|---|---|---|
groups |
object (map of string to string array) | no | Named groups and their members. Defaults to empty map. |
acls |
array of objects | no | ACL rules; each has action (string), src (string array), dst (string array). Defaults to empty array. |
autoApprovers |
object | no | Optional; overwritten by the handler before saving. |
When both acls and groups are empty, the ACL file is removed and the policy stanza is stripped from config.yaml (allow-all).
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Request body could not be bound to the ACL policy. |
500 |
marshal_error |
The policy could not be serialized to JSON. |
500 |
write_error |
The ACL file could not be written. |
Side effects
- Writes/removes
/etc/headscale/acl.hujson(mode0600). - Edits
/etc/headscale/config.yamlto add or remove the policy path/mode. - Reads enabled IPsec tunnel subnets from the
ipsec_tunnelstable. - Runs
systemctl restart headscale.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/acl \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"groups":{"group:admins":["user1"]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}'
POST /api/wireguard-vpn/advertise-routes¶
Advertises subnet routes and/or exit-node capability from the local Tailscale node, auto-enables any newly-discovered routes in Headscale, and refreshes CSF firewall rules and IPsec routing.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
| Field | Type | Required | Description |
|---|---|---|---|
routes |
array of string | no | CIDR subnets to advertise via tailscale set --advertise-routes. |
exit_node |
boolean | no | When true, advertises this node as an exit node. |
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Request body could not be parsed. |
500 |
tailscale_error |
The tailscale set command failed; message contains its output. |
Side effects
- Runs
tailscale set [--advertise-exit-node] [--advertise-routes=...]. - Runs
headscale routes list -o jsonandheadscale routes enable -r <id>for disabled routes. - If CSF is present, rewrites CSF pre/post rules and runs
csf -r. - Calls IPsec routing setup using the
ipsec_tunnelstable.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/advertise-routes \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"routes":["203.0.113.0/24"],"exit_node":true}'
GET /api/wireguard-vpn/apikeys¶
Lists the Headscale API keys via the local Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
The data array contains the apiKeys array returned by the Headscale /api/v1/apikey endpoint (empty array when none).
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
config_error |
VPN not configured (no stored API key). |
500 |
api_error |
The Headscale API request failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/apikeys \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/apikeys¶
Creates a new Headscale API key by invoking the headscale apikeys create CLI and returns the raw key value.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
api_error |
The headscale apikeys create command failed. |
Side effects
- Runs
headscale apikeys create.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/apikeys \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/install¶
Installs and starts Headscale end-to-end: downloads the binary, installs the Tailscale client, writes config and a systemd unit, creates a wireguard.localhost reverse-proxy webapp, starts the service, creates the default admin user, generates an API key, and connects the local Tailscale client.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
download_error |
Downloading the headscale binary failed. |
500 |
install_error |
Writing the headscale binary failed. |
500 |
config_error |
Writing config.yaml failed. |
500 |
service_error |
Writing the systemd unit failed. |
500 |
start_error |
Starting the headscale service failed. |
500 |
apikey_error |
Generating the API key failed. |
Side effects
- Downloads headscale via
curlto/tmp/headscaleand installs to/usr/local/bin/headscale. - Installs the Tailscale client (
tailscale.com/install.sh) and setsnet.ipv4.ip_forward=1when absent. - Writes
/etc/systemd/system/tailscaled.service.d/after-headscale.conf. - Creates
/etc/headscale,/var/lib/headscale,/var/run/headscale; writesconfig.yaml. - Writes
/etc/systemd/system/headscale.service. - Creates the
wireguard.localhostwebapp and updates thewebappstable; runsrewrite_user_confs. - Runs
systemctl daemon-reload,enable, andstart headscale. - Runs
headscale users create admin,headscale apikeys create,headscale preauthkeys create. - Updates the
vpn_configtable (api_key,installed = 1). - Runs
tailscale upto join the local control server.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/install \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/ipsec/¶
Lists all IPsec tunnels from the ipsec_tunnels table, merging live tunnel status parsed from strongSwan.
Response - 200 OK
{
"data": [
{
"id": 1,
"name": "site-a",
"local_ip": "203.0.113.10",
"remote_ip": "203.0.113.20",
"shared_secret": "...",
"remote_id": "",
"local_subnet": "0.0.0.0/0",
"remote_subnet": "0.0.0.0/0",
"ike_version": 2,
"ike_lifetime": "24h",
"tunnel_lifetime": "12h",
"dpd_delay": 10,
"dpd_timeout": 30,
"phase1_proposals": "[...]",
"phase2_proposals": "[...]",
"dh_group": "14,5",
"replay_detection": 1,
"pfs_enabled": 1,
"key_lifetime": 43200,
"enabled": 1,
"status": "up",
"created_at": "2026-01-01T00:00:00Z"
}
]
}
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
db_error |
The tunnel query failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/ipsec/ \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/ipsec/¶
Creates an IPsec tunnel: applies defaults for any omitted fields, inserts a row into ipsec_tunnels, writes strongSwan configs, reloads IPsec, whitelists the remote IP in CSF, and sets up routing.
Request body
{
"name": "site-a",
"local_ip": "203.0.113.10",
"remote_ip": "203.0.113.20",
"shared_secret": "",
"remote_id": "",
"local_subnet": "0.0.0.0/0",
"remote_subnet": "0.0.0.0/0",
"ike_version": 2,
"ike_lifetime": "24h",
"tunnel_lifetime": "12h",
"dpd_delay": 10,
"dpd_timeout": 30,
"phase1_proposals": [{ "enc": "aes256", "auth": "sha256" }],
"phase2_proposals": [{ "enc": "aes256gcm" }],
"dh_group": "14,5",
"replay_detection": 1,
"pfs_enabled": 1,
"key_lifetime": 43200
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Tunnel name. |
remote_ip |
string | yes | Remote peer IP. |
local_ip |
string | no | Local IP; auto-detected when empty. |
shared_secret |
string | no | PSK; auto-generated when empty. |
remote_id |
string | no | Remote IKE identity. |
local_subnet |
string | no | Local traffic selector; defaults to 0.0.0.0/0. |
remote_subnet |
string | no | Remote traffic selector; defaults to 0.0.0.0/0. |
ike_version |
integer | no | IKE version; defaults to 1. |
ike_lifetime |
string | no | Defaults to 24h. |
tunnel_lifetime |
string | no | Defaults to 12h. |
dpd_delay |
integer | no | Defaults to 10. |
dpd_timeout |
integer | no | Defaults to 30. |
phase1_proposals |
JSON array | no | Phase 1 proposals; default set applied when empty/null. |
phase2_proposals |
JSON array | no | Phase 2 proposals; default set applied when empty/null. |
dh_group |
string | no | Diffie-Hellman groups; defaults to 14,5. |
replay_detection |
integer (nullable) | no | Defaults to 1. |
pfs_enabled |
integer (nullable) | no | Defaults to 1. |
key_lifetime |
integer | no | Seconds; defaults to 43200. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Invalid body, or name/remote_ip missing. |
500 |
secret_error |
Failed to generate a shared secret. |
500 |
db_error |
The insert failed. |
500 |
config_error |
Tunnel was created but config write failed. |
Side effects
- Inserts a row into
ipsec_tunnels. - Writes strongSwan config files and reloads IPsec.
- Runs
csf -a <remote_ip>to whitelist the peer. - Sets up routing, firewall rules, and the XFRM interface.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/ipsec/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"site-a","remote_ip":"203.0.113.20"}'
DELETE /api/wireguard-vpn/ipsec/:id¶
Deletes the IPsec tunnel row, rewrites strongSwan configs, reloads IPsec, and re-applies routing.
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Tunnel row ID in ipsec_tunnels. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
db_error |
The delete failed. |
Side effects
- Deletes the row from
ipsec_tunnels. - Rewrites strongSwan configs, reloads IPsec, and re-runs routing setup.
Example
curl -X DELETE https://panel.example.com:2087/api/wireguard-vpn/ipsec/1 \
-H "Authorization: Bearer $TOKEN"
PUT /api/wireguard-vpn/ipsec/:id¶
Updates an existing IPsec tunnel row, rewrites strongSwan configs, reloads IPsec, and re-applies routing.
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Tunnel row ID in ipsec_tunnels. |
Request body
Same shape as the create endpoint (see POST /api/wireguard-vpn/ipsec/). name and remote_ip are required; phase1_proposals/phase2_proposals fall back to defaults when empty/null; replay_detection/pfs_enabled default to 1 when omitted.
{
"name": "site-a",
"remote_ip": "203.0.113.20",
"local_subnet": "0.0.0.0/0",
"remote_subnet": "0.0.0.0/0"
}
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Invalid body, or name/remote_ip missing. |
500 |
db_error |
The update failed. |
500 |
config_error |
Updated but config write failed. |
Side effects
- Updates the row in
ipsec_tunnels. - Rewrites strongSwan configs, reloads IPsec, and re-runs routing setup.
Example
curl -X PUT https://panel.example.com:2087/api/wireguard-vpn/ipsec/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"site-a","remote_ip":"203.0.113.20"}'
POST /api/wireguard-vpn/ipsec/:id/restart¶
Restarts a single IPsec tunnel by tearing down and re-initiating it via swanctl or the legacy ipsec CLI.
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Tunnel row ID; used to look up the tunnel name. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
404 |
not_found |
No tunnel with that ID. |
Side effects
- Runs
swanctl --terminate/--initiate(oripsec down/up) for the tunnel.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/ipsec/1/restart \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/ipsec/:id/toggle¶
Toggles a tunnel's enabled flag, rewrites configs, reloads IPsec, and re-applies routing.
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Tunnel row ID in ipsec_tunnels. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
404 |
not_found |
No tunnel with that ID. |
Side effects
- Updates the
enabledcolumn inipsec_tunnels. - Rewrites strongSwan configs, reloads IPsec, and re-runs routing setup.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/ipsec/1/toggle \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/ipsec/generate-secret¶
Generates a random IPsec pre-shared key.
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
secret_error |
Secret generation failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/ipsec/generate-secret \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/ipsec/install¶
Installs strongSwan using the detected package manager (apt/dnf/yum), enables and starts the service, and opens the required firewall ports.
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
unsupported_os |
No supported package manager (apt/dnf/yum) found. |
500 |
install_error |
The package install command failed. |
Side effects
- Runs the distro package install for strongSwan.
- Runs
systemctl enable/startforipsec,strongswan-starter, andstrongswan. - Opens IPsec firewall ports.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/ipsec/install \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/ipsec/local-ip¶
Returns the auto-detected local IP address to pre-fill the tunnel form.
Response - 200 OK
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/ipsec/local-ip \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/ipsec/status¶
Reports whether strongSwan is installed, whether it is running (systemd units or the charon process), and whether the swanctl interface is available.
Response - 200 OK
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/ipsec/status \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/nodes¶
Lists registered Headscale nodes via the local Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
The data array contains the nodes array returned by Headscale /api/v1/node (empty array when none).
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
config_error |
VPN not configured (no stored API key). |
500 |
api_error |
The Headscale API request failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/nodes \
-H "Authorization: Bearer $TOKEN"
DELETE /api/wireguard-vpn/nodes/:id¶
Deletes a node via the Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Headscale node ID. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
api_error |
The Headscale delete request failed. |
Side effects
- Calls Headscale
DELETE /api/v1/node/:id.
Example
curl -X DELETE https://panel.example.com:2087/api/wireguard-vpn/nodes/1 \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/nodes/:id/expire¶
Expires a node's authentication via the Headscale API, forcing it to re-authenticate.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Headscale node ID. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
api_error |
The Headscale expire request failed. |
Side effects
- Calls Headscale
POST /api/v1/node/:id/expire.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/nodes/1/expire \
-H "Authorization: Bearer $TOKEN"
PUT /api/wireguard-vpn/nodes/:id/ip¶
Changes a node's IPv4 address by updating the Headscale SQLite database directly and restarting headscale.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Headscale node ID. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
ipv4 |
string | yes | New IPv4 address for the node. |
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body invalid or ipv4 missing. |
500 |
db_error |
The sqlite3 update command failed. |
Side effects
- Runs
sqlite3against/var/lib/headscale/db.sqliteto updatenodes.ipv4. - Runs
systemctl restart headscale.
Example
curl -X PUT https://panel.example.com:2087/api/wireguard-vpn/nodes/1/ip \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ipv4":"203.0.113.50"}'
GET /api/wireguard-vpn/preauthkeys¶
Lists pre-auth keys via the Headscale API. When a user is given, returns that user's keys; otherwise it iterates all users and aggregates their keys.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Query parameters
| Name | Type | Optional | Description |
|---|---|---|---|
user |
string | yes | Filter to a single user. When omitted, keys from all users are aggregated. |
Response - 200 OK
The data array contains the preAuthKeys entries from Headscale (empty array when none).
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
config_error |
VPN not configured (no stored API key). |
500 |
api_error |
A Headscale API request failed. |
Example
curl -X GET "https://panel.example.com:2087/api/wireguard-vpn/preauthkeys?user=user1" \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/preauthkeys¶
Creates a pre-auth key for a user, computing an absolute expiration time from a duration keyword and forwarding the request to Headscale.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user |
string | yes | User the key belongs to. |
reusable |
boolean | no | Whether the key can be used more than once. |
ephemeral |
boolean | no | Whether nodes registered with it are ephemeral. |
expiry |
string | no | Duration: 1h, 24h, 7d, 30d, 90d, any Go duration, or ""/never for a far-future expiry. |
Response - 200 OK
This handler returns a flat (non-enveloped) body with the Headscale result under data:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body invalid or user missing. |
500 |
api_error |
The Headscale create request failed. |
Side effects
- Calls Headscale
POST /api/v1/preauthkey.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/preauthkeys \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user":"user1","reusable":true,"ephemeral":false,"expiry":"24h"}'
POST /api/wireguard-vpn/preauthkeys/expire¶
Expires a specific pre-auth key via the Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user |
string | no | User the key belongs to. |
key |
string | no | The pre-auth key value to expire. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body could not be parsed. |
500 |
api_error |
The Headscale expire request failed. |
Side effects
- Calls Headscale
POST /api/v1/preauthkey/expire.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/preauthkeys/expire \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user":"user1","key":"the-preauth-key"}'
GET /api/wireguard-vpn/routes¶
Lists advertised subnet routes via the Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
The data array contains the routes array from Headscale /api/v1/routes (empty array when none).
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
config_error |
VPN not configured (no stored API key). |
500 |
api_error |
The Headscale API request failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/routes \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/routes/:id/disable¶
Disables a subnet route via the Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Headscale route ID. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
api_error |
The Headscale disable request failed. |
Side effects
- Calls Headscale
POST /api/v1/routes/:id/disable.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/routes/1/disable \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/routes/:id/enable¶
Enables a subnet route via the Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
id |
integer | Headscale route ID. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
api_error |
The Headscale enable request failed. |
Side effects
- Calls Headscale
POST /api/v1/routes/:id/enable.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/routes/1/enable \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/service/:action¶
Controls the headscale systemd service. Only start, stop, and restart are accepted.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
action |
string | One of start, stop, restart. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Action is not one of start/stop/restart. |
500 |
service_error |
The systemctl command failed. |
Side effects
- Runs
systemctl <action> headscale.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/service/restart \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/settings¶
Returns Headscale settings parsed from config.yaml: server URL, IPv4/IPv6 subnets, and DNS configuration (global nameservers, split/restricted nameservers, MagicDNS, base domain, override-local-DNS).
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
{
"data": {
"server_url": "https://vpn.example.com",
"v4_subnet": "100.64.0.0/10",
"v6_subnet": "fd7a:115c:a1e0::/48",
"dns_global": ["1.1.1.1", "8.8.8.8"],
"dns_split": [{ "domain": "internal.example.com", "servers": ["203.0.113.2"] }],
"dns_magic": true,
"dns_base_domain": "vpn.local",
"dns_override_local": true
}
}
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/settings \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/settings¶
Updates the server URL and (optionally) rebuilds the DNS block in config.yaml, then restarts headscale and refreshes network/firewall state.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
{
"server_url": "https://vpn.example.com",
"dns_global": ["1.1.1.1", "8.8.8.8"],
"dns_split": [{ "domain": "internal.example.com", "servers": ["203.0.113.2"] }],
"dns_magic": true,
"dns_base_domain": "vpn.local",
"dns_override_local": true
}
| Field | Type | Required | Description |
|---|---|---|---|
server_url |
string | yes | Headscale server URL. |
dns_global |
array of string | no | Global nameservers. When non-null, the whole DNS block is rebuilt. |
dns_split |
array of objects | no | Split/restricted nameservers; each has domain and servers. |
dns_magic |
boolean (nullable) | no | Enable MagicDNS; defaults to true when omitted. |
dns_base_domain |
string | no | Base domain; defaults to vpn.local. |
dns_override_local |
boolean (nullable) | no | Override local DNS; defaults to true when omitted. |
Response - 200 OK
The checks value is the result of the network-readiness check.
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body invalid or server_url missing. |
500 |
read_error |
config.yaml could not be read. |
Side effects
- Rewrites
/etc/headscale/config.yaml(mode0600). - Runs
systemctl restart headscale. - Runs network-readiness checks and rewrites CSF pre rules.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/settings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"server_url":"https://vpn.example.com","dns_global":["1.1.1.1"]}'
POST /api/wireguard-vpn/settings/subnet¶
Changes the IPv4 prefix (v4) in config.yaml and restarts headscale.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
| Field | Type | Required | Description |
|---|---|---|---|
v4_subnet |
string | yes | New IPv4 CIDR prefix. |
Response - 200 OK
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body invalid or v4_subnet missing. |
500 |
read_error |
config.yaml could not be read. |
Side effects
- Rewrites the
v4:line in/etc/headscale/config.yaml(mode0600). - Runs
systemctl restart headscale.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/settings/subnet \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"v4_subnet":"100.64.0.0/10"}'
GET /api/wireguard-vpn/status¶
Reports Headscale install/run state, version, and node/user counts (counts are fetched from the Headscale API only when running).
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
This handler returns a flat (non-enveloped) body:
{
"installed": true,
"running": true,
"version": "headscale 0.25.1",
"node_count": 3,
"user_count": 2
}
When vpn_config has no row, it returns { "installed": false, "running": false }.
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/status \
-H "Authorization: Bearer $TOKEN"
GET /api/wireguard-vpn/users¶
Lists Headscale users via the local Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Response - 200 OK
The data array contains the users array from Headscale /api/v1/user (empty array when none).
Errors
| Status | Code | Meaning |
|---|---|---|
500 |
config_error |
VPN not configured (no stored API key). |
500 |
api_error |
The Headscale API request failed. |
Example
curl -X GET https://panel.example.com:2087/api/wireguard-vpn/users \
-H "Authorization: Bearer $TOKEN"
POST /api/wireguard-vpn/users¶
Creates a Headscale user via the local Headscale API.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | User name. Empty is rejected. |
Response - 200 OK
This handler returns a flat (non-enveloped) body with the Headscale result under data:
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
bad_request |
Body invalid or name missing. |
500 |
api_error |
The Headscale create request failed. |
Side effects
- Calls Headscale
POST /api/v1/user.
Example
curl -X POST https://panel.example.com:2087/api/wireguard-vpn/users \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"user1"}'
DELETE /api/wireguard-vpn/users/:name¶
Deletes a Headscale user (looked up by name to find the numeric ID) and removes that user from any ACL groups, restarting headscale if the ACL changed.
Guards: RequireSuperuser(), RequirePlugin(db, "wireguard-vpn")
Path parameters
| Name | Type | Description |
|---|---|---|
name |
string | User name to delete. |
Response - 200 OK
This handler returns a flat (non-enveloped) body:
Errors
| Status | Code | Meaning |
|---|---|---|
404 |
not_found |
No user with that name. |
500 |
api_error |
A Headscale API request failed. |
Side effects
- Calls Headscale
DELETE /api/v1/user/:id. - May rewrite
/etc/headscale/acl.hujson(mode0600) to drop the user from groups, then runssystemctl restart headscale.
Example