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:
| Credential | How you get it | Scope | Works on |
|---|---|---|---|
| Node API token | The api_token you configured on the node | Full (node-wide) | REST, WebSocket, gRPC |
| Operator API key | cenvero-str-ctl apikeys mint <label> (secret shown once) | Full (node-wide) | REST only |
| Tenant-scoped key | POST /api/v1/tenant/{id}/keys (see Multi-tenancy below) | Confined to one tenant | REST 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 }
}
| Field | Meaning |
|---|---|
state | active, warning (expiring soon), grace (just expired), frozen |
mutations_allowed | Whether state-changing requests are accepted right now |
hardware_grace | The license is valid but bound to hardware that no longer matches |
max_bandwidth_gbps | The plan's aggregate node ceiling; 0 means uncapped |
days_remaining | Negative 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 withlimit.) - Compression — send
Accept-Encoding: gzipand 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 aLocation: /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:
| Status | Meaning |
|---|---|
400 | Malformed request — bad/invalid body or parameter (e.g. an invalid CIDR). |
401 | Missing or invalid bearer token: {"error":"unauthorized"}. |
403 | Forbidden — see the cases below. |
404 | Unknown resource (e.g. an unknown network or record id). |
413 | Request body over the 1 MiB cap. |
415 | Body sent without Content-Type: application/json. |
429 | Rate-limited, or too many failed auth attempts (temporary IP block). |
501 | The operation is intentionally not supported (noted per-endpoint). |
503 | That 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 prefix | Required feature |
|---|---|
/networks | Private 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
| Field | Type | Notes |
|---|---|---|
id | string | Server-assigned network id. |
name | string | Required on create, unique per node. |
cidr | string | Required, IPv4 only (e.g. 10.20.0.0/24). |
gateway | string | Optional gateway IP. |
vlan | int | Optional VLAN id to tag the network. |
tenant_id | string | Optional — scopes the network to one of your tenants. |
created_at | string | UTC timestamp. |
host_gateway | object | {"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_startorrange_enddefaults 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_startandrange_endmust 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/bridges | List bridges and their member interfaces |
| POST | /api/v1/bridges | Create a bridge (name required) |
| DELETE | /api/v1/bridges/{name} | Delete a bridge |
| POST | /api/v1/bridges/{name}/ports | Attach 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).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/macbind | List bindings |
| POST | /api/v1/macbind | Create 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/forward | List port-forward rules |
| POST | /api/v1/forward | Create 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/rules | List rules (optional ?chain= filter) |
| POST | /api/v1/rules | Create 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/default | The default action for traffic no rule matches: {"default_action":"allow"} or "deny" |
| PUT | /api/v1/rules/default | Set it: body {"default_action":"deny"} (or "allow") |
Rule fields
| Field | Type | Notes |
|---|---|---|
id | integer | Assigned by the node (response only) |
action | string | Required. accept or drop |
priority | integer | Evaluation priority |
protocol | string | tcp, udp or icmp (omit for any). icmp is IPv4 ICMP only and takes no ports |
source_ip / dest_ip | string | Address or CIDR; both the same family when both are given |
source_port / dest_port | integer | A single port; a port rule matches TCP and UDP only |
dest_port_max | integer | Makes dest_port the start of an inclusive range |
interface | string | Bind the rule to one ingress device (e.g. cnv-user-br0) |
mac | string | Match a source MAC (aa:bb:cc:dd:ee:ff); omit for any |
level | string | Policy 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 |
comment | string | Free text |
chain | string | Optional; the only value is prerouting (where every rule applies) |
stateful | boolean | Optional; 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.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/lb | List VIPs |
| POST | /api/v1/lb | Create 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}/backends | Add a backend |
| DELETE | /api/v1/lb/{id}/backends/{bid} | Remove a backend |
| POST | /api/v1/lb/{id}/backends/{bid}/health | Manually set a backend up/down |
VIP fields — id, frontend_ip, and algorithm are required on create.
| Field | Type | Notes |
|---|---|---|
id | string | Your VIP identifier |
frontend_ip | string | The virtual IP |
frontend_port | integer | |
protocol | string | tcp or udp |
algorithm | string | round-robin, least-conn, source-hash, weighted, maglev, consistent-hash |
dsr_enabled | boolean | Direct server return |
health_check | object | Optional 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.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/bgp/status | Engine summary |
| GET | /api/v1/bgp/neighbors | List peers |
| POST | /api/v1/bgp/neighbors | Add a peer |
| DELETE | /api/v1/bgp/neighbors/{addr} | Remove a peer |
| GET | /api/v1/bgp/routes | Route table (?family=ipv4 default, or ipv6; anything else is 400) |
| POST | /api/v1/bgp/announce | Advertise a prefix |
| POST | /api/v1/bgp/withdraw | Withdraw a prefix |
| GET | /api/v1/bgp/prefix-lists | List prefix-lists |
| POST | /api/v1/bgp/prefix-lists | Create a prefix-list |
| GET | /api/v1/bgp/route-maps | List route-maps |
| POST | /api/v1/bgp/route-maps | Create a route-map |
| GET | /api/v1/bgp/policy | Show 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/bonds | List bonds |
| POST | /api/v1/bonds | Create a bond |
| GET | /api/v1/bonds/{id} | Get one bond |
| DELETE | /api/v1/bonds/{id} | Delete a bond |
| POST | /api/v1/bonds/{id}/members | Enslave a member NIC |
| DELETE | /api/v1/bonds/{id}/members/{iface} | Release a member NIC |
| PUT | /api/v1/bonds/{id}/mtu | Set 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/statusreports itspeer_stateassolo, 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.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/gateway/status | Current HA state |
| POST | /api/v1/gateway/failover | Yield the VIP to the peer |
| POST | /api/v1/gateway/failback | Take 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/gateway/snat | List NAT rules |
| POST | /api/v1/gateway/snat | Add 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
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/float | List floating IPs |
| POST | /api/v1/float | Assign 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/flows | List flows (?state=new/active/closed) |
| GET | /api/v1/flows/stats | Aggregate statistics |
| GET | /api/v1/flows/export | Download 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/accounting/summary | Totals for the period |
| GET | /api/v1/accounting/billing | Per-source usage + cost (?rate= $/Mbps) |
| GET | /api/v1/accounting/95th | 95th-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.)*
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/bandwidth | List limits + pools (or one limit via ?mac=) |
| POST | /api/v1/bandwidth | Create/update a per-MAC limit |
| POST | /api/v1/bandwidth/pools | Create a shared pool |
| POST | /api/v1/bandwidth/pools/members | Add a MAC to a pool |
| GET | /api/v1/bandwidth/quotas | List quotas (or one via ?mac=) |
| POST | /api/v1/bandwidth/quotas | Create/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.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/cluster | Cluster summary |
| GET | /api/v1/cluster/status | State, leader, voters and members |
| GET | /api/v1/cluster/state | Counts of the shared settings |
| POST | /api/v1/cluster/create | Form a cluster with this node as its first member (a task) |
| POST | /api/v1/cluster/join-codes | Make a join code |
| GET | /api/v1/cluster/join-codes | List 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-preview | Ask the cluster what joining it would mean, changing nothing (on the node that would join) |
| POST | /api/v1/cluster/join-with-code | Join 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/leave | Take this node out of its cluster |
| POST | /api/v1/cluster/members/{id}/rekey | Give a member a new identity (a task) |
| POST | /api/v1/cluster/ca/rotate | Replace the cluster's certificate authority (a task) |
| GET | /api/v1/cluster/resources | Every object of every member (see Working with other members) |
| GET | /api/v1/cluster/tasks | Every member's tasks |
| GET | /api/v1/cluster/audit | Every member's audit records |
| GET | /api/v1/cluster/events | Every member's events, as one stream |
| POST | /api/v1/cluster/join | Only 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:
| Route | Command line |
|---|---|
GET /api/v1/cluster/status | cluster status |
GET /api/v1/cluster/join-codes | cluster 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/leave | cluster 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" }
]
}
| Field | Meaning |
|---|---|
enabled | Whether this node is in a cluster |
state | This 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) |
leader | The leader's cluster address; empty when there is none |
is_leader | Whether this node is the leader |
cluster | The cluster's id and name |
voters | How many members vote |
peers | Every 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 |
error | Only 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" }
]
}
| Field | Meaning |
|---|---|
cluster | The cluster's id and name (the code itself carries only the id) |
members | How many nodes it has |
tenants.shared | This node's tenants the cluster does not have yet: they become the cluster's |
tenants.merged | This 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 |
conflicts | What 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/tenant | List tenants |
| POST | /api/v1/tenant | Create 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}/quota | Read / set a tenant's quota |
| GET / POST | /api/v1/tenant/{id}/keys | List / 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.
| Field | Type | Meaning |
|---|---|---|
max_bandwidth_bps | int64 | Bandwidth cap in bits/sec (0 = unlimited) |
max_vms | int | Virtual 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 tenant | PUT / DELETE /api/v1/tenant/{id} — rename, change status, delete |
GET /api/v1/tenant/{id}/quota — its caps and usage | PUT /api/v1/tenant/{id}/quota — change any cap |
GET /api/v1/billing/tenants/{id} — its billing state | POST /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 keys | Creating, 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/tenant/{id}/vms | The 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}/metrics | The machine's own figures, in the Prometheus text format of /metrics |
| POST | /api/v1/tenant/{id}/vms/{vmid}/start | Start it |
| POST | /api/v1/tenant/{id}/vms/{vmid}/stop | Ask it to shut down (optional {"timeout_seconds": N}) |
| POST | /api/v1/tenant/{id}/vms/{vmid}/force-stop | Cut its power |
| POST | /api/v1/tenant/{id}/vms/{vmid}/restart | Restart it (optional {"force": true}) |
| POST | /api/v1/tenant/{id}/vms/{vmid}/password | Set a guest user's password through the guest agent: {"user": "…", "password": "…"} |
| POST | /api/v1/tenant/{id}/vms/{vmid}/console | A single-use console ticket: {"type": "serial", "force": false} (type is vnc, the default, or serial) |
| GET | /api/v1/tenant/{id}/networks | The 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}/addresses | The public addresses the tenant's machines hold, and their gateway |
| GET | /api/v1/tenant/{id}/vms/{vmid}/rrd | The machine's graphs: the same query and answer as /api/v1/rrd/vms/{id} (see Graphs) |
| GET | /api/v1/tenant/{id}/audit | The 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
pathis 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}/auditshows 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:
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/tenant/{id}/tasks | The 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}/resources | The tenant's machines, networks and volumes in one list, with their state |
| GET | /api/v1/tenant/{id}/nodes | The 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:youorprovider). 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 aLocationheader 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.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/billing/tenants/{id}/suspend | Mark the tenant suspended and stop its workloads' traffic, both ways (see Billing Integration) |
| POST | /api/v1/billing/tenants/{id}/resume | Mark the tenant active and lift the suspension |
| POST | /api/v1/billing/tenants/{id}/limit | Apply an aggregate rate cap |
| POST | /api/v1/billing/tenants/{id}/unlimit | Remove 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/containers | List attached containers |
| POST | /api/v1/containers/attach | Attach a container netns |
| GET | /api/v1/containers/{id} | Get one container |
| POST | /api/v1/containers/{id}/detach | Detach (frees the endpoint) |
Attach body:
| Field | Type | Required | Notes |
|---|---|---|---|
runtime | string | yes | lxc, docker, or podman |
network_id | string | yes | A managed network you've already created |
netns_pid | int | yes | The container init PID whose network namespace is the target |
container_id | string | no | Your runtime's container id (for tracking) |
ip | string | no | Request a specific IP; omitted = next free in the network |
firewall | bool | no | Apply the managed per-container firewall rule |
id | string | no | Supply 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).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/tasks | Tasks, newest first |
| GET | /api/v1/tasks/{id} | One task |
| GET | /api/v1/tasks/{id}/log | Its log (start: the first line, from 1; limit: at most 2,000, default 500) |
| POST | /api/v1/tasks/{id}/cancel | Stop it, where the operation can stop safely |
GET /api/v1/tasks filters:
| Parameter | Meaning |
|---|---|
state | Comma-separated states: queued, running, succeeded, failed, cancelled, interrupted |
type | A type (vm.start), or every type under a prefix ending in a dot (backup.) |
object | Tasks on this machine, image, volume, snapshot or backup — a machine's history includes the attaching and detaching of its volumes |
owner | Tasks started with this key id (api-token for the node's token, root for its command line) |
tenant_id | Tasks on this tenant's objects (empty: the operator's own) |
since, until | Start time, RFC 3339 (2026-09-29T12:00:00Z) or a duration back from now (24h) |
limit | 1–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) orsystem(the node itself).viasays 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;
totalis 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);
omittedsays how many lines between them are no longer kept. To follow a running task, ask again fromnextuntilendedis 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
interruptedfrom 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_atstays 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/taskslists every member's tasks together, and a task begun on another member through this one names this one in its owner'svia(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,tenantandstatus, 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) andtenant_id(tenant_id=for the operator's own). - The answer carries an
ETag; send it back inIf-None-Matchand 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/resourceslists one node's, each with itsnode. A tenant is listed once, with thenodesit has resources on. A member that is not answering contributes its objects from its last answer, marked"stale": truewithlast_seen. Filters:type,tenant_id,nodeandstale;ETagand 304 as for/resources.GET /api/v1/cluster/tasksandGET /api/v1/cluster/audit— every member's tasks or audit records, newest first, with the same filters as on one node,limitup to 500, andnextto pass back for the next page. The members that did not answer are listed inunreachable, each with itsnode,nameandlast_seen. Each member keeps its own audit log: check one withGET /api/v1/nodes/<node id>/audit/verify.GET /api/v1/cluster/events— every member's events as one stream. Each frame'sid:isc:…: reconnect to the same member with it inLast-Event-IDto pick up where you left off. Anode.resyncevent ({"node": "<node id>"}) means one member's events could not all be caught up: reload that member's objects.resyncand 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:
| Status | Answer |
|---|---|
| 404 | no node <id> in this cluster |
| 409 | node <name> has not finished joining the cluster |
| 409 | node <name> runs a version that cannot be managed from here; update it, or use its own console at https://…/console/ (with console_url) |
| 409 | node <name>'s clock differs from this node's by <n> s; requests to it are refused until the clocks agree |
| 503 | node <name> is not answering (last seen <time>); try again when it is back (with last_seen) |
| 504 | node <name> did not answer in time |
| 502 | node <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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/alerts | List fired alerts (?state=firing/resolved/acknowledged) |
| GET | /api/v1/alerts/conditions | List conditions |
| POST | /api/v1/alerts/conditions | Create a condition |
| DELETE | /api/v1/alerts/conditions/{id} | Delete a condition |
| POST | /api/v1/alerts/conditions/{id}/actions | Attach an action |
| POST | /api/v1/alerts/{id}/ack | Acknowledge an alert |
| GET | /api/v1/alerts/history | Full 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/alertsis not a fire endpoint — alerts fire from configured conditions. It returns400pointing you to the condition/ack subpaths above.
Self-healing
The node runs periodic health checks and repairs what it can.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/heal | Latest check results |
| POST | /api/v1/heal/check | Run 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/backups | List backups |
| POST | /api/v1/backups | Create a backup (type: config (default) or full) |
| POST | /api/v1/backups/restore | Stage a full backup for a rollback (ref: id or path) |
| GET | /api/v1/backups/schedules | List schedules |
| POST | /api/v1/backups/schedules | Create 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" }
}
| Field | Meaning |
|---|---|
host | Hostname, uptime in seconds, CPU cores, total memory in MB |
nics | Each network card, with its byte counters |
traffic.total_bytes_in / total_bytes_out | Bytes through packet processing since it was last loaded |
traffic.active_flows | Connections being tracked now |
security.blocked_packets | Packets dropped by policy — firewall, blocked addresses, anti-spoofing, MAC binding and rate limits — since packet processing was last loaded |
security.active_rules | Firewall rules in force right now: every rule except scheduled ones whose window is closed |
security.threat_detections | Intrusion-detection hits so far |
active_alerts | Alerts currently firing |
cluster | Whether 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.
| Method | Path | Series |
|---|---|---|
| GET | /api/v1/rrd/node | cpu, 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:
| Parameter | Values |
|---|---|
timeframe | live (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, to | Instead 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 |
cf | avg (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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/audit | Records, newest first, with filters and pages |
| GET | /api/v1/audit/export | Every matching record as JSON lines (application/x-ndjson), oldest first, streamed |
| GET | /api/v1/audit/verify | Check the whole log; ?checkpoint=<seq>:<hash> also checks a record you kept |
| GET | /api/v1/tenant/{id}/audit | A tenant's own records (see The tenant portal) |
Filters (all optional, for the list and the export):
| Parameter | Keeps |
|---|---|
since, until | Records from / up to a time: RFC 3339 or Unix seconds |
principal | One principal, exactly as listed: api token, api key ak-12ab, tenant key tk-… (tenant t-acme), root |
kind | operator-token, operator-key, tenant-key, root (the node's command line), system (the node itself), unauthenticated |
tenant | Records made with one tenant's keys |
action | Actions starting with this: vms. matches vms.start, vms.delete, … |
object | Objects starting with this: /vms/vm-1a2b3c4d |
outcome | success, failure or refused |
limit | 1–1000 records (default 100) |
before, after, order | Pages: 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
}
| Field | Meaning |
|---|---|
seq | The record's number: 1, 2, 3, … on each node, with no holes |
principal, principal_kind | Who: a credential's name, never the credential |
tenant | Set when a tenant's own key acted |
via_node | In 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_ref | The web-console session the change came through |
client_ip, request_id | Where it came from; your X-Request-ID is kept when it is a plain id |
method, route | The API route (method is IPC and route the command for the command line) |
object, action | What it acted on, and a short name for what it did |
outcome, status | success, failure or refused, and the status code answered |
task_id | The task the change began, or the task a task record is about |
detail | created (the id of what a create made), refused_by (authentication, tenant scope, licence or plan), and for consoles console, reason, duration_seconds |
prev_hash, hash | The 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.
| Method | Path | Use instead |
|---|---|---|
| GET | /api/v1/topology | Build it from /networks, /bridges, /nics, /cluster/status |
| POST | /api/v1/config/batch | Apply settings individually |
| POST | /api/v1/cluster/profile | Nothing 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:
| Field | Meaning |
|---|---|
id | A unique id (random) |
seq | This node's sequence number: it only ever grows, also across agent restarts |
timestamp | When it happened, UTC |
node | The node it happened on |
object | What it is about, when it is about one thing: /vms/<id>, /volumes/<id>, /images/<node>/<id> |
tenant | The tenant that thing belongs to (absent for the operator's own) |
severity | info, 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:
| Type | Sent when |
|---|---|
compute.vm_state | A machine's state, desired state or flags change (state, desired, reason, flags) |
storage.volume_state | A volume's state, machine or size change; deleted when it is gone |
task.started, task.finished | A task begins or ends (the whole task) |
task.progress | A running task's progress or status changes (at most once a second per task) |
cluster.membership | A 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. TheOriginis 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 }
}
| Field | Type | Notes |
|---|---|---|
id | string | Unique event id |
type | string | Event type, e.g. alert.fired, firewall.block |
category | string | One of the categories above |
timestamp | string | UTC RFC 3339 |
payload | object | Event-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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/webhooks | List subscriptions (never includes secrets) |
| POST | /api/v1/webhooks | Register 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}/test | Send a one-shot test delivery |
Subscription fields
| Field | Type | Notes |
|---|---|---|
id | string | Server-assigned subscription id |
url | string | The http/https endpoint events are POSTed to |
categories | array | Event-category filter (uppercase); empty means every category |
created_at | string | UTC timestamp |
delivered | integer | Successful deliveries so far |
failed | integer | Deliveries that failed after all retries |
dropped | integer | Events dropped because the delivery queue was full |
last_status | integer | HTTP status of the most recent attempt (omitted until the first attempt) |
last_error | string | Most 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"
}
}
Thesecretis returned only here, at registration — it is the HMAC key your receiver needs to verify deliveries. Store it now; list andGETresponses 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:
| Header | Value |
|---|---|
X-Stratum-Signature | sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed by your webhook secret |
X-Stratum-Event | The event category (e.g. SECURITY) |
X-Stratum-Delivery | The event id (matches id in the body) |
X-Stratum-Timestamp | Send time, Unix seconds (UTC) |
User-Agent | cenvero-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.
| Method | Path | Per-item shape |
|---|---|---|
| POST | /api/v1/rules/batch | A firewall rule (as in POST /api/v1/rules) |
| POST | /api/v1/dns/records/batch | A 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:
| Outcome | Status |
|---|---|
| Every item created | 201 Created |
| Some created, some failed | 207 Multi-Status |
| No item created | 400 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/nics/{iface}/addresses | Addresses currently on an interface |
| POST | /api/v1/nics/{iface}/addresses | Put 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:
| Form | Example |
|---|---|
| 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/dhcp/scopes | List scopes |
| POST | /api/v1/dhcp/scopes | Bind 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;
DELETEremoves 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_secondscannot 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. Leaveserver_iporsubnet_maskout 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/dhcp/reservations | List reservations |
| POST | /api/v1/dhcp/reservations | Pin 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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/l7lb/status | Layer-7 balancer state |
| GET | /api/v1/l7lb/pools | Backend pools |
| GET | /api/v1/l7lb/frontends | Frontends (host/path routing, TLS) |
| GET | /api/v1/vrf | Virtual routing & forwarding devices |
| GET | /api/v1/geneve | Geneve overlay tunnels |
| GET | /api/v1/nat64/status | NAT64 configuration + live bindings |
| GET | /api/v1/plugins | Installed plugins |
| GET | /api/v1/apikeys | Operator 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 loopbackmetrics_bind_addr, default127.0.0.1:9090, described in Configuration). Both expose the samestratum_*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 standardgrpc.health.v1descriptor (or use a purpose-built health-probe client). Use the empty service name for overall node health, orcenvero.stratumfor 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-ctlcommand 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.