Skip to content

REST API

Everything the CLI does goes through this API. JSON in and out, versioned under /v1.

The same handlers are served on two transports:

Transport Address Auth
unix socket (always) api.socket, default /run/cerberd/<interface>.sock file permissions: root and the cerberd group
TCP (optional) api.listen, e.g. 10.99.0.1:7480 Authorization: Bearer <token>; TLS when api.tls_cert / api.tls_key are set

Enable TCP in /etc/cerberd/wg0.yaml:

api:
listen: 10.99.0.1:7480
token_file: /etc/cerberd/wg0.token # root:cerberd 0640
# tls_cert: /etc/cerberd/wg0.crt
# tls_key: /etc/cerberd/wg0.key
Terminal window
openssl rand -hex 32 | install -m640 -g cerberd /dev/stdin /etc/cerberd/wg0.token
systemctl restart cerberd@wg0

Examples:

Terminal window
# local socket
curl -s --unix-socket /run/cerberd/wg0.sock http://cerberd/v1/status
# TCP
curl -s -H "Authorization: Bearer $(cat /etc/cerberd/wg0.token)" http://10.99.0.1:7480/v1/peers

A missing or wrong token gets 401 with WWW-Authenticate: Bearer realm="cerberd".

  • Peer handle. {name} in paths is a peer name, an overlay address (10.99.0.2) or the full public key (URL-encode it: +, /, =).
  • Bodies are a single JSON object, at most 1 MiB, sent within 30 s. Unknown fields are rejected.
  • force query parameter: 1 or true.
  • Long requests. Peer changes may wait for routers; links/up can take minutes. There is no server-side deadline; set your client timeout to match.
  • Apply. Peer mutations commit to the database, then apply to wg0. If the apply fails the change stays stored and the response’s apply_error says why; the reconciler retries it.

Every non-2xx response has the same body:

{
"error": "conflict: ips 10.99.0.2/32 taken by office-1 (10.99.0.2/32)",
"conflicts": [{ "field": "ips", "value": "10.99.0.2/32", "peer": "office-1", "with": "10.99.0.2/32" }]
}
Status When Body extras
400 invalid request or field values problems
401 TCP only: missing or wrong bearer token
404 unknown peer
409 name, key or address already taken; no free address in a range conflicts
503 a router that must be reached first is unreachable; retry, or force
500 anything else (also logged by the daemon)

A rejected create or update with invalid fields has "error": "invalid peer: …" and lists each problem in problems, e.g. "10.20.23.5/24: host bits set (network is 10.20.23.0/24)".

A conflict’s field is name, public_key or ips; peer is the existing holder ((hub) for the hub’s own addresses) and with its overlapping address.

Method Path Does
GET /v1/status hub, nodes, links
GET /v1/events event history, optionally streamed
GET /v1/peers list peers
POST /v1/peers create a peer
GET /v1/peers/{name} one peer
PATCH /v1/peers/{name} change a peer
DELETE /v1/peers/{name} delete a peer
POST /v1/peers/{name}/block take a peer off the hub
POST /v1/peers/{name}/unblock put it back
POST /v1/peers/{name}/rotate-key new key pair
GET /v1/peers/{name}/config spoke config
GET /v1/conflicts who holds addresses in ranges
POST /v1/conflicts dry run of a create
GET /v1/overlay/free a free overlay address
POST /v1/links/up build a direct link
POST /v1/links/down remove a direct link
{
"hub": {
"interface": "wg0",
"version": "v1.4.0",
"overlay": "10.99.0.0/16",
"peers": 12,
"known": 14,
"blocked": 2,
"foreign": 0,
"reconcile": "no changes",
"reconcile_time": "2026-10-24T09:12:03Z"
},
"nodes": [
{
"name": "site-a",
"public_key": "…",
"overlay": "10.99.0.2",
"endpoint": "198.51.100.7:51820",
"online": true,
"last_rx": "2026-10-24T09:12:02Z",
"tags": ["offices"],
"prefixes": ["10.99.0.2/32", "10.21.0.0/24"],
"router": {
"driver": "routeros",
"url": "http://10.99.0.2",
"reachable": true,
"version": "7.24.5 (stable)",
"interface": "wg0",
"key_match": true,
"peers": 1
}
}
],
"links": []
}

hub may also carry error, reconcile_error and refused_removals (the max_removals guard held a reconcile back). A node’s router has not_checked (why it wasn’t inspected, e.g. the node is offline), error, and tagged objects (kind, id, tag). links entries have the link status shape.

Query: follow=1 keeps the response open and streams new events after the history.

Response: application/x-ndjson, one event per line.

Terminal window
curl -sN --unix-socket /run/cerberd/wg0.sock 'http://cerberd/v1/events?follow=1'

The peer object (responses never contain private keys):

