Exclusive Access · Invitation Only

Management API Reference

Every Stratum node runs a local management API so you can manage that node's networking yourself, from your own tooling. The full management surface is the REST API over HTTPS on port 7070, alongside a gRPC health endpoint on 7071 and a WebSocket event stream on 7072 — sharing one authentication model and one TLS certificate.

This page is the complete REST reference, plus the WebSocket event stream and the gRPC health endpoint. The base URL is:

https://<node-ip>:7070

All REST paths below are relative to this base and live under the /api/v1 prefix.


Getting started

1. The API is off until you set a token

The management API is disabled by default and fails closed: the agent will never serve these endpoints unauthenticated. It starts listening only when an API token is configured. With no token the API is cleanly disabled, and you manage the node through the panel and the local cenvero-str-ctl socket instead.

Ask the node for a token whenever you need one:

sudo cenvero-str-ctl api-token generate

It prints the token once — store it then, because it is not shown again. To choose the value yourself, pipe it in so it never reaches your shell history:

printf '%s' "$MY_TOKEN" | sudo cenvero-str-ctl api-token set
cenvero-str-ctl api-token status   # is one set? (never prints the value)
cenvero-str-ctl api-token clear    # remove it — the API stops at the next restart

The token is stored root-only on the node, in your local overrides, so a later panel sync does not discard it. You can also have the installer mint one up front with CENVERO_API_TOKEN=auto.

Installed with Compute? The compute and suite installer profiles turn the API on for you: they generate the token (stored root-only in /etc/cenvero-str/api-token, never printed), because the web console that runs your machines lives on the API. The installer ends by printing the console's address. To install a compute or suite node with the API off, set CENVERO_API_TOKEN=off. The fabric profile leaves the API off unless you ask for it.

It is not settable with cenvero-str-ctl config set — that command deliberately refuses credential keys, because a value passed as a command argument ends up in shell history. api-token exists for exactly this reason.

A new or changed token takes effect when the agent restarts (systemctl restart cenvero-stratum). The API listens only when all three hold: a token is configured, service rest is on, and a TLS certificate is available (TLS is mandatory — the agent refuses to serve plaintext). You can move or gate the listener without removing the token:

cenvero-str-ctl config set api_bind_address 10.0.0.5   # bind to a management IP
cenvero-str-ctl service rest off                        # stop serving REST
cenvero-str-ctl service rest on                         # serve it again
cenvero-str-ctl service status                          # show each service's state

2. Authenticate with a bearer token

Send the token in the Authorization header on every protected call:

Authorization: Bearer <token>

Three credential types are accepted, depending on the transport:

CredentialHow you get itScopeWorks on
Node API tokenThe api_token you configured on the nodeFull (node-wide)REST, WebSocket, gRPC
Operator API keycenvero-str-ctl apikeys mint <label> (secret shown once)Full (node-wide)REST only
Tenant-scoped keyPOST /api/v1/tenant/{id}/keys (see Multi-tenancy below)Confined to one tenantREST only

A tenant-scoped key may only act on its own tenant — /tenant/{id}/... and /billing/tenants/{id} where {id} is that key's tenant; any other path returns 403. Even there it can only read (the tenant, its quota and its billing state), manage the tenant's own keys (list, mint, revoke) and use the tenant portal routes (its own machines' state, figures, power actions, password resets and consoles; its own networks and public addresses — see The tenant portal under Multi-tenancy below). Changing the tenant, its quota or its billing state — update, delete, quota changes, suspend, resume, limit, unlimit — and creating or deleting machines, networks or volumes are reserved for the operator and return 403 to a tenant key (see *Tenant-scoped API keys* under Multi-tenancy below). The node API token and operator keys are never confined. The WebSocket and gRPC transports accept the node API token only (not operator or tenant-scoped keys). The local cenvero-str-ctl socket needs no token at all — it is the node's always-available lifeline and can never be disabled.

3. Verify connectivity

Two endpoints need no token and are handy for a connectivity check. Examples on this page use these shell variables:

export NODE=https://<node-ip>:7070
export TOKEN=<your-api-token>
curl -k "$NODE/api/v1/health"
{
  "status": "healthy",
  "uptime": "3h14m22s"
}

This endpoint is deliberately minimal. It answers one question — is the agent up, and for how long — and nothing more. Because it is reachable without a token, anything it returned would be readable by anyone who can reach the port, so it does not report the build version, the hardware, or any configuration. Point a load balancer or an uptime monitor at it and treat a 200 as alive.

For the node's version and its network-acceleration detail, use the authenticated status endpoint:

curl -k "$NODE/api/v1/status" -H "Authorization: Bearer $TOKEN"
{
  "status": "healthy",
  "version": "1.0.0",
  "uptime": "3h14m22s",
  "acceleration": {
    "mode": "software",
    "summary": "Software (CPU)",
    "detail": "Software (CPU) — hardware acceleration not available on this network card"
  }
}

License

GET /api/v1/license — the node's license: who it was issued to, its plan and channel, when it expires, the enforcement state, the plan's speed ceiling and the capability map. Read-only — it never contacts the license server and never changes anything, so it is safe to poll on a schedule.

curl -k "$NODE/api/v1/license" -H "Authorization: Bearer $TOKEN"
{
  "installed": true,
  "serial_number": "a3f9-...",
  "plan": "enterprise",
  "release_channel": "stable",
  "valid_until": "2026-12-31T00:00:00Z",
  "state": "active",
  "days_remaining": 152,
  "expired": false,
  "mutations_allowed": true,
  "hardware_grace": false,
  "max_bandwidth_gbps": 25,
  "features": { "bgp": true, "cluster": true, "ids": false }
}
FieldMeaning
stateactive, warning (expiring soon), grace (just expired), frozen
mutations_allowedWhether state-changing requests are accepted right now
hardware_graceThe license is valid but bound to hardware that no longer matches
max_bandwidth_gbpsThe plan's aggregate node ceiling; 0 means uncapped
days_remainingNegative once expired

Branch on mutations_allowed, not on state. License enforcement never severs traffic — a frozen node keeps forwarding packets and keeps its workloads running; what it refuses is changes. So this is the field that tells automation whether a write will be accepted.

With no license installed the answer is {"installed": false} with the identity fields absent, rather than a document full of blanks that reads like a license.

GET /api/v1/docs (also unauthenticated) returns a short machine-readable index of the endpoint paths — the same list published in this reference.

Because the node's certificate is privately managed, the curl examples below use -k to skip system trust. To pin the certificate instead, fetch its public key from the unauthenticated endpoint GET /api/v1/tls/pubkey (returns the PEM as text/plain).


Conventions

  • Base URL — https://<node-ip>:7070; every REST path is under /api/v1.
  • HTTPS only — the agent refuses to serve plaintext; your client must trust (or pin) the node's certificate.
  • JSON only — every request that carries a body must send Content-Type: application/json; anything else is rejected with 415. Responses are always JSON.
  • Body cap — request bodies over 1 MiB are rejected with 413.
  • Status codes — successful reads/updates return 200; resource creation returns 201.
  • Timestamps — UTC throughout (RFC 3339, e.g. 2026-06-30T14:05:00Z), unless a field is explicitly a Unix epoch.
  • No pagination — list endpoints return the full collection under a named key (e.g. {"networks": [ ... ]}); filters are provided per-endpoint via query parameters where noted. (The task list is the exception: it returns the newest 50 unless you ask for up to 500 with limit.)
  • Compression — send Accept-Encoding: gzip and JSON answers of 1 KiB or more come back compressed (Content-Encoding: gzip). The event stream is never compressed.
  • Tasks — an operation on a machine, an image or a volume answers with the id of the task that records it, in a "task" field and a Location: /api/v1/tasks/<id> header; the rest of the answer is unchanged.

Error responses

Errors are returned as JSON { "error": "..." } with a matching HTTP status:

StatusMeaning
400Malformed request — bad/invalid body or parameter (e.g. an invalid CIDR).
401Missing or invalid bearer token: {"error":"unauthorized"}.
403Forbidden — see the cases below.
404Unknown resource (e.g. an unknown network or record id).
413Request body over the 1 MiB cap.
415Body sent without Content-Type: application/json.
429Rate-limited, or too many failed auth attempts (temporary IP block).
501The operation is intentionally not supported (noted per-endpoint).
503That subsystem is not enabled/wired on this node: {"error":"<name> service not available"}.

A 403 is returned in any of these situations:

  • Source not allowed — when an IP allowlist (api_allowed_ips) is configured and your address is not on it: {"error":"forbidden: source address not allowed"}. In a cluster, each member applies its own list to your address, also when you reach it through another member; the member you called passes its refusal back unchanged (see Working with other members).
  • License frozen — a mutating call (POST/PUT/DELETE) while the license is frozen: {"error":"license inactive: changes are frozen until the license is renewed; existing workloads keep running"}. Reads (GET) are never blocked, and running workloads are untouched. A tenant-scoped key gets the same refusal in its customer's terms: {"error":"changes are paused on this server right now; your machines keep running. Contact your provider if this lasts."}. In a cluster, removing a member, leaving and revoking a join code are never frozen (see Cluster).
  • Feature not in your plan — see plan-gated subsystems below.
  • Tenant-scope violation — a tenant-scoped key used outside its own tenant: {"error":"forbidden: a tenant key may only act on its own tenant"}.
  • Operator-only change — a tenant-scoped key asking to change its own tenant, quota or billing state: {"error":"forbidden: this change is reserved for the operator; …"}. Send the call with the node API token or an operator key.

Rate limits

The API applies a per-source-IP token-bucket limit. The defaults are 1000 requests per minute with a burst of 100; exceed it and you get 429 {"error":"rate limit exceeded"}. Tune them with api_rate_limit (requests/minute) and api_rate_burst.

Plan-gated subsystems

Some subsystems are available only if your license plan includes them. For a plan-gated subsystem, every call — reads as well as writes — returns 403 with a message naming the missing feature (or "no license installed" when no license is active).

Path prefixRequired feature
/networksPrivate Networks
/dns/DNS
/dhcp/DHCP
/vxlan/VXLAN
/lb, /lb/Load Balancer
/bgp/BGP
/gateway/Gateway HA
/bandwidth, /bandwidth/Bandwidth shaping (limits + pools + quotas)
/cluster, /cluster/Cluster, except the five routes below

Five cluster routes need no clustering in the plan, and also work while the license is frozen: GET /cluster/status, GET /cluster/join-codes, DELETE /cluster/join-codes/{id}, DELETE /cluster/members/{id} and POST /cluster/leave. So a member whose plan lost clustering can still see where it stands and be taken out (see Cluster).

Everything else — IPAM, firewall /rules, /routes, /vlan, /bridges, /nics, /macbind, /forward, /bonds, /float, /flows, /accounting, /tenant, /billing, /containers, and the operations endpoints — is available on any active plan.


REST endpoint reference

Private networks

Managed private (SDN) networks. You create a network from a CIDR pool, and the agent automatically materializes one endpoint profile per usable host IP — each a fixed IP paired with a system-generated MAC. Attaching claims a free profile and programs its address binding into the data plane; detaching frees it. (Plan feature: Private Networks.)

Network fields

FieldTypeNotes
idstringServer-assigned network id.
namestringRequired on create, unique per node.
cidrstringRequired, IPv4 only (e.g. 10.20.0.0/24).
gatewaystringOptional gateway IP.
vlanintOptional VLAN id to tag the network.
tenant_idstringOptional — scopes the network to one of your tenants.
created_atstringUTC timestamp.
host_gatewayobject{"enabled", "snat", "dhcp"} — the host-gateway option (below). On create, pass "host_gateway": true and optionally "snat": true, "dhcp": true.

Endpoint fields: id, network_id, ip, mac, state (free or bound), and bound_at (set once bound).

List networks

GET /api/v1/networks

curl -k "$NODE/api/v1/networks" -H "Authorization: Bearer $TOKEN"
{
  "networks": [
    {
      "id": "net-a1b2c3d4",
      "name": "app-net",
      "cidr": "10.20.0.0/24",
      "gateway": "10.20.0.1",
      "vlan": 100,
      "tenant_id": "t-acme",
      "created_at": "2026-06-30T12:00:00Z"
    }
  ]
}

Create a network

POST /api/v1/networks — body: name and cidr required; gateway, vlan, tenant_id optional. Returns 201.

curl -k -X POST "$NODE/api/v1/networks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"app-net","cidr":"10.20.0.0/24","gateway":"10.20.0.1","vlan":100}'
{
  "status": "created",
  "network": {
    "id": "net-a1b2c3d4",
    "name": "app-net",
    "cidr": "10.20.0.0/24",
    "gateway": "10.20.0.1",
    "vlan": 100,
    "created_at": "2026-06-30T12:00:00Z"
  }
}

With "host_gateway": true (a gateway is then required) the node holds the network's gateway address on the workload bridge; "snat": true also lets the network reach the internet through the node's public address and "dhcp": true serves it over DHCP (both imply the host gateway). A setting that cannot be applied — snat on a node with no uplink configured, for instance — is refused with 400 and nothing is created.

curl -k -X POST "$NODE/api/v1/networks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"web","cidr":"10.60.0.0/24","gateway":"10.60.0.1","host_gateway":true,"snat":true}'

Set or remove a network's host gateway

PUT /api/v1/networks/{id}/host-gateway — body {"snat":bool,"dhcp":bool} (an empty body is the gateway alone). The body is the whole setting: what it no longer asks for is removed. DELETE turns the option off, removing everything it added. Both return the network; 404 for an unknown network.

curl -k -X PUT "$NODE/api/v1/networks/net-a1b2c3d4/host-gateway" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"snat":true}'
curl -k -X DELETE "$NODE/api/v1/networks/net-a1b2c3d4/host-gateway" -H "Authorization: Bearer $TOKEN"
{ "status": "updated", "network": { "id": "net-a1b2c3d4", "host_gateway": { "enabled": true, "snat": true, "dhcp": false } } }

Get a network

GET /api/v1/networks/{id}

curl -k "$NODE/api/v1/networks/net-a1b2c3d4" -H "Authorization: Bearer $TOKEN"
{ "network": { "id": "net-a1b2c3d4", "name": "app-net", "cidr": "10.20.0.0/24", "gateway": "10.20.0.1" } }

Delete a network

DELETE /api/v1/networks/{id}

curl -k -X DELETE "$NODE/api/v1/networks/net-a1b2c3d4" -H "Authorization: Bearer $TOKEN"
{ "status": "deleted", "id": "net-a1b2c3d4" }

List a network's endpoints

GET /api/v1/networks/{id}/endpoints

curl -k "$NODE/api/v1/networks/net-a1b2c3d4/endpoints" -H "Authorization: Bearer $TOKEN"
{
  "endpoints": [
    { "id": "ep-1111", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "02:ce:0a:14:00:02", "state": "bound", "bound_at": "2026-06-30T12:05:00Z" },
    { "id": "ep-2222", "network_id": "net-a1b2c3d4", "ip": "10.20.0.3", "mac": "02:ce:0a:14:00:03", "state": "free" }
  ]
}

There is one endpoint for every usable address except the gateway, which is reserved and never listed. Unless you supply one, an endpoint's MAC is derived from its IP: 02:ce: followed by the four octets of the address in hex.

Add ?mac=52:54:00:ab:01:02 to narrow the listing to a single address. This is the efficient way to answer "which endpoint is this workload on" when reconciling — a /24 otherwise returns 253 rows. Matching ignores case, and an address that is not present returns an empty list rather than an error.

curl -k "$NODE/api/v1/networks/net-a1b2c3d4/endpoints?mac=52:54:00:ab:01:02" \
  -H "Authorization: Bearer $TOKEN"

Attach an endpoint

POST /api/v1/networks/{id}/attach — claims an endpoint and binds it. Body is optional: send {"ip":"10.20.0.2"} to claim a specific address, or an empty body to take the next free one. Idempotent — attaching an already-bound IP returns that same endpoint. Returns 200.

Add "mac" to give the endpoint a specific hardware address instead of the one it was assigned. Use this when the workload already has a fixed address of its own — a virtual machine image, or an appliance whose license is tied to one — so the fabric accepts the address it will actually send from.

curl -k -X POST "$NODE/api/v1/networks/net-a1b2c3d4/attach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ip":"10.20.0.2"}'

# ...or claiming that address for a workload that already has this MAC
curl -k -X POST "$NODE/api/v1/networks/net-a1b2c3d4/attach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ip":"10.20.0.2","mac":"52:54:00:ab:01:02"}'
{
  "status": "attached",
  "endpoint": { "id": "ep-1111", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "02:ce:0a:14:00:02", "state": "bound", "bound_at": "2026-06-30T12:05:00Z" }
}

Change an endpoint's MAC address

PUT /api/v1/networks/{id}/endpoints/{eid}/mac — replaces the endpoint's hardware address, keeping its IP. Returns 200.

On a bound endpoint the anti-spoof binding moves to the new address as part of the change, so the workload is never left able to send from an address the fabric would reject. Setting the address it already has succeeds and changes nothing.

curl -k -X PUT "$NODE/api/v1/networks/net-a1b2c3d4/endpoints/ep-1111/mac" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mac":"52:54:00:ab:01:02"}'
{
  "status": "updated",
  "endpoint": { "id": "ep-1111", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "52:54:00:ab:01:02", "state": "bound", "bound_at": "2026-06-30T12:05:00Z" }
}

400 is returned for an address that is not a MAC, for a multicast address (one can never be a source address, so traffic from it would be dropped), and for an address already held by another endpoint — the response names the endpoint holding it, since two endpoints sharing an address would collide in the anti-spoof binding.

Detach an endpoint

POST /api/v1/networks/{id}/endpoints/{eid}/detach — frees the endpoint and releases its binding.

curl -k -X POST "$NODE/api/v1/networks/net-a1b2c3d4/endpoints/ep-1111/detach" \
  -H "Authorization: Bearer $TOKEN"
{
  "status": "detached",
  "endpoint": { "id": "ep-1111", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "52:54:00:ab:01:02", "state": "free" }
}

Routed public addresses

The public IPv4 addresses your provider routes to this server, handed to virtual machines one at a time. See Public Addresses for the model. (Plan feature: Private Networks.)

List routed addresses

GET /api/v1/routed-addresses — optional ?tenant_id= narrows the list to what one tenant may use and uses.

{
  "gateway": "169.254.1.1",
  "ranges": [ { "id": "rr-4f0c2a91d3e7", "cidr": "203.0.113.16/29", "tenant_id": "", "size": 8, "used": 1, "free": 7 } ],
  "addresses": [ { "ip": "203.0.113.16", "range_id": "rr-4f0c2a91d3e7", "tenant_id": "t-acme", "used_by": "vm-3f9a1c2e", "mac": "02:ce:cb:00:71:10" } ]
}

Add routed addresses

POST /api/v1/routed-addresses — body {"cidr": "203.0.113.16/29"} (or one address), optional tenant_id to reserve it for one tenant and description. Returns 201; 400 for a private, reserved or too-wide block; 409 when it overlaps a block you already added or one of your networks, or holds an address of the server itself.

Remove routed addresses

