Skip to content

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:

{ "data": { ... } }

On error:

{ "error": { "code": "string", "message": "string" } }

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

{ "data": { "success": true } }

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 (mode 0600).
  • Edits /etc/headscale/config.yaml to add or remove the policy path/mode.
  • Reads enabled IPsec tunnel subnets from the ipsec_tunnels table.
  • 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

{
  "routes": ["203.0.113.0/24"],
  "exit_node": true
}
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

{ "data": { "success": true } }

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 json and headscale 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_tunnels table.

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

{ "data": [] }

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:

{ "success": true, "key": "abcdef.generated-headscale-key" }

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:

{ "success": true, "message": "Headscale installed and running" }

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 curl to /tmp/headscale and installs to /usr/local/bin/headscale.
  • Installs the Tailscale client (tailscale.com/install.sh) and sets net.ipv4.ip_forward=1 when absent.
  • Writes /etc/systemd/system/tailscaled.service.d/after-headscale.conf.
  • Creates /etc/headscale, /var/lib/headscale, /var/run/headscale; writes config.yaml.
  • Writes /etc/systemd/system/headscale.service.
  • Creates the wireguard.localhost webapp and updates the webapps table; runs rewrite_user_confs.
  • Runs systemctl daemon-reload, enable, and start headscale.
  • Runs headscale users create admin, headscale apikeys create, headscale preauthkeys create.
  • Updates the vpn_config table (api_key, installed = 1).
  • Runs tailscale up to 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:

{ "success": true, "id": 1 }

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:

{ "success": true }

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:

{ "success": true }

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:

{ "success": true }

Errors

Status Code Meaning
404 not_found No tunnel with that ID.

Side effects

  • Runs swanctl --terminate/--initiate (or ipsec 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:

{ "success": true, "enabled": 1 }

Errors

Status Code Meaning
404 not_found No tunnel with that ID.

Side effects

  • Updates the enabled column in ipsec_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

{ "data": { "secret": "generated-psk-value" } }

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:

{ "success": true, "message": "strongSwan installed" }

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/start for ipsec, strongswan-starter, and strongswan.
  • 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

{ "data": { "ip": "203.0.113.10" } }

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

{ "data": { "installed": true, "running": true, "swanctl": true } }

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

{ "data": [] }

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:

{ "success": true }

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:

{ "success": true }

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

{ "ipv4": "203.0.113.50" }
Field Type Required Description
ipv4 string yes New IPv4 address for the node.

Response - 200 OK

{ "data": { "success": true } }

Errors

Status Code Meaning
400 bad_request Body invalid or ipv4 missing.
500 db_error The sqlite3 update command failed.

Side effects

  • Runs sqlite3 against /var/lib/headscale/db.sqlite to update nodes.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

{ "data": [] }

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

{
  "user": "user1",
  "reusable": true,
  "ephemeral": false,
  "expiry": "24h"
}
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:

{ "success": true, "data": { "preAuthKey": { "key": "..." } } }

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

{ "user": "user1", "key": "the-preauth-key" }
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:

{ "success": true }

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

{ "data": [] }

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:

{ "success": true }

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:

{ "success": true }

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:

{ "success": true, "running": true }

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

{ "data": { "success": true, "checks": {} } }

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 (mode 0600).
  • 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

{ "v4_subnet": "100.64.0.0/10" }
Field Type Required Description
v4_subnet string yes New IPv4 CIDR prefix.

Response - 200 OK

{ "data": { "success": true } }

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 (mode 0600).
  • 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

{ "data": [] }

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

{ "name": "user1" }
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:

{ "success": true, "data": { "user": { "name": "user1" } } }

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:

{ "success": true }

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 (mode 0600) to drop the user from groups, then runs systemctl restart headscale.

Example

curl -X DELETE https://panel.example.com:2087/api/wireguard-vpn/users/user1 \
  -H "Authorization: Bearer $TOKEN"