{
"name": "office-9",
"label": "Office 9",
"notes": "",
"public_key": "…",
"ips": ["10.99.23.31/32", "10.20.23.0/24"],
"overlay": "10.99.23.31",
"lan": "10.20.23.0/24",
"tags": ["offices"],
"blocked": false,
"has_private_key": true,
"has_preshared_key": false,
"endpoint": "",
"keepalive": 0,
"client": { "dns": [] },
"router": { "url": "https://10.99.23.31" },
"created_at": "2026-10-24T09:00:00Z",
"updated_at": "2026-10-24T09:00:00Z",
"hub": {
"on": true,
"online": true,
"endpoint": "198.51.100.9:13231",
"last_endpoint": "198.51.100.9:13231",
"last_handshake": "2026-10-24T09:11:40Z",
"rx": 1048576,
"tx": 524288
}
}
Field Meaning
ips all reservations: overlay /32s first (the first is primary), then the LAN /24
endpoint static endpoint set through the API, if any
keepalive hub-side persistent keepalive, seconds
client overrides of the client: defaults for the generated config: allowed_ips, dns, mtu, keepalive. Absent = inherited; 0 or [] is a value (keepalive: 0 drops it, dns: [] drops the DNS line)
router router binding, overrides of routeros.defaults: url, user, password_file, wg_interface, tls_insecure, opener_ttl, reachable. null = not a node
hub observed state on wg0: on (present), online, endpoints, last handshake, byte counters
apply_error set when the change is stored but not yet on wg0

Query: tag=<tag>, blocked=true|false. Response: an array of peers.

{
"name": "office-9",
"ips": ["10.99.23.31/32", "10.20.23.0/24"],
"label": "Office 9",
"tags": ["offices"],
"router": {}
}

Required: name, ips. Optional: private_key or public_key (neither: cerberd generates a key pair), preshared_key, label, notes, tags, endpoint, keepalive, client, router ({} binds the router with the defaults).

201 with the peer. 400 with problems, 409 with conflicts. A create racing another for the same addresses gets 409.

200 with the peer, 404 if unknown.

Absent fields stay as they are.

{ "name": "office-09", "ips": ["10.99.23.31/32"], "notes": "moved", "router": null }

Fields: name, ips (replaces all), label, notes, preshared_key ("" removes it), endpoint, keepalive, client, router (null removes the binding). Conflict-checked like a create. 200 with the peer.

Changing a node’s name, IPs or router binding first takes its direct links down (503 if a router can’t be reached for that). Links aren’t rebuilt automatically yet; run link up again.

Query: force. Takes the peer’s direct links down, blocks it and removes it from wg0, then deletes the record, keys and reservations. 204.

503 if a router needed to take a link down is unreachable (retry, or force=1). If the max_removals guard stops the removal, the delete fails and the peer stays blocked.

Query: force. Takes the peer’s direct links down, then removes it and its routes from wg0; the record, keys and reservations stay. 200 with the peer. 503 as for delete.

Back on wg0 with the same key and addresses. 200 with the peer.

Optional body: {"private_key": "…"} or {"public_key": "…"}. Empty body: cerberd generates a key pair. The peer’s direct links are taken down first (the link id changes with the key); the old key leaves wg0. 200 with the peer.

Query: format=wg-quick (default) or routeros.

Response: text/plain, Cache-Control: no-store. Contains the peer’s private and preshared keys when cerberd has them.

[Interface]
PrivateKey = …
Address = 10.99.23.31/32
DNS = 10.99.0.1
[Peer]
PublicKey = <hub public key>
AllowedIPs = 10.0.0.0/8
Endpoint = hub.example.net:51820
PersistentKeepalive = 25

Query: range=<prefix or address>, repeatable, at least one. Ranges are normalized (10.99.0.1/28 → 10.99.0.0/28). Blocked peers are included.

{"ranges": [{"range": "10.99.0.0/28", "peers": [
{"name": "office-1", "blocked": false, "ips": ["10.99.0.2/32", "10.21.0.0/24"], "overlapping": ["10.99.0.2/32"]}
]}]}

Dry run of a create: {"name"?, "ips", "private_key"? | "public_key"?}.

{"ok": false, "conflicts": [
{"field": "ips", "value": "10.99.0.2/32", "peer": "office-1", "with": "10.99.0.2/32"}
]}

problems lists validation errors. 200 either way; check ok.

Query: range=<prefix>. The lowest free overlay address in the range, skipping its first and last address:

{"overlay": "10.99.23.31/32"}

It isn’t reserved: a create with it gets 409 if someone took it meanwhile. 409 when the range is full.

{"a": "site-a", "b": "site-b", "force": false}

a and b are node names, overlay addresses or public key prefixes (4+ characters). force keeps the link even without RTT gain. Blocks until the link is built or given up. 200 with the link status:

{
"id": "3f9c1a2b7d04",
"a": "site-a",
"b": "site-b",
"state": "active",
"since": "2026-10-24T09:15:00Z",
"leader": "site-a",
"hub_rtt": "48.2ms",
"direct_rtt": "9.1ms",
"prefixes": {"site-a": ["10.22.0.0/24"], "site-b": ["10.21.0.0/24"]}
}

state is active, failed or no-gain (the link was removed; error says why). 400 when the request can’t run at all (unknown node, no router).

{"a": "site-a", "b": "site-b"}. 204; traffic is back on the hub.