DELETE /api/v1/routed-addresses/{id or prefix} — the prefix with its slash escaped (203.0.113.16%2F29) or written as a dash (203.0.113.16-29); ?cidr= works too. 409 while a virtual machine uses one of its addresses.

A virtual machine takes an address with "public_ip": "auto" (or a specific address) on one of the entries in its networks list at POST /api/v1/vms.

IP address management (IPAM)

Address pools and the individual IP allocations drawn from them. Creating a private network registers a matching pool automatically; you can also manage pools directly.

Pool fields: id, name (required), subnet (required CIDR), gateway, range_start, range_end, is_ipv6, and an optional tenant_id.

List pools

GET /api/v1/ipam/pools — optional ?tenant_id= filter.

curl -k "$NODE/api/v1/ipam/pools" -H "Authorization: Bearer $TOKEN"
{
  "pools": [
    { "id": 1, "name": "app-net", "tenant_id": "t-acme", "subnet": "10.20.0.0/24", "gateway": "10.20.0.1", "range_start": "10.20.0.2", "range_end": "10.20.0.254", "is_ipv6": false }
  ]
}

Create a pool

POST /api/v1/ipam/pools — name and subnet required; gateway, range_start, range_end, is_ipv6, tenant_id optional. Returns 201.

  • A missing range_start or range_end defaults to the first or last host address of the subnet.
  • The range must lie inside the subnet and run forwards (start at or before end); otherwise 400.
  • gateway, range_start and range_end must be IP addresses when given — a malformed one is 400, not silently ignored.
  • A pool whose subnet or range overlaps another pool of the same tenant is refused with 400. Different tenants may reuse the same private range.
  • The gateway is never handed out by allocate, even when it sits inside the range.
curl -k -X POST "$NODE/api/v1/ipam/pools" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"edge","subnet":"10.40.0.0/24","gateway":"10.40.0.1","range_start":"10.40.0.10","range_end":"10.40.0.200"}'
{
  "status": "created",
  "pool": { "id": 2, "name": "edge", "subnet": "10.40.0.0/24", "gateway": "10.40.0.1", "range_start": "10.40.0.10", "range_end": "10.40.0.200", "is_ipv6": false }
}

List allocations

GET /api/v1/ipam/allocations — every allocation across all pools; optional ?tenant_id= filter.

curl -k "$NODE/api/v1/ipam/allocations" -H "Authorization: Bearer $TOKEN"
{ "allocations": [ { "id": 10, "pool_id": 1, "ip": "10.20.0.5", "hostname": "web-1" } ] }

Allocate an IP

POST /api/v1/ipam/allocate — body: pool_id (required), hostname (optional). Returns the assigned address.

curl -k -X POST "$NODE/api/v1/ipam/allocate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"pool_id":1,"hostname":"web-2"}'
{ "status": "allocated", "id": 11, "pool_id": 1, "ip": "10.20.0.6", "hostname": "web-2" }

Release an IP

POST /api/v1/ipam/release — body: pool_id and ip (both required).

curl -k -X POST "$NODE/api/v1/ipam/release" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"pool_id":1,"ip":"10.20.0.6"}'
{ "status": "released" }

Delete a pool

DELETE /api/v1/ipam/pools/{id} — removes the pool and every allocation in it. Addresses handed out from the pool stop being reserved, so do this only once nothing is using them.

curl -k -X DELETE "$NODE/api/v1/ipam/pools/1" -H "Authorization: Bearer $TOKEN"
{ "status": "deleted", "id": 1 }

Deleting a pool that does not exist returns 404.


Bridges

The node's software bridges and their member ports. A node ships with two managed bridges — cnv-mgmt-br0 (management) and cnv-user-br0 (tenant/user traffic) — and you can create and wire additional ones.

MethodPathPurpose
GET/api/v1/bridgesList bridges and their member interfaces
POST/api/v1/bridgesCreate a bridge (name required)
DELETE/api/v1/bridges/{name}Delete a bridge
POST/api/v1/bridges/{name}/portsAttach an interface (interface required)
DELETE/api/v1/bridges/{name}/ports/{iface}Detach an interface
curl -k "$NODE/api/v1/bridges" -H "Authorization: Bearer $TOKEN"
{ "bridges": [ { "name": "cnv-user-br0", "interfaces": ["cnv-nic-1"] } ] }
# Create a bridge and attach a NIC
curl -k -X POST "$NODE/api/v1/bridges" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"cnv-svc-br0"}'

curl -k -X POST "$NODE/api/v1/bridges/cnv-svc-br0/ports" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"interface":"cnv-nic-2"}'
{ "status": "added", "bridge": "cnv-svc-br0", "interface": "cnv-nic-2" }

Detach a port → DELETE /api/v1/bridges/cnv-svc-br0/ports/cnv-nic-2 returns { "status": "removed", "bridge": "cnv-svc-br0", "interface": "cnv-nic-2" }. Delete a bridge → { "status": "deleted", "name": "cnv-svc-br0" }.


Network interfaces (NICs)

Read-only inventory of the node's physical interfaces, including the uplinks the installer renamed to cnv-nic-* (for those, original_name is empty). Renaming is a privileged boot-time operation and is not exposed over the API.

GET /api/v1/nics — pass ?refresh=1 to re-scan hardware before returning.

curl -k "$NODE/api/v1/nics" -H "Authorization: Bearer $TOKEN"
{
  "nics": [
    { "original_name": "", "stratum_name": "cnv-nic-0", "pci": "0000:01:00.0", "driver": "ixgbe", "speed_mbps": 10000, "status": "up" }
  ]
}

MAC bindings

Tie a MAC address to an authorized port/VLAN for the node's anti-spoofing guard. Managed-network endpoints get their bindings automatically; use these endpoints to manage bindings for addresses you bridge in from outside.

Binding fields: id, mac, port_id, vlan_id, and mode — hard (drop traffic from unbound MACs; the default) or soft (log but allow).

MethodPathPurpose
GET/api/v1/macbindList bindings
POST/api/v1/macbindCreate a binding (mac required)
DELETE/api/v1/macbind/{mac}Remove a binding by MAC
curl -k -X POST "$NODE/api/v1/macbind" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mac":"02:1a:4f:14:00:09","port_id":1,"vlan_id":100,"mode":"hard"}'
{ "status": "created", "binding": { "id": 4, "mac": "02:1a:4f:14:00:09", "port_id": 1, "vlan_id": 100, "mode": "hard" } }

Remove → DELETE /api/v1/macbind/02:1a:4f:14:00:09 returns { "status": "deleted", "mac": "02:1a:4f:14:00:09" }.


DHCP leases

Read the live DHCP lease table. (Plan feature: DHCP.)

GET /api/v1/dhcp/leases — optional ?state= filter, one of active, expired, revoked. expires_at is a Unix UTC timestamp.

curl -k "$NODE/api/v1/dhcp/leases?state=active" -H "Authorization: Bearer $TOKEN"
{
  "leases": [
    { "id": 3, "mac": "52:54:00:de:ad:01", "ip": "10.30.0.10", "state": "active", "hostname": "db-primary", "expires_at": 1782825600 }
  ]
}
Static reservations and early releases are managed from the CLI (cenvero-str-ctl dhcp reserve / dhcp release).

DNS

The authoritative DNS manager: managed zones and their records, plus DNSSEC material. (Plan feature: DNS.)

Record fields: id, zone_id, name, type (A, AAAA, PTR, CNAME, MX, TXT, NS, SOA, SRV), value, ttl (defaults to 300 when omitted), and an optional source_subnet for split-horizon answers.

List zones

GET /api/v1/dns/zones

curl -k "$NODE/api/v1/dns/zones" -H "Authorization: Bearer $TOKEN"
{ "zones": [ { "id": 1, "name": "app-net.internal.", "soa": "ns.app-net.internal.", "serial": 2026063001 } ] }

Create a zone

POST /api/v1/dns/zones — body: name (required). Returns 201.

curl -k -X POST "$NODE/api/v1/dns/zones" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"app-net.internal"}'
{ "status": "created", "zone": { "id": 1, "name": "app-net.internal.", "soa": "ns.app-net.internal.", "serial": 2026063001 } }

Delete a zone

DELETE /api/v1/dns/zones/{id} — removes the zone and every record in it. Names under the zone stop resolving immediately.

curl -k -X DELETE "$NODE/api/v1/dns/zones/1" -H "Authorization: Bearer $TOKEN"
{ "status": "deleted", "id": 1 }

Deleting a zone that does not exist returns 404.

List records

GET /api/v1/dns/records — lists all records, or one zone's with ?zone_id=N.

curl -k "$NODE/api/v1/dns/records?zone_id=1" -H "Authorization: Bearer $TOKEN"
{ "records": [ { "id": 5, "zone_id": 1, "name": "api", "type": "A", "value": "10.20.0.55", "ttl": 300 } ] }

Create a record

POST /api/v1/dns/records — body: zone_id, name, type, value required; ttl and source_subnet optional. Returns 201.

curl -k -X POST "$NODE/api/v1/dns/records" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"zone_id":1,"name":"db","type":"A","value":"10.20.0.10","ttl":300}'
{ "status": "created", "record": { "id": 6, "zone_id": 1, "name": "db", "type": "A", "value": "10.20.0.10", "ttl": 300 } }

Update a record

PUT /api/v1/dns/records/{id} — in-place update is not supported. The call returns 501; delete the record and recreate it instead.

{ "error": "updating a record in place is not supported; delete and recreate it" }

Delete a record

DELETE /api/v1/dns/records/{id}

curl -k -X DELETE "$NODE/api/v1/dns/records/6" -H "Authorization: Bearer $TOKEN"
{ "status": "deleted", "record_id": 6 }

DNSSEC material for a zone

GET /api/v1/dns/dnssec/{zone} — returns the zone's DNSKEY set and the DS record to publish in the parent zone. Keys are generated and persisted on the first call, so this is also how you enable DNSSEC for a zone.

curl -k "$NODE/api/v1/dns/dnssec/app-net.internal" -H "Authorization: Bearer $TOKEN"
{
  "zone": "app-net.internal.",
  "enabled": true,
  "dnskey": [
    "app-net.internal.\t3600\tIN\tDNSKEY\t257 3 15 <base64-public-key>",
    "app-net.internal.\t3600\tIN\tDNSKEY\t256 3 15 <base64-public-key>"
  ],
  "ds": "app-net.internal.\t3600\tIN\tDS\t12345 15 2 <hex-digest>"
}

Static & policy routing

Two related surfaces: routes placed in the kernel forwarding table (optionally a non-main table id), and the policy rules that steer matched traffic into a table.

Route fields: destination (required CIDR), gateway, interface, metric, table (0 = the main table; use a positive id for policy routing).

List routes

GET /api/v1/routes — optional ?table=N filter.

curl -k "$NODE/api/v1/routes" -H "Authorization: Bearer $TOKEN"
{ "routes": [ { "id": 1, "destination": "10.50.0.0/24", "gateway": "10.20.0.254", "interface": "", "metric": 100, "table": 0 } ] }

Create a route

POST /api/v1/routes — destination required; gateway, interface, metric, table optional (table must be >= 0). Returns 201.

curl -k -X POST "$NODE/api/v1/routes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"destination":"0.0.0.0/0","gateway":"203.0.113.1","table":100}'
{ "status": "created", "route": { "id": 2, "destination": "0.0.0.0/0", "gateway": "203.0.113.1", "interface": "", "metric": 0, "table": 100 } }

Delete a route

DELETE /api/v1/routes/{id} → { "status": "deleted", "id": 2 }.

List policy rules

GET /api/v1/routes/rules — the rules that direct matched traffic into a table.

curl -k "$NODE/api/v1/routes/rules" -H "Authorization: Bearer $TOKEN"
{ "rules": [ { "id": 1, "priority": 100, "from": "10.20.0.0/24", "to": "", "fwmark": 0, "iif": "", "oif": "", "table": 100 } ] }

Create a policy rule

POST /api/v1/routes/rules — table must be a positive id, and at least one selector (from, to, fwmark, iif, oif) is required. priority optional. Returns 201.

curl -k -X POST "$NODE/api/v1/routes/rules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"priority":100,"from":"10.20.0.0/24","table":100}'
{ "status": "created", "rule": { "id": 1, "priority": 100, "from": "10.20.0.0/24", "to": "", "fwmark": 0, "iif": "", "oif": "", "table": 100 } }

Delete a policy rule

DELETE /api/v1/routes/rules/{id} → { "status": "deleted", "id": 1 }.


Port forwarding

Forward an inbound public address:port to an internal target (destination NAT). This is how you publish an internal service on a public address.

MethodPathPurpose
GET/api/v1/forwardList port-forward rules
POST/api/v1/forwardCreate a port-forward rule
GET/api/v1/forward/{id}Get one port-forward rule
DELETE/api/v1/forward/{id}Delete a port-forward rule

Create — body: dest_ip, dest_port (the inbound match), target_ip, target_port (the internal target); all four required. Returns 201.

curl -k -X POST "$NODE/api/v1/forward" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"dest_ip":"203.0.113.10","dest_port":443,"target_ip":"10.20.0.10","target_port":8443}'
{
  "status": "created",
  "port_forward": { "id": 5, "dest_ip": "203.0.113.10", "dest_port": 443, "target_ip": "10.20.0.10", "target_port": 8443 }
}

List returns only port forwards — masquerade rules are listed under /api/v1/gateway/snat:

{ "port_forwards": [ { "id": 5, "dest_ip": "203.0.113.10", "dest_port": 443, "target_ip": "10.20.0.10", "target_port": 8443 } ] }

GET /api/v1/forward/5 returns { "port_forward": { ... } }, or 404 when no port forward has that id. Delete → DELETE /api/v1/forward/5 returns { "status": "deleted", "id": 5 }, or the same 404 when no port forward has that id. Port forwards and masquerade rules are numbered from one shared sequence, so a masquerade rule's id is not a port forward: remove those with DELETE /api/v1/gateway/snat/{id}.


VLAN lockdown

A per-VLAN allow/deny table for the tenant network. An **empty table means every VLAN is allowed** (the default). Add a row with allowed: false to lock a VLAN down; allowed: true records an explicit allow.

List VLAN policies

GET /api/v1/vlan

curl -k "$NODE/api/v1/vlan" -H "Authorization: Bearer $TOKEN"
{ "vlans": [ { "vlan_id": 200, "allowed": false } ] }

Set a VLAN policy

POST /api/v1/vlan — body: vlan_id (required, 1–4094) and allowed. Returns 201.

curl -k -X POST "$NODE/api/v1/vlan" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"vlan_id":200,"allowed":false}'
{ "status": "set", "vlan": { "vlan_id": 200, "allowed": false } }

Clear a VLAN policy

DELETE /api/v1/vlan/{id} — removes the row, returning that VLAN to the default.

curl -k -X DELETE "$NODE/api/v1/vlan/200" -H "Authorization: Bearer $TOKEN"
{ "status": "cleared", "vlan_id": 200 }

VXLAN overlays

Layer-2 overlay networks keyed by VNI (a 24-bit id, 1–16777215) plus their remote peers (VTEPs). (Plan feature: VXLAN.)

List overlay networks

GET /api/v1/vxlan/networks

curl -k "$NODE/api/v1/vxlan/networks" -H "Authorization: Bearer $TOKEN"
{
  "vxlan_networks": [
    { "vni": 1001, "subnet": "10.200.0.0/24", "peers": [ { "host": "node-b", "mac": "", "vtep_ip": "198.51.100.20" } ] }
  ]
}

Create an overlay network

POST /api/v1/vxlan/networks — body: vni (required, 1–16777215) and subnet. Returns 201.

curl -k -X POST "$NODE/api/v1/vxlan/networks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"vni":1001,"subnet":"10.200.0.0/24"}'
{ "status": "created", "vni": 1001, "subnet": "10.200.0.0/24" }

Delete an overlay network

DELETE /api/v1/vxlan/networks/{vni} → { "status": "deleted", "vni": 1001 }.

List peers

GET /api/v1/vxlan/networks/{vni}/peers

{ "vni": 1001, "peers": [ { "host": "node-b", "mac": "", "vtep_ip": "198.51.100.20" } ] }

Add a peer

POST /api/v1/vxlan/networks/{vni}/peers — body: host and vtep_ip (both required), mac optional. Returns 201. vtep_ip must be an IP address and mac, when given, a valid MAC; anything else is 400. A peer with no mac still works: the node sends broadcast and unknown traffic (which carries ARP) to its VTEP and learns the addresses behind it from the replies.

curl -k -X POST "$NODE/api/v1/vxlan/networks/1001/peers" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"host":"node-b","vtep_ip":"198.51.100.20"}'
{ "status": "added", "vni": 1001, "host": "node-b" }

Remove a peer

DELETE /api/v1/vxlan/networks/{vni}/peers/{host} → { "status": "removed", "vni": 1001, "host": "node-b" }.

Forwarding database

GET /api/v1/vxlan/fdb — the overlay MAC-to-VTEP forwarding table.

{ "fdb": [ { "mac": "02:ce:0a:c8:00:05", "vni": 1001, "vtep_ip": "198.51.100.20" } ] }

Firewall rules & connection tracking

Manage the node's firewall rule table. Each rule matches on protocol, source/destination address and port, ingress interface, and optionally a source MAC, and allows or drops what it matches. Rules apply to traffic arriving at the node — for the node itself and routed through it — before address translation. Every rule is stateful: the replies to a connection a rule allows are admitted automatically. To view tracked connections, see the **Traffic visibility → Flows** section below.

MethodPathPurpose
GET/api/v1/rulesList rules (optional ?chain= filter)
POST/api/v1/rulesCreate a rule
GET/api/v1/rules/{id}Get one rule
PUT/api/v1/rules/{id}Replace a rule (validated first; a new id is assigned)
DELETE/api/v1/rules/{id}Delete a rule
GET/api/v1/rules/defaultThe default action for traffic no rule matches: {"default_action":"allow"} or "deny"
PUT/api/v1/rules/defaultSet it: body {"default_action":"deny"} (or "allow")

Rule fields

FieldTypeNotes
idintegerAssigned by the node (response only)
actionstringRequired. accept or drop
priorityintegerEvaluation priority
protocolstringtcp, udp or icmp (omit for any). icmp is IPv4 ICMP only and takes no ports
source_ip / dest_ipstringAddress or CIDR; both the same family when both are given
source_port / dest_portintegerA single port; a port rule matches TCP and UDP only
dest_port_maxintegerMakes dest_port the start of an inclusive range
interfacestringBind the rule to one ingress device (e.g. cnv-user-br0)
macstringMatch a source MAC (aa:bb:cc:dd:ee:ff); omit for any
levelstringPolicy scope, which sets the order rules are checked in: global (default), bridge (or its synonym interface), vlan, private_network, mac, flow — broadest first. See Firewall
commentstringFree text
chainstringOptional; the only value is prerouting (where every rule applies)
statefulbooleanOptional; the only value is true (every rule is stateful)

A body is decoded strictly: an unrecognised key is 400, naming the key, so a misspelt matcher cannot silently widen a rule. So are an address, MAC or protocol that cannot be read, a chain other than prerouting, stateful: false, and the action reject — none of these is enforced, so none is accepted.

