Management API Reference
Every Stratum node runs a local management API so you can manage that node's
networking yourself, from your own tooling. There are three transports — a
REST API over HTTPS on port 7070, a gRPC twin on 7071, and a
WebSocket twin on 7072 — all sharing one authentication model, one TLS
certificate, and the same managers behind them.
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.
The token is a credential, so it is set once, at install time:
# Let the installer mint a fresh random token and enable the API
CENVERO_API_TOKEN=auto sudo ./install.sh
# ...or supply your own
CENVERO_API_TOKEN=<your-token> sudo ./install.sh
A minted token is stored root-only on the node. If you installed without one,
re-run the installer with CENVERO_API_TOKEN=auto to enable the API later. The
token is not changeable with cenvero-str-ctl config set — that command
deliberately refuses identity and credential keys; rotate it through the
installer (or a panel-delivered config) instead.
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 — i.e. /tenant/{id}/...
and /billing/tenants/{id}/... where {id} is that key's tenant; any other path
returns 403. 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",
"version": "1.0.0",
"uptime": "0s",
"acceleration": {
"mode": "software",
"summary": "Software (CPU)",
"detail": "Software (CPU) — hardware acceleration not available on this network card"
}
}
GET /api/v1/docs (also unauthenticated) returns a short machine-readable index
of the available endpoints.
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
- 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
- Timestamps — UTC throughout (RFC 3339, e.g.
2026-06-30T14:05:00Z), unless
- No pagination — list endpoints return the full collection under a named key
{"networks": [ ... ]}); filters are provided per-endpoint via query
parameters where noted.
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
{"error":"forbidden: source address not allowed"}.
- License frozen — a mutating call (POST/PUT/DELETE) while the license is
{"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.
- Feature not in your plan — see plan-gated subsystems below.
- Tenant-scope violation — a tenant-scoped key used outside its own tenant.
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 |
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. |
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"
}
}
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.1", "mac": "02:1a:4f:14:00:01", "state": "free" },
{ "id": "ep-2222", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "02:1a:4f:14:00:02", "state": "bound", "bound_at": "2026-06-30T12:05:00Z" }
]
}
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.
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"}'
{
"status": "attached",
"endpoint": { "id": "ep-2222", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "02:1a:4f:14:00:02", "state": "bound", "bound_at": "2026-06-30T12:05:00Z" }
}
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-2222/detach" \
-H "Authorization: Bearer $TOKEN"
{
"status": "detached",
"endpoint": { "id": "ep-2222", "network_id": "net-a1b2c3d4", "ip": "10.20.0.2", "mac": "02:1a:4f:14:00:02", "state": "free" }
}
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.1", "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.
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" }
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. 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": "eth0", "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 } }
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). Useful on a gateway node to publish an internal service.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/forward | Create a port-forward rule |
| DELETE | /api/v1/forward/{id} | Delete a port-forward rule |
| GET | /api/v1/forward, /api/v1/forward/{id} | Not yet available — returns 501 |
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 }
}
Delete → DELETE /api/v1/forward/5 returns { "status": "deleted", "id": 5 }.
Listing/fetching individual rules is not yet exposed and returns 501; track
your rule ids from the create response.
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.
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:1a:4f: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 chain, protocol,
source/destination IP and port, ingress interface, and optionally a source MAC,
and applies an action. Setting stateful: true on a rule enables connection
tracking for that rule (return traffic of established connections is matched
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 (a new id is assigned) |
| DELETE | /api/v1/rules/{id} | Delete a rule |
Rule fields
| Field | Type | Notes |
|---|---|---|
id | integer | Assigned by the node (response only) |
chain | string | Required. input, output, forward, prerouting, postrouting |
action | string | Required. accept, drop, reject, log |
priority | integer | Evaluation priority |
protocol | string | e.g. tcp, udp, icmp (omit for any) |
source_ip / dest_ip | string | Address or CIDR |
source_port / dest_port | integer | |
stateful | boolean | Enable connection tracking for this rule |
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: global (default), bridge, vlan, mac, flow, private_network |
Empty/zero optional fields are omitted from responses; id, chain, priority,
action, and stateful always appear.
Create a rule
curl -k -X POST "$NODE/api/v1/rules" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"chain": "forward",
"action": "drop",
"priority": 100,
"protocol": "tcp",
"source_ip": "198.51.100.0/24",
"dest_port": 22,
"stateful": true,
"comment": "block ssh from that net"
}'
{
"status": "created",
"rule": { "id": 7, "chain": "forward", "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):
curl -k "$NODE/api/v1/rules?chain=forward" -H "Authorization: Bearer $TOKEN"
{ "rules": [ { "id": 7, "chain": "forward", "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 deletes the old rule and
inserts the replacement, which receives a new id (returned in the response).
The body uses the same fields as create (chain and action required).
curl -k -X PUT "$NODE/api/v1/rules/7" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"chain":"forward","action":"reject","priority":100,"protocol":"tcp","source_ip":"198.51.100.0/24","dest_port":22}'
{ "status": "updated", "rule": { "id": 8, "chain": "forward", "priority": 100, "protocol": "tcp", "source_ip": "198.51.100.0/24", "dest_port": 22, "action": "reject", "stateful": false } }
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/ipv6) |
| 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 (allow or deny), and optional
ge/le prefix-length bounds.
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 (allow/deny), 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
For gateway nodes running an active/standby pair, inspect HA state and trigger failover/failback. Floating IPs are virtual addresses bound to a primary endpoint with an optional standby for takeover.
Gateway HA
(Plan feature: Gateway HA.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/gateway/status | Current HA state |
| POST | /api/v1/gateway/failover | Force this node to take over (become active) |
| POST | /api/v1/gateway/failback | Trigger failback |
curl -k "$NODE/api/v1/gateway/status" -H "Authorization: Bearer $TOKEN"
local_state/peer_state are active, standby, or solo:
{
"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"
}
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" }.
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 |
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", "TotalBps": 10000000000, "AllocatedBps": 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).
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
Inspect and manage this node's clustering state. Cluster membership is normally
provisioned through the panel-delivered configuration; these endpoints let you
read status and, when clustering is enabled, manage membership and the node
profile (compute for tenant workloads or gateway for edge routing).
(Plan feature: Cluster. See also the Clustering guide.)
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/cluster | Brief cluster summary |
| GET | /api/v1/cluster/status | State, leader, peers, and profile |
| GET | /api/v1/cluster/state | Replicated-state counts |
| POST | /api/v1/cluster/join | Join a peer (node_id, address required) |
| POST | /api/v1/cluster/leave | Leave the cluster |
| POST | /api/v1/cluster/profile | Set the node profile (mode) |
curl -k "$NODE/api/v1/cluster/status" -H "Authorization: Bearer $TOKEN"
{ "state": "leader", "leader": "10.0.0.5", "is_leader": true, "peers": ["10.0.0.6"], "profile": "compute", "services": [] }
GET /api/v1/cluster/state returns replicated-resource counts:
{ "blocklist_count": 0, "ip_allocations_count": 12, "vxlan_peers_count": 2, "floating_ips_count": 1, "tenants_count": 3 }
POST /api/v1/cluster/profile with {"mode":"gateway"} returns
{ "mode": "gateway", "services": [ ... ] }. Join/leave return
{ "status": "joined", ... } / { "status": "left" }, or 503 when clustering
is not enabled on the node.
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 a tenant's resource usage and reports live consumption. **0 means
unlimited** for any field.
| Field | Type | Meaning |
|---|---|---|
max_vms | int | Max workloads/endpoints (0 = unlimited) |
max_ips | int | Max allocated IPs (0 = unlimited) |
max_bandwidth_bps | int64 | Aggregate bandwidth cap in bits/sec (0 = unlimited) |
max_rules | int | Max firewall rules (0 = unlimited) |
used_vms, used_ips, used_bandwidth, used_rules | — | Read-only live usage |
curl -k -X PUT "$NODE/api/v1/tenant/t-9f3a21/quota" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"max_vms":10,"max_ips":32,"max_bandwidth_bps":1000000000,"max_rules":50}'
{
"status": "quota-set",
"quota": { "tenant_id": "t-9f3a21", "max_vms": 10, "max_ips": 32, "max_bandwidth_bps": 1000000000, "max_rules": 50, "used_vms": 3, "used_ips": 7, "used_bandwidth": 0, "used_rules": 12 }
}
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" }.
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 also call
/billing/tenants/{id}/... for its own tenant.) 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 |
| POST | /api/v1/billing/tenants/{id}/resume | Mark the tenant active |
| 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 }.
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" }.
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, connections,
quota, threat) and operator (gt, lt, eq) are required; threshold,
target, duration_secs, cooldown_secs optional. Returns 201.
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": "cond-1", "metric_type": "bandwidth", "threshold": 900000000, "operator": "gt", "target": "global", "duration_secs": 60, "cooldown_secs": 300 } }
Attach an action — type is websocket, webhook, or log; for webhook,
config is the URL to POST to when the alert fires. Returns 201.
curl -k -X POST "$NODE/api/v1/alerts/conditions/cond-1/actions" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"type":"webhook","config":"https://hooks.example.net/stratum"}'
{ "condition_id": "cond-1", "type": "webhook" }
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": [ ... ] }; delete a condition returns { "removed": "cond-1" }.
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 and restore them, and manage schedules.
| 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 | Restore a backup (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 — a config restore applies in place
({ "restored": "bk-01", "staged": false }); a full restore unpacks to a staging
area and reports staged: true with a note describing the manual completion step
(stop the agent, restore the staged snapshot, restart).
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" }.
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
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;
heartbeats (: keepalive) hold the connection open:
: connected
event: SECURITY
data: {"id":"b1c2...","type":"firewall.block","category":"SECURITY","timestamp":"2026-06-30T14:13:00Z","payload":{"src":"203.0.113.7","rule":"deny-inbound"}}
: keepalive
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.
| 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_4f8a1b2c3d4e5f6a7b8c9d0e"
}
}
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": [
{ "chain": "forward", "action": "drop", "protocol": "tcp", "dest_port": 23 },
{ "chain": "forward", "action": "drop", "protocol": "tcp", "dest_port": 2323 }
]
}'
{
"created": 2,
"failed": 0,
"results": [
{ "index": 0, "ok": true, "rule": { "id": 11, "chain": "forward", "priority": 0, "protocol": "tcp", "dest_port": 23, "action": "drop", "stateful": false } },
{ "index": 1, "ok": true, "rule": { "id": 12, "chain": "forward", "priority": 0, "protocol": "tcp", "dest_port": 2323, "action": "drop", "stateful": false } }
]
}
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" }
]
}
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_goroutines Number of goroutines
# TYPE stratum_goroutines gauge
stratum_goroutines 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="forward",reason="acl"} 1204
The exposition covers the families the node already collects: runtime gauges
(stratum_uptime_seconds, stratum_goroutines), 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 Raft-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 (mTLS) 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.
Feedback
Found a gap, an inaccuracy, or something you wish this API did? We want to hear it.
Open a request from your account dashboard, or reach the team through the support
channel listed on your panel. 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 — joining nodes into a cluster.
- Licensing — activation, renewal, and the enforcement states.