REST API
Everything the CLI does goes through this API. JSON in and out, versioned
under /v1.
Transports and authentication
Section titled “Transports and authentication”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.keyopenssl rand -hex 32 | install -m640 -g cerberd /dev/stdin /etc/cerberd/wg0.tokensystemctl restart cerberd@wg0Examples:
# local socketcurl -s --unix-socket /run/cerberd/wg0.sock http://cerberd/v1/status
# TCPcurl -s -H "Authorization: Bearer $(cat /etc/cerberd/wg0.token)" http://10.99.0.1:7480/v1/peersA missing or wrong token gets 401 with WWW-Authenticate: Bearer realm="cerberd".
Conventions
Section titled “Conventions”- 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.
forcequery parameter:1ortrue.- Long requests. Peer changes may wait for routers;
links/upcan 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’sapply_errorsays why; the reconciler retries it.
Errors
Section titled “Errors”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.
Endpoints
Section titled “Endpoints”| 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 |
Status and events
Section titled “Status and events”GET /v1/status
Section titled “GET /v1/status”{ "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.
GET /v1/events
Section titled “GET /v1/events”Query: follow=1 keeps the response open and streams new events after the
history.
Response: application/x-ndjson, one event per line.
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 |
GET /v1/peers
Section titled “GET /v1/peers”Query: tag=<tag>, blocked=true|false. Response: an array of peers.
POST /v1/peers
Section titled “POST /v1/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.
GET /v1/peers/{name}
Section titled “GET /v1/peers/{name}”200 with the peer, 404 if unknown.
PATCH /v1/peers/{name}
Section titled “PATCH /v1/peers/{name}”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.
DELETE /v1/peers/{name}
Section titled “DELETE /v1/peers/{name}”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.
POST /v1/peers/{name}/block
Section titled “POST /v1/peers/{name}/block”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.
POST /v1/peers/{name}/unblock
Section titled “POST /v1/peers/{name}/unblock”Back on wg0 with the same key and addresses. 200 with the peer.
POST /v1/peers/{name}/rotate-key
Section titled “POST /v1/peers/{name}/rotate-key”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.
GET /v1/peers/{name}/config
Section titled “GET /v1/peers/{name}/config”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/32DNS = 10.99.0.1
[Peer]PublicKey = <hub public key>AllowedIPs = 10.0.0.0/8Endpoint = hub.example.net:51820PersistentKeepalive = 25Addresses
Section titled “Addresses”GET /v1/conflicts
Section titled “GET /v1/conflicts”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"]}]}]}POST /v1/conflicts
Section titled “POST /v1/conflicts”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.
GET /v1/overlay/free
Section titled “GET /v1/overlay/free”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.
POST /v1/links/up
Section titled “POST /v1/links/up”{"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).
POST /v1/links/down
Section titled “POST /v1/links/down”{"a": "site-a", "b": "site-b"}. 204; traffic is back on the hub.