On a member of a cluster, a drop rule whose source_ip is a member's address is refused when it could drop the cluster's own traffic: POST and PUT answer 409, and in POST /api/v1/rules/batch that rule fails while the others are added. See Members' addresses are never blocked.

Empty/zero optional fields are omitted from responses; id, chain, priority, action, and stateful always appear. A rule saved before these checks that still carries a setting that is not enforced is returned with not_enforced (a list of {field, detail}) and, when the node leaves it out entirely, "not_applied": true — see Rules saved before these checks.

Create a rule

curl -k -X POST "$NODE/api/v1/rules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "action": "drop",
    "priority": 100,
    "protocol": "tcp",
    "source_ip": "198.51.100.0/24",
    "dest_port": 22,
    "comment": "block ssh from that net"
  }'
{
  "status": "created",
  "rule": { "id": 7, "chain": "prerouting", "priority": 100, "protocol": "tcp", "source_ip": "198.51.100.0/24", "dest_port": 22, "action": "drop", "comment": "block ssh from that net", "stateful": true }
}

List rules (optionally filter by chain — useful to find rules saved under another chain before these checks, e.g. ?chain=forward):

curl -k "$NODE/api/v1/rules" -H "Authorization: Bearer $TOKEN"
{ "rules": [ { "id": 7, "chain": "prerouting", "priority": 100, "protocol": "tcp", "source_ip": "198.51.100.0/24", "dest_port": 22, "action": "drop", "comment": "block ssh from that net", "stateful": true } ] }

Replace a rule. There is no in-place edit; PUT replaces the old rule with the new one, which receives a new id (returned in the response). The body uses the same fields as create (action required).

  • The replacement is validated before anything changes, and the swap is a single step: an invalid body is 400, a replacement that does not fit is 409, and in either case the existing rule stays exactly as it was.
  • An unknown id is 404.
  • A schedule on the old rule carries over to the replacement.
  • This is how to correct a rule listed with not_enforced.
curl -k -X PUT "$NODE/api/v1/rules/7" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action":"drop","priority":100,"protocol":"tcp","source_ip":"198.51.100.0/24","dest_port":22,"comment":"no ssh from that net"}'
{ "status": "updated", "rule": { "id": 8, "chain": "prerouting", "priority": 100, "protocol": "tcp", "source_ip": "198.51.100.0/24", "dest_port": 22, "action": "drop", "comment": "no ssh from that net", "stateful": true } }

Default action. The verdict for traffic no rule matches. It starts as allow; set deny to refuse whatever no rule allows, once your allow rules are in place (including your own access to the node, and replies to connections the node opens itself — see The default action).

curl -k -X PUT "$NODE/api/v1/rules/default" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"default_action":"deny"}'
{ "status": "set", "default_action": "deny" }

Delete a rule: DELETE /api/v1/rules/8 → { "status": "deleted", "id": 8 }.


Load balancer

L4 virtual IPs (VIPs) with a pool of backends. Create a VIP, attach/detach backends, override backend health, and optionally configure an active health check. (Plan feature: Load Balancer.)

MethodPathPurpose
GET/api/v1/lbList VIPs
POST/api/v1/lbCreate a VIP
GET/api/v1/lb/{id}Get one VIP
PUT/api/v1/lb/{id}Not supported — returns 501
DELETE/api/v1/lb/{id}Delete a VIP
POST/api/v1/lb/{id}/backendsAdd a backend
DELETE/api/v1/lb/{id}/backends/{bid}Remove a backend
POST/api/v1/lb/{id}/backends/{bid}/healthManually set a backend up/down

VIP fields — id, frontend_ip, and algorithm are required on create.

FieldTypeNotes
idstringYour VIP identifier
frontend_ipstringThe virtual IP
frontend_portinteger
protocolstringtcp or udp
algorithmstringround-robin, least-conn, source-hash, weighted, maglev, consistent-hash
dsr_enabledbooleanDirect server return
health_checkobjectOptional active check (see below)

health_check: type (tcp or http; empty disables it), interval_sec, timeout_sec, threshold, http_path.

Create a VIP with an HTTP health check:

curl -k -X POST "$NODE/api/v1/lb" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "id": "web-vip",
    "frontend_ip": "203.0.113.10",
    "frontend_port": 443,
    "protocol": "tcp",
    "algorithm": "round-robin",
    "dsr_enabled": false,
    "health_check": { "type": "http", "interval_sec": 5, "timeout_sec": 2, "threshold": 3, "http_path": "/healthz" }
  }'
{
  "status": "created",
  "load_balancer": { "id": "web-vip", "frontend_ip": "203.0.113.10", "frontend_port": 443, "protocol": "tcp", "algorithm": "round-robin", "dsr_enabled": false, "backends": [] }
}

Add a backend (id and ip required; weight drives the weighted algorithm's share):

curl -k -X POST "$NODE/api/v1/lb/web-vip/backends" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"app-1","ip":"10.0.0.11","port":443,"weight":1}'
{ "status": "added", "vip_id": "web-vip", "backend_id": "app-1" }

Get a VIP (shows backends with live health and active connection counts):

{
  "load_balancer": {
    "id": "web-vip", "frontend_ip": "203.0.113.10", "frontend_port": 443, "protocol": "tcp", "algorithm": "round-robin", "dsr_enabled": false,
    "backends": [ { "id": "app-1", "ip": "10.0.0.11", "port": 443, "weight": 1, "healthy": true, "active_conns": 12 } ]
  }
}

Override a backend's health (a configured active check may flip it back on the next probe):

curl -k -X POST "$NODE/api/v1/lb/web-vip/backends/app-1/health" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"healthy": false}'
{ "status": "set", "vip_id": "web-vip", "backend_id": "app-1", "healthy": false }

Remove a backend → { "status": "removed", "vip_id": "web-vip", "backend_id": "app-1" }. Delete a VIP → { "status": "deleted", "id": "web-vip" }.

A VIP cannot be edited in place. PUT /api/v1/lb/{id} returns 501:

{ "error": "updating a VIP in place is not supported; delete and recreate it" }

BGP & route filtering

Peer with upstream routers, advertise and withdraw prefixes, and shape what you accept/announce with prefix-lists, route-maps, and per-neighbor import/export policy. (Plan feature: BGP.)

MethodPathPurpose
GET/api/v1/bgp/statusEngine summary
GET/api/v1/bgp/neighborsList peers
POST/api/v1/bgp/neighborsAdd a peer
DELETE/api/v1/bgp/neighbors/{addr}Remove a peer
GET/api/v1/bgp/routesRoute table (?family=ipv4 default, or ipv6; anything else is 400)
POST/api/v1/bgp/announceAdvertise a prefix
POST/api/v1/bgp/withdrawWithdraw a prefix
GET/api/v1/bgp/prefix-listsList prefix-lists
POST/api/v1/bgp/prefix-listsCreate a prefix-list
GET/api/v1/bgp/route-mapsList route-maps
POST/api/v1/bgp/route-mapsCreate a route-map
GET/api/v1/bgp/policyShow import/export policy bindings
POST/api/v1/bgp/policy/{dir}Bind a route-map ({dir} = import or export)

Status:

curl -k "$NODE/api/v1/bgp/status" -H "Authorization: Bearer $TOKEN"
{ "status": "running", "neighbors": 2, "routes": 17, "bfd_sessions_up": 2 }

Add a peer. peer_addr, peer_as, and local_as are required. Optional: hold_time, keepalive_interval, md5_key (TCP-MD5 auth; never returned in reads), and BFD (bfd_enabled, bfd_interval_ms, bfd_multiplier — the failure-detection time is interval × multiplier).

curl -k -X POST "$NODE/api/v1/bgp/neighbors" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "peer_addr": "192.0.2.1", "peer_as": 64512, "local_as": 64513,
    "hold_time": 90, "keepalive_interval": 30,
    "bfd_enabled": true, "bfd_interval_ms": 250, "bfd_multiplier": 3
  }'

List peers (state is one of Idle, Connect, Active, OpenSent, OpenConfirm, Established):

{
  "neighbors": [
    {
      "id": "192.0.2.1", "peer_as": 64512, "local_as": 64513, "peer_addr": "192.0.2.1",
      "state": "Established", "hold_time": 90, "keepalive_interval": 30,
      "bfd_enabled": true, "bfd_interval_ms": 250, "bfd_multiplier": 3,
      "last_state_change": "2026-06-30T11:58:02Z", "messages_in": 412, "messages_out": 410,
      "graceful_restart": false, "bfd_status": "Up", "bfd_detect_time_ms": 750, "peer_graceful_restart": false
    }
  ]
}

Remove a peer → DELETE /api/v1/bgp/neighbors/192.0.2.1 returns { "status": "deleted", "addr": "192.0.2.1" }.

Advertise a prefix (prefix required, valid CIDR; next_hop and communities optional):

curl -k -X POST "$NODE/api/v1/bgp/announce" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"prefix":"203.0.113.0/24","next_hop":"192.0.2.254","communities":["64512:100"]}'
{ "status": "announced", "prefix": "203.0.113.0/24" }

Withdraw a prefix: POST /api/v1/bgp/withdraw with {"prefix":"203.0.113.0/24"} returns { "status": "withdrawn", "prefix": "203.0.113.0/24" }.

GET /api/v1/bgp/routes returns the current route table as { "routes": [ ... ], "family": "ipv4" }. Each route carries its next hop, AS path, communities, local-preference, MED, and origin.

Prefix-lists

A named, ordered list of CIDR matchers used by route-maps. name is required; each entry has prefix (CIDR), action, and optional ge/le prefix-length bounds. action is allow or permit, deny or block, in any letter case (an empty action is an allow); any other value is 400, so a typo can never turn a deny into a permit.

curl -k -X POST "$NODE/api/v1/bgp/prefix-lists" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "customer-routes",
    "entries": [
      { "prefix": "203.0.113.0/24", "action": "allow", "ge": 24, "le": 32 },
      { "prefix": "0.0.0.0/0",       "action": "deny" }
    ]
  }'
{ "status": "created", "prefix_list": "customer-routes" }

GET /api/v1/bgp/prefix-lists returns { "prefix_lists": [ ... ] }, each with its name and entries.

Route-maps

A named, sequenced policy that matches routes and sets attributes. name is required; each entry has seq, action (the same values as a prefix-list entry; anything else is 400), match_prefix (a prefix-list name), and optional set_local_pref, set_community, set_med.

curl -k -X POST "$NODE/api/v1/bgp/route-maps" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "prefer-customer",
    "entries": [ { "seq": 10, "action": "allow", "match_prefix": "customer-routes", "set_local_pref": 200, "set_community": "64512:100" } ]
  }'
{ "status": "created", "route_map": "prefer-customer" }

GET /api/v1/bgp/route-maps:

{
  "route_maps": [
    { "name": "prefer-customer", "entries": [ { "seq": 10, "action": "allow", "match_prefix": "customer-routes", "set_local_pref": 200, "set_community": "64512:100", "set_med": 0 } ] }
  ]
}

Apply import/export policy

Bind a route-map to a neighbor in a direction. {dir} in the path is import or export; the body needs neighbor and route_map (both required).

curl -k -X POST "$NODE/api/v1/bgp/policy/import" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"neighbor":"192.0.2.1","route_map":"prefer-customer"}'
{ "status": "applied", "direction": "import", "neighbor": "192.0.2.1", "route_map": "prefer-customer" }

GET /api/v1/bgp/policy returns the current bindings as neighbor → route-map maps:

{ "import": { "192.0.2.1": "prefer-customer" }, "export": { "192.0.2.1": "announce-only" } }

NIC bonds

Aggregate physical NICs into a bond for redundancy or throughput. Create a bond, enslave/release member interfaces, and set a consistent MTU across the bond and its members.

MethodPathPurpose
GET/api/v1/bondsList bonds
POST/api/v1/bondsCreate a bond
GET/api/v1/bonds/{id}Get one bond
DELETE/api/v1/bonds/{id}Delete a bond
POST/api/v1/bonds/{id}/membersEnslave a member NIC
DELETE/api/v1/bonds/{id}/members/{iface}Release a member NIC
PUT/api/v1/bonds/{id}/mtuSet the bond+members MTU

Create a bond. name and mode are required; mode is active-backup or 802.3ad (LACP). id is optional — one is generated if omitted.

curl -k -X POST "$NODE/api/v1/bonds" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"cnv-bond0","mode":"active-backup","mtu":1500}'
{
  "status": "created",
  "bond": { "id": "bond-9f2a1c44d0e7b3a8", "name": "cnv-bond0", "mode": "active-backup", "mtu": 1500, "active_slave": "", "members": [] }
}

Enslave a member (member state is active, standby, or failed):

curl -k -X POST "$NODE/api/v1/bonds/bond-9f2a1c44d0e7b3a8/members" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"interface":"cnv-nic-0"}'
{ "status": "enslaved", "id": "bond-9f2a1c44d0e7b3a8", "interface": "cnv-nic-0" }

Get a bond:

{
  "bond": {
    "id": "bond-9f2a1c44d0e7b3a8", "name": "cnv-bond0", "mode": "active-backup", "mtu": 1500, "active_slave": "cnv-nic-0",
    "members": [ { "interface": "cnv-nic-0", "state": "active" }, { "interface": "cnv-nic-1", "state": "standby" } ]
  }
}

Set the MTU:

curl -k -X PUT "$NODE/api/v1/bonds/bond-9f2a1c44d0e7b3a8/mtu" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mtu":9000}'
{ "status": "mtu-set", "id": "bond-9f2a1c44d0e7b3a8", "mtu": 9000 }

Release a member → DELETE /api/v1/bonds/{id}/members/cnv-nic-1 returns { "status": "released", "id": "...", "interface": "cnv-nic-1" }. Delete a bond → { "status": "deleted", "id": "..." }.


Gateway HA & floating IPs

Gateway failover is coming in a later release. You cannot pair two nodes yourself yet, and a floating IP cannot move to another node yet. Until then every node stands alone for failover. GET /api/v1/gateway/status reports its peer_state as solo, and a floating IP stays on the node you assign it on — in a cluster too, where its assignment is recorded on every member. See Gateway High Availability.

For two nodes paired active/standby, these endpoints inspect HA state and trigger failover/failback. A node with no HA peer is active and holds its configured VIP itself. Floating IPs are virtual addresses bound to a primary endpoint, with an optional standby that will take over in a later release.

Gateway HA

(Plan feature: Gateway HA — setting up a pair is coming in a later release.)

MethodPathPurpose
GET/api/v1/gateway/statusCurrent HA state
POST/api/v1/gateway/failoverYield the VIP to the peer
POST/api/v1/gateway/failbackTake the VIP back when this node is the preferred owner

Source NAT (masquerade)

How a private tenant subnet reaches the internet through the node's public address. Traffic leaving source_cidr via interface is rewritten to that interface's address, and replies are translated back.

MethodPathPurpose
GET/api/v1/gateway/snatList NAT rules
POST/api/v1/gateway/snatAdd a masquerade rule
DELETE/api/v1/gateway/snat/{id}Remove a rule
curl -k -X POST "$NODE/api/v1/gateway/snat" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"source_cidr":"10.20.0.0/24","interface":"cnv-nic-1"}'
{ "status": "created", "rule": { "id": 3, "type": "snat", "source_ip": "10.20.0.0/24", "interface": "cnv-nic-1" } }

GET /api/v1/gateway/snat returns { "nat_rules": [ ... ] } with the same rule objects, using the same field names as gateway snat list on the CLI: id, type, source_ip, dest_ip, source_port, dest_port and interface. A field that does not apply to a rule is left out. The list includes port forwards (type port_forward, with the public address and port in dest_ip / dest_port and the workload in source_ip / source_port) as well as masquerade rules (snat). An empty list is [].

source and wan are accepted as aliases for source_cidr and interface, matching the names the CLI uses. These are the same rules cenvero-str-ctl gateway snat manages — both go through the same manager, so the two views cannot drift.

curl -k "$NODE/api/v1/gateway/status" -H "Authorization: Bearer $TOKEN"

local_state is active or standby; peer_state is active, standby, or solo (no peer configured). heartbeat is running, not running (a peer is configured but cannot be heard, so the node never takes the VIP on its own), or not needed (no peer):

{
  "local_state": "active", "peer_state": "standby", "vip": "203.0.113.1",
  "last_heartbeat": "2026-06-30T12:00:01Z", "uptime": "72h3m12s", "active_conns": 1024, "peer_addr": "10.0.0.6",
  "heartbeat": "running"
}
curl -k -X POST "$NODE/api/v1/gateway/failover" -H "Authorization: Bearer $TOKEN"
{ "status": "failover_triggered" }

POST /api/v1/gateway/failback returns { "status": "failback_triggered" }.

Floating IPs

MethodPathPurpose
GET/api/v1/floatList floating IPs
POST/api/v1/floatAssign a floating IP
GET/api/v1/float/{id}Get one floating IP
DELETE/api/v1/float/{id}Release a floating IP

Assign a floating IP. ip is required; primary and standby name the endpoints. state is active, standby, or failover.

curl -k -X POST "$NODE/api/v1/float" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"ip":"203.0.113.7","primary":"web-01","standby":"web-02"}'
{
  "status": "created",
  "floating_ip": { "id": "fip-7c1e", "ip": "203.0.113.7", "primary": "web-01", "standby": "web-02", "state": "active", "created_at": "2026-06-30T12:00:00Z" }
}

List:

{ "floating_ips": [ { "id": "fip-7c1e", "ip": "203.0.113.7", "primary": "web-01", "standby": "web-02", "active_host": "node-a", "state": "active", "created_at": "2026-06-30T12:00:00Z" } ] }

Release → DELETE /api/v1/float/fip-7c1e returns { "status": "deleted", "id": "fip-7c1e" }.

Floating IPs are saved on the node: they survive an agent restart or a reboot with the same ids, and the ones this node owns are brought back up on its interface when the agent starts.


Traffic visibility

Read-only views of live connections, usage accounting, and the bandwidth limits/quotas you have configured.

Flows

The live connection table the node tracks, plus aggregate stats and a downloadable export. All reads. With no traffic tracked these return empty results rather than erroring.

MethodPathPurpose
GET/api/v1/flowsList flows (?state=new/active/closed)
GET/api/v1/flows/statsAggregate statistics
GET/api/v1/flows/exportDownload flows (?format=csv/json, ?state=)
curl -k "$NODE/api/v1/flows?state=active" -H "Authorization: Bearer $TOKEN"
{
  "flows": [
    {
      "id": "f-001", "src_ip": "10.0.0.11", "dst_ip": "203.0.113.5", "src_port": 51000, "dst_port": 443, "protocol": "tcp",
      "bytes_in": 12000, "bytes_out": 3400, "packets_in": 40, "packets_out": 28,
      "start_time": "2026-06-30T11:59:00Z", "last_seen": "2026-06-30T12:00:00Z", "state": "active"
    }
  ],
  "count": 1
}

Stats (per_protocol/per_state are always present; top_talkers is ranked by packet volume):

{
  "stats": {
    "total_flows": 128, "active_flows": 73, "total_bytes": 9120384, "total_packets": 21044,
    "per_protocol": { "tcp": 110, "udp": 18 }, "per_state": { "active": 73, "closed": 55 },
    "top_talkers": [ { "ip": "10.0.0.11", "packets": 8044, "bytes": 4011008, "flows": 12 } ]
  }
}

Export streams a file (Content-Disposition: attachment). format defaults to json; pass format=csv for a spreadsheet-friendly download:

curl -k "$NODE/api/v1/flows/export?format=csv&state=active" \
  -H "Authorization: Bearer $TOKEN" -o flows.csv

Accounting

Per-period usage roll-ups derived from the node's traffic accounting. All three accept an optional window via ?start= and ?end= (RFC 3339); the default window is the current UTC calendar month to now.

MethodPathPurpose
GET/api/v1/accounting/summaryTotals for the period
GET/api/v1/accounting/billingPer-source usage + cost (?rate= $/Mbps)
GET/api/v1/accounting/95th95th-percentile in/out Mbps
curl -k "$NODE/api/v1/accounting/summary" -H "Authorization: Bearer $TOKEN"
{
  "summary": {
    "total_bandwidth_gb": 12.5, "total_bytes": 12500000000, "total_packets": 9000000,
    "total_vms": 4, "active_ips": 4, "period_start": "2026-06-01T00:00:00Z", "period_end": "2026-06-30T12:00:00Z"
  }
}

Billing. rate is an optional $/Mbps figure for 95th-percentile billing; omit it (or pass 0) to get per-source usage and P95 with zero cost.

curl -k "$NODE/api/v1/accounting/billing?rate=1.50" -H "Authorization: Bearer $TOKEN"
{
  "billing": {
    "period": "2026-06-01/2026-06-30", "total_usd": 42.00,
    "items": [ { "mac": "aa:bb:cc:dd:ee:ff", "bytes_total": 8000000000, "gb": 8.0, "p95_mbps": 28.0, "cost_usd": 42.00 } ]
  }
}

95th percentile:

{ "percentile_95th": { "in_mbps": 31.2, "out_mbps": 18.7, "period_start": "2026-06-01T00:00:00Z", "period_end": "2026-06-30T00:00:00Z" } }

Bandwidth limits, pools & quotas

Per-MAC rate limits, shared pools, and monthly usage quotas. *(Plan feature: Bandwidth shaping.)*

MethodPathPurpose
GET/api/v1/bandwidthList limits + pools (or one limit via ?mac=)
POST/api/v1/bandwidthCreate/update a per-MAC limit
POST/api/v1/bandwidth/poolsCreate a shared pool
POST/api/v1/bandwidth/pools/membersAdd a MAC to a pool
GET/api/v1/bandwidth/quotasList quotas (or one via ?mac=)
POST/api/v1/bandwidth/quotasCreate/update a quota
GET/api/v1/bandwidth/quotas/{mac}Get one MAC's quota + status
DELETE/api/v1/bandwidth/{id}Remove a limit — that MAC becomes unshaped
DELETE/api/v1/bandwidth/pools/{id}Remove a shared pool
DELETE/api/v1/bandwidth/pools/{id}/members/{mac}Remove one MAC from a pool

Removing a limit stops shaping that MAC — it is not throttled at all afterwards, so it draws whatever the node's licensed ceiling allows. Removing a pool, or removing one member from a pool, leaves those MACs unshaped in the same way. An unknown limit or pool is 404; removing a MAC that is not a member of an existing pool is 400.

curl -k -X DELETE "$NODE/api/v1/bandwidth/limit-1" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/bandwidth/pools/1" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/bandwidth/pools/1/members/aa:bb:cc:dd:ee:ff" \
  -H "Authorization: Bearer $TOKEN"

Limit fields — id and target_mac are required. rate_bps, burst_bytes, and guaranteed_bps are in bits/bytes per second; direction is up, down, or both (default both). pool_id links the limit to a shared pool.

curl -k -X POST "$NODE/api/v1/bandwidth" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"vm-11-cap","target_mac":"aa:bb:cc:dd:ee:ff","rate_bps":1000000000,"burst_bytes":125000,"direction":"both"}'
{
  "status": "updated",
  "limit": { "id": "vm-11-cap", "target_mac": "aa:bb:cc:dd:ee:ff", "interface_id": 0, "rate_bps": 1000000000, "burst_bytes": 125000, "guaranteed_bps": 0, "direction": "both" }
}

List (returns all limits and pools; pass ?mac= to get a single limit, 404 if none). Note that pool objects use capitalized field names:

{
  "limits": [ { "id": "vm-11-cap", "target_mac": "aa:bb:cc:dd:ee:ff", "interface_id": 0, "rate_bps": 1000000000, "burst_bytes": 125000, "guaranteed_bps": 0, "direction": "both" } ],
  "pools": [ { "id": "tenant-a-pool", "name": "Tenant A shared", "total_bps": 10000000000, "allocated_bps": 2000000000, "members": ["aa:bb:cc:dd:ee:ff"] } ]
}

Create a shared pool — body: id and total_bps (both required), name optional. Several MACs can then draw from the pool's aggregate cap.

curl -k -X POST "$NODE/api/v1/bandwidth/pools" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"tenant-a-pool","name":"Tenant A shared","total_bps":10000000000}'
{ "status": "created", "id": "tenant-a-pool" }

Add a MAC to a pool — body: pool_id and member_mac (both required), rate_bps optional (the member's own ceiling within the pool). Adding a MAC that is already a member replaces its rate rather than allocating it twice. A rate that would take the pool's allocations past its total_bps, or a member_mac that is not a MAC address, is 400; an unknown pool is 404.

curl -k -X POST "$NODE/api/v1/bandwidth/pools/members" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"pool_id":"tenant-a-pool","member_mac":"aa:bb:cc:dd:ee:ff","rate_bps":1000000000}'
{ "status": "added", "pool_id": "tenant-a-pool", "member_mac": "aa:bb:cc:dd:ee:ff" }

Quotas. A quota caps a MAC's monthly usage and resets at the start of each UTC month. id, mac, and monthly_limit_bytes (> 0) are required. action is notify, throttle, or block; enforced: true opts into a hard cap (default is advisory — alert and count only).

curl -k -X POST "$NODE/api/v1/bandwidth/quotas" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"id":"q-vm11","mac":"aa:bb:cc:dd:ee:ff","monthly_limit_bytes":1000000000000,"action":"throttle","enforced":true}'

The response includes the derived status (ok, warning, exceeded), remaining_bytes, and whether the MAC is currently throttled:

{
  "status": "updated",
  "quota": {
    "id": "q-vm11", "mac": "aa:bb:cc:dd:ee:ff", "monthly_limit_bytes": 1000000000000, "used_bytes": 250000000000, "reset_day": 0,
    "action": "throttle", "enforced": true, "current_period": "2026-06", "status": "ok", "remaining_bytes": 750000000000, "throttled": false
  }
}

Get one MAC's quota: GET /api/v1/bandwidth/quotas/aa:bb:cc:dd:ee:ff returns { "quota": { ... } } with the same shape, or 404 { "error": "no quota for MAC ..." }. GET /api/v1/bandwidth/quotas (no ?mac=) returns { "quotas": [ ... ] }.


Cluster

Form a cluster, add and remove members, and read the whole cluster from any member (agent 1.0.0-rc.81 or later on every member). How a cluster works, what it shares and what stays with a node is in the Clustering guide. The /api/v1/cluster/… routes, reads included, need clustering in the licence of the node you call, except five, which work whatever the licence says (below). A tenant key cannot use any of them (403). (Plan feature: Cluster — every member needs its own licence that includes it.)

MethodPathPurpose
GET/api/v1/clusterCluster summary
GET/api/v1/cluster/statusState, leader, voters and members
GET/api/v1/cluster/stateCounts of the shared settings
POST/api/v1/cluster/createForm a cluster with this node as its first member (a task)
POST/api/v1/cluster/join-codesMake a join code
GET/api/v1/cluster/join-codesList the join codes (never the codes themselves)
DELETE/api/v1/cluster/join-codes/{id}Revoke a join code that has not been used
POST/api/v1/cluster/join-previewAsk the cluster what joining it would mean, changing nothing (on the node that would join)
POST/api/v1/cluster/join-with-codeJoin this node to a cluster (a task)
DELETE/api/v1/cluster/members/{id}Take a member out of the cluster, from any member
POST/api/v1/cluster/leaveTake this node out of its cluster
POST/api/v1/cluster/members/{id}/rekeyGive a member a new identity (a task)
POST/api/v1/cluster/ca/rotateReplace the cluster's certificate authority (a task)
GET/api/v1/cluster/resourcesEvery object of every member (see Working with other members)
GET/api/v1/cluster/tasksEvery member's tasks
GET/api/v1/cluster/auditEvery member's audit records
GET/api/v1/cluster/eventsEvery member's events, as one stream
POST/api/v1/cluster/joinOnly for a cluster set up by hand with an earlier version (see below)

Five routes work whatever the licence says: without clustering in it, and while it is frozen. So a member whose plan lost clustering can still see where it stands and withdraw a code, and a node can always be taken out:

RouteCommand line
GET /api/v1/cluster/statuscluster status
GET /api/v1/cluster/join-codescluster join-code list
DELETE /api/v1/cluster/join-codes/{id}cluster join-code revoke <id>
DELETE /api/v1/cluster/members/{id}cluster remove <node-id>
POST /api/v1/cluster/leavecluster leave

Every other cluster route needs clustering in the licence: the summary (GET /api/v1/cluster), /cluster/state, the cluster-wide views (resources, tasks, audit and events), and every route that forms, joins or changes the cluster. The join preview, which changes nothing, also works while the licence is frozen; the other changes follow the licence state as any change does. A change only the cluster's leader can make — a removal, a join code, a rotation — is passed to the leader by the member you call, so you can call any member.

Status

curl -k "$NODE/api/v1/cluster/status" -H "Authorization: Bearer $TOKEN"
{
  "enabled": true,
  "state": "leader",
  "leader": "10.0.0.5:7073",
  "is_leader": true,
  "cluster": { "id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", "name": "prod" },
  "voters": 1,
  "peers": [
    { "id": "3f2a0c1e-9b7d-4c1a-8e2f-0123456789ab", "address": "10.0.0.5:7073", "state": "leader",
      "name": "edge-1", "voter": true, "pinned": true, "online": true },
    { "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "address": "10.0.0.6:7073", "state": "non-voter",
      "name": "edge-2", "voter": false, "pinned": true, "online": true, "last_seen": "2026-09-30T11:59:58Z" }
  ]
}
FieldMeaning
enabledWhether this node is in a cluster
stateThis node's role: leader, follower, or candidate (an election is under way). disabled on a node that is not in a cluster, not running when it is in one but its clustering could not start (the agent log says why)
leaderThe leader's cluster address; empty when there is none
is_leaderWhether this node is the leader
clusterThe cluster's id and name
votersHow many members vote
peersEvery member, this node included: id (what the other routes name a node by), name, address (its cluster address), state (leader, follower, or non-voter), voter, pinned (the cluster has recorded its identity), online, and last_seen for the others
errorOnly on a member that is not taking part in its cluster, saying why in a sentence (see A member that is not taking part); absent otherwise

GET /api/v1/cluster reports the same, wrapped as { "cluster": { … } }, where the member list is called members. A node that is not in a cluster answers state disabled with an empty list.

A member that is not taking part

A member keeps running on its own, with changes to shared settings paused on it, in two cases; error says which.

Its clustering could not start when the agent started. state is not running, and error reads:

clustering could not start on this node; it keeps running on its own, and changes to shared settings are paused until clustering starts; the agent log has the reason

It reaches no leader, and none of the members it knows answers when it asks them which members the cluster has now. (A member that was switched off asks them as it starts, so that it learns which nodes joined or were removed while it was away.) error then reads:

this node reaches no leader of its cluster, and none of the members it knows answered when it asked which members the cluster has now; it keeps running on its own and changes to shared settings are paused until it reaches the cluster; the agent log has the reason

In both cases the same sentence is the reason of the node's own entry in GET /api/v1/nodes.

Form a cluster

curl -k -X POST "$NODE/api/v1/cluster/create" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"prod","bind_addr":"10.0.0.5"}'

202, with the task that does the work and a Location header naming it:

{ "task": "tsk-3f2a0c1e9b7d-0mumq7fmz-a05dee", "cluster": { "id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", "name": "prod" } }

name is up to 64 bytes: 64 characters of unaccented letters, digits and punctuation, fewer with accented letters or other scripts, which take two to four bytes per character. bind_addr is an address of this node, as 10.0.0.5 or 10.0.0.5:7073: not a wildcard, and always on port 7073; anything else is 400. A node that is already a member of a cluster, or still holds an earlier cluster's data, is 409 with what to do.

Join codes

curl -k -X POST "$NODE/api/v1/cluster/join-codes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"expires_in":"30m"}'

201 — the only answer that ever carries the code:

{ "id": "9f86d081884c7d65", "code": "STRJ1-AQ8eLTxLWml4h5altMPS4fCgoaKjpKWmp6ipqqus…", "expires_at": "2026-09-30T12:15:00Z" }

expires_in is a number of seconds or a duration such as "15m": from 60 seconds to 24 hours, 15 minutes when left out. A code works once. A cluster holds at most 20 active codes (made, and not yet used, revoked or expired); revoke one to make room.

GET /api/v1/cluster/join-codes lists them without the codes:

{
  "join_codes": [
    { "id": "9f86d081884c7d65", "created_by": "api key ak-1f2e", "created_at": "2026-09-30T12:00:00Z",
      "expires_at": "2026-09-30T12:15:00Z", "state": "active", "used_by": "", "used_at": null },
    { "id": "0d1e2f3a4b5c6d7e", "created_by": "root", "created_at": "2026-09-30T09:00:00Z",
      "expires_at": "2026-09-30T09:15:00Z", "state": "used",
      "used_by": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "used_at": "2026-09-30T09:03:12Z" }
  ]
}

state is active, used, revoked or expired. DELETE /api/v1/cluster/join-codes/{id} revokes an unused one and answers { "id": "9f86d081884c7d65", "state": "revoked" }.

Join a cluster

First, if you like, the preview. On the new node, ask the cluster what joining it would mean. Nothing changes anywhere and the code is not used up:

curl -k -X POST "$NODE/api/v1/cluster/join-preview" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"code":"STRJ1-…","adopt_cluster_tenants":false}'
{
  "cluster": { "id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0", "name": "prod" },
  "members": 2,
  "tenants": {
    "shared": [ { "id": "t-beta", "name": "Beta" } ],
    "merged": [ { "id": "t-gamma", "name": "Gamma" } ]
  },
  "conflicts": [
    { "type": "tenant", "id": "t-acme", "local_name": "acme", "cluster_name": "Acme Corp" },
    { "type": "allocation", "id": "7:10.20.0.5", "local_name": "vm-1a2b3c4d", "cluster_name": "vm-9f8e7d6c" }
  ]
}
FieldMeaning
clusterThe cluster's id and name (the code itself carries only the id)
membersHow many nodes it has
tenants.sharedThis node's tenants the cluster does not have yet: they become the cluster's
tenants.mergedThis node's tenants the cluster already has (the same id and name, or the same id with adopt_cluster_tenants): the cluster's record wins
conflictsWhat would stop the join, as in the join's 409 below

The preview is refused exactly as the join would refuse the same code (wrong, expired, used, revoked, a full cluster, a node already in a cluster), so it tells you nothing a join with the code would not. Like a join, it counts toward the cluster's limit on join attempts.

Then the join, on the new node, with a code made on any member:

curl -k -X POST "$NODE/api/v1/cluster/join-with-code" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"code":"STRJ1-…","bind_addr":"10.0.0.6","adopt_cluster_tenants":false}'

202 { "task": "tsk-…" }: the node was admitted, and the task finishes the join. When the node's records clash with the cluster's, nothing is joined and the answer is 409 with each conflict:

{
  "error": "this node's records conflict with the cluster's (listed in conflicts); resolve them, or join with adopt_cluster_tenants to let the cluster's tenants win",
  "conflicts": [
    { "type": "tenant", "id": "t-acme", "local_name": "acme", "cluster_name": "Acme Corp" },
    { "type": "allocation", "id": "7:10.20.0.5", "local_name": "vm-1a2b3c4d", "cluster_name": "vm-9f8e7d6c" }
  ]
}

A tenant conflict means the same id with another name: set adopt_cluster_tenants to true to let the cluster's record win. An address allocated to different owners has to be resolved before joining.

A code that is wrong, or another cluster's, is 403 the join code was not accepted; an expired, used or revoked one is 409 this join code has expired, this join code has already been used or this join code was revoked; a full cluster is 409 this cluster has 32 nodes, the most it supports.

Remove a member, or leave

curl -k -X DELETE "$NODE/api/v1/cluster/members/7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "Authorization: Bearer $TOKEN"
{ "status": "removed", "node_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7" }

The member is refused by the others from that moment. If the voting members cannot agree to the removal, the answer is 503 removal pending: the cluster could not agree; the node can no longer reach the others and the member stays cut off. An id that is not a member is 404.

POST /api/v1/cluster/leave takes the node you call out of its cluster and answers { "status": "left" }; it becomes a standalone node. On a member that is not the leader, the node asks the leader to take it out and answers once it is standalone. If the leader's answer is lost and the node has not seen itself removed, the answer is 503:

{ "error": "the cluster's leader did not answer, and this node has not seen itself removed: if `cluster status` on another member still lists this node, run the leave again; if it does not, run `cenvero-str-ctl cluster forget --yes` here" }

A removal that names the node you call is a leave, and can answer the same 503. Removing and leaving work whatever the licence says (see Cluster).

Membership events

Every member announces each change of the cluster's membership on its own event stream, as the event cluster.membership in the CLUSTER category, one event per node that changed. Its type, category, object and payload (the other fields are those of every event):

{ "type": "cluster.membership", "category": "CLUSTER", "object": "/cluster/members/7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "payload": { "node": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "name": "edge-2", "change": "promoted", "voter": true } }

change is admitted (a join code was accepted; the node has not finished joining), joined (it is a member, perhaps without a vote yet), promoted (it votes), demoted (it no longer votes) or removed (it was removed or left, or its join never finished). voter is whether it votes now. A node that leaves, is removed or forgets its cluster announces its own removal. A member that restarts does not announce again what happened before it stopped: read GET /api/v1/nodes afresh instead.

Identities and the certificate authority

POST /api/v1/cluster/members/{id}/rekey gives member {id} a new identity (local names the node you call), and POST /api/v1/cluster/ca/rotate replaces the cluster's certificate authority, the members keeping their identities. Both answer 202 { "task": "tsk-…" }. Each member's identity, and when it expires, is in GET /api/v1/nodes (cert_expires_at).

Shared settings and a node that cannot reach the leader

GET /api/v1/cluster/state counts the shared settings:

{ "blocklist_count": 0, "ip_allocations_count": 12, "vxlan_peers_count": 2, "floating_ips_count": 1, "tenants_count": 3 }

On a member that cannot reach the cluster's leader — the smaller side of a network split, or a node cut off from the others — changes to tenants and overlay peers are refused with 503 and nothing changes on the node: creating, updating, suspending, resuming or limiting a tenant, setting its quota where that changes max_bandwidth_bps, deleting it (the /tenant and /billing/tenants routes), and adding or removing a VXLAN peer. Such a change has to reach the cluster as a whole, so that every member applies the same one. (A tenant's other quotas belong to each node, and can still be changed there.) The answer:

{ "error": "the cluster has no leader reachable from this node; changes to shared settings are paused until it has one" }

Make the change on a member that can reach the others, or restore the network between the members. Reads keep working. See A node cut off from the others.

Deleting a tenant on a cluster deletes it, and revokes its tenant API keys, on every member. It is refused with 409 while a member still holds the tenant's machines, networks or volumes (tenant <id> still has machines, networks or volumes on node <name>) or cannot be asked (node <name> is not answering, so its resources cannot be checked).

A cluster set up by hand

POST /api/v1/cluster/join (node_id, address), on the leader, adds a member to a cluster set up by hand with an earlier version, before join codes existed. A cluster formed with join codes answers 409 add nodes with a join code. POST /api/v1/cluster/profile answers 501: node profiles were removed, and every node runs the same services.


Multi-tenancy

A tenant is one of your downstream customers on this node. Tenants have a lifecycle (active → suspended → deleted), a resource quota, and their own scoped API keys.

MethodPathPurpose
GET/api/v1/tenantList tenants
POST/api/v1/tenantCreate a tenant (name required)
GET/api/v1/tenant/{id}Get a tenant
PUT/api/v1/tenant/{id}Update name and/or status
DELETE/api/v1/tenant/{id}Delete a tenant (cascades its keys, quota, networks & IPAM)
GET / PUT/api/v1/tenant/{id}/quotaRead / set a tenant's quota
GET / POST/api/v1/tenant/{id}/keysList / mint scoped API keys
DELETE/api/v1/tenant/{id}/keys/{kid}Revoke a scoped key

Create / list tenants

curl -k -X POST "$NODE/api/v1/tenant" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"acme-corp"}'
{
  "status": "created",
  "tenant": { "id": "t-9f3a21", "name": "acme-corp", "status": "active", "created_at": "2026-06-30T14:05:00Z", "updated_at": "2026-06-30T14:05:00Z" }
}

Returns 201. GET /api/v1/tenant returns { "tenants": [ ... ] }; status is active, suspended, or deleted.

Update a tenant

PUT /api/v1/tenant/{id} — body: name and/or status (active, suspended, deleted).

curl -k -X PUT "$NODE/api/v1/tenant/t-9f3a21" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"acme-corp","status":"suspended"}'
{ "status": "updated", "tenant": { "id": "t-9f3a21", "name": "acme-corp", "status": "suspended", "updated_at": "2026-06-30T14:06:00Z" } }

Delete a tenant. DELETE /api/v1/tenant/t-9f3a21 returns { "status": "deleted", "id": "t-9f3a21" }. Deleting a tenant cascades cleanup of everything scoped to it: its scoped API keys (which stop authenticating immediately), its quota, and its private networks and IPAM pools/allocations.

Tenant quota

A quota caps how much bandwidth a tenant may use and, with Compute (early access), how many virtual machines it may have on this node. 0 means unlimited.

FieldTypeMeaning
max_bandwidth_bpsint64Bandwidth cap in bits/sec (0 = unlimited)
max_vmsintVirtual machines on this node (0 = unlimited). Checked when a machine is created; lowering it never touches existing machines. used_vms in the answer is the current count

PUT changes only the fields you send; every other field keeps its current value, so setting one field can never silently lift another. It answers 400 for a body with nothing to change or a negative value (0 is how you say unlimited), and 404 for a tenant that does not exist. max_ips and max_rules are still accepted for compatibility but are not enforced.

curl -k -X PUT "$NODE/api/v1/tenant/t-9f3a21/quota" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"max_bandwidth_bps":1000000000}'
{
  "status": "quota-set",
  "quota": { "tenant_id": "t-9f3a21", "max_bandwidth_bps": 1000000000, "used_ips": 2, "max_vms": 0, "used_vms": 0 }
}

The cap is applied on the way out of the node and behaves as described in Tenants & Bandwidth — TCP settles at the rate, traffic over it is dropped rather than queued. Every plan is otherwise unlimited: there is no cap on how many addresses, workloads or firewall rules a tenant may have.

Tenant-scoped API keys

Mint a key that authenticates as the tenant but is **confined to that tenant's resources** — ideal for handing to the tenant's own automation or your per-customer billing logic. Create body: name (label) and ttl_hours (0/omitted = no expiry).

curl -k -X POST "$NODE/api/v1/tenant/t-9f3a21/keys" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"acme-automation","ttl_hours":720}'
{
  "status": "created",
  "key": {
    "id": "k-7b1c44", "tenant_id": "t-9f3a21", "name": "acme-automation",
    "created_at": "2026-06-30T14:07:00Z", "expires_at": "2026-07-30T14:07:00Z",
    "secret": "tnk_3f8a...e91c", "note": "store this secret now; it is shown only once"
  }
}
The secret is returned once at creation and never again. Store it immediately; list responses omit it.

List → GET /api/v1/tenant/t-9f3a21/keys returns { "keys": [ ... ] } (no secrets). Revoke → DELETE /api/v1/tenant/t-9f3a21/keys/k-7b1c44 returns { "status": "revoked", "id": "k-7b1c44" }. The key must belong to the tenant in the path; a key of another tenant answers 404, as an unknown key does.

If the node cannot save the change, neither call reports success. A mint answers 500 and returns no key. A revoke answers 500 and says so: the key stops working at once, but it could work again after the agent restarts, so send the same call again — it saves the revocation. Deleting a tenant behaves the same way: 500 when it could not be saved, with the tenant's keys refused from that moment.

What a tenant-scoped key can do. These keys are meant for your customers, so a key can see its own tenant and look after its own keys, but it cannot change what you sell it or whether it is running:

A tenant key can (its own tenant only)Reserved for the operator (403 to a tenant key)
GET /api/v1/tenant/{id} — the tenantPUT / DELETE /api/v1/tenant/{id} — rename, change status, delete
GET /api/v1/tenant/{id}/quota — its caps and usagePUT /api/v1/tenant/{id}/quota — change any cap
GET /api/v1/billing/tenants/{id} — its billing statePOST /api/v1/billing/tenants/{id}/suspend, /resume, /limit, /unlimit
GET / POST /api/v1/tenant/{id}/keys, DELETE /api/v1/tenant/{id}/keys/{kid} — list, mint and revoke its own tenant's keysCreating, deleting, resizing or re-plugging machines, and creating or deleting networks and volumes
The tenant portal routes below — its own machines, networks and public addresses, and its own tasks, resources and nodes (read only)Everything else on the node: listing or creating tenants, the operator's /vms, /networks, /tasks, /resources, /nodes, /metrics and /license routes, cancelling a task, and every route outside its tenant

A key minted with a tenant key belongs to the same tenant and **never outlives the key that minted it**: its expiry is capped at the minting key's, so a key you hand out for a day cannot mint one that lasts forever. A tenant key with no expiry mints keys that follow the ttl_hours you send. Anything the table does not list as allowed is refused to a tenant key, including endpoints added in later versions until they are listed here.

The tenant portal

These routes give a customer's key (and the web console's customer portal, which calls them) its own machines, networks and public addresses. The operator's token and keys may call them too, for any tenant.

MethodPathPurpose
GET/api/v1/tenant/{id}/vmsThe tenant's machines
GET/api/v1/tenant/{id}/vms/{vmid}One machine, with what its guest agent reports
GET/api/v1/tenant/{id}/vms/{vmid}/metricsThe machine's own figures, in the Prometheus text format of /metrics
POST/api/v1/tenant/{id}/vms/{vmid}/startStart it
POST/api/v1/tenant/{id}/vms/{vmid}/stopAsk it to shut down (optional {"timeout_seconds": N})
POST/api/v1/tenant/{id}/vms/{vmid}/force-stopCut its power
POST/api/v1/tenant/{id}/vms/{vmid}/restartRestart it (optional {"force": true})
POST/api/v1/tenant/{id}/vms/{vmid}/passwordSet a guest user's password through the guest agent: {"user": "…", "password": "…"}
POST/api/v1/tenant/{id}/vms/{vmid}/consoleA single-use console ticket: {"type": "serial", "force": false} (type is vnc, the default, or serial)
GET/api/v1/tenant/{id}/networksThe tenant's private networks
GET/api/v1/tenant/{id}/networks/{nid}One network, with the tenant's machines on it
GET/api/v1/tenant/{id}/addressesThe public addresses the tenant's machines hold, and their gateway
GET/api/v1/tenant/{id}/vms/{vmid}/rrdThe machine's graphs: the same query and answer as /api/v1/rrd/vms/{id} (see Graphs)
GET/api/v1/tenant/{id}/auditThe audit records of what the tenant's own keys did: portal sign-ins, power actions, password resets, consoles, key changes (see Audit log)
  • Only its own. A machine or network of another tenant — or of the operator — answers 404 exactly as an id that does not exist does, so a key learns nothing about what else runs on the node.
  • What it shows of a machine. Its name, state, size, hostname, interfaces (address, MAC, network, bandwidth), open consoles (which kind and since when, not who opened them) and guest agent. The node's own device names and the operator's notes are not included; a machine in trouble carries a plain state_reason.
  • A suspended tenant keeps every read. Start, stop, force stop, restart, password and console answer 403: this account is suspended: its virtual machines are stopped, …. A console ticket issued before the suspension is refused when it is used.
  • Gates. The licence freeze refuses the power actions and password resets (the console ticket and every read follow the read gates, as the operator's do). A plan without Compute refuses the power actions and password resets; a plan without private networks refuses the networks and addresses routes.
  • The console ticket works like the operator's (see Consoles): the answer's path is the WebSocket to open, /api/v1/vms/{vmid}/console/vnc|serial?ticket=…. The tenant key itself is not accepted on the WebSocket; the ticket is.
  • Every change a tenant key makes (power actions, password resets, console tickets, key changes) and every portal sign-in is recorded in the node's audit log, naming the key's id; the password never appears in a record or a log. GET /api/v1/tenant/{id}/audit shows the tenant those records — the time, which of its keys, the address, the action, the machine and the outcome — and nothing of the operator's.

The tenant's own tasks, resources and nodes, read only:

MethodPathPurpose
GET/api/v1/tenant/{id}/tasksThe tasks on the tenant's machines and volumes, whoever started them, newest first (filters: state, type, object, since, limit)
GET/api/v1/tenant/{id}/tasks/{tid}One of them
GET/api/v1/tenant/{id}/resourcesThe tenant's machines, networks and volumes in one list, with their state
GET/api/v1/tenant/{id}/nodesThe nodes the tenant has something on
  • A tenant's view of a task says what it did (type), on which of its objects, how far it got, when it started and ended, and whether the tenant or the provider started it (started_by: you or provider). A failed task says so in fixed words; the node, the key that started it and the server's own wording are not shown. Cancelling a task is the operator's.
  • A task, machine or network of anyone else answers 404 exactly as a missing one does.
  • The power actions and password resets above answer with their task's id ("task"), and a Location header naming the tenant's own view of it.

Billing automation (operator hook)

These endpoints are your hook for gating downstream customers. Your billing system calls them — typically with a minted operator API key (apikeys mint) — to suspend a non-paying customer, resume them on payment, or apply/clear a bandwidth limit. A tenant here is one of your customers. A tenant-scoped key may read its own tenant's billing state (GET /billing/tenants/{id}) but gets 403 on suspend, resume, limit and unlimit — a customer cannot lift its own suspension or cap. These are mutating calls and are frozen (403) while the node's license is inactive.

MethodPathPurpose
POST/api/v1/billing/tenants/{id}/suspendMark the tenant suspended and stop its workloads' traffic, both ways (see Billing Integration)
POST/api/v1/billing/tenants/{id}/resumeMark the tenant active and lift the suspension
POST/api/v1/billing/tenants/{id}/limitApply an aggregate rate cap
POST/api/v1/billing/tenants/{id}/unlimitRemove the rate cap
GET/api/v1/billing/tenants/{id}Read the customer's billing state
curl -k -X POST "$NODE/api/v1/billing/tenants/t-9f3a21/suspend" -H "Authorization: Bearer $TOKEN"
{ "id": "t-9f3a21", "status": "suspended" }

Rate-limit — body: rate_mbps (int64, Mbps). Negative is treated as 0; capped at 1000000 (1 Tbps). Stored as rate_mbps × 1,000,000 bits/sec.

curl -k -X POST "$NODE/api/v1/billing/tenants/t-9f3a21/limit" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"rate_mbps":100}'
{ "id": "t-9f3a21", "max_bandwidth_bps": 100000000 }

unlimit returns { "id": "t-9f3a21", "max_bandwidth_bps": 0 }; GET returns { "id": "t-9f3a21", "name": "acme-corp", "status": "active", "max_bandwidth_bps": 100000000 }.

Enforcement happens on the node you call: call every node that carries the tenant's traffic. See Billing Integration.

CLI equivalent: cenvero-str-ctl billing suspend|resume|limit|unlimit|status <tenant-id>.

Container networking

Attach a container's network namespace to one of your managed private networks. The container claims a managed endpoint (IP + MAC) and is wired with a veth pair into the network's bridge — the same attach path a VM uses, so containers share the network's IP/MAC pool and firewall.

MethodPathPurpose
GET/api/v1/containersList attached containers
POST/api/v1/containers/attachAttach a container netns
GET/api/v1/containers/{id}Get one container
POST/api/v1/containers/{id}/detachDetach (frees the endpoint)

Attach body:

FieldTypeRequiredNotes
runtimestringyeslxc, docker, or podman
network_idstringyesA managed network you've already created
netns_pidintyesThe container init PID whose network namespace is the target
container_idstringnoYour runtime's container id (for tracking)
ipstringnoRequest a specific IP; omitted = next free in the network
firewallboolnoApply the managed per-container firewall rule
idstringnoSupply your own record id; omitted = generated
curl -k -X POST "$NODE/api/v1/containers/attach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"runtime":"docker","network_id":"net-7a","netns_pid":48213,"container_id":"9c2f...","firewall":true}'
{
  "status": "attached",
  "container": {
    "id": "c-12ab", "runtime": "docker", "container_id": "9c2f...", "netns_pid": 48213,
    "veth_host": "cnv-veth-12ab", "veth_container": "eth0", "bridge": "cnv-user-br0",
    "mac": "02:42:0a:00:00:05", "ip": "10.0.0.5", "network_id": "net-7a", "endpoint_id": "ep-31"
  }
}

Returns 201. List → GET /api/v1/containers returns { "containers": [ ... ] }. Detach → POST /api/v1/containers/c-12ab/detach tears down the veth, releases the endpoint, and returns { "status": "detached", "id": "c-12ab" }.


Tasks

Every operation on a virtual machine, an image or a volume is recorded as a task: creating, starting, stopping, restarting, resizing, updating and deleting machines, adding and removing their interfaces, setting a guest password, downloading and importing images, creating, growing, attaching, detaching and deleting volumes, their snapshots, backups and restores. A task records who started it, what it acts on, how far it got, a log, and how it ended. Tasks are kept for 30 days (at most 20,000 per node).

MethodPathPurpose
GET/api/v1/tasksTasks, newest first
GET/api/v1/tasks/{id}One task
GET/api/v1/tasks/{id}/logIts log (start: the first line, from 1; limit: at most 2,000, default 500)
POST/api/v1/tasks/{id}/cancelStop it, where the operation can stop safely

GET /api/v1/tasks filters:

ParameterMeaning
stateComma-separated states: queued, running, succeeded, failed, cancelled, interrupted
typeA type (vm.start), or every type under a prefix ending in a dot (backup.)
objectTasks on this machine, image, volume, snapshot or backup — a machine's history includes the attaching and detaching of its volumes
ownerTasks started with this key id (api-token for the node's token, root for its command line)
tenant_idTasks on this tenant's objects (empty: the operator's own)
since, untilStart time, RFC 3339 (2026-09-29T12:00:00Z) or a duration back from now (24h)
limit1–500, default 50
curl -k "$NODE/api/v1/tasks?state=running" -H "Authorization: Bearer $TOKEN"
{
  "node": "3f2a0c1e-9b7d-4c1a-8e2f-0123456789ab",
  "tasks": [
    {
      "id": "tsk-3f2a0c1e9b7d-0mumq7fmz-a05dee",
      "node": "3f2a0c1e-9b7d-4c1a-8e2f-0123456789ab",
      "type": "backup.create",
      "object": {"kind": "volume", "id": "vol-1a2b3c4d", "path": "/volumes/vol-1a2b3c4d"},
      "related": {"kind": "backup", "id": "bk-5e6f7a8b"},
      "tenant_id": "acme",
      "owner": {"kind": "operator-key", "id": "ak-7f3c", "via": "api"},
      "state": "running",
      "status": "backing up",
      "progress": {"done": 734003200, "total": 2147483648, "unit": "bytes", "percent": 34.2},
      "cancellable": true,
      "log_lines": 2,
      "started_at": "2026-09-29T12:00:01.204Z",
      "ended_at": null,
      "duration_seconds": 41.6
    }
  ]
}
  • Who started it (owner): operator-token (the node's API token), operator-key (an operator API key, by id), tenant-key (a customer's key, with its tenant), root (the node's command line) or system (the node itself). via says whether the request came straight to the API, through the web console (console) or from the command line (cli). A key's secret is never recorded.
  • Progress is in bytes for image downloads and imports, backups, restores and copies of an image into a volume; total is 0 when the size is not known in advance. It is updated at most once a second.
  • The log keeps a task's first 200 and last 1,800 lines (at most 256 KiB); omitted says how many lines between them are no longer kept. To follow a running task, ask again from next until ended is true. A log never holds a password, a key or user-data.
  • Cancel works for image downloads and imports, backups and restores (202; the task then ends cancelled). Anything else answers 409: this task cannot be cancelled; it will finish or roll back on its own. A finished task answers 409 too. Cancelling follows the licence freeze like any other change.
  • After a restart. A task that was running when the agent stopped reads interrupted from the moment the agent starts again. When the node then finishes or undoes the operation — an unfinished download is discarded, an unfinished create is rolled back, a delete is finished, a backup still running is followed again, a stop still under way is completed — the task ends with that outcome and its log says so (settled after the agent restarted: …); interrupted_at stays on it.
  • Task ids (tsk-…) are unique and sort by start time; the first part names the node the task ran on.
  • A tenant key cannot use these routes (403); it reads its own tasks through the tenant portal.
  • In a cluster, GET /api/v1/cluster/tasks lists every member's tasks together, and a task begun on another member through this one names this one in its owner's via (see Working with other members).

Task progress is also announced on the event stream as task.started, task.progress and task.finished.

The same from the node's command line: cenvero-str-ctl task list|show|log|cancel (see CLI Reference).

Resources and nodes

GET /api/v1/resources returns everything on the node in one list — the node itself, its machines, networks, volumes, images and tenants — with each one's state: what the web console's tree of objects is drawn from.

curl -k "$NODE/api/v1/resources?type=vm" -H "Authorization: Bearer $TOKEN"
  • Every entry has type (node, vm, network, volume, image, tenant), id, node, name, tenant and status, plus what matters for its type: a machine's desired state, flags, size, addresses and image; a network's range and gateway; a volume's size, machine and snapshot and backup counts; an image's size.
  • Filters: type (comma-separated) and tenant_id (tenant_id= for the operator's own).
  • The answer carries an ETag; send it back in If-None-Match and an unchanged list answers 304.

GET /api/v1/nodes lists the nodes you can manage from here: this node, and on a member of a cluster every member. Each entry has its id and name, self (the node you called), status (online, offline, pending while it has not finished joining, or unknown before this node has heard from it), stale (its objects are what it last reported) and last_seen, its role in the cluster (leader, follower, non-voter or pending; standalone on a node that is not in a cluster) and whether it is a voter, its version, whether it is manageable from here (with the reason when it is not), its api_address and cluster_address, its licence (plan, state, days left and the features it includes — never the key, serial or customer), whether Compute and Storage are ready (modules), how far its clock is from this node's (clock_offset_ms) and when its cluster identity expires (cert_expires_at). In the entry of the node you called, reason is empty unless it is a member that is not taking part in its cluster (see A member that is not taking part).

The node's entry, in both lists, also carries figures: its newest reading, taken every 10 seconds, under the names and units of its graphs — cpu and iowait in percent, load1, load5, load15, mem_used, mem_total, swap_used, swap_total, root_used, root_total, data_used, data_total, pool_used, pool_total in bytes, net_rx and net_tx in bytes per second — and at, when it was taken. A figure not known yet is left out (the rates need two readings). A machine's figures are its graphs'.

A tenant key cannot use either (403); it has /api/v1/tenant/{id}/resources and /api/v1/tenant/{id}/nodes (see the tenant portal).

Working with other members

On a member of a cluster (agent 1.0.0-rc.81 or later), the API of any member reaches every member. The member you call needs clustering in its licence; the member that holds the object does the work and applies its own licence state and rules, as if you had called it directly. A tenant key can use none of this (403).

Each member's own address allowlist applies. A member that limits who may reach its API (api_allowed_ips) judges your address — the address of the client that called the first member — even when you reach it through another member. If it does not accept your address, it refuses with the same 403 {"error":"forbidden: source address not allowed"} it would give you directly, and the member you called passes it back unchanged. This applies to its consoles too. The command line on a member is not affected: cluster leave and cluster remove run there reach the leader whatever the leader's list says.

Another member's objects. Put /nodes/<node id> in front of any path of this reference — local names the node you call:

curl -k -X POST "$NODE/api/v1/nodes/7c9e6679-7425-40de-944b-e07fc1f90ae7/vms/vm-9f8e7d6c/start" \
  -H "Authorization: Bearer $TOKEN"

The request and the answer are the ones that path has on the node itself, and a Location header in the answer points back through /api/v1/nodes/<node id>/…. Cluster settings are not reached this way: /api/v1/nodes/<node id>/cluster/… is 400 — call /api/v1/cluster/… on any member instead.

The whole cluster in one list:

  • GET /api/v1/cluster/resources — every member's objects, as /resources lists one node's, each with its node. A tenant is listed once, with the nodes it has resources on. A member that is not answering contributes its objects from its last answer, marked "stale": true with last_seen. Filters: type, tenant_id, node and stale; ETag and 304 as for /resources.
  • GET /api/v1/cluster/tasks and GET /api/v1/cluster/audit — every member's tasks or audit records, newest first, with the same filters as on one node, limit up to 500, and next to pass back for the next page. The members that did not answer are listed in unreachable, each with its node, name and last_seen. Each member keeps its own audit log: check one with GET /api/v1/nodes/<node id>/audit/verify.
  • GET /api/v1/cluster/events — every member's events as one stream. Each frame's id: is c:…: reconnect to the same member with it in Last-Event-ID to pick up where you left off. A node.resync event ({"node": "<node id>"}) means one member's events could not all be caught up: reload that member's objects. resync and the rest work as on one node. Traffic samples (TRAFFIC) are not included.

On a node that is not in a cluster, use /resources, /tasks, /audit and /events: the /cluster/… forms need clustering in the licence.

A member that does not accept your address (its api_allowed_ips) is listed in unreachable in the task and audit lists. Its objects still appear in /api/v1/cluster/resources, and its events in /api/v1/cluster/events: this is a known limitation.

When another member cannot do it, the member you call answers for it:

StatusAnswer
404no node <id> in this cluster
409node <name> has not finished joining the cluster
409node <name> runs a version that cannot be managed from here; update it, or use its own console at https://…/console/ (with console_url)
409node <name>'s clock differs from this node's by <n> s; requests to it are refused until the clocks agree
503node <name> is not answering (last seen <time>); try again when it is back (with last_seen)
504node <name> did not answer in time
502node <name> refused the request from this node; this node's log has the reason

A member that has just stopped answering is not treated as down at once. A request sent to it in that time waits until the member you call gives up on it, about 15 to 20 seconds after it stopped answering, and then gets the 503 above; from then on, requests to it get the 503 at once. The cluster-wide lists mark the member "stale": true sooner, as soon as one attempt to read its objects fails.

Who did what is recorded on both members: the member you called records a forward naming the other member in detail.target, and the member that did the work records the action itself, with via_node naming the member you called (see Audit log).

Operations

Alerting

Define conditions (a threshold on a metric), attach actions (notify on fire), and review/acknowledge fired alerts.

MethodPathPurpose
GET/api/v1/alertsList fired alerts (?state=firing/resolved/acknowledged)
GET/api/v1/alerts/conditionsList conditions
POST/api/v1/alerts/conditionsCreate a condition
DELETE/api/v1/alerts/conditions/{id}Delete a condition
POST/api/v1/alerts/conditions/{id}/actionsAttach an action
POST/api/v1/alerts/{id}/ackAcknowledge an alert
GET/api/v1/alerts/historyFull alert history

Create a condition — metric_type (bandwidth, pps, vm_down, threat) and operator (gt, lt, eq) are required; threshold, target (default global), duration_secs (how long the condition must hold before it fires; not for threat) and cooldown_secs (default 300; the shortest time between two alerts for the same condition and target) are optional. Both durations are at most 2592000 (30 days). Returns 201 with the stored condition, including the id it was given — use that id to attach actions or to delete it. How conditions fire and resolve is described in Monitoring.

curl -k -X POST "$NODE/api/v1/alerts/conditions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"metric_type":"bandwidth","operator":"gt","threshold":900000000,"target":"global","duration_secs":60,"cooldown_secs":300}'
{ "condition": { "id": "3f1c9a0e5b7d4c2a8e6f1b0d9c3a7e5f", "metric_type": "bandwidth", "threshold": 900000000, "operator": "gt", "duration_secs": 60, "target": "global", "cooldown_secs": 300 } }

A connections or quota condition is refused with 400: nothing on a node measures either. A condition that cannot work as stored (one of those, kept from an earlier version) is listed with a warning explaining why.

Attach an action — type is websocket, webhook, or log; for webhook, config is the URL to POST to when an alert fires or resolves, and secret optionally sets the signing secret (one is generated when omitted). Each type can be attached once per condition; attaching it again replaces it. Returns 201; for a webhook the answer carries the secret — the only time it is shown.

curl -k -X POST "$NODE/api/v1/alerts/conditions/3f1c9a0e5b7d4c2a8e6f1b0d9c3a7e5f/actions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"type":"webhook","config":"https://hooks.example.net/stratum"}'
{
  "condition_id": "3f1c9a0e5b7d4c2a8e6f1b0d9c3a7e5f",
  "type": "webhook",
  "config": "https://hooks.example.net/stratum",
  "secret": "whsec_EXAMPLE_shown_once_store_it_now"
}

The URL must reach a public address (the same rule as for event webhooks, below), otherwise the call answers 400. Deliveries are signed and carry the same headers as event webhooks, with X-Stratum-Event: ALERT, X-Stratum-Delivery set to the alert id followed by -firing or -resolved, and User-Agent: cenvero-stratum-alert/1; the body is the alert:

{
  "id": "a55e0c1d2b3f4a5b6c7d8e9f0a1b2c3d",
  "condition_id": "3f1c9a0e5b7d4c2a8e6f1b0d9c3a7e5f",
  "metric_type": "bandwidth",
  "target": "global",
  "value": 912345678,
  "state": "resolved",
  "fired_at": "2026-09-28T14:02:05Z",
  "resolved_at": "2026-09-28T14:09:40Z",
  "resolved_reason": "cleared"
}

A failed delivery is tried up to three times; the delivery id stays the same, so a receiver can ignore a repeat.

Acknowledge an alert — by defaults to operator:

curl -k -X POST "$NODE/api/v1/alerts/a-55/ack" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"by":"[email protected]"}'
{ "acknowledged": "a-55", "by": "[email protected]" }

GET /api/v1/alerts and /alerts/history return { "alerts": [ ... ] } / { "history": [ ... ] }, newest first. An alert's state is firing, acknowledged (still open) or resolved; a resolved alert has resolved_at and resolved_reason (cleared, not_reported, condition_removed, superseded or manual). Resolved alerts are kept for 30 days, and at most the 10,000 most recent alerts are kept. Deleting a condition resolves its open alerts and returns { "removed": "<id>" }.

POST /api/v1/alerts is not a fire endpoint — alerts fire from configured conditions. It returns 400 pointing you to the condition/ack subpaths above.

Self-healing

The node runs periodic health checks and repairs what it can.

MethodPathPurpose
GET/api/v1/healLatest check results
POST/api/v1/heal/checkRun the full sweep now (returns fresh results)
curl -k "$NODE/api/v1/heal" -H "Authorization: Bearer $TOKEN"
{
  "checks": [
    { "name": "bridge-mgmt", "healthy": true, "last_checked": "2026-06-30T14:11:00Z" },
    { "name": "bridge-user", "healthy": false, "error": "link down", "repaired": true, "last_checked": "2026-06-30T14:11:00Z" }
  ]
}

Each check reports name, healthy, and last_checked; plus error, repaired, and repair_error when relevant.

Backups

Create config/full backups, list them, stage a full backup for a rollback, and manage schedules. A config backup is a readable export of the node's firewall rules (every setting of each), the firewall's default action, and its networks and address pools, kept for your records; it cannot be restored. A full backup holds the node's complete state, the firewall included, and is what you roll back to. A backup whose settings cannot be read fails with an error rather than leaving them out.

MethodPathPurpose
GET/api/v1/backupsList backups
POST/api/v1/backupsCreate a backup (type: config (default) or full)
POST/api/v1/backups/restoreStage a full backup for a rollback (ref: id or path)
GET/api/v1/backups/schedulesList schedules
POST/api/v1/backups/schedulesCreate a schedule
DELETE/api/v1/backups/schedules/{id}Remove a schedule
curl -k -X POST "$NODE/api/v1/backups" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"type":"config"}'
{ "backup": { "id": "bk-02", "type": "config", "size": 20480, "created_at": "2026-06-30T14:12:00Z", "retention": 7 } }

Returns 201. Restore — only a full backup can be restored. The node unpacks it into a staging area and answers 200 with staged: true and a note naming the step that completes the rollback: stop the agent, put the staged snapshot in place, and start it again. The running node is never replaced while the agent runs.

{ "restored": "bk-01", "staged": true, "note": "full backup unpacked to the restore/ staging dir; stop the agent and swap restore/stratum.db into place to complete the rollback" }

A config backup is refused with 400 (*only a full backup can be restored; a config backup is an export of the node's settings for your records and is not applied*); a reference outside the node's backups is 400, and one that names no backup file is 404.

Create a schedule — expression (e.g. daily@03:00, required), type (config (default) or full), retention (default 7).

curl -k -X POST "$NODE/api/v1/backups/schedules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"expression":"daily@03:00","type":"config","retention":14}'
{ "schedule": { "id": "sch-01", "expression": "daily@03:00", "type": "config", "retention_count": 14, "next_run": "2026-07-01T03:00:00Z" } }

Delete → { "removed": "sch-01" }.

Node dashboard

GET /api/v1/dashboard — one aggregated snapshot of the node for a status screen, in a single request instead of polling a dozen endpoints. Every figure is read from the node itself. A figure the node cannot read — or a subsystem that is not running — is null, never a made-up 0, so an idle node and an unknown one look different.

curl -k "$NODE/api/v1/dashboard" -H "Authorization: Bearer $TOKEN"
{
  "host": { "hostname": "node-a", "uptime_secs": 86400, "cpu_cores": 16, "memory_mb": 64214 },
  "nics": [ { "name": "cnv-nic-0", "speed_mbps": 10000, "rx_bytes": 481920512, "tx_bytes": 90211844, "status": "up" } ],
  "traffic": { "total_bytes_in": 9120384, "total_bytes_out": 7730112, "active_flows": 73 },
  "security": { "blocked_packets": 1204, "active_rules": 12, "threat_detections": 0 },
  "active_alerts": [],
  "cluster": { "enabled": false, "node_count": 1, "leader_id": "", "state": "disabled" }
}
FieldMeaning
hostHostname, uptime in seconds, CPU cores, total memory in MB
nicsEach network card, with its byte counters
traffic.total_bytes_in / total_bytes_outBytes through packet processing since it was last loaded
traffic.active_flowsConnections being tracked now
security.blocked_packetsPackets dropped by policy — firewall, blocked addresses, anti-spoofing, MAC binding and rate limits — since packet processing was last loaded
security.active_rulesFirewall rules in force right now: every rule except scheduled ones whose window is closed
security.threat_detectionsIntrusion-detection hits so far
active_alertsAlerts currently firing
clusterWhether clustering is on, how many members (1 when it is off), the leader's id, and this node's role

The dashboard is the figures of this moment, with no per-machine figures and no peak rate. For history — the node's CPU, memory, disks and traffic, each interface's and each machine's — see Graphs.

Graphs

The node keeps the history of its own figures, of each interface it manages and of each virtual machine, for graphs — no collector needed. Reads, never refused by a licence freeze or a plan.

MethodPathSeries
GET/api/v1/rrd/nodecpu, iowait (percent of all cores); load1, load5, load15; mem_used, mem_total, swap_used, swap_total; root_used, root_total, data_used, data_total, pool_used, pool_total (bytes); net_rx, net_tx (bytes per second on the node's network cards). The answer also lists the interfaces that have a history
GET/api/v1/rrd/interfaces/{name}rx, tx, bytes per second
GET/api/v1/rrd/vms/{id}cpu (percent of the machine's own vCPUs); mem_used (host memory it occupies), mem_total (memory the guest has); disk_read, disk_write, net_rx, net_tx (bytes per second, network from the machine's side)

Query parameters:

ParameterValues
timeframelive (every 10 s, 15 s for a machine; about the last hour), hour, day (a point a minute), week (every 10 minutes), month (every hour), year (every 12 hours). Default hour
res, from, toInstead of timeframe: a resolution (live, 60, 600, 3600, 43200 seconds, or 1m, 10m, 1h, 12h) and a range (RFC 3339 or Unix seconds). With from alone, the finest resolution that reaches back that far
cfavg (default): the average of each point; max: its maximum
curl -k "$NODE/api/v1/rrd/node?timeframe=day" -H "Authorization: Bearer $TOKEN"
{
  "kind": "node", "id": "node", "timeframe": "day", "archive": "day", "cf": "avg",
  "step": 60, "start": 1790035200, "end": 1790121540,
  "series": { "cpu": [3.2, 2.9, null, 4.1], "mem_used": [2147483648, 2151677952, null, 2155872256] },
  "interfaces": ["cnv-mgmt-br0", "cnv-nic-0", "cnv-user-br0"]
}

Point i covers the step seconds from start + i × step (Unix seconds, UTC); the last point is the one in progress. null is a gap: nothing was measured then (the node was off, the machine was stopped). A machine that exists but has not run answers an empty history; one that does not exist, 404. The history takes a fixed amount of disk; see Monitoring.

Tenant keys are refused here (403); a tenant reads its own machines' graphs at GET /api/v1/tenant/{id}/vms/{vmid}/rrd, where another tenant's machine or the operator's answers 404 like one that does not exist.

Audit log

Every change made on the node — through this API, the web console, the tenant portal or cenvero-str-ctl — is recorded, with console sign-ins and machine consoles. Reads are not recorded. No record holds a key, token, password or request body. See Monitoring for what is recorded and how long it is kept.

MethodPathPurpose
GET/api/v1/auditRecords, newest first, with filters and pages
GET/api/v1/audit/exportEvery matching record as JSON lines (application/x-ndjson), oldest first, streamed
GET/api/v1/audit/verifyCheck the whole log; ?checkpoint=<seq>:<hash> also checks a record you kept
GET/api/v1/tenant/{id}/auditA tenant's own records (see The tenant portal)

Filters (all optional, for the list and the export):

ParameterKeeps
since, untilRecords from / up to a time: RFC 3339 or Unix seconds
principalOne principal, exactly as listed: api token, api key ak-12ab, tenant key tk-… (tenant t-acme), root
kindoperator-token, operator-key, tenant-key, root (the node's command line), system (the node itself), unauthenticated
tenantRecords made with one tenant's keys
actionActions starting with this: vms. matches vms.start, vms.delete, …
objectObjects starting with this: /vms/vm-1a2b3c4d
outcomesuccess, failure or refused
limit1–1000 records (default 100)
before, after, orderPages: pass the answer's next as before for the next page; after=N or order=asc reads oldest first
curl -k "$NODE/api/v1/audit?action=vms.&outcome=refused&limit=20" -H "Authorization: Bearer $TOKEN"
{
  "records": [
    {
      "seq": 1204, "time": "2026-09-29T14:03:11.52Z", "node": "7c1e…",
      "principal": "tenant key tk-5f2a (tenant t-acme)", "principal_kind": "tenant-key",
      "tenant": "t-acme", "via_node": "", "session_ref": "3fa9c0d2e1b4",
      "client_ip": "203.0.113.9", "request_id": "9b1f…",
      "method": "POST", "route": "/api/v1/tenant/{id}/vms/{vmid}/start", "object": "/vms/vm-1a2b3c4d",
      "action": "tenant.vms.start", "outcome": "refused", "status": 403, "task_id": "",
      "detail": { "refused_by": "licence" },
      "prev_hash": "5e0c…", "hash": "a91b…"
    }
  ],
  "next": 1204
}
FieldMeaning
seqThe record's number: 1, 2, 3, … on each node, with no holes
principal, principal_kindWho: a credential's name, never the credential
tenantSet when a tenant's own key acted
via_nodeIn a cluster, the member the request was made on, when another member passed it to this one (empty otherwise). The member it was made on records a forward, with detail.target naming this node
session_refThe web-console session the change came through
client_ip, request_idWhere it came from; your X-Request-ID is kept when it is a plain id
method, routeThe API route (method is IPC and route the command for the command line)
object, actionWhat it acted on, and a short name for what it did
outcome, statussuccess, failure or refused, and the status code answered
task_idThe task the change began, or the task a task record is about
detailcreated (the id of what a create made), refused_by (authentication, tenant scope, licence or plan), and for consoles console, reason, duration_seconds
prev_hash, hashThe chain: the fingerprint of the record before, and of this one

Tasks. Besides the record of the request that began a task, the task's start and end are records of their own: method is TASK, route the task type (vm.create, image.download, …), action one of task.started, task.succeeded, task.failed, task.cancelled or task.interrupted (the node restarted while it ran; when the node then finishes or undoes the work, its end follows), and principal whoever started it. They never hold the task's log or error; read those at /api/v1/tasks/{id}. A tenant sees the task records of the tasks its own keys began.

Verify walks every record and answers:

{ "ok": true, "checked": 48211, "first_seq": 1, "last_seq": 48211, "head_hash": "a91b…" }

When the chain is broken, ok is false and broken names the first record where it breaks and why — a record changed after it was written, a record missing, one that does not link to the record before it, or records removed without the removal record retention leaves. Keep last_seq and head_hash somewhere off the node: ?checkpoint=<last_seq>:<head_hash> later tells you whether the log was rewritten since (checkpoint.matches).

Checking an export yourself. Each exported line is one record. Its hash is the SHA-256 of: the text cenvero-stratum-audit-v1 and a newline; then prev_hash; seq and the time (Unix nanoseconds) as 8-byte big-endian integers; node, principal, principal_kind, tenant, via_node, session_ref, client_ip, request_id, method, route, object, action, outcome; status as an 8-byte big-endian integer; task_id; and detail as compact JSON with its keys sorted (empty when there is none) — each text field as a 4-byte big-endian length followed by its bytes. Each record's prev_hash is the previous record's hash (64 zeros for record 1).

A cluster. Each member keeps its own log, with its own chain. GET /api/v1/cluster/audit lists every member's records together, and each member's chain is checked on that member (see Working with other members).

Tenant keys are refused on /api/v1/audit and its sub-routes (403). At GET /api/v1/tenant/{id}/audit a tenant key reads the records its own tenant's keys made — seq, time, principal, client_ip, request_id, action, object, outcome, status — with the same filters; another tenant's id is refused.

Not implemented

These paths exist on the router and return 501 Not Implemented with an explanation. They are listed here so you do not spend time discovering by experiment that they return nothing useful.

MethodPathUse instead
GET/api/v1/topologyBuild it from /networks, /bridges, /nics, /cluster/status
POST/api/v1/config/batchApply settings individually
POST/api/v1/cluster/profileNothing to select — every node runs the same services
PUT/api/v1/dns/records/{id}Delete the record and create it again
PUT/api/v1/lb/{id}Delete the VIP and create it again

Each answers 501 with a message naming the alternative. They previously returned a success-shaped body — an empty topology, "applied" with zero changes — which is indistinguishable from a real, empty answer. A 501 you can branch on is more useful than a success you cannot trust.

Event log (Server-Sent Events)

Stream system events as they happen over a long-lived HTTP connection. For a push-style socket see the Real-time WebSocket section below.

GET /api/v1/events?categories=<CSV> — categories is an optional comma-separated filter (uppercase). Omit it for all categories. Valid categories:

TRAFFIC  SECURITY  BANDWIDTH  DHCP  DNS  NETWORK  ALERT  SYSTEM  LB  CLUSTER  COMPUTE
curl -k -N "$NODE/api/v1/events?categories=SECURITY,ALERT" -H "Authorization: Bearer $TOKEN"

The response is text/event-stream. Each event arrives as an SSE frame with an id; heartbeats (: keepalive) hold the connection open:

: connected
retry: 3000

id: 3f2a0c1e-9b7d-4c1a-8e2f-0123456789ab:1790000000000042
event: COMPUTE
data: {"id":"b1c2...","seq":1790000000000042,"type":"compute.vm_state","category":"COMPUTE","timestamp":"2026-09-29T14:13:00Z","node":"3f2a0c1e-9b7d-4c1a-8e2f-0123456789ab","object":"/vms/vm-1a2b3c4d","tenant":"acme","severity":"info","payload":{"vm":"vm-1a2b3c4d","name":"web-01","tenant":"acme","state":"running","desired":"running","reason":"","flags":[]}}

: keepalive

Every event carries:

FieldMeaning
idA unique id (random)
seqThis node's sequence number: it only ever grows, also across agent restarts
timestampWhen it happened, UTC
nodeThe node it happened on
objectWhat it is about, when it is about one thing: /vms/<id>, /volumes/<id>, /images/<node>/<id>
tenantThe tenant that thing belongs to (absent for the operator's own)
severityinfo, warning or error

Picking up where you left off. Each frame's id: line is <node>:<seq>. Reconnect with it in the Last-Event-ID header (browsers do this on their own; ?last_event_id= works too) and the stream first sends every event you missed, then carries on — nothing lost, nothing twice. The node keeps the last 2,048 events for this. When what you missed is no longer there — too long ago, from before the agent restarted, or from another node — or when you read too slowly and events were dropped, the stream sends a resync event instead: reload what you show from the API. Traffic samples (TRAFFIC) are not kept for replay and carry no id: line.

When the stream ends on its own. A stream opened with a key ends, with an end event saying why, once that key is revoked or the node's token changes; one opened through a web console session ends when the session does.

Every member of a cluster at once. GET /api/v1/cluster/events carries the events of every member as one stream, with the same filter; see Working with other members.

Events you will see for machines, volumes, tasks and the cluster:

TypeSent when
compute.vm_stateA machine's state, desired state or flags change (state, desired, reason, flags)
storage.volume_stateA volume's state, machine or size change; deleted when it is gone
task.started, task.finishedA task begins or ends (the whole task)
task.progressA running task's progress or status changes (at most once a second per task)
cluster.membershipA node of the cluster was admitted, joined, given or relieved of a vote, or removed (node, name, change, voter; see Membership events)

Real-time WebSocket

For a bidirectional, push-style stream, connect to the node's WebSocket endpoint:

wss://<node-ip>:7072/ws

Enable it once with cenvero-str-ctl service websocket on. The WebSocket authenticates with the node API token only (not operator-minted or tenant-scoped keys).

1. Connect & authenticate

Provide the token either as a query parameter or an Authorization header:

wss://<node-ip>:7072/ws?token=<api-token>

or Authorization: Bearer <api-token>.

Browser clients can't set request headers on a WebSocket, so use the ?token= form there. The Origin is restricted to the node's own host by default; configure additional allowed origins on the node (api_allowed_origins) if you connect from a different web origin.

2. Subscribe

After the socket opens, send a subscribe message naming the categories you want (uppercase, the same set as the SSE stream). Until you subscribe, **no events are delivered**. Subscriptions are additive; there is no acknowledgement message — matching events simply begin to flow.

{ "action": "subscribe", "categories": ["SECURITY", "ALERT", "NETWORK"] }

3. Receive events

Each event is delivered as a JSON text frame:

{
  "id": "b1c2d3...",
  "type": "alert.fired",
  "category": "ALERT",
  "timestamp": "2026-06-30T14:13:00Z",
  "payload": { "condition_id": "cond-1", "value": 950000000 }
}
FieldTypeNotes
idstringUnique event id
typestringEvent type, e.g. alert.fired, firewall.block
categorystringOne of the categories above
timestampstringUTC RFC 3339
payloadobjectEvent-specific detail (omitted when empty)

Example — wscat

wscat --no-check -c "wss://node.example.net:7072/ws?token=$TOKEN"
# once connected:
> {"action":"subscribe","categories":["SECURITY","ALERT"]}
< {"id":"b1c2...","type":"alert.fired","category":"ALERT","timestamp":"2026-06-30T14:13:00Z","payload":{...}}

(--no-check skips TLS verification for a privately-managed node certificate during testing; in production, trust the node's certificate instead.)

Example — browser

const ws = new WebSocket("wss://node.example.net:7072/ws?token=YOUR_API_TOKEN");

ws.onopen = () => {
  ws.send(JSON.stringify({ action: "subscribe", categories: ["ALERT", "SECURITY"] }));
};

ws.onmessage = (e) => {
  const evt = JSON.parse(e.data);
  console.log(evt.category, evt.type, evt.payload);
};
A client that can't keep up with the event rate is dropped to protect the node; reconnect and re-subscribe to resume.

Event webhooks

Register HTTP endpoints that the node POSTs a signed event to when something happens — an out-of-band, push-style alternative to holding open the SSE /api/v1/events stream or a WebSocket. A webhook is a URL, an optional event-category filter, and a per-webhook secret. Subscriptions are stored on the node and survive a restart, and every delivery carries an HMAC-SHA256 signature so your receiver can verify it. Managing webhooks uses the standard api_token bearer, like the rest of this API.

Delivery is fail-safe: events are handed to a bounded, out-of-band worker pool, so a slow, hanging, or broken receiver can never block the node's event processing or the other webhooks. Each delivery is attempted with a per-attempt timeout and a few retries with exponential backoff; if the delivery queue is ever full, the event is dropped (and counted) rather than blocking.

The URL must be http or https and reach a public address: the node refuses loopback, private and shared (carrier-grade NAT) ranges, link-local addresses including the cloud metadata address, multicast and unspecified addresses, both when the webhook is registered and again on every delivery. A receiver that answers with a redirect is not followed, and deliveries connect directly — a proxy set in the agent's environment is not used.

MethodPathPurpose
GET/api/v1/webhooksList subscriptions (never includes secrets)
POST/api/v1/webhooksRegister a subscription (returns the secret once)
GET/api/v1/webhooks/{id}Get one subscription
DELETE/api/v1/webhooks/{id}Delete a subscription
POST/api/v1/webhooks/{id}/testSend a one-shot test delivery

Subscription fields

FieldTypeNotes
idstringServer-assigned subscription id
urlstringThe http/https endpoint events are POSTed to
categoriesarrayEvent-category filter (uppercase); empty means every category
created_atstringUTC timestamp
deliveredintegerSuccessful deliveries so far
failedintegerDeliveries that failed after all retries
droppedintegerEvents dropped because the delivery queue was full
last_statusintegerHTTP status of the most recent attempt (omitted until the first attempt)
last_errorstringMost recent error, if any (omitted when the last attempt succeeded)

The category filter uses the same uppercase categories as the event stream; an unknown category is rejected. Omit the filter (or send an empty list) to receive every category:

TRAFFIC  SECURITY  BANDWIDTH  DHCP  DNS  NETWORK  ALERT  SYSTEM  LB  CLUSTER

Register a webhook

POST /api/v1/webhooks — body: url (required, http or https); categories (optional filter); secret (optional). Omit secret and the node generates a strong one (prefixed whsec_). Returns 201.

curl -k -X POST "$NODE/api/v1/webhooks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/stratum","categories":["SECURITY","ALERT"]}'
{
  "webhook": {
    "id": "9f1c2d3e4a5b6c7d",
    "url": "https://hooks.example.com/stratum",
    "categories": ["ALERT", "SECURITY"],
    "created_at": "2026-06-30T14:20:00Z",
    "delivered": 0,
    "failed": 0,
    "dropped": 0,
    "secret": "whsec_EXAMPLE_shown_once_store_it_now"
  }
}
The secret is returned only here, at registration — it is the HMAC key your receiver needs to verify deliveries. Store it now; list and GET responses never include it. Lost it? Delete the webhook and register a new one.

List → GET /api/v1/webhooks returns { "webhooks": [ ... ] } (secret-free). Get one → GET /api/v1/webhooks/9f1c2d3e4a5b6c7d returns { "webhook": { ... } }. Delete → DELETE /api/v1/webhooks/9f1c2d3e4a5b6c7d returns { "deleted": "9f1c2d3e4a5b6c7d" }.

Delivery format

Each delivery is an HTTP POST with Content-Type: application/json. The body is the event itself — the same JSON shape delivered over the SSE and WebSocket streams (id, type, category, timestamp, payload). These headers accompany every delivery:

HeaderValue
X-Stratum-Signaturesha256= followed by the hex HMAC-SHA256 of the raw request body, keyed by your webhook secret
X-Stratum-EventThe event category (e.g. SECURITY)
X-Stratum-DeliveryThe event id (matches id in the body)
X-Stratum-TimestampSend time, Unix seconds (UTC)
User-Agentcenvero-stratum-webhook/1

Example delivered body:

{
  "id": "b1c2d3e4f5",
  "type": "firewall.block",
  "category": "SECURITY",
  "timestamp": "2026-06-30T14:20:05Z",
  "payload": { "src": "203.0.113.7", "rule": "deny-inbound" }
}

Verify a delivery by recomputing the signature over the exact bytes you received and comparing it (in constant time) to the X-Stratum-Signature header:

# body = the raw request body; SECRET = your webhook secret
printf '%s' "$body" | openssl dgst -sha256 -hmac "$SECRET"
# prepend "sha256=" to the hex digest, then compare to X-Stratum-Signature

Test a webhook

POST /api/v1/webhooks/{id}/test sends one synchronous test delivery — a webhook_test event in the SYSTEM category — so you can confirm the receiver is reachable and verifies the signature.

curl -k -X POST "$NODE/api/v1/webhooks/9f1c2d3e4a5b6c7d/test" \
  -H "Authorization: Bearer $TOKEN"
{ "tested": "9f1c2d3e4a5b6c7d", "status": "delivered" }

If the receiver is unreachable or returns a non-2xx status, the call reports 502 with { "error": "...", "webhook_id": "..." } — the node is fine, the downstream endpoint is not. An unknown id returns 404.


Bulk create

Two convenience endpoints create many items in one request, each item using the same shape as the corresponding single-create call. Every item is applied independently and the response reports per-item success or failure, so one bad item never aborts the rest.

MethodPathPer-item shape
POST/api/v1/rules/batchA firewall rule (as in POST /api/v1/rules)
POST/api/v1/dns/records/batchA DNS record (as in POST /api/v1/dns/records)

Send the items as a JSON array under rules (firewall) or records (DNS). A batch may hold up to 1000 items; an empty array, or one over the cap, is rejected with 400. Firewall /rules/batch is available on any active plan; /dns/records/batch requires the DNS plan feature. Both are mutating calls, so they are frozen (403) while the node's license is inactive.

The response carries created and failed counts and a results array — one entry per submitted item, in order, each with its index, an ok flag, and either the created object (rule / record) or an error string. The HTTP status reflects the batch as a whole:

OutcomeStatus
Every item created201 Created
Some created, some failed207 Multi-Status
No item created400 Bad Request

Create several firewall rules:

curl -k -X POST "$NODE/api/v1/rules/batch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "rules": [
      { "action": "drop", "protocol": "tcp", "dest_port": 23 },
      { "action": "drop", "protocol": "tcp", "dest_port": 2323 }
    ]
  }'
{
  "created": 2,
  "failed": 0,
  "results": [
    { "index": 0, "ok": true, "rule": { "id": 11, "chain": "prerouting", "priority": 0, "protocol": "tcp", "dest_port": 23, "action": "drop", "stateful": true } },
    { "index": 1, "ok": true, "rule": { "id": 12, "chain": "prerouting", "priority": 0, "protocol": "tcp", "dest_port": 2323, "action": "drop", "stateful": true } }
  ]
}

A partial batch (one item is missing a required field) returns 207, with the valid items created and the bad one reported in place:

curl -k -X POST "$NODE/api/v1/dns/records/batch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "records": [
      { "zone_id": 1, "name": "a", "type": "A", "value": "10.20.0.1" },
      { "zone_id": 1, "name": "b", "type": "A" }
    ]
  }'
{
  "created": 1,
  "failed": 1,
  "results": [
    { "index": 0, "ok": true, "record": { "id": 20, "zone_id": 1, "name": "a", "type": "A", "value": "10.20.0.1", "ttl": 300 } },
    { "index": 1, "error": "zone_id, name, type and value are required" }
  ]
}

Host addressing

The step between a network reserved its gateway address and *something on the host answers on it*. Creating a network does not configure that address on an interface; this is how you do it.

MethodPathPurpose
GET/api/v1/nics/{iface}/addressesAddresses currently on an interface
POST/api/v1/nics/{iface}/addressesPut an address on it
DELETE/api/v1/nics/{iface}/addresses/{cidr}Remove one
curl -k -X POST "$NODE/api/v1/nics/cnv-user-br0/addresses" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"address":"10.20.0.1/24"}'
{ "status": "configured", "interface": "cnv-user-br0", "address": "10.20.0.1/24",
  "addresses": ["10.20.0.1/24", "fe80::.../64"] }

You usually do not need to name the interface. POST /api/v1/addresses defaults to the workload bridge, which is where a managed network's gateway address belongs and the only sensible answer on a normal node:

curl -k -X POST "$NODE/api/v1/addresses" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"address":"10.20.0.1/24"}'

Name one explicitly — in the path or as "interface" in the body — when the node has a second bridge or a dedicated interface.

The prefix is required — a bare address is refused rather than guessed. Assuming /32 where you meant /24 produces an interface that looks correct and cannot reach its own subnet.

Adding is idempotent, so you can reconcile toward a desired state instead of tracking what you have already done.

The management interface is refused. It carries the address this node is reached on, and a call that strips it is not recoverable without console access. Reads are allowed on every interface — seeing the node's own addressing is what a panel needs to render a network page, and reading cannot strand anything.

To delete, put the address in the path with its prefix URL-escaped:

curl -k -X DELETE "$NODE/api/v1/nics/cnv-user-br0/addresses/10.20.0.1%2F24" \
  -H "Authorization: Bearer $TOKEN"

Escaping a slash inside a path segment behaves differently across HTTP clients, proxies and shells, so every one of these is accepted and means the same thing:

FormExample
Prefix escaped.../addresses/10.20.0.1%2F24
Dash instead of the slash.../addresses/10.20.0.1-24
Plain slash.../addresses/10.20.0.1/24
Query parameter.../addresses?address=10.20.0.1/24
Bare address.../addresses/10.20.0.1

A bare address carries no prefix, so the prefix is read back off the interface. That is deliberate: removing 10.20.0.1/32 when the interface holds 10.20.0.1/24 would match nothing and quietly remove nothing. If the address is not on the interface you get 404 rather than a silent success.

Omitting the interface works here too — DELETE /api/v1/addresses/... defaults to the workload bridge, the same as POST.

The management interface is refused on every one of these forms.

DHCP scopes

A network's address pool exists from the moment you create it. The DHCP server still will not answer for that subnet until a scope binds serving to the pool. Without one the server has nothing to offer and, per RFC 2131, stays silent — the client retries with no reply, which looks exactly like a broken connection.

MethodPathPurpose
GET/api/v1/dhcp/scopesList scopes
POST/api/v1/dhcp/scopesBind serving for a subnet to a pool
DELETE/api/v1/dhcp/scopes/{subnet}Remove a scope
curl -k -X POST "$NODE/api/v1/dhcp/scopes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"subnet":"10.20.0.0/24","pool_id":16,"gateway":"10.20.0.1",
       "subnet_mask":"255.255.255.0","dns":["1.1.1.1"],"lease_seconds":3600}'

subnet and pool_id are required. Find the pool with GET /api/v1/ipam/pools — a network's pool carries the network's name. A scope with no pool is refused, because it would reproduce exactly the silent failure this exists to prevent. Addresses in gateway, subnet_mask, server_ip and dns must be valid, or the call is 400.

Two things to plan around:

  • A scope added here is kept across restarts. Posting a scope for a subnet that already has one replaces it; DELETE removes it for good. Scopes in the node configuration (dhcp_scopes) are applied at every start — deleting one of those here lasts until the next start, and an API scope for the same subnet takes its place. lease_seconds cannot be negative (400).
  • How a request is matched. A request that reaches the node directly is served from the scope whose subnet contains an address the node holds on the interface it arrived on; a relayed request, from the scope whose subnet holds the relay's giaddr. Leave server_ip or subnet_mask out and the node's own address on that subnet and the subnet's own mask are sent. See DHCP & DNS.

Deleting takes the subnet URL-escaped: .../dhcp/scopes/10.20.0.0%2F24. As with interface addressing, the dash form (10.20.0.0-24), a plain slash and ?subnet=10.20.0.0/24 are all accepted. Any address inside the subnet resolves to the scope covering it, so 10.20.0.5/24 removes the 10.20.0.0/24 scope.

Reservations, node identity and services

The last of what used to need a shell.

Static DHCP reservations

Pin an address to a MAC so a workload always gets the same one.

MethodPathPurpose
GET/api/v1/dhcp/reservationsList reservations
POST/api/v1/dhcp/reservationsPin an address to a MAC
DELETE/api/v1/dhcp/reservations/{mac}Release one
curl -k -X POST "$NODE/api/v1/dhcp/reservations" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mac":"02:ce:0a:4d:00:02","ip":"10.20.0.50","hostname":"db-1"}'

The address is matched to its pool from the address itself, so a reservation does not name a network — and an address outside every configured range is refused rather than stored against nothing.

Node identity

GET /api/v1/node returns the hardware id this node's license and certificates are bound to, plus its version.

curl -k "$NODE/api/v1/node" -H "Authorization: Bearer $TOKEN"
{ "hardware_id": "b57bd87...", "hardware_id_available": true,
  "mode": "gateway", "version": "1.0.0-rc.74" }

mode is always gateway: it is kept only so existing integrations that read it do not break. There is one kind of node, and every node routes.

This is the value you match a pending activation or certificate request against before approving it. hardware_id_available is false where the id could not be derived — on a virtual machine, where the binding inputs are not stable. Show that rather than presenting the value as an identity.

Services

GET /api/v1/service reports each network service: the operator's switch, the address it binds, and whether it is actually listening.

curl -k "$NODE/api/v1/service" -H "Authorization: Bearer $TOKEN"

Read-only, deliberately. A switch takes effect at the next agent restart, and an API that flips one without being able to restart the agent would leave you unable to tell whether anything happened. Toggling stays local until the agent can apply it live.

Subsystem reads

These subsystems are configured from the CLI and can now be read over the API, so a panel can show what a node actually has without shelling in. Each returns the same view its CLI command shows — they call the same manager.

MethodPathPurpose
GET/api/v1/l7lb/statusLayer-7 balancer state
GET/api/v1/l7lb/poolsBackend pools
GET/api/v1/l7lb/frontendsFrontends (host/path routing, TLS)
GET/api/v1/vrfVirtual routing & forwarding devices
GET/api/v1/geneveGeneve overlay tunnels
GET/api/v1/nat64/statusNAT64 configuration + live bindings
GET/api/v1/pluginsInstalled plugins
GET/api/v1/apikeysOperator API keys — metadata only
curl -k "$NODE/api/v1/l7lb/status" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/vrf"        -H "Authorization: Bearer $TOKEN"

A subsystem that is not running on this node answers 503 naming it, rather than an empty list — "not available here" and "none configured" are different answers and a panel should be able to tell them apart.

/apikeys returns id, label, scope and timestamps. It never returns key material: a minted key is shown once at creation and is not recoverable.

Creating and changing these is still CLI-only. Their mutating commands take positional arguments rather than a JSON body, and exposing them as a passthrough would have locked in an awkward shape; they will get designed request bodies rather than a quick wrapper.

Metrics scrape

GET /api/v1/metrics returns the node's metrics in **Prometheus text exposition format**, behind the same api_token bearer as the rest of the API — so a Prometheus scrape config authenticates like any other client. It is read-only (a GET, never blocked by a license freeze) and simply renders already-collected counters and gauges; it never touches the data plane.

This is distinct from the optional standalone metrics listener (the loopback metrics_bind_addr, default 127.0.0.1:9090, described in Configuration). Both expose the same stratum_* metric set; this endpoint surfaces it on the authenticated REST API so you can scrape it over the management port without opening a second listener.
curl -k "$NODE/api/v1/metrics" -H "Authorization: Bearer $TOKEN"

Unlike the JSON endpoints, the response is Content-Type: text/plain; version=0.0.4; charset=utf-8 — the standard Prometheus exposition body, one # HELP/# TYPE header per metric family followed by its samples:

# HELP stratum_uptime_seconds Agent uptime in seconds
# TYPE stratum_uptime_seconds gauge
stratum_uptime_seconds 86400
# HELP stratum_active_tasks Active internal tasks
# TYPE stratum_active_tasks gauge
stratum_active_tasks 42
# HELP stratum_nic_rx_bytes Total received bytes per NIC
# TYPE stratum_nic_rx_bytes counter
stratum_nic_rx_bytes{interface="cnv-nic-0"} 481920512
# HELP stratum_firewall_drops_total Total firewall drops
# TYPE stratum_firewall_drops_total counter
stratum_firewall_drops_total{chain="input",reason="acl"} 1204

The exposition covers the families the node already collects: the node's own figures every 10 seconds (stratum_node_*: CPU busy and I/O wait, load, memory, swap, filesystems, and bytes per managed interface — see Monitoring), runtime gauges (stratum_uptime_seconds, stratum_active_tasks), per-VM byte/packet counters, per-bridge connection gauges, per-NIC rx/tx byte counters, per-reason firewall-drop counters, load-balancer active-connection gauges, BGP session-state and cluster-state gauges, and an alerts-fired counter.

A minimal Prometheus scrape configuration:

scrape_configs:
  - job_name: cenvero-stratum
    scheme: https
    metrics_path: /api/v1/metrics
    authorization:
      credentials: <your-api-token>
    tls_config:
      insecure_skip_verify: true   # node cert is privately managed; pin it in production
    static_configs:
      - targets: ["node.example.com:7070"]

gRPC endpoint

The node also exposes a gRPC endpoint:

<node-ip>:7071

Enable it with cenvero-str-ctl service grpc on. It uses the **same TLS certificate as REST/WebSocket and requires the node API token** in the authorization metadata on every call (authorization: Bearer <api-token>). The node may optionally require a client certificate when configured.

The gRPC port serves the standard gRPC Health Checking protocol (grpc.health.v1.Health), so load balancers and orchestration systems can probe node liveness over gRPC. The full management surface is the REST API documented above — gRPC is for health/liveness probing, not a REST mirror.

grpcurl -H "authorization: Bearer $TOKEN" \
  node.example.net:7071 grpc.health.v1.Health/Check
{ "status": "SERVING" }
Server reflection is intentionally disabled; supply the standard grpc.health.v1 descriptor (or use a purpose-built health-probe client). Use the empty service name for overall node health, or cenvero.stratum for the agent's service.

Every endpoint, ready to paste

One line per endpoint, generated from the routes the agent actually serves — so it cannot drift from the code. Set these once and the rest copies straight out:

NODE=https://your-node:7070
TOKEN=your-api-token

Placeholders in braces are yours to fill. Request bodies for the mutating calls are in the sections above; this is the shape and the address, not a replacement for them.

Accounting & billing data

curl -k "$NODE/api/v1/accounting/95th" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/accounting/billing" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/accounting/summary" -H "Authorization: Bearer $TOKEN"

Host addressing (default interface)

curl -k -X POST "$NODE/api/v1/addresses" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Alerting

curl -k "$NODE/api/v1/alerts" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/alerts" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/alerts/conditions" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/alerts/conditions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/alerts/conditions/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/alerts/conditions/{id}/actions" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/alerts/history" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/alerts/{id}/ack" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Operator API keys

curl -k "$NODE/api/v1/apikeys" -H "Authorization: Bearer $TOKEN"

Audit

curl -k "$NODE/api/v1/audit?limit=50" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/audit/export" -H "Authorization: Bearer $TOKEN" -o audit.jsonl
curl -k "$NODE/api/v1/audit/verify" -H "Authorization: Bearer $TOKEN"

Graph history

curl -k "$NODE/api/v1/rrd/node?timeframe=day" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/rrd/interfaces/cnv-nic-0?timeframe=live" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/rrd/vms/{id}?timeframe=week&cf=max" -H "Authorization: Bearer $TOKEN"

Backups

curl -k "$NODE/api/v1/backups" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/backups" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/backups/restore" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/backups/schedules" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/backups/schedules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/backups/schedules/{id}" -H "Authorization: Bearer $TOKEN"

Bandwidth & quotas

curl -k "$NODE/api/v1/bandwidth" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bandwidth" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/bandwidth/pools" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/bandwidth/pools/members" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bandwidth/pools/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/bandwidth/pools/{id}/members/{mac}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/bandwidth/quotas" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bandwidth/quotas" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/bandwidth/quotas/{mac}" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/bandwidth/{id}" -H "Authorization: Bearer $TOKEN"

BGP

curl -k -X POST "$NODE/api/v1/bgp/announce" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/bgp/neighbors" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bgp/neighbors" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bgp/neighbors/{addr}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/bgp/policy" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bgp/policy/{dir}" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/bgp/prefix-lists" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bgp/prefix-lists" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/bgp/route-maps" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bgp/route-maps" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/bgp/routes" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/bgp/status" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bgp/withdraw" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Billing automation

curl -k "$NODE/api/v1/billing/tenants/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/billing/tenants/{id}/limit" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/billing/tenants/{id}/resume" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/billing/tenants/{id}/suspend" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/billing/tenants/{id}/unlimit" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

NIC bonds

curl -k "$NODE/api/v1/bonds" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bonds" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bonds/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/bonds/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bonds/{id}/members" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bonds/{id}/members/{iface}" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/bonds/{id}/mtu" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Bridges

curl -k "$NODE/api/v1/bridges" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bridges" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bridges/{name}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/bridges/{name}/ports" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/bridges/{name}/ports/{iface}" -H "Authorization: Bearer $TOKEN"

Cluster

curl -k "$NODE/api/v1/cluster" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/cluster/status" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/cluster/state" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/cluster/create" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/cluster/join-codes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/cluster/join-codes" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/cluster/join-codes/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/cluster/join-preview" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/cluster/join-with-code" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/cluster/leave" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/cluster/members/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/cluster/members/{id}/rekey" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/cluster/ca/rotate" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/cluster/resources" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/cluster/tasks" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/cluster/audit" -H "Authorization: Bearer $TOKEN"
curl -k -N "$NODE/api/v1/cluster/events" -H "Authorization: Bearer $TOKEN"
# A cluster set up by hand with an earlier version
curl -k -X POST "$NODE/api/v1/cluster/join" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
# 501 — see Not implemented
curl -k -X POST "$NODE/api/v1/cluster/profile" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Configuration

# 501 — see Not implemented
curl -k -X POST "$NODE/api/v1/config/batch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Container networking

curl -k "$NODE/api/v1/containers" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/containers/attach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/containers/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/containers/{id}/detach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Dashboard

curl -k "$NODE/api/v1/dashboard" -H "Authorization: Bearer $TOKEN"

DHCP

curl -k "$NODE/api/v1/dhcp/leases" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/dhcp/reservations" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/dhcp/reservations" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/dhcp/reservations/{mac}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/dhcp/scopes" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/dhcp/scopes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/dhcp/scopes/{subnet}" -H "Authorization: Bearer $TOKEN"

DNS

curl -k "$NODE/api/v1/dns/dnssec/{zone}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/dns/records" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/dns/records" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/dns/records/batch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/dns/records/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/dns/records/{id}" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'   # 501
curl -k "$NODE/api/v1/dns/zones" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/dns/zones" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/dns/zones/{id}" -H "Authorization: Bearer $TOKEN"

Endpoint index

curl -k "$NODE/api/v1/docs" -H "Authorization: Bearer $TOKEN"

Event stream

curl -k -N "$NODE/api/v1/events" -H "Authorization: Bearer $TOKEN"
curl -k -N "$NODE/api/v1/events" -H "Authorization: Bearer $TOKEN" -H "Last-Event-ID: {node}:{seq}"

Nodes and resources

curl -k "$NODE/api/v1/nodes" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/resources" -H "Authorization: Bearer $TOKEN"
# Any route of another cluster member: /api/v1/nodes/{node}/ in front of its path
curl -k "$NODE/api/v1/nodes/{node}/vms" -H "Authorization: Bearer $TOKEN"

Floating IPs

curl -k "$NODE/api/v1/float" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/float" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/float/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/float/{id}" -H "Authorization: Bearer $TOKEN"

Flow tracking

curl -k "$NODE/api/v1/flows" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/flows/export" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/flows/stats" -H "Authorization: Bearer $TOKEN"

Port forwarding

curl -k "$NODE/api/v1/forward" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/forward" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/forward/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/forward/{id}" -H "Authorization: Bearer $TOKEN"

Gateway & NAT

curl -k -X POST "$NODE/api/v1/gateway/failback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/gateway/failover" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/gateway/snat" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/gateway/snat" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/gateway/snat/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/gateway/status" -H "Authorization: Bearer $TOKEN"

Geneve tunnels

curl -k "$NODE/api/v1/geneve" -H "Authorization: Bearer $TOKEN"

Self-healing

curl -k "$NODE/api/v1/heal" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/heal/check" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Health

curl -k "$NODE/api/v1/health" -H "Authorization: Bearer $TOKEN"

Address pools

curl -k -X POST "$NODE/api/v1/ipam/allocate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/ipam/allocations" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/ipam/pools" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/ipam/pools" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/ipam/pools/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/ipam/release" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Layer-7 load balancer

curl -k "$NODE/api/v1/l7lb/frontends" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/l7lb/pools" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/l7lb/status" -H "Authorization: Bearer $TOKEN"

Layer-4 load balancer

curl -k "$NODE/api/v1/lb" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/lb" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/lb/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/lb/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/lb/{id}" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'   # 501
curl -k -X POST "$NODE/api/v1/lb/{id}/backends" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/lb/{id}/backends/{bid}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/lb/{id}/backends/{bid}/health" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

License

curl -k "$NODE/api/v1/license" -H "Authorization: Bearer $TOKEN"

MAC bindings

curl -k "$NODE/api/v1/macbind" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/macbind" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/macbind/{mac}" -H "Authorization: Bearer $TOKEN"

Metrics

curl -k "$NODE/api/v1/metrics" -H "Authorization: Bearer $TOKEN"

NAT64

curl -k "$NODE/api/v1/nat64/status" -H "Authorization: Bearer $TOKEN"

Networks & endpoints

curl -k "$NODE/api/v1/networks" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/networks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/networks/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/networks/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/networks/{id}/attach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/networks/{id}/endpoints" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/networks/{id}/endpoints/{eid}/detach" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X PUT "$NODE/api/v1/networks/{id}/endpoints/{eid}/mac" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X PUT "$NODE/api/v1/networks/{id}/host-gateway" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/networks/{id}/host-gateway" -H "Authorization: Bearer $TOKEN"

Routed public addresses

curl -k "$NODE/api/v1/routed-addresses" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/routed-addresses" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/routed-addresses/{id}" -H "Authorization: Bearer $TOKEN"

Interfaces & addressing

curl -k "$NODE/api/v1/nics" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/nics/{iface}/addresses" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/nics/{iface}/addresses" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/nics/{iface}/addresses/{cidr}" -H "Authorization: Bearer $TOKEN"

Node identity

curl -k "$NODE/api/v1/node" -H "Authorization: Bearer $TOKEN"

Plugins

curl -k "$NODE/api/v1/plugins" -H "Authorization: Bearer $TOKEN"

Routing

curl -k "$NODE/api/v1/routes" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/routes" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/routes/rules" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/routes/rules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/routes/rules/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X DELETE "$NODE/api/v1/routes/{id}" -H "Authorization: Bearer $TOKEN"

Firewall rules

curl -k "$NODE/api/v1/rules" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/rules" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X POST "$NODE/api/v1/rules/batch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/rules/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/rules/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/rules/{id}" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Services

curl -k "$NODE/api/v1/service" -H "Authorization: Bearer $TOKEN"

Status

curl -k "$NODE/api/v1/status" -H "Authorization: Bearer $TOKEN"

Tenants

curl -k "$NODE/api/v1/tenant" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/tenant" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/tenant/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tenant/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/tenant/{id}" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/tenant/{id}/keys" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/tenant/{id}/keys" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/tenant/{id}/keys/{kid}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tenant/{id}/quota" -H "Authorization: Bearer $TOKEN"
curl -k -X PUT "$NODE/api/v1/tenant/{id}/quota" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k "$NODE/api/v1/tenant/{id}/tasks" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tenant/{id}/tasks/{tid}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tenant/{id}/resources" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tenant/{id}/nodes" -H "Authorization: Bearer $TOKEN"

Tasks

curl -k "$NODE/api/v1/tasks" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tasks/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/tasks/{id}/log" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/tasks/{id}/cancel" -H "Authorization: Bearer $TOKEN"

TLS

curl -k "$NODE/api/v1/tls/pubkey" -H "Authorization: Bearer $TOKEN"

Topology

curl -k "$NODE/api/v1/topology" -H "Authorization: Bearer $TOKEN"   # 501 — see Not implemented

VLAN lockdown

curl -k "$NODE/api/v1/vlan" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/vlan" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/vlan/{id}" -H "Authorization: Bearer $TOKEN"

VRF devices

curl -k "$NODE/api/v1/vrf" -H "Authorization: Bearer $TOKEN"

VXLAN overlays

curl -k "$NODE/api/v1/vxlan/fdb" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/vxlan/networks" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/vxlan/networks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/vxlan/networks/{vni}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/vxlan/networks/{vni}/peers" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/vxlan/networks/{vni}/peers" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/vxlan/networks/{vni}/peers/{host}" -H "Authorization: Bearer $TOKEN"

Webhooks

curl -k "$NODE/api/v1/webhooks" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/webhooks" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'
curl -k -X DELETE "$NODE/api/v1/webhooks/{id}" -H "Authorization: Bearer $TOKEN"
curl -k "$NODE/api/v1/webhooks/{id}" -H "Authorization: Bearer $TOKEN"
curl -k -X POST "$NODE/api/v1/webhooks/{id}/test" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}'

Feedback

Found a gap, an inaccuracy, or something you wish this API did? We want to hear it. Tell us through the contact form on the website. Please include the agent version (cenvero-str-ctl version) and the endpoint in question.

See also

  • CLI Reference — the same managers from the local cenvero-str-ctl command line.
  • Configuration — node config, ports, and the API settings.
  • Clustering — forming a cluster and managing every node from any of them.
  • Licensing — activation, renewal, and the enforcement states.
↓ This page as JSON ↓ All documentation as JSON