{
    "product": "Cenvero Stratum",
    "documentation_url": "https://www.stratum.cenvero.com/docs",
    "generated_at": "2026-08-03T06:43:14+00:00",
    "format": "cenvero-docs-v1",
    "includes_html": false,
    "document_count": 30,
    "documents": [
        {
            "slug": "introduction",
            "title": "Introduction",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/introduction",
            "headings": [
                {
                    "level": 1,
                    "text": "Introduction"
                },
                {
                    "level": 2,
                    "text": "What you get"
                },
                {
                    "level": 2,
                    "text": "Every node is the same node"
                },
                {
                    "level": 2,
                    "text": "The two bridges"
                },
                {
                    "level": 2,
                    "text": "How a node is managed"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 581,
            "markdown": "# Introduction\n\nCenvero Stratum is a software-defined networking platform for bare-metal\ninfrastructure. It runs your entire network stack — switching and routing,\nDHCP and DNS, load balancing, firewalling, and multi-host clustering — as a\nsingle agent on the servers you already own. There is no external controller to\noperate, no hardware appliance to buy, and no per-port licensing.\n\nA node runs a single agent. It handles packet processing in the kernel, manages\nthe host's network interfaces — putting them back if something changes them\noutside Stratum — and coordinates with its peers in a high-availability cluster.\nThe result is a complete, self-contained fabric that behaves the same on one\nserver or a thousand.\n\n## What you get\n\n- **In-kernel data plane.** Packet processing happens in the kernel on a fast\n  path for line-rate forwarding, with an optional kernel module that detects\n  out-of-band changes to managed interfaces so the agent reconciles them back to\n  their intended configuration.\n- **Batteries included.** DHCP, authoritative DNS, an L4 load balancer, a\n  zero-trust firewall, BGP edge routing, and per-tenant bandwidth control ship in\n  the box — no add-ons.\n- **Multi-host by default.** Nodes form a high-availability cluster and stretch a\n  VXLAN overlay across hosts, racks, and data centers. A workload's endpoint\n  keeps its addressing as it moves between hosts.\n- **Operable.** A single configuration file per node, one CLI\n  (`cenvero-str-ctl`), and a management panel for fleet-wide releases, updates,\n  and licensing.\n\n## Every node is the same node\n\nThere is one kind of node. It hosts workloads and attaches their endpoints to\nthe fabric, *and* it routes traffic between the fabric and the outside world —\nNAT, upstream route exchange, north-south load balancing. You do not pick a node\ntype at install, and any node can take on any of that later.\n\nWhat limits a node is how many interfaces it has free, not what it was\ninstalled as. See [Nodes and Interfaces](/docs/nodes).\n\n> Earlier versions split nodes into Compute and Gateway. That split has been\n> removed, and a node updating from an older version is migrated automatically.\n\n## The two bridges\n\nStratum separates management traffic from workload traffic on every node:\n\n- A **management bridge** — agent control, clustering, and HA signalling.\n- A **workload bridge** — carries workload traffic.\n\nThis keeps the control plane reachable even when workload networks are\nsaturated or misconfigured.\n\n## How a node is managed\n\nThe agent reads a single configuration file per node, keeps its local\nstate on disk, and writes its own logs. You drive it locally with\n`cenvero-str-ctl` and centrally from the management panel.\n\n## Where to go next\n\nIf you are evaluating or learning:\n\n- **[Concepts](/docs/concepts)** — the vocabulary used throughout these docs.\n- **[How Stratum Works](/docs/architecture)** — the components, the packet path, and what survives what.\n\nIf you are deploying:\n\n- **[Installation](/docs/installation)** — get the agent onto a node and activated.\n- **[Quick Start](/docs/quickstart)** — define a network and attach your first endpoint.\n- **[Configuration](/docs/configuration)** — the node configuration model in depth.\n\nIf you are running it:\n\n- **[Operations](/docs/operations)** — logs, updates, health checks, troubleshooting.\n- **[Monitoring](/docs/monitoring)** — which source answers which question.\n- **[Security Model](/docs/security)** — what is enforced, and your hardening checklist.\n- **[Licensing](/docs/licensing)** — plans, activation, and enforcement.\n- **[API Reference](/docs/api)** — the agent's local REST/operator API.\n\nThe whole documentation set is also downloadable as JSON from the\n[documentation index](/docs), for offline reading or indexing.\n\n> Stratum is licensed software. Installation and binary downloads require a\n> valid license — see [Licensing](/docs/licensing) to get one.\n"
        },
        {
            "slug": "concepts",
            "title": "Concepts",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/concepts",
            "headings": [
                {
                    "level": 1,
                    "text": "Concepts"
                },
                {
                    "level": 2,
                    "text": "The building blocks"
                },
                {
                    "level": 2,
                    "text": "Addressing"
                },
                {
                    "level": 2,
                    "text": "Overlays"
                },
                {
                    "level": 2,
                    "text": "Policy and protection"
                },
                {
                    "level": 2,
                    "text": "Traffic control"
                },
                {
                    "level": 2,
                    "text": "Routing"
                },
                {
                    "level": 2,
                    "text": "Clustering"
                },
                {
                    "level": 2,
                    "text": "Licence and lifecycle"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 1001,
            "markdown": "# Concepts\n\nThe words used throughout these docs, and what they mean in Stratum specifically.\nSeveral are general networking terms that carry a narrower meaning here — those\nare the ones worth reading even if the term is familiar.\n\n## The building blocks\n\n**Node** — one physical server running the agent. Nodes are peers, and there is\nonly one kind: every node hosts workloads and routes. What differs between two\nnodes is the interfaces they hold and what you have put on them — see\n[Nodes and Interfaces](/docs/nodes).\n\n**Network** — a subnet the fabric carries, with its own address range, gateway,\nand policy. This is the unit you create first and attach everything else to. A\nnetwork can exist on one node or stretch across many.\n\n**Endpoint** — the attachment point where a workload joins a network. It has an\naddress and a hardware address, and the fabric enforces both: traffic claiming to\ncome from an endpoint must actually match its registration.\n\n**Tenant** — an ownership boundary. Networks, endpoints, and bandwidth allowances\nbelong to a tenant, which is what lets one node carry several customers' traffic\nwithout them reaching each other. Suspending a tenant affects everything under it\nat once.\n\n**Workload** — whatever is using an endpoint: a virtual machine, a container, or a\nphysical host on a port.\n\n## Addressing\n\n**IPAM** *(IP address management)* — the record of which addresses in a network are\nallocated, reserved, or free. Reservations tie an address to a specific hardware\naddress so a workload always gets the same one.\n\n**Floating address** — an address that is not permanently attached to one node.\nIt lives on whichever node currently holds a role, and moves when that role moves.\nThis is how gateway failover keeps a stable address.\n\n**VIP** *(virtual IP)* — the address clients connect to for a load-balanced\nservice. The load balancer accepts traffic on the VIP and distributes it to\nbackends.\n\n## Overlays\n\nThese four terms describe one mechanism, and they are easy to confuse.\n\n**Underlay** — the ordinary network that already connects your nodes. Whatever\ncarries traffic between their real addresses.\n\n**Overlay** — a network Stratum builds *on top of* the underlay so that workloads\non different nodes appear to share a subnet. It exists only as far as the nodes\nare concerned; the underlay sees only traffic between nodes.\n\n**VNI** *(virtual network identifier)* — the number that identifies one overlay.\nNodes sharing an overlay must use the same VNI; different overlays use different\nones, which is what keeps them separate over the same underlay.\n\n**VTEP** *(tunnel endpoint)* — the address a node sends and receives overlay\ntraffic from. In practice this is the node's own address on the underlay. Each\nnode must know the others' VTEPs, or it has nowhere to send overlay traffic — see\n[Networking Overview](/docs/networking/overview).\n\n**Encapsulation** — wrapping an overlay packet inside an underlay packet for the\ntrip between nodes, and unwrapping it on arrival. This is why an overlay packet is\nslightly larger than the payload it carries, and why overlay networks want a\nlittle headroom in the underlay's maximum packet size.\n\n## Policy and protection\n\n**Rule** — one firewall decision: match traffic by direction, address, protocol\nand port, then allow or deny it. Rules are evaluated in priority order.\n\n**Default action** — what happens to traffic no rule matched. Set it to deny for a\nzero-trust posture, where traffic is refused unless something explicitly permits\nit. See [Zero-Trust Firewall](/docs/networking/firewall).\n\n**Stateful** — a firewall that remembers established conversations, so replies to\ntraffic you allowed are permitted without a matching rule in the other direction.\n\n**Connection tracking** — the table of those remembered conversations. It is worth\nknowing this exists because it explains a common surprise: tightening a rule\naffects *new* connections, while ones already established keep going until they\nend or are flushed.\n\n**Anti-spoofing** — enforcing that traffic from an endpoint carries that\nendpoint's registered addresses, so one workload cannot impersonate another.\n\n**Block list** — addresses refused outright, checked before the rules.\n\n## Traffic control\n\n**Shaping** — limiting how much bandwidth something may use. Applied on the way\nout of an interface.\n\n**Quota** — a cap on total volume over a period, as opposed to a rate limit.\n\n**Accounting** — the record of how much traffic something actually used, which is\nwhat billing integrations read.\n\n## Routing\n\n**BGP** — the protocol nodes use to exchange routes with your upstream routers, so\nthe rest of your network learns how to reach the fabric.\nSee [BGP Edge Routing](/docs/networking/bgp).\n\n**Peer / neighbour** — the router on the other end of a BGP session.\n\n**Announce** — offering a network to your peers, so traffic for it is sent to this\nnode.\n\n**NAT** *(network address translation)* — rewriting addresses in transit. Two uses\nhere: letting many private workloads share one public address on the way out, and\npublishing an internal service on a public address and port. See\n[Gateway NAT](/docs/networking/gateway-nat).\n\n## Clustering\n\n**Cluster** — nodes that share configuration and coordinate with each other.\n\n**Leader** — the member that currently accepts changes to shared state. Chosen by\nelection; losing it triggers a new election and does not stop traffic.\n\n**Heartbeat** — the frequent signal between two nodes paired for high\navailability, so each knows the other is alive. Missing several in a row is what\ntriggers failover.\n\n**Failover** — a standby taking over from a failed peer, including the floating\naddress.\n\n## Licence and lifecycle\n\n**Activation** — binding a licence to one specific machine. A licence covers a\nnumber of machines; each is activated separately.\n\n**Enforcement state** — how the agent is currently treating the licence: *active*,\n*warning* as expiry approaches, *grace* just past it, and *frozen*. Frozen blocks\nchanges only — traffic never stops. See [Licensing](/docs/licensing).\n\n**Release channel** — whether a licence runs released builds or pre-release ones.\nThe two must match, and the agent refuses a build from the wrong channel.\n\n**Manifest** — the signed list of available releases the agent checks when looking\nfor an update.\n\n## Where to go next\n\n- **[How Stratum Works](/docs/architecture)** — how these pieces fit together.\n- **[Quick Start](/docs/quickstart)** — put the main ones to use.\n- **[CLI Reference](/docs/cli)** — the commands behind each concept.\n"
        },
        {
            "slug": "architecture",
            "title": "How Stratum Works",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/architecture",
            "headings": [
                {
                    "level": 1,
                    "text": "How Stratum Works"
                },
                {
                    "level": 2,
                    "text": "What happens when you change something"
                },
                {
                    "level": 2,
                    "text": "How traffic moves through a node"
                },
                {
                    "level": 3,
                    "text": "Where a packet can be stopped"
                },
                {
                    "level": 2,
                    "text": "The two networks on every node"
                },
                {
                    "level": 2,
                    "text": "One kind of node"
                },
                {
                    "level": 2,
                    "text": "More than one node"
                },
                {
                    "level": 2,
                    "text": "What survives what"
                },
                {
                    "level": 2,
                    "text": "Where a node keeps its state"
                },
                {
                    "level": 2,
                    "text": "Ways to reach a node"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 1145,
            "markdown": "# How Stratum Works\n\nBackground reading for operating a node: what happens when you run a command,\nhow traffic moves through a node, and what keeps working when something fails.\nNothing here is configured directly — for the commands see the\n[CLI Reference](/docs/cli), and for the vocabulary see [Concepts](/docs/concepts).\n\n## What happens when you change something\n\nEvery change follows the same path, and knowing it explains most of what you will\nsee in practice.\n\nWhen you add a firewall rule, create a network, or set a bandwidth limit, the\nagent checks the request, records it, and applies it to the traffic path. The\ncommand returns once all three have happened — so when a command reports success,\nthe change is already live. There is no separate \"apply\" step and no delay to\nwait out.\n\nApplying a change and carrying traffic are separate jobs. That has one important\nconsequence:\n\n> **If the agent stops, traffic keeps flowing.** Everything already configured\n> stays in force. What you lose is the ability to *change* anything — no new\n> rules, no new networks, no API — until it is running again.\n\nThis is why an expired or revoked licence freezes changes but never drops\ntraffic, and why a node that has lost contact with the panel keeps forwarding\nexactly as it was. It also means a node whose agent has quietly died can look\ncompletely healthy from the outside, which is worth remembering when something\nseems fine but will not accept changes.\n\n## How traffic moves through a node\n\nTraffic is inspected as it arrives and again as it leaves.\n\n**On the way in**, a packet is checked in order, and the cheapest checks come\nfirst so unwanted traffic is discarded before it costs anything:\n\n1. **Obviously unwanted traffic is dropped** at the network card.\n2. **The sender is verified.** On a workload network, a packet must carry the\n   address and hardware address the endpoint is registered with. This is what\n   stops one workload impersonating another — it is enforced on every packet, not\n   assumed.\n3. **Policy is applied** — your firewall rules, in priority order, plus any\n   blocked addresses.\n4. **The packet is delivered.** Locally if the destination is on this node, over\n   the overlay if it is on another node, or out through the gateway if it is\n   leaving the fabric.\n\n**On the way out**, traffic passes the shaping stage, where bandwidth limits and\ntraffic priority apply.\n\nNot every stage runs on every packet: anything you have not enabled is not in\nthe path at all, and a packet that never leaves the fabric never reaches the\ngateway stage.\n\n### Where a packet can be stopped\n\nWhen traffic is not arriving, it was stopped at one of those points, and each has\na different way of showing it:\n\n| Symptom | Likely stage | Where to look |\n|---|---|---|\n| Nothing arrives from one workload | Sender verification | Check the endpoint's registered address and MAC |\n| Some traffic passes, some does not | Firewall policy | `firewall list` — check order and the default action |\n| A change did not take effect on an existing connection | Policy applies to new connections | Existing connections continue until they end; flush them to force re-evaluation |\n| Traffic reaches the node but not the internet | Gateway | `gateway status`, and see [Gateway NAT](/docs/networking/gateway-nat) |\n| Traffic does not reach another node | Overlay | `vxlan peers` — see [Networking Overview](/docs/networking/overview) |\n\n## The two networks on every node\n\nEvery node keeps its own traffic separate from your workloads':\n\n- **`cnv-mgmt-br0`** — the agent's own traffic: the API, clustering, and\n  failover signalling.\n- **`cnv-user-br0`** — workload traffic.\n\nThey are separate so a saturated or misconfigured workload network cannot cost\nyou access to the node. If you can reach the management side you can still reach\nthe agent, read its state, and repair the workload side. Keep the management\nnetwork reachable only by operators.\n\nInterfaces Stratum manages start with `cnv-`. The names are short because the\nkernel limits interface names to 15 characters.\n\n## One kind of node\n\nEvery node runs the same agent and does the same work: it hosts workloads and\nattaches them to the fabric, and it moves traffic between the fabric and the\noutside world — address translation, routing to upstream networks, north-south\nload balancing. There is no node type to choose.\n\nNodes differ only in what you give them: the interfaces they hold and the\nworkloads and networks you put on them. A node with nothing attached is an edge\nrouter; a node you never point at an uplink simply forwards nothing outward.\nSee [Nodes and Interfaces](/docs/nodes).\n\n> The Compute/Gateway split from earlier versions has been removed. Nodes\n> updating from an older version are migrated with no operator action.\n\n## More than one node\n\nNodes are peers. There is no controller to install, and no single node whose loss\nstops the others forwarding.\n\n- **A network can span nodes**, so two workloads on different hosts share a\n  subnet and keep their addresses if they move between hosts. See\n  [Networking Overview](/docs/networking/overview).\n- **Shared configuration** is agreed between cluster members through an elected\n  leader. Losing the leader starts an election; it does not interrupt traffic.\n- **Gateway failover**: a pair of nodes watch each other continuously and\n  share an address that moves to the survivor. See\n  [Gateway High Availability](/docs/clustering/high-availability).\n\n## What survives what\n\n| Event | Traffic | Changes |\n|---|---|---|\n| The agent stops or crashes | Keeps flowing | Blocked until it restarts |\n| The agent is restarted automatically | Keeps flowing | Brief pause |\n| The node reboots | Interrupted, then restored from the node's own records | Resume once started |\n| The licence expires or is revoked | Keeps flowing | Frozen — see [Licensing](/docs/licensing) |\n| The cluster leader is lost | Keeps flowing | Brief pause during the election |\n| Contact with the panel is lost | Keeps flowing | Local changes still work |\n\n## Where a node keeps its state\n\n| Path | Holds |\n|---|---|\n| `/etc/cenvero-str/` | The node's configuration. |\n| `/var/lib/cenvero-str/` | The node's own records and licence material. |\n| `/var/log/cenvero-str/` | Logs. |\n| `/run/cenvero-str/` | Runtime files, recreated on every boot. |\n\n**Back up the first two.** They are what a node needs to come back as itself. See\n[Operations](/docs/operations).\n\n## Ways to reach a node\n\n| Interface | Port | Use |\n|---|---|---|\n| Command line | — | `cenvero-str-ctl`, on the node itself. Always available. |\n| REST API | 7070 | Automation and integrations. Off until you set a token. |\n| gRPC | 7071 | Streaming integrations. |\n| WebSocket | 7072 | Live event feed. |\n\nThe command line works locally and needs no network, so it keeps working when the\nAPI is switched off or misconfigured — which makes it the right tool for\nrecovering a node. See the [Management API Reference](/docs/api) for the network\ninterfaces, and [Operations](/docs/operations) for turning them on.\n\n## Where to go next\n\n- **[Concepts](/docs/concepts)** — the vocabulary used across these docs.\n- **[Security Model](/docs/security)** — what is enforced, and what is yours to configure.\n- **[Networking Overview](/docs/networking/overview)** — networks, overlays, and routing.\n- **[Operations](/docs/operations)** — running, updating, and troubleshooting a node.\n"
        },
        {
            "slug": "nodes",
            "title": "Nodes and Interfaces",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/nodes",
            "headings": [
                {
                    "level": 1,
                    "text": "Nodes and Interfaces"
                },
                {
                    "level": 2,
                    "text": "Compute mode has been removed"
                },
                {
                    "level": 3,
                    "text": "It was not a security boundary"
                },
                {
                    "level": 2,
                    "text": "What a node does"
                },
                {
                    "level": 2,
                    "text": "Interfaces: the thing that actually limits a node"
                },
                {
                    "level": 3,
                    "text": "What that means in practice"
                },
                {
                    "level": 3,
                    "text": "The two forwarding interfaces are not roles"
                },
                {
                    "level": 3,
                    "text": "On a host with one physical card"
                },
                {
                    "level": 2,
                    "text": "Combining interfaces: link bonding"
                },
                {
                    "level": 2,
                    "text": "Reading a node back"
                },
                {
                    "level": 2,
                    "text": "Choosing a layout"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1775,
            "markdown": "# Nodes and Interfaces\n\nEvery node runs the same agent and does the same job: it carries workloads, enforces policy on their traffic, and routes between the networks it holds and whatever lies beyond them. There is no node type to pick and nothing at install time that decides which of those a node is allowed to do.\n\nWhat actually shapes a deployment is **interfaces** — how many a node has, and what each one is already doing. That is what this page is about.\n\n## Compute mode has been removed\n\nEarlier versions asked each node to be one of two things: *Compute*, which hosted workloads but did not route, or *Gateway*, which routed. **That split is gone. Every node routes.**\n\nYou do not have to do anything about it. A node updating to this version has its configuration rewritten as part of the update — no reinstall, no mode to choose, no command to run. Networks, endpoints, firewall rules, tenants and addressing are untouched, and a node that was already a gateway carries on exactly as before.\n\nWhy it went:\n\n- **It could only be decided once.** The mode was fixed at install and could not be changed afterwards, so getting it wrong meant reinstalling a live, licensed machine to correct a single setting.\n- **Almost nobody actually chose it.** The installer never asked and never wrote the setting, and a node without it was treated as Compute — so nearly every node was Compute by accident rather than by intent.\n- **It broke routed addressing.** A Compute node did not route between networks. On a single-card dedicated server — the common shape — that made routed additional addresses, which is how most providers sell extra IPs, simply not work.\n\n### It was not a security boundary\n\nWorth being explicit about, because it is the one thing people assume they are losing.\n\nCompute mode never decided who may talk to whom. It happened not to route, which closed one path as a side effect — that is not the same as a policy, and it protected nobody deliberately. What keeps traffic separated is enforced in the path the packet is already taking, and does not depend on whether a node routes:\n\n- **Sender verification.** Traffic from an endpoint must carry the address and hardware address that endpoint is registered with, checked on every packet. This is what stops one workload impersonating another.\n- **Firewall policy.** Your rules are applied wherever traffic enters the node, so a rule is in force at every entry point rather than on one interface.\n- **Different customers cannot reach each other.** Traffic between the private networks of two *different* tenants is refused outright, in the packet path, whether or not you have written a single firewall rule. This replaced what the old mode was accidentally doing, and it is stronger: private addresses are not reachable from outside either, so there is no route around it.\n- **Your own networks are yours to decide about.** Two networks belonging to the same tenant are not separated by default — that is the firewall's decision. Set its default action to `deny` and the separation becomes explicit and auditable rather than incidental. See [Zero-Trust Firewall](/docs/networking/firewall) and [Security Model](/docs/security).\n\nOne thing that is deliberately *not* blocked: two workloads reaching each other by their **public** addresses, when both are on the same node. That traffic is turned around inside the node instead of going out to your provider and back — faster, and it never touches your uplink. Blocking it would separate nothing, because a public address is reachable from the internet anyway; it would only make the same conversation slower. It is still fully metered and still bound by every bandwidth limit and quota. See [Gateway NAT](/docs/networking/gateway-nat).\n\n## What a node does\n\nOne node handles all of it:\n\n- **Carries workloads.** Endpoints attach to networks, and the node enforces each endpoint's identity.\n- **Serves the networks it holds.** Addressing, DHCP, DNS, firewall policy, bandwidth limits and quotas.\n- **Routes.** Between its own networks, and out to whatever is upstream — including address translation so a private subnet reaches the internet over one public address, published services, and route exchange with your upstream routers.\n- **Cooperates with its peers.** Shared state, a stretched overlay, and failover of a floating address between a pair of nodes.\n\nNothing on that list is reserved for a particular kind of node. What limits a node is how many interfaces it has free, which is the next section.\n\n## Interfaces: the thing that actually limits a node\n\nThis is the part worth understanding properly, because it — not any node setting — is what constrains a deployment.\n\n**An interface can carry only one fast-path attachment.** They cannot be stacked. So each of these jobs needs its *own* interface:\n\n| Job | Needs its own interface |\n|---|---|\n| Management (agent, cluster, failover signalling) | the management bridge |\n| Workload traffic | the workload bridge |\n| The uplink the node forwards out of | the WAN interface |\n| The inward side it forwards to | the LAN interface |\n| L4 load balancer VIP traffic | a dedicated VIP interface |\n\n> **You do not need a separate physical card for each.** A VLAN sub-interface or a virtual interface counts as its own interface for this purpose. What you cannot do is point two of these jobs at the *same* interface.\n\n### What that means in practice\n\nA node that hosts workloads and nothing else needs two interfaces:\n\n```\ncnv-mgmt-br0    management  (agent, cluster, HA channel)\ncnv-user-br0    workloads\n```\n\nA node that also forwards traffic across its edge, and load-balances, needs more, because the uplink, the inward side and the VIP each want their own:\n\n```\ncnv-mgmt-br0    management  (the first card is its port)\ncnv-nic-1       the internet-facing uplink\ncnv-nic-2       the inward side\ncnv-nic-3       load-balancer VIP traffic\n```\n\nSo the answer to \"how many cards does this node need?\" is **one per distinct job it is doing** — a node doing more jobs needs more interfaces. Add the load balancer and it needs one more again.\n\nIf you skip the load balancer's interface, everything else still works — you simply have no L4 balancing, and the symptom is a VIP that looks perfectly configured and passes no traffic. See [Load Balancer](/docs/networking/load-balancer).\n\n### The two forwarding interfaces are not roles\n\nThe node's configuration names two interfaces for the forwarding path. They are placements, not roles: the node decides which direction a packet is going from its routing table, not from which setting named an interface. So the question to ask when planning is not \"which one is the WAN\" — it is \"are there two free interfaces\". See [Configuration](/docs/configuration).\n\n### On a host with one physical card\n\nWorth being direct about, because it is the common shape on a dedicated server with a single public address.\n\n**Hosting workloads on one card is fine.** The uplink is enslaved to the management bridge, which carries the host's address, and the workload bridge carries workloads. Two interfaces, one card, nothing contended:\n\n```\ncnv-mgmt-br0    management  (holds the host address; cnv-nic-0 is its port)\ncnv-user-br0    workloads\n```\n\n**Forwarding between two separate interfaces is a different matter.** Both sides want their own fast-path attachment, and on a one-card host both candidates are already spoken for: the workload bridge carries the workload attachment, and the management path deliberately carries none at all — attaching workload anti-spoofing to the host's own path has taken a machine off the network before, which is why that exclusion exists.\n\nA VLAN sub-interface or a virtual interface pair is what creates the missing one. **We have not validated that layout, and we would rather say so than hand you a configuration to try on a machine you cannot get back.** If you need it on single-card hosts, talk to us before you build a product around it.\n\n## Combining interfaces: link bonding\n\nBonding is the other half of the interface story. Instead of splitting jobs across interfaces, it joins several physical interfaces into **one logical interface** that the rest of Stratum treats as a single NIC — so a bond can be the workload bridge's uplink, or the interface the node forwards out of.\n\nThere are two modes, and they answer different questions.\n\n| Mode | What it gives you | What it needs |\n|---|---|---|\n| **`active-backup`** | **Survival.** One member carries all traffic; if its link dies, another takes over. Throughput stays that of a single link. | Nothing from your switch — the members can even go to two different switches, which is what protects you from losing one. |\n| **`802.3ad`** (LACP) | **Capacity and survival.** Traffic is spread across all members, so total throughput can exceed one link, and a failed member drops out. | A matching port-channel configured on the switch. Without it the bond will not come up. |\n\n> **Two links does not mean one connection goes twice as fast.** With LACP, traffic is distributed *per conversation* — each connection picks a member and stays on it. Ten connections spread across two links; one large single transfer still rides one link at that link's speed. If you need a single stream faster, you need a faster link, not more of them.\n\nPick `active-backup` when you want redundancy and control nothing about the switch. Pick `802.3ad` when you own the switch configuration and want the aggregate capacity.\n\nThe bond becomes a normal interface once created, so everything in the interface table above applies to it — a bond can hold one job, not several. See [CLI Reference](/docs/cli) for the `bond` commands.\n\n## Reading a node back\n\nTo see what a node is — its hardware id, its agent version, and what it believes about itself:\n\n```bash\ncenvero-str-ctl node info\n```\n\nThe interfaces it uses are in its configuration, which you inspect rather than edit:\n\n```bash\nsudo cenvero-str-ctl config show\n```\n\nSee [Configuration](/docs/configuration) for every field, and how a configuration reaches a node.\n\n## Choosing a layout\n\n| You want | Use |\n|---|---|\n| A single node to learn on, or a self-contained deployment | One node — it hosts workloads *and* reaches the internet |\n| More workload capacity | More nodes in one cluster; each carries workloads and its own policy |\n| No single point of failure at the edge | Two nodes paired for high availability, sharing a floating address that moves to the survivor |\n| A node dedicated to edge traffic | A node with no workloads attached — same software, you simply give it nothing to host |\n\n## See also\n\n- [Gateway NAT & Internet Access](/docs/networking/gateway-nat) — reaching the internet and publishing services.\n- [Load Balancer](/docs/networking/load-balancer) — why the L4 balancer needs its own interface.\n- [BGP Edge Routing](/docs/networking/bgp) — exchanging routes with upstream routers.\n- [Gateway High Availability](/docs/clustering/high-availability) — a redundant pair sharing a floating address.\n- [Configuration](/docs/configuration) — the interface fields, and how configuration is delivered.\n"
        },
        {
            "slug": "installation",
            "title": "Installation and First Configuration",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/installation",
            "headings": [
                {
                    "level": 1,
                    "text": "Installation and First Configuration"
                },
                {
                    "level": 2,
                    "text": "Before you begin"
                },
                {
                    "level": 2,
                    "text": "1. Get a licence key"
                },
                {
                    "level": 2,
                    "text": "2. Check the kernel first"
                },
                {
                    "level": 2,
                    "text": "3. Run the installer"
                },
                {
                    "level": 3,
                    "text": "Unattended install"
                },
                {
                    "level": 2,
                    "text": "4. Know what you just installed"
                },
                {
                    "level": 2,
                    "text": "5. Activate the node"
                },
                {
                    "level": 2,
                    "text": "6. See what is actually running"
                },
                {
                    "level": 2,
                    "text": "7. Understand what is exposed"
                },
                {
                    "level": 2,
                    "text": "8. Turn on the management API — only if you need it"
                },
                {
                    "level": 3,
                    "text": "Generate one on the node"
                },
                {
                    "level": 3,
                    "text": "Supply your own instead"
                },
                {
                    "level": 3,
                    "text": "Check or remove it"
                },
                {
                    "level": 3,
                    "text": "At install time, if you prefer"
                },
                {
                    "level": 3,
                    "text": "Then enable the APIs you want"
                },
                {
                    "level": 2,
                    "text": "9. Get the node its certificate"
                },
                {
                    "level": 2,
                    "text": "10. Configure the node's job"
                },
                {
                    "level": 2,
                    "text": "Verify the install"
                },
                {
                    "level": 2,
                    "text": "Troubleshooting"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                },
                {
                    "level": 2,
                    "text": "Take this guide with you"
                }
            ],
            "word_count": 2701,
            "markdown": "# Installation and First Configuration\n\nThis is the complete guide: a bare server at the start, a licensed node carrying\nreal traffic at the end. Work through it in order. Nothing here assumes you have\nused Stratum before.\n\nThere are two halves. **Install** (steps 1–5) gets the agent running and\nlicensed. **Configure** (steps 6–10) is the part people skip and then wonder why\nnothing responds — a freshly installed node deliberately starts with almost\neverything switched off.\n\n## Before you begin\n\n| Requirement | Detail |\n|-------------|--------|\n| Operating system | Debian 11+, Ubuntu 22.04+, RHEL 8+, Rocky Linux 8+, or AlmaLinux 8+ |\n| Kernel | 5.8 or newer with kernel type information enabled (`CONFIG_DEBUG_INFO_BTF=y`) |\n| Architecture | x86-64 (amd64) or arm64 |\n| Privileges | `root` — the agent manages interfaces and loads the in-kernel data plane |\n| Network | Outbound HTTPS to your panel |\n| Interfaces | One is enough to start. Two (management + workload) is the recommended layout |\n| Licence | A valid key. The download itself is gated on it |\n\nA stock Debian 12 kernel is **older than this** and will not run the data plane.\nThe installer checks before it changes anything — see step 2.\n\n## 1. Get a licence key\n\nYou cannot download anything without one. The key gates the installer, the\nbinaries, and every later update.\n\nRegister on the site, place an **order** for the plan and node count you want,\nfollow the payment instructions, and submit your transaction reference and\nproof. Payment is verified by a person, and the licence is issued once it is —\nlicences are never handed out automatically. It then appears in your account\nwith its key.\n\nSee [Licensing](/docs/licensing) for the full model, and\n[Your Account](/docs/your-account) for where the key lives.\n\n## 2. Check the kernel first\n\nThe data plane runs inside the kernel, so the kernel has to be new enough. The\ninstaller does not guess: it loads the real data plane as a dry run before\ntouching your system, and stops if that fails.\n\nIf your kernel is too old, it tells you and offers three ways forward:\n\n- **Auto-upgrade** — installs a newer kernel, reboots, and resumes the install by itself.\n- **Show commands and stop** — prints exactly what to run, and changes nothing.\n- **Abort** — nothing is touched.\n\nA piped, non-interactive install never reboots you silently. It stops with the\nmanual instructions unless you explicitly ask otherwise with\n`CENVERO_KERNEL_UPGRADE=auto`.\n\n## 3. Run the installer\n\n```bash\ncurl -sSL -H \"X-License-Key: YOUR_LICENSE_KEY\" \\\n  https://stratum.cenvero.com/install | bash\n```\n\nSubstitute your own key, and your own panel host if you self-host. The key can\nalso travel as `?key=YOUR_LICENSE_KEY` in the URL or as an\n`Authorization: Bearer YOUR_LICENSE_KEY` header — prefer the header, since URLs\nend up in logs.\n\nEvery artifact is verified before it is written to disk: it must match the\nchecksum the panel advertises over TLS, **and** carry a valid publisher\nsignature. An artifact that fails either check is refused. The same verification\nruns on later self-updates.\n\nThe installer prompts for host IP, subnet, gateway, the interfaces to use, and\nthe address pool. It reads those prompts from the terminal directly, so it still\nworks under `curl … | bash`.\n\n### Unattended install\n\nSupply every setting through the environment and it runs with no prompts:\n\n```bash\nCENVERO_LICENSE_KEY=... \\\nCENVERO_HOST_IP=203.0.113.10 \\\nCENVERO_SUBNET=203.0.113.0/24 \\\nCENVERO_GATEWAY=203.0.113.1 \\\nCENVERO_MGMT_NIC=eth0 \\\nCENVERO_VM_NIC=eth1 \\\nCENVERO_POOL_START=203.0.113.100 \\\nCENVERO_POOL_END=203.0.113.200 \\\n  bash install.sh\n```\n\n| Variable | Purpose |\n|----------|---------|\n| `CENVERO_LICENSE_KEY` | Your licence key |\n| `CENVERO_HOST_IP` | This node's management address |\n| `CENVERO_SUBNET` | The management subnet, in CIDR form |\n| `CENVERO_GATEWAY` | Default gateway |\n| `CENVERO_MGMT_NIC` | Interface for management traffic |\n| `CENVERO_VM_NIC` | Interface for workload traffic |\n| `CENVERO_POOL_START` / `CENVERO_POOL_END` | Address range handed out to workloads |\n| `CENVERO_API_TOKEN` | Off unless set. `auto` generates one, or supply your own. Can also be done later — see step 8 |\n| `CENVERO_KERNEL_UPGRADE` | `auto`, `manual`, or `skip` for the kernel check in step 2 |\n| `CENVERO_REINSTALL` | Set to `1` to re-run over an existing install, keeping config and data |\n| `CENVERO_PANEL_URL` | Your panel, if you self-host |\n| `CENVERO_CHANNEL` | Pins this node to one release channel |\n| `CENVERO_API_DOMAIN` | Extra hostname to include on the node's certificate |\n\nThe installer is **idempotent**. It detects a previous install and stops rather\nthan overwriting your configuration and state. Use `CENVERO_REINSTALL=1` when\nyou genuinely mean to re-run.\n\n## 4. Know what you just installed\n\nTwo names look alike and are not the same thing. This trips up nearly everyone\nonce:\n\n| Name | What it is |\n|------|-----------|\n| `cenvero-stratum` | The **service**. This is what `systemctl` acts on |\n| `cenvero-str-ctl` | The **command-line tool**. It talks to the running service |\n\nSo restarting the node is:\n\n```bash\nsystemctl restart cenvero-stratum\n```\n\n`systemctl restart cenvero-str-ctl` fails with \"unit not found\" — that is the\nCLI, not a service. And `cenvero-str-ctl restart` is not a command either: the\nCLI talks *to* the agent, it does not start or stop it.\n\nWhere things live:\n\n| Path | Contents |\n|------|----------|\n| `/etc/cenvero-str/` | Node configuration |\n| `/var/lib/cenvero-str/` | State and local data |\n| `/var/log/cenvero-str/` | Logs |\n| `/run/cenvero-str/` | The local control socket |\n\n## 5. Activate the node\n\nActivation binds the licence to this specific machine and pulls down its signed\nlicence.\n\n```bash\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\n```\n\nThe key is **positional** — there is no `--key` flag. This raises an activation\nrequest for this machine, which you then **confirm in your account**. Once you\nconfirm, the agent installs the signed licence on its own and begins enforcing\nit.\n\nTo check where it has got to:\n\n```bash\ncenvero-str-ctl license status\ncenvero-str-ctl hardware\n```\n\n`hardware` prints this machine's hardware id, which is what you match against\nthe pending request in the panel when you have several machines activating at\nonce.\n\nIf activation is refused with a **node quota** message, this licence's node\nslots are all in use. Each machine you approve takes one slot; a machine you\nreject or revoke gives its slot back.\n\n---\n\nAt this point the node is installed and licensed. Everything below is\nconfiguration — and a fresh node starts with most services deliberately off, so\nthis half is not optional.\n\n---\n\n## 6. See what is actually running\n\n```bash\ncenvero-str-ctl status\ncenvero-str-ctl service\n```\n\n`service` is the one to read carefully. It looks like this:\n\n```text\nSERVICE        ENABLED  ADDRESS              STATE      NOTE\nREST API       off      203.0.113.10:7070    down       no api_token configured\ngRPC API       off      203.0.113.10:7071    down       no api_token configured\nWebSocket API  off      203.0.113.10:7072    down       no api_token configured\nMetrics        on       127.0.0.1:9090       listening  -\nDNS            on       127.0.0.1:53         listening  -\nDHCP           on       :67                  listening  -\n```\n\nRead it as three independent columns:\n\n- **ENABLED** is your switch — what you asked for. It takes effect at the next restart.\n- **STATE** is a live check of whether something is actually listening right now.\n- **NOTE** explains any gap between the two.\n\nA service can be enabled and still down. That is not a contradiction: it means\nyour switch was recorded but something else is stopping the service from\nstarting, and NOTE tells you what. For the three management APIs, that something\nis almost always a missing API token — see step 8.\n\nThe local control socket is always up and is not a service you can toggle. That\nis why `cenvero-str-ctl` keeps working even with every network API off.\n\nApply an enable/disable change with:\n\n```bash\nsystemctl restart cenvero-stratum\n```\n\n## 7. Understand what is exposed\n\nDefault ports, and who can reach them:\n\n| Port | Service | Bound to | Reachable from |\n|------|---------|----------|----------------|\n| 7070 | REST API | node address | the network — **off until you configure a token** |\n| 7071 | gRPC API | node address | the network — **off until you configure a token** |\n| 7072 | WebSocket API | node address | the network — **off until you configure a token** |\n| 9090 | Metrics | `127.0.0.1` | this machine only |\n| 53 | DNS | `127.0.0.1` | this machine only |\n| 67 | DHCP | all interfaces | the local network |\n\nTwo things worth taking in.\n\nThe three management APIs are **off by design**, not broken. With no API token\nconfigured the agent refuses to open a management API at all, rather than\nopening one with no credential on it. If you never turn them on, you manage the\nnode from the panel and from `cenvero-str-ctl` locally, and nothing is listening\non those ports.\n\nDHCP is the one service bound to every interface, because it has to answer\nbroadcasts from clients that do not have an address yet. On a node with a public\naddress, that means UDP/67 is reachable from outside unless your firewall drops\nit. Worth a rule if the node faces the internet.\n\n## 8. Turn on the management API — only if you need it\n\nYou need this if something other than you at a terminal has to drive the node:\nyour own automation, a script, an orchestrator. If the panel and the CLI are\nenough, skip this step and leave the surface closed.\n\nThe token is **not created for you**, and that is deliberate — a node that minted\nits own API credential would be granting access nobody asked for. You ask for one\nexplicitly, at any time, on a node that is already running. You do not need to\nreinstall anything.\n\n### Generate one on the node\n\nThe usual way. The node mints a strong random token, stores it, and shows it to\nyou:\n\n```bash\nsudo cenvero-str-ctl api-token generate\n```\n\nIt prints the token **once**. Copy it then — it is not displayed again, and\nthere is no command that will show it back to you. If you lose it, generate a\nnew one; that replaces the old, and anything still using the old token starts\ngetting 401 after the next restart.\n\n### Supply your own instead\n\nIf you need a specific value — one your automation already holds, or one from\nyour secret manager — pipe it in. Piping keeps it out of your shell history,\nwhich is why there is no flag to pass it as an argument:\n\n```bash\nprintf '%s' \"$MY_TOKEN\" | sudo cenvero-str-ctl api-token set\n```\n\nAnything under 16 characters is refused. A token short enough to guess is worse\nthan no token at all, because you would believe the API was protected.\n\n### Check or remove it\n\n```bash\ncenvero-str-ctl api-token status   # is one set? (never prints the value)\ncenvero-str-ctl api-token clear    # remove it — the APIs stop at the next restart\n```\n\n`status` deliberately reports only whether a token exists, never what it is.\n\n### At install time, if you prefer\n\n`CENVERO_API_TOKEN=auto` mints one during the install, and\n`CENVERO_API_TOKEN=<value>` sets yours — the same result, before the node first\nstarts. It is entirely optional: generating one later is fully supported and\nchanges nothing else about the node.\n\nWhichever way you set it, the token is kept in your local overrides, so a later\nsync from the panel will not discard it.\n\n### Then enable the APIs you want\n\n```bash\ncenvero-str-ctl service on rest\nsystemctl restart cenvero-stratum\n```\n\nThe API speaks **HTTPS**, never plaintext, so reach it as\n`https://<node>:7070/…`. An `http://` URL will fail even with everything\nconfigured correctly. Your node's certificate is signed by your own account\nauthority, so a browser warns until you trust that root.\n\nThe name comes after `on` — `service on rest`, not `service rest on`. Valid\nnames are `rest`, `grpc`, `websocket`, `metrics`, `dns` and `dhcp`, and `off`\ntakes the same form.\n\nThe API binds only when **all three** hold: a token is configured, the service is\non, and the node has a TLS certificate (step 9 — the agent refuses to serve\nplaintext). If any one is missing, the port stays closed and a browser gets\n\"connection refused\". `cenvero-str-ctl service status` tells you which.\n\nEvery protected endpoint then requires that token as a bearer credential.\n[API Reference](/docs/api) has the details.\n\n## 9. Get the node its certificate\n\nThe node serves its APIs over TLS. Ask the panel to sign a certificate for it:\n\n```bash\nsudo cenvero-str-ctl tls request\n```\n\nYou then **approve the request in your account**, under TLS. Once approved:\n\n```bash\nsudo cenvero-str-ctl tls pull\ncenvero-str-ctl tls info\n```\n\n`tls info` should report `mode: ca-signed` with an expiry. Note the commands\nhere are `tls info` and `tls pending-status` — there is no `tls status`.\n\nRenewal is automatic. See [TLS and Licence](/docs/tls-and-license).\n\n## 10. Configure the node's job\n\nNow give the node something to do. There is no node type to pick — every node\nhosts workloads *and* routes. What it can do is limited only by how many\ninterfaces it has free, which [Nodes and Interfaces](/docs/nodes) explains.\n\n**Define networks and addressing.** Create the networks your workloads sit on\nand the pools they draw addresses from:\n\n```bash\ncenvero-str-ctl network list\ncenvero-str-ctl ipam pools\ncenvero-str-ctl ipam allocations\n```\n\nSee [Networking Overview](/docs/networking/overview).\n\n**Set up DNS and DHCP.** Both are already listening. What they lack is your\nzones, scopes and reservations. See [DHCP and DNS](/docs/networking/dhcp-dns).\n\n**Write firewall rules.** A new node has no rules of its own. See\n[Firewall](/docs/networking/firewall).\n\n**Add a path to the internet.** Source-NAT lets a private tenant subnet reach\nthe internet through one public address. See\n[Gateway and NAT](/docs/networking/gateway-nat).\n\n**Set bandwidth and quotas.** Your plan already caps the node's total speed.\nThese divide it up between tenants. See [Tenants](/docs/tenants).\n\n## Verify the install\n\nRun through this list. Everything should be true before you put real traffic on\nthe node.\n\n| Check | Command | Expected |\n|-------|---------|----------|\n| Service is up | `systemctl status cenvero-stratum` | active (running) |\n| Agent responds | `cenvero-str-ctl status` | reports its state |\n| Licence is live | `cenvero-str-ctl license status` | active, with an expiry |\n| Certificate is signed | `cenvero-str-ctl tls info` | `mode: ca-signed` |\n| Services match intent | `cenvero-str-ctl service` | what you enabled is listening |\n| Node is healthy | `cenvero-str-ctl heal` | all checks pass |\n| Metrics answer | `curl 127.0.0.1:9090/metrics` | a metrics response |\n\n## Troubleshooting\n\n**`systemctl restart cenvero-str-ctl` says unit not found.** The service is\n`cenvero-stratum`. `cenvero-str-ctl` is the CLI.\n\n**`cenvero-str-ctl restart` says unknown command.** The CLI talks to the agent;\nit does not control the service. Use `systemctl restart cenvero-stratum`.\n\n**The REST, gRPC and WebSocket APIs are all `down`.** Expected on a fresh node\nwith no API token. This is fail-closed behaviour, not a fault. Fix it with\n`cenvero-str-ctl api-token generate`, then `service on rest`, then restart.\n\n**The API port refuses the connection** (`ERR_CONNECTION_REFUSED`, or `curl`\nreporting connection refused). Nothing is listening there. Enabling the service\nis only half of it — without a token the server does not start, so the port stays\nclosed. Check `cenvero-str-ctl api-token status` first, then\n`cenvero-str-ctl service status`: the row should read enabled and `listening`.\n\n**`service on rest` said it was enabled, but ENABLED still shows `off`.** Your\nswitch *was* saved — confirm with `cenvero-str-ctl config show`. On builds\nbefore this behaviour was corrected, the ENABLED column also folded in whether\nan API token exists, so a service you had genuinely enabled still displayed as\noff when no token was configured. Set a token (step 8) and the row resolves.\nENABLED now reports only your switch.\n\n**`curl 127.0.0.1:9090` returns 404.** That port serves `/metrics` and nothing\nelse. The bare root is not a page. Use `curl 127.0.0.1:9090/metrics`.\n\n**Activation fails with a node quota message.** Every slot on the licence is\ntaken. Reject or revoke a machine you no longer run and its slot comes back, or\nraise the node count on the licence.\n\n**`tls pull` succeeds but the panel shows nothing to approve.** The node needs\nto be registered before it can raise a request. Check `cenvero-str-ctl license\nstatus` first — if the node never registered, that is the thing to fix, and a\nnode quota message is the usual reason.\n\n**The install stopped over the kernel.** Your kernel is older than the data\nplane needs. Step 2 covers your options.\n\n**Something else.** `cenvero-str-ctl heal` runs the health checks, and the logs\nare in `/var/log/cenvero-str/`.\n\n## Where to go next\n\n- [Quick Start](/docs/quickstart) — define a network and attach a workload\n- [Configuration](/docs/configuration) — the node configuration in full\n- [Nodes and Interfaces](/docs/nodes) — what a node does, and the interfaces it needs\n- [Networking Overview](/docs/networking/overview) — how traffic moves\n- [Clustering Overview](/docs/clustering/overview) — join this node to others\n- [Operations](/docs/operations) — day-to-day running\n- [Upgrades](/docs/upgrades) — keeping the node current\n\n## Take this guide with you\n\nEvery page here is downloadable, so you can keep it alongside your runbooks or\nfeed it to your own tooling:\n\n- **This page** — [installation.json](/docs/installation.json)\n- **The whole documentation set** — [docs.json](/docs.json)\n\nBoth return the Markdown source, so they stay readable offline and are not tied\nto this site. Add `?include=html` to either for the rendered HTML as well.\n"
        },
        {
            "slug": "quickstart",
            "title": "Quick Start",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/quickstart",
            "headings": [
                {
                    "level": 1,
                    "text": "Quick Start"
                },
                {
                    "level": 2,
                    "text": "1. Define a network"
                },
                {
                    "level": 2,
                    "text": "2. Attach a workload to an endpoint"
                },
                {
                    "level": 2,
                    "text": "3. Add a firewall policy"
                },
                {
                    "level": 2,
                    "text": "4. Define a load-balanced VIP"
                },
                {
                    "level": 2,
                    "text": "5. Confirm"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 505,
            "markdown": "# Quick Start\n\nThis walkthrough takes an installed, activated node and gets a workload onto a\nmanaged network with DHCP, DNS, and a firewall policy — end to end.\n\nIf you have not installed the agent yet, start with\n[Installation](/docs/installation).\n\n## 1. Define a network\n\nA *network* is a managed private network: an IP CIDR whose usable host addresses\nare each materialized into an **endpoint profile** (an IP paired with a generated\nMAC). Create one with the CLI:\n\n```bash\nsudo cenvero-str-ctl network create \\\n  --name app-net \\\n  --cidr 10.20.0.0/24 \\\n  --gateway 10.20.0.1\n```\n\nStratum allocates the segment on the workload bridge, starts a DHCP server\nfor the pool, and serves authoritative DNS for the network's zone.\n\nList what you have:\n\n```bash\ncenvero-str-ctl network list\n```\n\n```text\n{\n  \"data\": {\n    \"networks\": [\n      {\n        \"id\": \"net-069088314b4e4712\",\n        \"name\": \"app-net\",\n        \"cidr\": \"10.20.0.0/24\",\n        \"gateway\": \"10.20.0.1\",\n        \"vlan\": 0,\n        \"tenant_id\": \"\",\n        \"created_at\": \"2026-07-24T20:53:31Z\"\n      }\n    ]\n  },\n  \"status\": \"ok\"\n}\n```\n\nNote the generated `id` — the other `network` commands refer to a network by\nthat id, not by its name.\n\n## 2. Attach a workload to an endpoint\n\nClaim a free endpoint for your workload. Stratum returns the endpoint's IP and a\ngenerated MAC, wires it into the bridge, and programs the data plane (including\nthe IP↔MAC anti-spoof binding) for it:\n\n```bash\nsudo cenvero-str-ctl network attach net-069088314b4e4712 --ip 10.20.0.50\n```\n\nConfigure your workload's interface with the returned MAC and it picks up the\nendpoint's IP from DHCP, a working default gateway, and DNS — no guest agent\nrequired.\n\n```bash\ncenvero-str-ctl network endpoints net-069088314b4e4712\n```\n\n```text\n{\n  \"data\": {\n    \"endpoints\": [\n      {\n        \"id\": \"ep-3f2a91c7\",\n        \"network_id\": \"net-069088314b4e4712\",\n        \"ip\": \"10.20.0.50\",\n        \"mac\": \"52:54:00:ab:cd:01\",\n        \"state\": \"bound\"\n      }\n    ]\n  },\n  \"status\": \"ok\"\n}\n```\n\n## 3. Add a firewall policy\n\nEach firewall rule is a single JSON object. Allow inbound HTTP and HTTPS to the\nendpoint, and let it reach out. Ports are matched one at a time, so HTTP and\nHTTPS are two rules:\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"dest_port\":443,\"stateful\":true}'\n\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"dest_port\":80,\"stateful\":true}'\n\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"source_ip\":\"10.20.0.50\",\"stateful\":true}'\n```\n\n`\"stateful\": true` puts the flow in the connection-tracking table so return\ntraffic is admitted automatically. See [Firewall](/docs/networking/firewall) for\nthe full policy model and every rule field.\n\n## 4. Define a load-balanced VIP\n\nDeclare an L4 virtual IP, then attach a backend to it so you can scale\nhorizontally later:\n\n```bash\nsudo cenvero-str-ctl lb create '{\"id\":\"web-lb\",\"frontend_ip\":\"10.20.0.10\",\"frontend_port\":80,\"protocol\":\"tcp\",\"algorithm\":\"least-conn\"}'\n\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"web-lb\",\"id\":\"web-1\",\"ip\":\"10.20.0.50\",\"port\":80,\"weight\":1}'\n```\n\nAdd and remove backends live with `lb add-backend` / `lb remove-backend`. See\n[Load Balancer](/docs/networking/load-balancer).\n\n## 5. Confirm\n\n```bash\ncenvero-str-ctl status\n```\n\nYou now have a workload on a managed network with DHCP, DNS, a firewall policy,\nand a load-balancer VIP defined — all from one agent.\n\n## Where to go next\n\n- **[Configuration](/docs/configuration)** — the node configuration model in depth.\n- **[Clustering Overview](/docs/clustering/overview)** — stretch the network across hosts.\n- **[CLI Reference](/docs/cli)** — every command in one place.\n"
        },
        {
            "slug": "configuration",
            "title": "Configuration",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/configuration",
            "headings": [
                {
                    "level": 1,
                    "text": "Configuration"
                },
                {
                    "level": 2,
                    "text": "How configuration is delivered and layered"
                },
                {
                    "level": 3,
                    "text": "How the config is signed and verified"
                },
                {
                    "level": 2,
                    "text": "Launch flags"
                },
                {
                    "level": 2,
                    "text": "Identity and node fields"
                },
                {
                    "level": 2,
                    "text": "Paths"
                },
                {
                    "level": 2,
                    "text": "Ports"
                },
                {
                    "level": 2,
                    "text": "API"
                },
                {
                    "level": 2,
                    "text": "TLS"
                },
                {
                    "level": 2,
                    "text": "Cluster"
                },
                {
                    "level": 2,
                    "text": "Gateway and HA"
                },
                {
                    "level": 2,
                    "text": "Overlay (VXLAN) and load balancer"
                },
                {
                    "level": 2,
                    "text": "Intrusion detection (IDS)"
                },
                {
                    "level": 2,
                    "text": "DNS"
                },
                {
                    "level": 2,
                    "text": "DHCP"
                },
                {
                    "level": 2,
                    "text": "Metrics"
                },
                {
                    "level": 2,
                    "text": "Network services (on/off switches)"
                },
                {
                    "level": 2,
                    "text": "Operator local overrides"
                },
                {
                    "level": 3,
                    "text": "Settable keys (the local whitelist)"
                },
                {
                    "level": 3,
                    "text": "Keys that cannot be set locally"
                },
                {
                    "level": 2,
                    "text": "What is *not* in this file"
                },
                {
                    "level": 2,
                    "text": "Ports summary"
                },
                {
                    "level": 2,
                    "text": "A note on time"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 2853,
            "markdown": "# Configuration\n\nThe agent starts from sensible **built-in defaults** and, on top of those, loads\nan optional node configuration file. If the file is absent the defaults are used,\nso a freshly-installed node runs without any config at all. Point the agent at a\nspecific file with `--config <path>`.\n\n```bash\n# Run with the default config (if one is present)\ncenvero-stratum\n\n# Or an explicit path\ncenvero-stratum --config /etc/cenvero-str/config.cenvero-stratum\n```\n\nThis page is the **full reference** for every configuration field: what it is,\nits type, default, accepted values, and which command controls it. Use it\nalongside the [CLI Reference](/docs/cli).\n\n## How configuration is delivered and layered\n\nThere are three layers, applied in order — **each later layer wins**:\n\n1. **Built-in defaults.** Compiled into the agent (the table values marked\n   *default* below). A node with no config file at all runs entirely on these.\n2. **The node config file** (`config.cenvero-stratum`). A **signed** configuration\n   file the provisioning tooling and the panel produce — it is not hand-edited.\n   The panel delivers this file already signed; the agent verifies it on load (see\n   below). Distributing one signed file rolls identical settings across a fleet.\n3. **Operator local overrides** (`config.local.cenvero-stratum`). A small sidecar\n   you set on the node itself with `cenvero-str-ctl config set` / `service`. These\n   take precedence over the panel-delivered config **and survive a panel re-sync**\n   — see the *Operator local overrides* section below.\n\n> The provisioning installer writes the *initial* config file for you; you don't\n> author it by hand. Day-to-day local adjustments go through `config set` and\n> `service`, which write the override sidecar — never the main file.\n\n### How the config is signed and verified\n\nThe node config and the override sidecar are stored as signed configuration files\nthe agent loads directly — they are not hand-written text.\n\nFor production, the panel **cryptographically signs** the config it delivers, and\nthe agent **verifies that signature on load** so it refuses to boot from a forged\nor tampered file delivered over the network. The trust model is deliberately\nfail-closed where it matters and permissive where it can't hurt you:\n\n- **No file on disk** — the agent boots on built-in defaults (a fresh node still\n  comes up).\n- **A signed config** (the normal panel-delivered case) — the signature **must**\n  verify; a tampered or wrong-key file is **rejected**.\n- **An unsigned local bootstrap config** (what the installer writes on first\n  install) — accepted with a loud log warning, so a brand-new node can start,\n  register, and sync. A network attacker can't exploit this: substituting an\n  unsigned file only yields installer defaults, and the next panel-signed config\n  still must verify.\n\nYou inspect (never edit) the on-disk file with `cenvero-str-ctl config show`,\nwhich decodes and prints it; secret values are redacted unless you add\n`--include-secrets`.\n\n```bash\nsudo cenvero-str-ctl config show               # decoded, secrets redacted\nsudo cenvero-str-ctl config show --format json # machine-readable\n```\n\n## Launch flags\n\nA handful of settings can be overridden at process launch (these affect that one\nrun; persistent changes belong in the config or the override sidecar):\n\n| Flag | Type | Default | What it does |\n|------|------|---------|--------------|\n| `--config <path>` | path | (none) | Path to the node config file. If omitted, built-in defaults are used. |\n| `--bind <addr>` | IP | (from config) | Override the API bind address for this run. |\n| `--log-level <level>` | enum | `info` | Log verbosity: `debug`, `info`, `warn`, or `error`. |\n| `--allow-unsigned-config` | flag | `false` | **Dev only, insecure.** Load the config without verifying its signature. Never use in production. |\n\n## Identity and node fields\n\nThese describe the node itself. `node_id` and `license_server` are\n**panel/identity-owned**: set at install or by the panel-signed config (and, where\nnoted, refused by `config set` — see *Keys that cannot be set locally* below).\n`log_level` is the exception — it is operator-settable, per its row.\n\n| Key | Type | Default | Accepted values | What it does | Controlled by |\n|-----|------|---------|-----------------|--------------|---------------|\n| `node_id` | string | (empty) | UUID | Stable node identity; binds the license. Also the VXLAN VTEP source fallback when it parses as an IP. | Install / panel |\n| `license_server` | string (URL) | `https://license.cenvero.com` | Base URL | License/activation server. Empty falls back to the built-in default. | Install / panel |\n| `log_level` | enum | `info` | `debug`, `info`, `warn`, `error` | Agent log verbosity. | `config set log_level` |\n\n> **There is no node-mode setting any more.** Older versions carried a\n> `node_mode` field that chose between a Compute node and a Gateway node. Every\n> node now routes, the field is gone, and a node updating from an older version\n> has its configuration migrated automatically — see\n> [Nodes and Interfaces](/docs/nodes).\n\n## Paths\n\nFilesystem locations the agent uses. These follow the standard `cenvero-str`\nlayout and are set by the installer; they are not adjusted with `config set`.\n\n| Key | Type | Default | What it does |\n|-----|------|---------|--------------|\n| `data_dir` | path | `/var/lib/cenvero-str/` | Persistent state — the node's own records and supporting files. |\n| `config_dir` | path | `/etc/cenvero-str/` | Configuration directory (holds the config file and the override sidecar). |\n| `socket_path` | path | `/run/cenvero-str/cenvero-str.sock` | Unix socket `cenvero-str-ctl` uses to talk to the agent. |\n\n## Ports\n\nThe agent listens on a fixed set of ports. The defaults rarely need changing; when\nthey do, the five listen ports are settable via the local override sidecar. Open\nthese between cluster members on the **management network only**.\n\n| Key | Type | Default | Protocol | Purpose | Controlled by |\n|-----|------|---------|----------|---------|---------------|\n| `port_rest` | int (1-65535) | `7070` | HTTPS | REST management API. | `config set port_rest` |\n| `port_grpc` | int (1-65535) | `7071` | TCP | gRPC management API (node-to-node control). | `config set port_grpc` |\n| `port_websocket` | int (1-65535) | `7072` | WebSocket | Live events / streaming. | `config set port_websocket` |\n| `port_cluster` | int (1-65535) | `7073` | TCP | Cluster state replication. | `config set port_cluster` |\n| `port_ha_heartbeat` | int (1-65535) | `7074` | UDP | Gateway HA heartbeat. | `config set port_ha_heartbeat` |\n\n## API\n\nThe local management API (REST / gRPC / WebSocket). The bind address, rate limits,\nand allow-lists are settable locally; the **bearer token is a credential** and is\nrefused by `config set` (rotate it at install or via the panel).\n\n| Key | Type | Default | Accepted values | What it does | Controlled by |\n|-----|------|---------|-----------------|--------------|---------------|\n| `api_bind_address` | IP | `0.0.0.0` | Any IP (use `0.0.0.0` / `::` for all interfaces) | IP the REST/gRPC/WebSocket APIs listen on. A host:port or hostname is rejected — this is an IP only. | `config set api_bind_address` |\n| `api_rate_limit` | int (≥ 0) | `1000` | `0` disables the limiter | Requests per minute per key. | `config set api_rate_limit` |\n| `api_rate_burst` | int (≥ 0) | `100` | `0` or higher | Burst allowance above the per-minute rate. | `config set api_rate_burst` |\n| `api_allowed_ips` | list of IP/CIDR | (empty) | Comma-separated IPs/CIDRs; empty = allow all | IPs/CIDRs allowed to reach the API. | `config set api_allowed_ips` |\n| `api_allowed_origins` | list of string | (empty) | Comma-separated origins; empty = same-host only | Allowed WebSocket `Origin` values. | `config set api_allowed_origins` |\n| `api_token` | string (secret) | (empty) | Bearer token | Token required for protected REST/gRPC/WS endpoints. Empty means the local management API is **disabled**. | Install / panel (refused by `config set`) |\n\n> Set the API token at install time with\n> `cenvero-str-ctl config encode --generate-api-token` (mints a fresh random token\n> and enables the API). It is a credential, not an operational setting.\n\n## TLS\n\nTLS material for the management API. Paths are managed by the certificate manager\n(`cenvero-str-ctl tls ...`) — they are **not** set with `config set`.\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `tls_auto_generate` | bool | `true` | Auto-generate a self-signed certificate on first start. | Install / cert manager |\n| `tls_cert_path` | path | `/etc/cenvero-str/tls/server.crt` | TLS certificate. | Cert manager (refused by `config set`) |\n| `tls_key_path` | path | `/etc/cenvero-str/tls/server.key` | TLS private key. | Cert manager (refused by `config set`) |\n| `tls_pubkey_path` | path | `/etc/cenvero-str/tls/server.pub` | TLS public key file. | Cert manager (refused by `config set`) |\n| `grpc_client_ca_dir` | path | (empty) | When set, contains `ca.pem` used to require + verify gRPC client certificates (mTLS). | Provisioned (refused by `config set`) |\n\n## Cluster\n\nClustering identity and transport security. These are panel-orchestrated /\nprovisioned and are **not** set with `config set`. Cluster TLS material is\nmandatory — clustering refuses to start without it (no plain-TCP downgrade).\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `cluster_enabled` | bool | `false` | Whether this node participates in a cluster. | Panel |\n| `cluster_bind_addr` | string | (empty) | Address the cluster transport binds. | Panel (refused by `config set`) |\n| `cluster_bootstrap` | bool | `false` | Whether this node bootstraps a new cluster. | Panel |\n| `cluster_cert_dir` | path | (empty) | Directory holding `cert.pem`, `key.pem`, and `ca.pem` for cluster mTLS. | Provisioned (refused by `config set`) |\n\n## Gateway and HA\n\nThese name the two interfaces the node's forwarding path is placed on, and\nconfigure the HA pairing. Naming the interfaces is what puts NAT and routing on\nthem — there is no separate switch to turn the gateway path on. Leave them empty\nand the node still hosts workloads and serves its networks; it simply forwards\nnothing across an edge. The **HA shared key is a cluster secret** and is refused\nby `config set` (it must match the peer).\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `gateway_wan_interface` | string | (empty) | The outward-facing interface the forwarding path (NAT + routing) uses. | Install / panel |\n| `gateway_lan_interface` | string | (empty) | The inward-facing interface the forwarding path uses. | Install / panel |\n| `wan_dhcp` | bool | `false` | Run the built-in DHCP client on the WAN interface to obtain the uplink address. When false, the WAN address is static/config. | Install / panel |\n| `gateway_vip` | string (IP) | (empty) | Virtual IP the HA pair owns; the ACTIVE node assumes it and announces it via gratuitous ARP, releasing it on losing ACTIVE. Empty registers no VIP. | Install / panel |\n| `gateway_peer_addr` | string | (empty) | HA peer address (`host` or `host:port`; the HA heartbeat port is appended when absent). Empty disables HA peering (solo gateway). | Install / panel |\n| `gateway_priority` | int | `0` | Biases which node becomes ACTIVE; higher wins. `0` leaves the manager default. | Install / panel |\n| `gateway_shared_key` | string (secret) | (empty) | Shared key authenticating the HA heartbeat; **must match the peer**. | Install / panel (refused by `config set`) |\n\n## Overlay (VXLAN) and load balancer\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `vtep_local_ip` | string (IP) | (empty) | Underlay source IP for VXLAN tunnels. Empty falls back to `node_id` **only if** it parses as an IP (a hostname `node_id` is rejected with a warning). | Install / panel |\n| `lb_interface` | string | (empty) | VIP-facing interface the L4 load balancer uses. Empty leaves the load balancer unattached. Must be a **dedicated** interface — not one already used by the bridge or gateway data plane. | Install / panel |\n\n## Intrusion detection (IDS)\n\nOpt-in per-source scan/flood detection on an interface's ingress.\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `ids_interface` | string | (empty) | Interface whose ingress the IDS collector watches. Empty leaves IDS disabled. May be an interface already used by the data plane — typically the uplink/WAN or `cnv-user-br0`. | Install / panel |\n| `ids_auto_block` | bool | `false` | `false` = **alert only** (a detection raises an event but does not touch traffic). `true` adds a confirmed scanner/flooder's IPv4 source to the firewall blocklist with an auto-expiring TTL. Opt-in because a false positive would cut off a legitimate source. | Install / panel |\n\n## DNS\n\nThe built-in resolver. The listen address and client ACL are settable locally;\nDNSSEC is panel-managed.\n\n| Key | Type | Default | Accepted values | What it does | Controlled by |\n|-----|------|---------|-----------------|--------------|---------------|\n| `dns_listen_addr` | string | (empty) | IP, or `host:port`, or empty | Address the DNS server binds. Empty binds the management bridge address only (not the WAN), so the resolver isn't exposed by default. | `config set dns_listen_addr` |\n| `dns_allowed_clients` | list of IP/CIDR | (empty) | Comma-separated IPs/CIDRs; empty = local/private ranges | Clients permitted to query the resolver. Empty falls back to local/private ranges — never an open resolver. | `config set dns_allowed_clients` |\n| `dnssec_enabled` | bool | `false` | `true`/`false` | Authoritative DNSSEC signing (per-zone keys, RRSIG/DNSKEY served when the client sets the DO bit). Default off = unsigned answers (back-compat). | Install / panel |\n\n## DHCP\n\nThe built-in DHCP server. Per-network scopes are panel-managed (set as\noperational resources, not in the static config); the **DHCP service itself** is\ntoggled with `service dhcp`.\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `dhcp_scopes` | list | (empty = single default pool) | Per-network DHCP scopes: each binds a client subnet to its own IPAM pool and reply parameters (gateway / mask / DNS / lease length). Matched by `giaddr` (relayed) or the receiving interface (directly attached). | Panel / API |\n\n## Metrics\n\n| Key | Type | Default | What it does | Controlled by |\n|-----|------|---------|--------------|---------------|\n| `metrics_bind_addr` | string (host:port) | `127.0.0.1:9090` | Address the Prometheus `/metrics` endpoint binds. Defaults to loopback so it isn't exposed on `0.0.0.0`; point it at a management address to scrape it off-box. The host must be an IP, not a hostname. | `config set metrics_bind_addr` |\n\n## Network services (on/off switches)\n\nEach network service can be turned on or off. These are stored **inverted** as\n`*_disabled` keys, so the default — every service running — is preserved for any\nolder config. The friendly front door is `cenvero-str-ctl service <name> on|off`;\nthe raw equivalent is `config set <name>_disabled true|false`, and the two\nround-trip with `config show`. All default to **enabled**.\n\n| Service name | `config` key | Default | What it gates |\n|--------------|--------------|---------|---------------|\n| `rest` | `rest_disabled` | enabled | The REST management API (also serves the operator/billing API). |\n| `grpc` | `grpc_disabled` | enabled | The gRPC management API. |\n| `websocket` | `websocket_disabled` | enabled | The WebSocket management API. |\n| `metrics` | `metrics_disabled` | enabled | The Prometheus `/metrics` endpoint. |\n| `dns` | `dns_disabled` | enabled | The built-in DNS server bind (on top of the listen-address gate). |\n| `dhcp` | `dhcp_disabled` | enabled | The built-in DHCP protocol server bind (the lease table still runs). |\n\n```bash\n# Toggle a service (writes the override sidecar; applies on restart)\nsudo cenvero-str-ctl service dns off\nsudo cenvero-str-ctl service status      # ENABLED / ADDRESS / live STATE per service\n\n# Equivalent raw form\nsudo cenvero-str-ctl config set dns_disabled true\n```\n\n> The **IPC control socket is not a toggleable service** — it is how\n> `cenvero-str-ctl` talks to the agent, so it can never be disabled. Disabling the\n> REST API is allowed but warns first, because it also carries the operator/billing\n> API.\n\n## Operator local overrides\n\n`config set` and `service` never touch the main `config.cenvero-stratum` file.\nThey write a separate **operator local-overrides sidecar**\n(`config.local.cenvero-stratum`, mode `0600`) in the config directory. The agent\noverlays this on top of its config at boot, so:\n\n- A locally-set field **takes effect on the next agent restart**, and\n- it **survives a configuration re-sync from the panel** (which rebuilds the main\n  config from defaults but never touches the sidecar). Local always wins.\n\n```bash\n# The general form is: config set <key> <value>\nsudo cenvero-str-ctl config set api_rate_limit 2000\n\n# Legacy shortcut flags for the three most common API keys\nsudo cenvero-str-ctl config set --api-bind 10.0.0.5 --api-rate-limit 2000 --api-rate-burst 200\n\n# Run with no value to list every settable key\nsudo cenvero-str-ctl config set\n\n# Apply the change\nsudo systemctl restart cenvero-stratum\n```\n\n### Settable keys (the local whitelist)\n\nOnly these safe operational keys can be set locally. Everything else is refused.\n\n`log_level`, `api_bind_address`, `api_rate_limit`, `api_rate_burst`,\n`api_allowed_ips`, `api_allowed_origins`, `api_read_timeout_secs`,\n`api_write_timeout_secs`, `api_idle_timeout_secs`, `port_rest`, `port_grpc`,\n`port_websocket`, `port_cluster`, `port_ha_heartbeat`, `metrics_bind_addr`,\n`dns_listen_addr`, `dns_allowed_clients`, `dns_upstreams`,\n`heal_interval_seconds`, `heal_disabled_checks`, and the six service switches\n(`rest_disabled`, `grpc_disabled`, `websocket_disabled`, `metrics_disabled`,\n`dns_disabled`, `dhcp_disabled`).\n\n### Keys that cannot be set locally\n\nIdentity, the license server, credentials, and signing / cluster-secret / TLS\nmaterial are **refused** by `config set` with an explanation — they are panel- or\nidentity-owned and must never come from a local edit:\n\n| Refused key | Why |\n|-------------|-----|\n| `node_id` | Panel-assigned identity; binds the license. |\n| `license_server` | Panel-controlled (set at install / by the signed config). |\n| `api_token` | A credential — rotate it at install/panel, not via `config set`. |\n| `gateway_shared_key` | A cluster secret that must match the HA peer. |\n| `cluster_bind_addr` | Clustering identity/secret is panel-orchestrated. |\n| `cluster_cert_dir` | Cluster mTLS material is provisioned. |\n| `grpc_client_ca_dir` | gRPC client-mTLS CA material is provisioned. |\n| `tls_cert_path` | TLS material is managed by the cert manager. |\n| `tls_key_path` | TLS material is managed by the cert manager. |\n| `tls_pubkey_path` | TLS material is managed by the cert manager. |\n\n## What is *not* in this file\n\nOperational resources — **networks, endpoints, IP pools, firewall rules, load\nbalancers, DNS/DHCP zones** — are **not** part of the node config. They are\nmanaged at runtime through the agent's API and `cenvero-str-ctl` (and replicated\nacross a cluster), not baked into the static node config. Use the relevant\n[CLI](/docs/cli) command group for each.\n\n## Ports summary\n\nOpen these between cluster members on the management network only:\n\n| Port | Protocol | Purpose |\n|------|----------|---------|\n| 7070 | HTTPS | Management API |\n| 7071 | TCP | Node-to-node control |\n| 7072 | WebSocket | Live events / streaming |\n| 7073 | TCP | Cluster state replication |\n| 7074 | UDP | Gateway HA |\n\n## A note on time\n\nStratum works in UTC everywhere and cross-checks the host clock against NTP.\nLarge time drift is treated as a tamper signal for licensing, so keep `chronyd`\nor `systemd-timesyncd` running on every node.\n\n## Next steps\n\n- **[CLI Reference](/docs/cli)** — the full command surface.\n- **[Clustering Overview](/docs/clustering/overview)** — joining nodes into a cluster.\n- **[Licensing](/docs/licensing)** — how enforcement interacts with the agent.\n"
        },
        {
            "slug": "operations",
            "title": "Operations & Troubleshooting",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/operations",
            "headings": [
                {
                    "level": 1,
                    "text": "Operations & Troubleshooting"
                },
                {
                    "level": 2,
                    "text": "Systemd units"
                },
                {
                    "level": 2,
                    "text": "Logs"
                },
                {
                    "level": 2,
                    "text": "Network services: on / off"
                },
                {
                    "level": 2,
                    "text": "Changing settings"
                },
                {
                    "level": 2,
                    "text": "Updates"
                },
                {
                    "level": 2,
                    "text": "Health checks"
                },
                {
                    "level": 2,
                    "text": "Troubleshooting"
                },
                {
                    "level": 3,
                    "text": "The agent won't start"
                },
                {
                    "level": 3,
                    "text": "`cenvero-str-ctl` says the agent is unreachable (exit code 3)"
                },
                {
                    "level": 3,
                    "text": "License is not active"
                },
                {
                    "level": 3,
                    "text": "TLS certificate is pending"
                },
                {
                    "level": 3,
                    "text": "Node is not registered"
                },
                {
                    "level": 3,
                    "text": "\"REST API disabled\" — set an API token"
                },
                {
                    "level": 3,
                    "text": "Where to look first"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 1943,
            "markdown": "# Operations & Troubleshooting\n\nDay-to-day operation of a Stratum node: managing its systemd units, finding the\nlogs, turning individual network services on and off, changing settings, applying\nupdates, and a runbook for the issues you are most likely to hit.\n\nEverything here runs **on the node itself** as `root`. The operator tool,\n`cenvero-str-ctl`, talks to the running agent over a local socket — see the\n[CLI Reference](/docs/cli) for the full command surface.\n\n## Systemd units\n\nA node runs three systemd units, installed and enabled by the installer:\n\n| Unit | What it does |\n|------|--------------|\n| `cenvero-stratum` | The agent itself — control plane, APIs, and the in-kernel data plane. |\n| `cenvero-str-watchdog` | A small separate watchdog that monitors the agent and restarts it if it stops responding. It is bound to the agent (`BindsTo`), so it follows the agent's lifecycle. |\n| `cenvero-str-network` | A one-shot boot unit that brings up the host uplinks and the two bridges (`cnv-mgmt-br0`, `cnv-user-br0`) **before** the agent starts, so the interfaces are present when the agent attaches to them. |\n\nManage them with the usual `systemctl` verbs:\n\n```bash\n# Status and recent log lines for the agent\nsystemctl status cenvero-stratum\n\n# Start / stop / restart the agent (the watchdog follows it)\nsudo systemctl restart cenvero-stratum\nsudo systemctl stop cenvero-stratum\nsudo systemctl start cenvero-stratum\n\n# The watchdog and the boot-time network unit\nsystemctl status cenvero-str-watchdog\nsystemctl status cenvero-str-network\n```\n\nRestarting the agent is safe: because the data plane runs in the kernel and the\ninterfaces stay held across the swap, the brief restart does **not** tear down\nexisting traffic. Stopping the agent leaves the kernel data plane and the bridges\nin place.\n\n> Do not disable `cenvero-str-network` while the node is in service — it owns the\n> host networking the agent attaches to.\n\n## Logs\n\nThe agent logs structured JSON to standard out, which under systemd is captured\nby the **journal**. That is the first place to look:\n\n```bash\n# Follow the agent's live log\njournalctl -u cenvero-stratum -f\n\n# Last 200 lines\njournalctl -u cenvero-stratum -n 200\n\n# Since a time, or only errors and worse\njournalctl -u cenvero-stratum --since \"1 hour ago\"\njournalctl -u cenvero-stratum -p err\n\n# The watchdog has its own unit log\njournalctl -u cenvero-str-watchdog -n 100\n```\n\nOn-disk files live under **`/var/log/cenvero-str/`** — most notably the installer\nlog written during install. The agent's self-healing service prunes old rotated\nlog files (older than 7 days) from this directory when disk space runs low, so it\nis also where any rotated logs accumulate.\n\nTurn up the detail when you need it (see *Changing settings* below):\n\n```bash\nsudo cenvero-str-ctl config set log_level debug\nsudo systemctl restart cenvero-stratum\n```\n\nValid levels are `debug`, `info`, `warn`, and `error`. Set it back to `info` when\nyou are done — `debug` is noisy.\n\n## Network services: on / off\n\nThe agent exposes several **network services** you can independently enable or\ndisable. View them and their live state with `service status`, and flip each one\nwith `service <name> on|off`:\n\n```bash\n# See every service: its switch, its address:port, and whether it is listening\ncenvero-str-ctl service status\n\n# Turn services off / on\nsudo cenvero-str-ctl service rest off\nsudo cenvero-str-ctl service metrics on\n```\n\nThe toggleable services are:\n\n| Service | Name | What it is |\n|---------|------|------------|\n| REST API | `rest` | The HTTPS management & operator/billing API. |\n| gRPC API | `grpc` | The gRPC management API. |\n| WebSocket API | `websocket` | The live-events / streaming API. |\n| Metrics | `metrics` | The Prometheus `/metrics` endpoint. |\n| DNS | `dns` | The built-in DNS server. |\n| DHCP | `dhcp` | The built-in DHCP server. |\n\nThe verb-first form works too — `service off rest`, `service on dns` — and `-h`\non the group shows the help.\n\nA few notes:\n\n- **A change takes effect on the next agent restart.** After toggling a service,\n  apply it with `sudo systemctl restart cenvero-stratum`, then re-check with\n  `service status`.\n- **The local control socket can never be disabled** — it is how `cenvero-str-ctl`\n  reaches the agent (including the command you just ran). Trying to turn it off is\n  refused with an explanation.\n- **Disabling the REST API cuts off remote callers.** The REST API also serves the\n  operator/billing API (tenant suspend/resume/limit). Turning it off is allowed —\n  it is your call — but the command warns you first; the local socket and the panel\n  are unaffected.\n\nUnder the hood, `service <name> on|off` writes the matching off-switch into the\nsame operator local-overrides file that `config set` uses (see below), so a panel\nre-sync never clobbers your choice.\n\n## Changing settings\n\n`config set` adjusts a node's **operational settings** on a running node. It\nrecords your change in an operator local-override file that takes precedence over\nthe panel-delivered config and **survives a re-sync** — it never edits the signed\nnode config itself.\n\n```bash\n# Inspect the active configuration (paths, ports, API settings, services)\ncenvero-str-ctl config show\n\n# Change a setting, then restart to apply it\nsudo cenvero-str-ctl config set api_rate_limit 2000\nsudo systemctl restart cenvero-stratum\n```\n\nThe key names match what you see in `config show`, so the same name round-trips\nbetween the two. Commonly-set keys include:\n\n| Key | Meaning |\n|-----|---------|\n| `log_level` | Agent log level: `debug`, `info`, `warn`, `error` |\n| `api_bind_address` | IP the REST/gRPC/WebSocket APIs listen on (e.g. `0.0.0.0` or a management IP) |\n| `api_rate_limit` / `api_rate_burst` | API requests per minute per key, and the burst allowance (`0` rate disables the limiter) |\n| `api_allowed_ips` | Comma-separated IPs/CIDRs allowed to reach the API (empty = allow all) |\n| `port_rest`, `port_grpc`, `port_websocket` | API listen ports |\n| `metrics_bind_addr` | Prometheus metrics bind address `host:port` |\n| `dns_listen_addr`, `dns_allowed_clients` | Built-in DNS server bind address and allowed clients |\n\nRun `cenvero-str-ctl config set -h` for the full whitelist and per-key help.\n\n> **What you cannot set locally.** Identity, signing, and cluster-secret fields —\n> `node_id`, `license_server`, `api_token`, `gateway_shared_key`, and TLS/cluster\n> material — are **not** settable with `config set` by design. They are\n> panel-assigned or provisioned. Reaching for one returns a clear message telling\n> you where it is managed instead (e.g. TLS material is handled by\n> `cenvero-str-ctl tls …`, and the API token is rotated via the installer/panel).\n>\n> **Every `config set` change applies on the next restart.** It is saved\n> immediately, but the running agent only picks it up when it restarts.\n\n## Updates\n\nUpdates are **pull-based**: the agent polls a signed manifest from the panel and,\nwhen a newer fully-verified release for its channel is available, downloads,\nverifies, and applies it on its own. You can check or trigger this manually:\n\n```bash\n# What's installed now\ncenvero-str-ctl status\n\n# Ask the agent to check the manifest for a newer release (no download)\ncenvero-str-ctl update check\n\n# Apply an available update now (otherwise it applies on the next poll)\nsudo cenvero-str-ctl update apply\n```\n\n`update check` reports the current version, the latest on offer for your license\nchannel, and whether an update is available — without downloading anything. If the\npanel is unreachable it says so plainly rather than failing hard. `update apply`\nperforms the pull, checksum + publisher-signature verification, an atomic\nin-place swap, and a watchdog-supervised restart, with self-rollback if the\npost-restart health check fails. See [Upgrades](/docs/upgrades) for the full\nmodel (channels, downgrade protection, and rollback).\n\nA node in the **Frozen** license state keeps running its current version but\ncannot pull updates until the license is renewed.\n\n## Health checks\n\nThe agent continuously self-checks core subsystems (disk space, memory, the\ndatabase, the bridges, and the in-kernel data plane) and attempts an automatic\nrepair when one fails. Inspect or force a run:\n\n```bash\n# Latest result for every health check\ncenvero-str-ctl heal status\n\n# Force an immediate run now\nsudo cenvero-str-ctl heal check\n```\n\n---\n\n## Troubleshooting\n\n### The agent won't start\n\n**1. Read the journal** — it almost always names the cause:\n\n```bash\njournalctl -u cenvero-stratum -n 100 --no-pager\nsystemctl status cenvero-stratum\n```\n\n**2. Confirm you are running as `root`** and the host network came up:\n\n```bash\nsystemctl status cenvero-str-network\n```\n\nThe network unit must succeed first — it creates the bridges the agent attaches\nto. If it failed, fix the host networking and `sudo systemctl restart\ncenvero-str-network`.\n\n**3. Inspect the config the agent is loading:**\n\n```bash\nsudo cenvero-str-ctl config show\n```\n\nA config that fails to decode falls back to defaults with a note in the output.\nA *signed* config that fails verification is **rejected** — re-fetch a fresh\npanel-signed config rather than hand-editing the file.\n\n**4. Check disk space** and the data directory (`/var/lib/cenvero-str/`). A full disk\nstops the agent from writing its state; the health check reclaims rotated logs\nbut a genuinely full volume needs operator action.\n\n### `cenvero-str-ctl` says the agent is unreachable (exit code 3)\n\nThe CLI talks to the agent over its local socket. If commands report the agent is\nunreachable, the agent process is not running — start it and re-check:\n\n```bash\nsudo systemctl start cenvero-stratum\ncenvero-str-ctl status\n```\n\n### License is not active\n\nCheck the installed license and this machine's hardware identity:\n\n```bash\nsudo cenvero-str-ctl license status\n```\n\n- **Expired / in grace / frozen** — renew it. Existing traffic keeps running even\n  when frozen; only new or changing operations are blocked. Renew with\n  `sudo cenvero-str-ctl license renew` (or fetch a fresh one after renewing in\n  your account). See [Licensing](/docs/licensing) for the warn → grace → freeze\n  model.\n- **Not activated** — activate this machine with `sudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX`, then confirm it in your account.\n- **Hardware-ID unavailable** — `license status` shows the machine's hardware ID.\n  The binding requires firmware/hardware identifiers that are not exposed inside\n  generic virtual machines, so a node must be **bare metal**. If the ID shows as\n  unavailable, you are likely running in an unsupported virtualized environment.\n- **Wrong release channel** — a stable license runs only stable builds and a\n  pre-release license runs only beta/RC builds; a mismatch fails closed. Make sure\n  the build you installed matches your license's channel.\n\n### TLS certificate is pending\n\nAfter install the agent generates its own certificate; if a customer-approval or\ndomain step is outstanding the certificate can show as pending. Confirm what the\nagent is using and re-issue if needed:\n\n```bash\nsudo cenvero-str-ctl config show          # shows the TLS cert/key paths in use\nsudo cenvero-str-ctl tls -h               # certificate management commands\n```\n\nTLS material is managed by the certificate manager (`cenvero-str-ctl tls …`), not\n`config set`. Adding or removing a SAN domain re-generates the certificate.\n\n### Node is not registered\n\nRegistration binds the node to its license and mints its node token. If a node\nnever registered, re-check connectivity to the panel and the license, then let it\nre-register:\n\n```bash\ncenvero-str-ctl status                    # shows license + registration state\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\n```\n\nRegistration needs outbound HTTPS to your management/license server and a valid\nlicense. If the panel is unreachable, the agent keeps retrying — fix connectivity\nand it completes on its own.\n\n### \"REST API disabled\" — set an API token\n\nIf a client gets a *REST API disabled* (or 401/unauthorized) response, two\nsettings govern access:\n\n**1. Is the service enabled?** Confirm with `cenvero-str-ctl service status`. If\n`rest` is **off**, turn it back on and restart:\n\n```bash\nsudo cenvero-str-ctl service rest on\nsudo systemctl restart cenvero-stratum\n```\n\n**2. Is an API token set?** The REST/gRPC/WebSocket APIs require the bearer token\nto be configured. `config show` reports **Token set: yes/no**. The token is a\n**credential**, so it is *not* set with `config set` — it is provisioned by the\ninstaller (which generates one and prints it) or rotated via the panel. If no\ntoken is set, re-run the installer / fetch a panel-signed config to provision\none, then restart the agent.\n\n### Where to look first\n\n| Symptom | First check |\n|---------|-------------|\n| Agent down / crash-looping | `journalctl -u cenvero-stratum -n 100` |\n| CLI \"agent unreachable\" | `systemctl status cenvero-stratum` |\n| License problems | `cenvero-str-ctl license status` |\n| A service not answering | `cenvero-str-ctl service status` |\n| Settings not taking effect | Did you `systemctl restart cenvero-stratum`? |\n| Overall health | `cenvero-str-ctl heal status` |\n\n## Next steps\n\n- **[CLI Reference](/docs/cli)** — the full command surface.\n- **[Configuration](/docs/configuration)** — the node config and what it controls.\n- **[Licensing](/docs/licensing)** — the enforcement state machine.\n- **[Upgrades](/docs/upgrades)** — how updates are pulled and verified.\n"
        },
        {
            "slug": "monitoring",
            "title": "Monitoring & Observability",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/monitoring",
            "headings": [
                {
                    "level": 1,
                    "text": "Monitoring & Observability"
                },
                {
                    "level": 2,
                    "text": "Status: is this node healthy?"
                },
                {
                    "level": 2,
                    "text": "Metrics: how much of what"
                },
                {
                    "level": 2,
                    "text": "Flows: who is talking to whom"
                },
                {
                    "level": 2,
                    "text": "Alerts: tell me when something happens"
                },
                {
                    "level": 2,
                    "text": "Audit: who changed what"
                },
                {
                    "level": 2,
                    "text": "Live events"
                },
                {
                    "level": 2,
                    "text": "What to watch"
                },
                {
                    "level": 2,
                    "text": "Reaching a node from a collector"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 903,
            "markdown": "# Monitoring & Observability\n\nWhat a node can tell you about itself, and which source to reach for. There are\nfive, and picking the wrong one is the usual reason a question feels hard to\nanswer.\n\n| Source | Answers | Shape |\n|---|---|---|\n| **Status** | Is this node healthy right now? | One snapshot |\n| **Metrics** | How much of what, over time? | Counters and gauges |\n| **Flows** | Who is actually talking to whom? | Live connections |\n| **Alerts** | Tell me when something crosses a line | Push |\n| **Audit** | Who changed what, and when? | Append-only history (API only) |\n\nLogs sit underneath all of them — see [Operations](/docs/operations) for reading\nthem.\n\n## Status: is this node healthy?\n\nThe first command to run, and usually the only one needed:\n\n```bash\ncenvero-str-ctl status\n```\n\nIt reports whether the agent is running, the licence state, the data plane, and\nthe bridges. When something is wrong this is where it shows up first.\n\nHealth checks run on their own schedule and record their results:\n\n```bash\ncenvero-str-ctl heal status      # latest result for every check\ncenvero-str-ctl heal check       # force a run now, don't wait for the schedule\n```\n\n`heal status` is the better command when you want to know whether a problem is\n*recurring* rather than whether it is happening this second.\n\n## Metrics: how much of what\n\n```bash\ncenvero-str-ctl metrics\ncenvero-str-ctl metrics --format json\n```\n\nA snapshot of the node's counters. The JSON form is what you scrape into whatever\nyou already use for graphing — the same data is available over the API at\n`GET /api/v1/metrics` for a collector that cannot run commands on the node.\n\nFor traffic volume specifically — how much a tenant or endpoint used, rather than\nhow the node is behaving — use accounting instead, which is what billing reads:\n\n```bash\ncenvero-str-ctl bandwidth list          # configured limits\ncenvero-str-ctl quota list              # volume caps and consumption\n```\n\n## Flows: who is talking to whom\n\nMetrics tell you a link is busy. Flows tell you *what* is making it busy.\n\n```bash\ncenvero-str-ctl flow list      # live connections\ncenvero-str-ctl flow stats     # aggregate view\n```\n\nThis is the tool for \"the network is slow\", \"is this rule doing anything\", and\n\"what is this host actually connecting to\". Over the API, flows can also be\nexported for offline analysis.\n\nTwo things to know. Flows show conversations the data plane is currently tracking,\nso a connection that finished is gone — this is a live view, not a history. And a\nlong-established connection appears even after you have tightened a rule against\nit, because existing conversations survive a policy change until they end or are\nflushed. That surprise is covered in\n[Zero-Trust Firewall](/docs/networking/firewall).\n\n## Alerts: tell me when something happens\n\nEverything above is you asking. Alerts are the node telling you.\n\n```bash\ncenvero-str-ctl alert condition list      # what is being watched\ncenvero-str-ctl alert list                # what is currently firing\ncenvero-str-ctl alert history             # what has fired before\ncenvero-str-ctl alert status              # is alerting itself working?\ncenvero-str-ctl alert ack <id>            # acknowledge a firing alert\n```\n\nConditions define what to watch for. Actions define what happens when one fires,\nso an alert can reach a system you already run rather than waiting to be noticed.\n\n**Check `alert status` occasionally.** It reports whether action dispatch is\nsucceeding. Alerting that is configured but silently failing to deliver is worse\nthan no alerting, because it is mistaken for quiet.\n\n## Audit: who changed what\n\nAn append-only record of changes made through the agent, with who made them and\nwhen. Reach for it when the question is \"why is this configured like this\" or\n\"what changed just before it broke\" — that second question is answered faster here\nthan in the logs.\n\nThe audit trail is available over the API at `GET /api/v1/audit`; there is no CLI\ncommand for it, so reading it needs the API turned on (see\n[Security Model](/docs/security)). On a node where the API is off, the agent's\nlogs in `/var/log/cenvero-str/` are the fallback — see\n[Operations](/docs/operations).\n\n## Live events\n\nFor a continuous feed rather than polling, the agent publishes events over a\nWebSocket on port 7072, grouped into categories (traffic, bandwidth, security,\nDHCP, DNS, network, alerts, system, load balancing, and clustering) so a consumer\ncan subscribe to only what it cares about. See the\n[Management API Reference](/docs/api).\n\n## What to watch\n\nIf you are setting up monitoring for the first time, start here:\n\n- **Agent up, and licence not frozen.** A frozen licence blocks changes silently\n  from a traffic standpoint — everything keeps flowing, so nothing looks wrong\n  until a change fails. Watch the licence state, not just the process.\n- **Health-check results.** Repeated failures of one check are the earliest\n  warning of most problems.\n- **Certificate expiry.** See [TLS/SSL & Licence Operations](/docs/tls-and-license).\n- **Gateway failover events.** A failover that happened and recovered is easy to\n  miss, and it usually means something is worth investigating.\n- **Cluster leadership changes.** Occasional is normal; frequent is not.\n- **Alert dispatch failures.** As above — verify the alerting path works.\n\n## Reaching a node from a collector\n\nThe CLI works over a local socket and needs no network, which is why it keeps\nworking when the API is off. A remote collector needs the API turned on, which\nmeans a token and an address allowlist — see [Security Model](/docs/security)\nbefore exposing it.\n\n## Where to go next\n\n- **[Operations](/docs/operations)** — logs, health checks, and troubleshooting.\n- **[Management API Reference](/docs/api)** — the endpoints behind these commands.\n- **[Security Model](/docs/security)** — before exposing the API to a collector.\n"
        },
        {
            "slug": "security",
            "title": "Security",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/security",
            "headings": [
                {
                    "level": 1,
                    "text": "Security"
                },
                {
                    "level": 2,
                    "text": "Protected without any setup"
                },
                {
                    "level": 2,
                    "text": "Your checklist"
                },
                {
                    "level": 2,
                    "text": "Available if you want it"
                },
                {
                    "level": 2,
                    "text": "What is *not* protected"
                },
                {
                    "level": 2,
                    "text": "If a node may be compromised"
                },
                {
                    "level": 2,
                    "text": "Reporting a security issue"
                },
                {
                    "level": 2,
                    "text": "Where to go next"
                }
            ],
            "word_count": 746,
            "markdown": "# Security\n\nWhat Stratum protects on its own, what you need to set up yourself, and what it\ndeliberately does not protect against. The last part matters most: assuming a\nboundary exists where it does not is how people get hurt.\n\n## Protected without any setup\n\nYou do not need to configure any of this.\n\n- **The management API is closed until you open it.** It will not serve requests\n  without a token, and there is no insecure fallback.\n- **The API is encrypted.** There is no unencrypted way in.\n- **Repeated failed sign-ins are locked out**, and an attacker cannot turn that\n  lockout into a way of denying access to a legitimate user.\n- **Configuration and licences are verified before they are trusted.** Anything\n  altered in transit, or issued by anyone other than Cenvero, is refused —\n  checked on the node itself, so it works offline.\n- **Workloads cannot impersonate each other.** Traffic from a workload must match\n  what that workload is registered as. This is enforced on every packet.\n- **Changes made outside Stratum are undone.** If something alters an interface\n  Stratum manages, it is put back.\n\n## Your checklist\n\nNone of this happens on its own.\n\n**Deny by default in the firewall.** Until you do, traffic matching no rule is\nallowed. See [Zero-Trust Firewall](/docs/networking/firewall).\n\n**Treat the API token as a credential.** It grants full control of the node.\nCreate it at install time, keep it out of shell history and version control, and\nreplace it if it may have been exposed.\n\n**Limit who can reach the API.** It can be restricted to specific addresses. An\nAPI reachable from the internet is a mistake even with a strong token — keep it\non your management network.\n\n**Keep the management network private.** Stratum assumes only your operators can\nreach it.\n\n**Keep nodes updated.** Updates are pulled, never pushed, so a node nobody\nupdates stays vulnerable. See [Upgrades](/docs/upgrades).\n\n**Back up `/etc/cenvero-str/` and `/var/lib/cenvero-str/`.** They are what a node\nneeds to come back as itself.\n\n## Available if you want it\n\n- **Stricter interface protection.** By default an unauthorised change to a\n  managed interface is undone shortly after it happens. On a suitably configured\n  kernel it can be refused outright instead. Off unless you enable it.\n- **Client certificates** on the streaming interface, in addition to a token.\n- **Intrusion detection**, surfacing scanning and flooding patterns. Off by\n  default.\n\n## What is *not* protected\n\n**Someone with root on the node.** The design protects against what arrives over\nthe network, not against an administrator of the machine itself. Anyone with root\ncan stop or replace what is running there. Verification exists to stop a remote\nattacker substituting configuration or licences — it does not, and cannot,\nconstrain someone who already owns the box.\n\n**Interface protection is repair, not prevention.** By default a change is\ndetected and undone, so there is a window where it applied. Treat it as\ntamper-evidence with automatic repair rather than a lock.\n\n**Plugins are trusted code.** Signing proves a plugin is genuinely the one its\ndeveloper published and has not been altered since. That is authenticity, not\ncontainment — a plugin you install runs with real access to the node, exactly\nlike any other software you install as root. **Only install plugins from sources\nyou trust.** See [Installing Plugins](/docs/plugins/installing).\n\n**A frozen licence is not a security control.** It blocks changes; traffic keeps\nflowing. To take a node out of service, isolate it on the network.\n\n**Existing connections survive a rule change.** Tightening a rule governs new\nconnections; conversations already open continue until they end. If you are\ncutting traffic off during an incident, clear them explicitly — see\n[Zero-Trust Firewall](/docs/networking/firewall).\n\n## If a node may be compromised\n\n1. **Isolate it on the network**, upstream — not using the node's own firewall.\n2. **Revoke its licence** from your account. That stops it making changes; it\n   does not stop traffic.\n3. **Collect evidence before restarting anything.** Logs are in\n   `/var/log/cenvero-str/`. Restarting is the first instinct and it can cost you\n   the answer.\n4. **Replace the API token** and any tenant keys the node held.\n5. **Rebuild rather than clean up.** If root was obtained, reinstall.\n\n## Reporting a security issue\n\nReport it through your account rather than publicly, with enough detail to\nreproduce it.\n\n## Where to go next\n\n- **[Zero-Trust Firewall](/docs/networking/firewall)** — the policy model in full.\n- **[TLS/SSL & Licence Operations](/docs/tls-and-license)** — certificates and licences.\n- **[Operations](/docs/operations)** — logs, health checks, and recovery.\n"
        },
        {
            "slug": "cli",
            "title": "CLI Reference",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/cli",
            "headings": [
                {
                    "level": 1,
                    "text": "CLI Reference"
                },
                {
                    "level": 2,
                    "text": "Status & system"
                },
                {
                    "level": 3,
                    "text": "`status`"
                },
                {
                    "level": 3,
                    "text": "`version`"
                },
                {
                    "level": 3,
                    "text": "`metrics`"
                },
                {
                    "level": 3,
                    "text": "`hardware`"
                },
                {
                    "level": 3,
                    "text": "`pull`"
                },
                {
                    "level": 2,
                    "text": "System management"
                },
                {
                    "level": 3,
                    "text": "`config`"
                },
                {
                    "level": 3,
                    "text": "`service`"
                },
                {
                    "level": 3,
                    "text": "`api-token`"
                },
                {
                    "level": 3,
                    "text": "`tls`"
                },
                {
                    "level": 3,
                    "text": "`update`"
                },
                {
                    "level": 3,
                    "text": "`license`"
                },
                {
                    "level": 2,
                    "text": "Networking"
                },
                {
                    "level": 3,
                    "text": "`network`"
                },
                {
                    "level": 3,
                    "text": "`nic`"
                },
                {
                    "level": 3,
                    "text": "`ipam`"
                },
                {
                    "level": 3,
                    "text": "`dhcp`"
                },
                {
                    "level": 3,
                    "text": "`dns`"
                },
                {
                    "level": 3,
                    "text": "`vlan`"
                },
                {
                    "level": 3,
                    "text": "`vxlan`"
                },
                {
                    "level": 3,
                    "text": "`route`"
                },
                {
                    "level": 2,
                    "text": "Security"
                },
                {
                    "level": 3,
                    "text": "`firewall`"
                },
                {
                    "level": 3,
                    "text": "`rules`"
                },
                {
                    "level": 2,
                    "text": "Traffic shaping & visibility"
                },
                {
                    "level": 3,
                    "text": "`bandwidth`"
                },
                {
                    "level": 3,
                    "text": "`quota`"
                },
                {
                    "level": 3,
                    "text": "`flow`"
                },
                {
                    "level": 2,
                    "text": "Services & HA"
                },
                {
                    "level": 3,
                    "text": "`lb`"
                },
                {
                    "level": 3,
                    "text": "`bgp`"
                },
                {
                    "level": 3,
                    "text": "`gateway`"
                },
                {
                    "level": 3,
                    "text": "`ha`"
                },
                {
                    "level": 3,
                    "text": "`bond`"
                },
                {
                    "level": 3,
                    "text": "`cluster`"
                },
                {
                    "level": 2,
                    "text": "Tenants & billing"
                },
                {
                    "level": 3,
                    "text": "`tenant`"
                },
                {
                    "level": 3,
                    "text": "`apikeys`"
                },
                {
                    "level": 3,
                    "text": "`billing`"
                },
                {
                    "level": 2,
                    "text": "Operations"
                },
                {
                    "level": 3,
                    "text": "`backup`"
                },
                {
                    "level": 3,
                    "text": "`alert`"
                },
                {
                    "level": 3,
                    "text": "`heal`"
                },
                {
                    "level": 3,
                    "text": "`container`"
                },
                {
                    "level": 3,
                    "text": "`plugin`"
                },
                {
                    "level": 3,
                    "text": "`node`"
                },
                {
                    "level": 2,
                    "text": "Exit codes"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 3607,
            "markdown": "# CLI Reference\n\n`cenvero-str-ctl` is the operator interface to a node. It talks to the running\nagent over a local socket, so it only works **on the node itself** and needs\n`root` for any command that changes state. The socket is always available — it is\nthe node's management lifeline and can never be disabled.\n\n```bash\ncenvero-str-ctl <group> <command> [args] [flags]\n```\n\n- Add `--format json` (or `yaml`) to any command for machine-readable output\n  (the default is a human table/JSON view).\n- Add `-h` to any group or command for its full help, subcommands, and flags.\n- The same managers are reachable over the node's [REST API](/docs/api); this CLI\n  is the local equivalent.\n\nThe command groups, by area:\n\n| Area | Groups |\n|---|---|\n| Status & system | `status` `version` `metrics` `hardware` `pull` |\n| System management | `config` `service` `tls` `update` `license` |\n| Networking | `network` `nic` `ipam` `dhcp` `dns` `vlan` `vxlan` `geneve` `vrf` `route` |\n| Security | `firewall` `rules` |\n| Traffic shaping & visibility | `bandwidth` `quota` `flow` |\n| Services & HA | `lb` `l7lb` `bgp` `gateway` `nat64` `ha` `bond` `cluster` |\n| Tenants & billing | `tenant` `apikeys` `billing` |\n| Operations | `backup` `alert` `heal` `container` `plugin` `node` |\n\n---\n\n## Status & system\n\n### `status`\n\n```bash\ncenvero-str-ctl status        # health summary: version, license, data plane, bridges, cluster\n```\n\n### `version`\n\n```bash\ncenvero-str-ctl version       # agent version\n```\n\n### `metrics`\n\n```bash\ncenvero-str-ctl metrics       # current metrics snapshot\n```\n\n### `hardware`\n\nPrint this machine's **hardware ID** — the value an activation or SSL request is\nbound to. Use it to verify a pending request in your account before approving it.\n\n```bash\ncenvero-str-ctl hardware\n```\n\n### `pull`\n\nForce an immediate pull of the node's CA-signed TLS certificate **and** a license\nre-sync (fetch a pending activation, auto-renew, re-evaluate) now, instead of\nwaiting for the next background poll. Run it right after approving a node's\nSSL/license in your account.\n\n```bash\nsudo cenvero-str-ctl pull\n```\n\n---\n\n## System management\n\n### `config`\n\n| Command | Purpose |\n|---|---|\n| `config set [<key> <value>]` | Change a whitelisted operational setting (run with no args to list keys) |\n| `config show` | Print the decoded on-disk configuration (secrets redacted) |\n| `config dump` | Dump the on-disk configuration |\n| `config encode` | Write the node's signed configuration from flags (used by the installer) |\n\n`config set` writes to an **operator local-overrides** file that the agent layers\non top of its configuration at the next restart — and that a panel re-sync will\n**not** clobber (local always wins). Use the positional `config set <key> <value>`\nform, or the `--api-bind` / `--api-rate-limit` / `--api-rate-burst` shortcuts.\nApply with `systemctl restart cenvero-stratum`; verify with `config show`.\n\n```bash\nsudo cenvero-str-ctl config set api_rate_limit 2000\nsudo cenvero-str-ctl config set --api-bind 10.0.0.5 --api-rate-limit 2000 --api-rate-burst 200\nsudo cenvero-str-ctl config set            # list every settable key\n```\n\n**Settable keys:** `log_level`, `api_bind_address`, `api_rate_limit`,\n`api_rate_burst`, `api_allowed_ips`, `api_allowed_origins`,\n`api_read_timeout_secs`, `api_write_timeout_secs`, `api_idle_timeout_secs`,\n`port_rest`, `port_grpc`, `port_websocket`, `port_cluster`, `port_ha_heartbeat`,\n`metrics_bind_addr`, `dns_listen_addr`, `dns_allowed_clients`, `dns_upstreams`,\n`heal_interval_seconds`, `heal_disabled_checks`, and the six service switches\n(`rest_disabled`, `grpc_disabled`, `websocket_disabled`, `metrics_disabled`,\n`dns_disabled`, `dhcp_disabled`). Identity, the license server, the API token, and\nany signing/cluster-secret/TLS material are **refused** — they are panel- or\nidentity-owned, not local operational settings. See\n[Configuration](/docs/configuration) for the full field reference.\n\n`config show` / `dump` are read-only and decode the actual on-disk file (table,\n`--format json`, or `--format yaml`); pass `--include-secrets` to reveal redacted\nvalues, or `--file <path>` to inspect a specific file. `config encode` is what the\ninstaller uses to write the node's configuration and prints the resulting\n`api_token`.\n\n### `service`\n\nSee and toggle the agent's network services. `service status` shows each service's\n**ENABLED** switch, its **ADDRESS:PORT**, and a live **STATE**. Toggle a service on\nor off; the change applies on the next agent restart.\n\n| Command | Purpose |\n|---|---|\n| `service status` | Show each service (aliases: `list`, `top`, `ls`, `ps`) |\n| `service <name> on` | Enable a service (also `service on <name>`) |\n| `service <name> off` | Disable a service (also `service off <name>`) |\n\nToggleable services: `rest`, `grpc`, `websocket`, `metrics`, `dns`, `dhcp`.\nDisabling `rest` warns first (it also serves the operator/billing API). The **IPC\ncontrol socket is not toggleable** — it is how this CLI reaches the agent.\n\n```bash\nsudo cenvero-str-ctl service status\nsudo cenvero-str-ctl service rest off\nsudo cenvero-str-ctl service websocket on\nsudo systemctl restart cenvero-stratum   # apply\n```\n\nEnabling `rest`, `grpc` or `websocket` is only half of it — the management APIs\nalso require an API token before they will serve, so a service you switch on with\nno token configured stays `down`. See `api-token` below.\n\n### `api-token`\n\nManage the bearer token the management APIs (REST, gRPC, WebSocket) require. With\nno token the agent refuses to open a management API at all rather than serve one\nwithout a credential, so this is what turns them on.\n\n| Command | Purpose |\n|---|---|\n| `api-token generate` | Mint a strong random token, store it, print it **once** |\n| `api-token set` | Store a token you supply, read from **stdin** |\n| `api-token status` | Report whether a token is set — never prints the value |\n| `api-token clear` | Remove it; the APIs stop serving at the next restart |\n\n```bash\nsudo cenvero-str-ctl api-token generate\nsudo cenvero-str-ctl service on rest\nsudo systemctl restart cenvero-stratum\n```\n\n`generate` shows the token once and it is not recoverable afterwards — copy it\nthen. To choose the value yourself, pipe it in so it never reaches your shell\nhistory; there is deliberately no flag to pass it as an argument:\n\n```bash\nprintf '%s' \"$MY_TOKEN\" | sudo cenvero-str-ctl api-token set\n```\n\nTokens shorter than 16 characters are refused. The value is stored in your local\noverrides, so a later configuration sync from the panel will not discard it. It is\nnot settable through `config set`, which rejects credential keys for the same\nshell-history reason.\n\nA management API binds only when **all three** hold: a token exists, the service\nis on, and the node has a TLS certificate — the agent never serves plaintext.\n\n### `tls`\n\nManage the management API's TLS certificate. After install the node serves a\ntemporary self-signed certificate; in CA mode it obtains a CA-signed certificate\nonce you approve the request in your account (Account → TLS). The private key never\nleaves the node.\n\n| Command | Purpose |\n|---|---|\n| `tls info` | Live certificate: mode (`ca-signed`/`self-signed`), expiry, SAN domains |\n| `tls show` | The certificate / key / public-key file locations |\n| `tls pubkey` | Print the public-key file location |\n| `tls request` | Request a CA-signed certificate from the panel now (online) |\n| `tls pull` | Pull this node's CA-signed certificate now (collect it once approved) |\n| `tls regenerate` | Generate a fresh certificate now (hot-reloaded, no restart) |\n| `tls csr` | Generate a key + CSR locally **without** contacting the panel (offline) |\n| `tls install <cert> [chain]` | Install a signed certificate obtained out-of-band |\n| `tls pending-status` | Report any pending certificate request (online or offline) |\n| `tls reset-pending` | Discard a pending offline CSR so you can start fresh |\n| `tls reset` | Clear the current certificate + pending request and re-obtain a fresh one |\n| `tls domain list` | List the SAN domains on the certificate |\n| `tls domain add <domain>` | Add a SAN domain (regenerates the certificate) |\n| `tls domain remove <domain>` | Remove a SAN domain (regenerates the certificate) |\n\n```bash\nsudo cenvero-str-ctl tls info\nsudo cenvero-str-ctl tls pull                 # collect the CA-signed cert after approval\nsudo cenvero-str-ctl tls domain add api.example.net\n```\n\nThe certificate always covers the node's IP(s) plus loopback; a domain you point at\nthe node's IP can be added with `tls domain add`. See\n[TLS & Licensing](/docs/tls-and-license) for the approval flow.\n\n### `update`\n\nPull-based agent updates from your licensed release channel.\n\n| Command | Purpose |\n|---|---|\n| `update check` | Report current vs latest version + channel (no download) |\n| `update apply` | Download, verify, and apply an available update now (alias `update now`) |\n| `update rollback` | Roll back to the previously-installed version |\n\n```bash\ncenvero-str-ctl update check\nsudo cenvero-str-ctl update apply\nsudo cenvero-str-ctl update rollback   # undo the last apply\n```\n\n`update apply` verifies the download's signature, swaps the binary atomically, and\nrestarts under watchdog supervision with self-rollback if the post-restart health\ncheck fails. A node in the **Frozen** license state keeps running but cannot pull\nupdates until the license is renewed. See [Upgrades](/docs/upgrades).\n\n`update rollback` restores the binary that the most recent `update apply`\npreserved, undoing that update; restart the agent (`systemctl restart\ncenvero-stratum`) to run the restored version. If no update has been applied on\nthis node there is nothing to restore and it reports *\"no previous version to roll\nback to\"*. Rollback is a change, so it is refused while the license is **Frozen**.\nRe-applying a version at or below the highest version this node has ever run is\nrefused by downgrade protection — to move forward again, install a newer release.\n\n### `license`\n\n| Command | Purpose |\n|---|---|\n| `license activate <license-key>` | Send this machine's activation request (confirm it in your account) |\n| `license fetch <license-key>` | Fetch the signed license once it has been confirmed |\n| `license renew` | Force a license renewal for this machine |\n| `license refresh` | Re-fetch the signed license for this machine |\n| `license load <file>` | Load a signed license file manually |\n| `license status` | Full installed-license picture: identity, validity/state, features, hardware binding |\n\nThe license key is **positional**, not a flag. Activation binds the license to this\nmachine's hardware ID and waits for you to confirm the machine in your account; the\nagent then installs the signed license automatically.\n\n```bash\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\nsudo cenvero-str-ctl license status\n```\n\n`license status` is read-only and works offline from on-disk state, so it still\nanswers when the agent is down or the node cannot reach the licence server.\n\nThe same picture is available over the API as `GET /api/v1/license`, which reads\nthe running agent rather than the disk — useful for checking a fleet's expiry\ndates without a shell on every box. See [API Reference](/docs/api).\n\nSee [Licensing](/docs/licensing) for the warning → grace → freeze model.\n\n---\n\n## Networking\n\n### `network`\n\nA *network* is a managed private network: a CIDR pool whose usable host IPs are\neach materialized into an **endpoint profile** (a fixed IP paired with a generated\nMAC). You attach a workload by claiming a free endpoint — the endpoint *is* the\nworkload's network identity.\n\n| Command | Purpose |\n|---|---|\n| `network create` | Create a network (`--name --cidr [--gateway --vlan --tenant]`) |\n| `network list` | List networks |\n| `network show <id>` | Network detail + its endpoints |\n| `network endpoints <id>` | List a network's endpoint profiles (IP ↔ MAC) |\n| `network attach <id> [--ip] [--mac]` | Claim an endpoint (optionally a specific IP and MAC) |\n| `network set-mac <endpoint-id> <mac>` | Change an endpoint's MAC, keeping its IP |\n| `network detach <endpoint-id>` | Free a bound endpoint |\n| `network delete <id>` | Delete a network |\n\n```bash\nsudo cenvero-str-ctl network create --name app-net --cidr 10.20.0.0/24 --gateway 10.20.0.1\nsudo cenvero-str-ctl network attach <network-id> --ip 10.20.0.50\n```\n\nEach endpoint is an IP paired with a MAC address, and the MAC is assigned for you.\nPass `--mac` when the workload already has a fixed address of its own — a virtual\nmachine image, or an appliance whose licence is tied to one — so the fabric\naccepts the address it will actually send from:\n\n```bash\nsudo cenvero-str-ctl network attach <network-id> --ip 10.20.0.50 --mac 52:54:00:ab:01:02\n\n# ...or change it afterwards, keeping the same IP\nsudo cenvero-str-ctl network set-mac <endpoint-id> 52:54:00:ab:01:02\n```\n\nA MAC must be unique across endpoints, and a multicast address is refused — one\ncan never be a source address, so traffic from it would be dropped.\n\n### `nic`\n\n| Command | Purpose |\n|---|---|\n| `nic list` | List detected network interfaces |\n| `nic detect` | Re-scan and list interfaces |\n| `nic accel` | Show the acceleration mode (hardware vs software) per interface |\n\n### `ipam`\n\n| Command | Purpose |\n|---|---|\n| `ipam pools` | List IP pools |\n| `ipam allocations` | List every allocation (optional tenant id) |\n| `ipam allocate` | Allocate an IP address |\n| `ipam release` | Release an IP address |\n\n### `dhcp`\n\n| Command | Purpose |\n|---|---|\n| `dhcp leases` | List DHCP leases |\n| `dhcp reservations` | List static MAC→IP reservations |\n| `dhcp reserve <mac> <ip> [--hostname]` | Pin a static MAC→IP reservation |\n| `dhcp release <mac>` | Remove a static reservation, returning the address to the pool |\n\n```bash\nsudo cenvero-str-ctl dhcp reserve 52:54:00:ab:01:02 10.0.0.50 --hostname db01\n```\n\nA reserved address is handed out on the client's next request, survives an agent\nrestart, and appears immediately in `dhcp leases`.\n\n### `dns`\n\nManage authoritative zones, records, recursive forwarders, and DNSSEC.\n\n| Command | Purpose |\n|---|---|\n| `dns zones` | List zones |\n| `dns records [zone_id]` / `dns list [zone_id]` | List records (optionally for one zone) |\n| `dns add <zone_id> <name> <type> <value> [ttl] [source_subnet]` | Add a record |\n| `dns delete <record_id>` | Delete a record |\n| `dns dnssec <zone>` | Show a zone's DNSSEC keys + the DS record to lodge with the parent |\n| `dns zone list/add <name>/delete <zone_id>` | Zone CRUD |\n| `dns record list [zone_id]/add .../delete <record_id>` | Record CRUD |\n| `dns forwarder list/set <ip…>/add <ip…>/remove <ip…>` | Manage recursive forwarders |\n\nEach forwarder is an IP or `IP:port` (bare IPs default to `:53`). `dns forwarder`\nchanges apply to the running agent; persist them across restarts with\n`config set dns_upstreams <ip,...>`.\n\n```bash\nsudo cenvero-str-ctl dns zone add app-net.internal\nsudo cenvero-str-ctl dns add 1 api A 10.20.0.55 300\n```\n\n### `vlan`\n\nA per-VLAN allow/deny table. An empty table means every VLAN is allowed (default).\n\n| Command | Purpose |\n|---|---|\n| `vlan list` | List the allow/deny policy |\n| `vlan deny <id>` | Lock down a VLAN |\n| `vlan allow <id>` | Record an explicit allow |\n| `vlan clear <id>` | Clear a VLAN's policy (back to default-open) |\n\n### `vxlan`\n\n| Command | Purpose |\n|---|---|\n| `vxlan list` | List overlay networks |\n| `vxlan create <vni> <subnet>` | Create an overlay network |\n| `vxlan delete <vni>` | Delete an overlay network |\n| `vxlan peers <vni>` | List a VNI's remote peers |\n| `vxlan peer-add <vni> <host> <mac> <vtep_ip>` | Add a remote peer |\n| `vxlan peer-remove <vni> <host>` | Remove a remote peer |\n| `vxlan fdb` | Dump the overlay forwarding database |\n\n### `route`\n\nStatic routes (optionally in a non-main table) and the policy rules that steer\ntraffic into those tables.\n\n| Command | Purpose |\n|---|---|\n| `route list [table]` | List routes (optional table id) |\n| `route add <json>` | Add a route — JSON `{destination,gateway,interface,metric,table}` |\n| `route delete <id>` | Delete a route by id |\n| `route rule list` | List policy-routing rules |\n| `route rule add <json>` | Add a rule — JSON `{priority,from,to,fwmark,iif,oif,table}` |\n| `route rule delete <id>` | Delete a policy-routing rule by id |\n\n---\n\n## Security\n\n### `firewall`\n\n| Command | Purpose |\n|---|---|\n| `firewall allow` | Add an allow rule |\n| `firewall deny` | Add a deny rule |\n| `firewall list` | List firewall rules |\n| `firewall delete` | Delete a rule |\n| `firewall conntrack` | Show the connection-tracking table |\n| `firewall conntrack-flush [ipv4]` | Drop connection-tracking state so rule changes apply to connections that are already open (optionally just one peer) |\n| `firewall schedule set/clear/list` | Attach a time-of-day/day-of-week activation window (UTC) to a rule |\n| `firewall connlimit set/clear/status` | Per-source concurrent-connection limits (IPv4) |\n\n### `rules`\n\nThe structured firewall rule table (chains, priorities, stateful matching).\n\n| Command | Purpose |\n|---|---|\n| `rules list` | List all rules |\n| `rules add` | Add a rule |\n| `rules remove <id>` | Remove a rule by id |\n| `rules preset <web/db/mail/game/minimal/open>` | Apply a server preset rule-set atomically |\n| `rules batch <json-rule-array>` | Apply a JSON array of rules atomically |\n\n---\n\n## Traffic shaping & visibility\n\n### `bandwidth`\n\n| Command | Purpose |\n|---|---|\n| `bandwidth list` | List per-MAC rate limits and shared pools |\n| `bandwidth set` | Create/update a per-MAC rate limit |\n| `bandwidth pool` | Manage shared bandwidth pools |\n\n### `quota`\n\nMonthly per-MAC usage caps that reset at the start of each UTC month.\n\n| Command | Purpose |\n|---|---|\n| `quota list` | List quotas with their status |\n| `quota set <json>` | Create/update a quota |\n| `quota get <mac>` | Show one MAC's quota + status |\n\n### `flow`\n\n| Command | Purpose |\n|---|---|\n| `flow list` | List active flows (optional state filter) |\n| `flow stats` | Aggregate flow statistics |\n\n---\n\n## Services & HA\n\n### `lb`\n\nL4 virtual IPs (VIPs) with a backend pool.\n\n| Command | Purpose |\n|---|---|\n| `lb list` | List VIPs |\n| `lb create` | Create a VIP and backend set |\n| `lb show` | Show a VIP's detail |\n| `lb backends <id>` | List a VIP's backends |\n| `lb add-backend` / `lb remove-backend` | Adjust backends live |\n| `lb drain <vip_id> <backend_id>` | Stop sending new connections to a backend and let the existing ones finish |\n| `lb set-health` | Configure a VIP's health check |\n| `lb delete` | Delete a VIP |\n\n### `bgp`\n\n| Command | Purpose |\n|---|---|\n| `bgp status` | Engine status |\n| `bgp neighbors` | List peers |\n| `bgp routes` / `bgp rib` / `bgp fib` | Route table / RIB / forwarding table |\n| `bgp peer` | Manage peers (add/list/remove) |\n| `bgp announce` / `bgp withdraw` | Advertise / withdraw a prefix |\n| `bgp announcements` | List active announcements |\n| `bgp prefix-list` | Manage prefix-lists (`add <json>` / `list`) |\n| `bgp route-map` | Manage route-maps (`add <json>` / `list`) |\n| `bgp policy` | Bind import/export route-maps (`import/export <neighbor> <route-map>` / `list`) |\n| `bgp import-policy` | Manage import policy |\n\n### `gateway`\n\n| Command | Purpose |\n|---|---|\n| `gateway status` | Gateway HA status |\n| `gateway failover` | Trigger a manual gateway failover |\n\n### `ha`\n\nGateway high-availability: two paired nodes run active/standby and the active node\nowns a virtual IP (VIP), taking it over on failover.\n\n| Command | Purpose |\n|---|---|\n| `ha status` | Show HA status (active/standby/solo, VIP, peer) |\n| `ha set-peer` | (Provisioned via the panel-delivered config) |\n| `ha configure` | (Provisioned via the panel-delivered config) |\n\nHA is configured from the panel-delivered configuration (peer address, priority,\nVIP, and a shared secret) and read at agent boot. Use `ha status` and the\n[REST gateway endpoints](/docs/api) for live status and\nmanual failover.\n\n### `bond`\n\nCombine physical NICs into a bond for redundancy or throughput. A bond is created\nwith a device name you choose (use the `cnv-` prefix, max 15 chars) in one of two\nmodes: `active-backup` (one member carries traffic, another takes over on failure)\nor `802.3ad` (LACP aggregation, needs a matching switch port-channel).\n\n| Command | Purpose |\n|---|---|\n| `bond list` | List bonds |\n| `bond show <id>` | Show a bond (mode, MTU, active member, members) |\n| `bond create --name <dev> --mode active-backup/802.3ad [--mtu N]` | Create a bond |\n| `bond add-member <id> --iface <nic>` | Enslave a NIC |\n| `bond remove-member <id> --iface <nic>` | Release a NIC |\n| `bond set-mtu <id> --mtu N` | Set the MTU across the bond + members |\n| `bond delete <id>` | Delete a bond (releases its members first) |\n\n```bash\nsudo cenvero-str-ctl bond create --name cnv-bond0 --mode active-backup\nsudo cenvero-str-ctl bond add-member <bond-id> --iface cnv-nic-1\nsudo cenvero-str-ctl bond set-mtu <bond-id> --mtu 9000\n```\n\n`create` prints the bond's **logical id** (e.g. `bond-1a2b…`); use that id — not the\ndevice name — with the other subcommands. Members are referenced by interface name.\n\n### `cluster`\n\nInspect this node's clustering state. Every member is the same kind of node, so\nthere is nothing to choose here — `cluster status` reports the local role in the\ncluster (leader or follower), not a node type.\n\n| Command | Purpose |\n|---|---|\n| `cluster status` | Cluster state, leader, and peers |\n| `cluster join` | (Configured via the panel-delivered config / REST API) |\n| `cluster leave` | (Configured via the panel-delivered config / REST API) |\n\nCluster membership is configured through the panel-delivered configuration; peers\nauthenticate with mutual TLS (no join token to type). Use `cluster status` to check\nthe local state. See [Clustering](/docs/clustering/overview).\n\n---\n\n## Tenants & billing\n\n### `tenant`\n\nTenants are your downstream customers on this node, each with a resource quota and\noptional scoped API keys.\n\n| Command | Purpose |\n|---|---|\n| `tenant list` | List tenants |\n| `tenant create --name <name>` | Create a tenant |\n| `tenant delete <id>` | Delete a tenant — also removes its scoped API keys, quota, private networks, and IPAM pools/allocations |\n| `tenant quota <id>` | Show a tenant's bandwidth cap |\n| `tenant quota-set <id> --max-bandwidth-bps N` | Set a tenant's bandwidth cap (`0` = unlimited) |\n| `tenant key-generate <id> [--name <label>] [--ttl 720h]` | Mint a scoped API key for a tenant |\n| `tenant key-list <id>` | List a tenant's scoped API keys |\n| `tenant key-revoke <key-id>` | Revoke a scoped API key |\n\nA scoped key authenticates as the tenant but is confined to that tenant's resources.\nThe secret is shown only once, at mint time.\n\n### `apikeys`\n\nOperator API keys for the billing API. A minted key authenticates your billing\nsystem against the agent's `/api/v1/billing` endpoints.\n\n| Command | Purpose |\n|---|---|\n| `apikeys mint <label>` | Mint a new key (secret shown once) |\n| `apikeys list` | List keys (no secrets) |\n| `apikeys revoke <id>` | Revoke a key by id |\n\n### `billing`\n\nDrive one of your customers' (a tenant's) account state — the same actions your\nbilling system calls over REST.\n\n| Command | Purpose |\n|---|---|\n| `billing suspend <tenant-id>` | Suspend a tenant |\n| `billing resume <tenant-id>` | Resume a tenant |\n| `billing limit <tenant-id> --rate-mbps N` | Apply an aggregate rate cap (Mbps; `0` = unlimited) |\n| `billing unlimit <tenant-id>` | Remove a tenant's rate limit |\n| `billing status <tenant-id>` | Show a tenant's billing state |\n\nSee [Billing Integration](/docs/billing-integration).\n\n---\n\n## Operations\n\n### `backup`\n\n| Command | Purpose |\n|---|---|\n| `backup create [config/full]` | Create a backup (default `config`) |\n| `backup restore <id/path>` | Restore from a backup |\n| `backup list` | List backups |\n| `backup schedule add <expression> [config/full] [retention]` | Add a schedule (e.g. `daily@03:00 full 7`) |\n| `backup schedule remove <id>` | Remove a schedule |\n\n### `alert`\n\n| Command | Purpose |\n|---|---|\n| `alert status` | Alerting status (counts + action-dispatch success/failure) |\n| `alert list` | List fired alerts (optional state filter) |\n| `alert history` | Full alert history |\n| `alert ack <alert_id>` | Acknowledge a firing alert |\n| `alert condition list` | List threshold conditions |\n| `alert condition add <json>` | Add a condition, e.g. `'{\"metric_type\":\"pps\",\"operator\":\"gt\",\"threshold\":1000}'` |\n| `alert condition remove <id>` | Remove a condition |\n| `alert action list` | List configured actions across all conditions |\n| `alert action add <condition_id> <websocket/log/webhook> [config]` | Attach an action to a condition |\n\n### `heal`\n\n| Command | Purpose |\n|---|---|\n| `heal status` | Latest health-check results |\n| `heal check` | Force an immediate health-check run |\n\n### `container`\n\nAttach a container's network namespace to a managed network using the same plumbing\na VM endpoint uses (a managed IP + MAC, a veth pair into the network's bridge).\n\n| Command | Purpose |\n|---|---|\n| `container list` | List attached containers |\n| `container show <id>` | Show an attachment by its logical id |\n| `container attach --runtime <lxc/docker/podman> --network <id> --netns-pid <pid> [--ip <ip>] [--firewall]` | Attach a container netns |\n| `container detach <id>` | Detach (tears down the veth, frees the endpoint) |\n\n```bash\nsudo cenvero-str-ctl container attach --runtime docker --network net-1 --netns-pid 12345\n```\n\n`--netns-pid` is the PID of any process inside the container (e.g. its init).\n`attach` prints a logical container id; use that id with `show` / `detach`.\n\n### `plugin`\n\n| Command | Purpose |\n|---|---|\n| `plugin install <target>` | Install a plugin — a bare `<name>` from the official store, `<username>/<name>` from a trusted external store, or a local package path |\n| `plugin list` | List installed plugins |\n| `plugin show` | Show plugin detail |\n| `plugin remove` | Remove a plugin |\n| `plugin verify` | Verify a plugin package signature |\n| `plugin enable` / `plugin disable` | Enable / disable a plugin |\n| `plugin store add <signed-store-file>` | Trust an external plugin store (verified offline) |\n| `plugin store list` | List trusted plugin stores |\n| `plugin store remove <username>` | Remove a trusted external store |\n\nThe official Cenvero store is always present (its plugins install as a bare\n`<name>`). An added external store's plugins install as `<username>/<name>`. See\n[Plugins](/docs/plugins/overview).\n\n### `node`\n\n| Command | Purpose |\n|---|---|\n| `node info` | Node information: hardware id, agent version |\n\n---\n\n## Exit codes\n\n| Code | Meaning |\n|---|---|\n| 0 | Success |\n| 1 | General error |\n| 2 | Invalid arguments |\n| 3 | Agent unreachable (is `cenvero-stratum.service` running?) |\n| 4 | Operation blocked by license enforcement (see [Licensing](/docs/licensing)) |\n\n## See also\n\n- **[API Reference](/docs/api)** — the same managers over REST/WebSocket/gRPC.\n- **[Configuration](/docs/configuration)** — the node config model and every field.\n- **[Operations](/docs/operations)** — day-2 running, updates, and troubleshooting.\n"
        },
        {
            "slug": "api",
            "title": "Management API Reference",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/api",
            "headings": [
                {
                    "level": 1,
                    "text": "Management API Reference"
                },
                {
                    "level": 2,
                    "text": "Getting started"
                },
                {
                    "level": 3,
                    "text": "1. The API is off until you set a token"
                },
                {
                    "level": 3,
                    "text": "2. Authenticate with a bearer token"
                },
                {
                    "level": 3,
                    "text": "3. Verify connectivity"
                },
                {
                    "level": 3,
                    "text": "Licence"
                },
                {
                    "level": 2,
                    "text": "Conventions"
                },
                {
                    "level": 3,
                    "text": "Error responses"
                },
                {
                    "level": 3,
                    "text": "Rate limits"
                },
                {
                    "level": 3,
                    "text": "Plan-gated subsystems"
                },
                {
                    "level": 1,
                    "text": "REST endpoint reference"
                },
                {
                    "level": 2,
                    "text": "Private networks"
                },
                {
                    "level": 3,
                    "text": "List networks"
                },
                {
                    "level": 3,
                    "text": "Create a network"
                },
                {
                    "level": 3,
                    "text": "Get a network"
                },
                {
                    "level": 3,
                    "text": "Delete a network"
                },
                {
                    "level": 3,
                    "text": "List a network's endpoints"
                },
                {
                    "level": 3,
                    "text": "Attach an endpoint"
                },
                {
                    "level": 3,
                    "text": "Change an endpoint's MAC address"
                },
                {
                    "level": 3,
                    "text": "Detach an endpoint"
                },
                {
                    "level": 2,
                    "text": "IP address management (IPAM)"
                },
                {
                    "level": 3,
                    "text": "List pools"
                },
                {
                    "level": 3,
                    "text": "Create a pool"
                },
                {
                    "level": 3,
                    "text": "List allocations"
                },
                {
                    "level": 3,
                    "text": "Allocate an IP"
                },
                {
                    "level": 3,
                    "text": "Release an IP"
                },
                {
                    "level": 3,
                    "text": "Delete a pool"
                },
                {
                    "level": 2,
                    "text": "Bridges"
                },
                {
                    "level": 2,
                    "text": "Network interfaces (NICs)"
                },
                {
                    "level": 2,
                    "text": "MAC bindings"
                },
                {
                    "level": 2,
                    "text": "DHCP leases"
                },
                {
                    "level": 2,
                    "text": "DNS"
                },
                {
                    "level": 3,
                    "text": "List zones"
                },
                {
                    "level": 3,
                    "text": "Create a zone"
                },
                {
                    "level": 3,
                    "text": "Delete a zone"
                },
                {
                    "level": 3,
                    "text": "List records"
                },
                {
                    "level": 3,
                    "text": "Create a record"
                },
                {
                    "level": 3,
                    "text": "Update a record"
                },
                {
                    "level": 3,
                    "text": "Delete a record"
                },
                {
                    "level": 3,
                    "text": "DNSSEC material for a zone"
                },
                {
                    "level": 2,
                    "text": "Static & policy routing"
                },
                {
                    "level": 3,
                    "text": "List routes"
                },
                {
                    "level": 3,
                    "text": "Create a route"
                },
                {
                    "level": 3,
                    "text": "Delete a route"
                },
                {
                    "level": 3,
                    "text": "List policy rules"
                },
                {
                    "level": 3,
                    "text": "Create a policy rule"
                },
                {
                    "level": 3,
                    "text": "Delete a policy rule"
                },
                {
                    "level": 2,
                    "text": "Port forwarding"
                },
                {
                    "level": 2,
                    "text": "VLAN lockdown"
                },
                {
                    "level": 3,
                    "text": "List VLAN policies"
                },
                {
                    "level": 3,
                    "text": "Set a VLAN policy"
                },
                {
                    "level": 3,
                    "text": "Clear a VLAN policy"
                },
                {
                    "level": 2,
                    "text": "VXLAN overlays"
                },
                {
                    "level": 3,
                    "text": "List overlay networks"
                },
                {
                    "level": 3,
                    "text": "Create an overlay network"
                },
                {
                    "level": 3,
                    "text": "Delete an overlay network"
                },
                {
                    "level": 3,
                    "text": "List peers"
                },
                {
                    "level": 3,
                    "text": "Add a peer"
                },
                {
                    "level": 3,
                    "text": "Remove a peer"
                },
                {
                    "level": 3,
                    "text": "Forwarding database"
                },
                {
                    "level": 2,
                    "text": "Firewall rules & connection tracking"
                },
                {
                    "level": 2,
                    "text": "Load balancer"
                },
                {
                    "level": 2,
                    "text": "BGP & route filtering"
                },
                {
                    "level": 3,
                    "text": "Prefix-lists"
                },
                {
                    "level": 3,
                    "text": "Route-maps"
                },
                {
                    "level": 3,
                    "text": "Apply import/export policy"
                },
                {
                    "level": 2,
                    "text": "NIC bonds"
                },
                {
                    "level": 2,
                    "text": "Gateway HA & floating IPs"
                },
                {
                    "level": 3,
                    "text": "Gateway HA"
                },
                {
                    "level": 3,
                    "text": "Source NAT (masquerade)"
                },
                {
                    "level": 3,
                    "text": "Floating IPs"
                },
                {
                    "level": 2,
                    "text": "Traffic visibility"
                },
                {
                    "level": 3,
                    "text": "Flows"
                },
                {
                    "level": 3,
                    "text": "Accounting"
                },
                {
                    "level": 3,
                    "text": "Bandwidth limits, pools & quotas"
                },
                {
                    "level": 2,
                    "text": "Cluster"
                },
                {
                    "level": 2,
                    "text": "Multi-tenancy"
                },
                {
                    "level": 3,
                    "text": "Create / list tenants"
                },
                {
                    "level": 3,
                    "text": "Update a tenant"
                },
                {
                    "level": 3,
                    "text": "Tenant quota"
                },
                {
                    "level": 3,
                    "text": "Tenant-scoped API keys"
                },
                {
                    "level": 2,
                    "text": "Billing automation (operator hook)"
                },
                {
                    "level": 2,
                    "text": "Container networking"
                },
                {
                    "level": 2,
                    "text": "Operations"
                },
                {
                    "level": 3,
                    "text": "Alerting"
                },
                {
                    "level": 3,
                    "text": "Self-healing"
                },
                {
                    "level": 3,
                    "text": "Backups"
                },
                {
                    "level": 3,
                    "text": "Node dashboard"
                },
                {
                    "level": 3,
                    "text": "Not implemented"
                },
                {
                    "level": 3,
                    "text": "Event log (Server-Sent Events)"
                },
                {
                    "level": 2,
                    "text": "Real-time WebSocket"
                },
                {
                    "level": 3,
                    "text": "1. Connect & authenticate"
                },
                {
                    "level": 3,
                    "text": "2. Subscribe"
                },
                {
                    "level": 3,
                    "text": "3. Receive events"
                },
                {
                    "level": 3,
                    "text": "Example — `wscat`"
                },
                {
                    "level": 3,
                    "text": "Example — browser"
                },
                {
                    "level": 2,
                    "text": "Event webhooks"
                },
                {
                    "level": 3,
                    "text": "Register a webhook"
                },
                {
                    "level": 3,
                    "text": "Delivery format"
                },
                {
                    "level": 3,
                    "text": "Test a webhook"
                },
                {
                    "level": 2,
                    "text": "Bulk create"
                },
                {
                    "level": 2,
                    "text": "Host addressing"
                },
                {
                    "level": 2,
                    "text": "DHCP scopes"
                },
                {
                    "level": 2,
                    "text": "Subsystem reads"
                },
                {
                    "level": 2,
                    "text": "Metrics scrape"
                },
                {
                    "level": 2,
                    "text": "gRPC endpoint"
                },
                {
                    "level": 2,
                    "text": "Every endpoint, ready to paste"
                },
                {
                    "level": 3,
                    "text": "Accounting & billing data"
                },
                {
                    "level": 3,
                    "text": "Alerting"
                },
                {
                    "level": 3,
                    "text": "Operator API keys"
                },
                {
                    "level": 3,
                    "text": "Audit"
                },
                {
                    "level": 3,
                    "text": "Backups"
                },
                {
                    "level": 3,
                    "text": "Bandwidth & quotas"
                },
                {
                    "level": 3,
                    "text": "BGP"
                },
                {
                    "level": 3,
                    "text": "Billing automation"
                },
                {
                    "level": 3,
                    "text": "NIC bonds"
                },
                {
                    "level": 3,
                    "text": "Bridges"
                },
                {
                    "level": 3,
                    "text": "Cluster"
                },
                {
                    "level": 3,
                    "text": "Configuration"
                },
                {
                    "level": 3,
                    "text": "Container networking"
                },
                {
                    "level": 3,
                    "text": "Dashboard"
                },
                {
                    "level": 3,
                    "text": "DHCP"
                },
                {
                    "level": 3,
                    "text": "DNS"
                },
                {
                    "level": 3,
                    "text": "Endpoint index"
                },
                {
                    "level": 3,
                    "text": "Event stream"
                },
                {
                    "level": 3,
                    "text": "Floating IPs"
                },
                {
                    "level": 3,
                    "text": "Flow tracking"
                },
                {
                    "level": 3,
                    "text": "Port forwarding"
                },
                {
                    "level": 3,
                    "text": "Gateway & NAT"
                },
                {
                    "level": 3,
                    "text": "Geneve tunnels"
                },
                {
                    "level": 3,
                    "text": "Self-healing"
                },
                {
                    "level": 3,
                    "text": "Health"
                },
                {
                    "level": 3,
                    "text": "Address pools"
                },
                {
                    "level": 3,
                    "text": "Layer-7 load balancer"
                },
                {
                    "level": 3,
                    "text": "Layer-4 load balancer"
                },
                {
                    "level": 3,
                    "text": "Licence"
                },
                {
                    "level": 3,
                    "text": "MAC bindings"
                },
                {
                    "level": 3,
                    "text": "Metrics"
                },
                {
                    "level": 3,
                    "text": "NAT64"
                },
                {
                    "level": 3,
                    "text": "Networks & endpoints"
                },
                {
                    "level": 3,
                    "text": "Network interfaces & addressing"
                },
                {
                    "level": 3,
                    "text": "Plugins"
                },
                {
                    "level": 3,
                    "text": "Routing"
                },
                {
                    "level": 3,
                    "text": "Firewall rules"
                },
                {
                    "level": 3,
                    "text": "Status"
                },
                {
                    "level": 3,
                    "text": "Tenants"
                },
                {
                    "level": 3,
                    "text": "TLS"
                },
                {
                    "level": 3,
                    "text": "Topology"
                },
                {
                    "level": 3,
                    "text": "VLAN lockdown"
                },
                {
                    "level": 3,
                    "text": "VRF devices"
                },
                {
                    "level": 3,
                    "text": "VXLAN overlays"
                },
                {
                    "level": 3,
                    "text": "Webhooks"
                },
                {
                    "level": 2,
                    "text": "Feedback"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 15464,
            "markdown": "# Management API Reference\n\nEvery Stratum node runs a **local management API** so you can manage that node's\nnetworking yourself, from your own tooling. The full management surface is the\n**REST** API over HTTPS on port **`7070`**, alongside a **gRPC** health endpoint\non **`7071`** and a **WebSocket** event stream on **`7072`** — sharing one\nauthentication model and one TLS certificate.\n\nThis page is the complete REST reference, plus the WebSocket event stream and the\ngRPC health endpoint. The base URL is:\n\n```\nhttps://<node-ip>:7070\n```\n\nAll REST paths below are relative to this base and live under the `/api/v1`\nprefix.\n\n---\n\n## Getting started\n\n### 1. The API is off until you set a token\n\nThe management API is **disabled by default** and **fails closed**: the agent\nwill *never* serve these endpoints unauthenticated. It starts listening only when\nan **API token** is configured. With no token the API is cleanly disabled, and\nyou manage the node through the panel and the local `cenvero-str-ctl` socket\ninstead.\n\nAsk the node for a token whenever you need one:\n\n```bash\nsudo cenvero-str-ctl api-token generate\n```\n\nIt prints the token **once** — store it then, because it is not shown again. To\nchoose the value yourself, pipe it in so it never reaches your shell history:\n\n```bash\nprintf '%s' \"$MY_TOKEN\" | sudo cenvero-str-ctl api-token set\n```\n\n```bash\ncenvero-str-ctl api-token status   # is one set? (never prints the value)\ncenvero-str-ctl api-token clear    # remove it — the API stops at the next restart\n```\n\nThe token is stored root-only on the node, in your local overrides, so a later\npanel sync does not discard it. You can also have the installer mint one up\nfront with `CENVERO_API_TOKEN=auto`.\n\nIt is **not** settable with `cenvero-str-ctl config set` — that command\ndeliberately refuses credential keys, because a value passed as a command\nargument ends up in shell history. `api-token` exists for exactly this reason.\n\nThe API listens only when **all three** hold: a token is configured,\n`service rest` is on, and a TLS certificate is available (TLS is mandatory — the\nagent refuses to serve plaintext). You can move or gate the listener without\nremoving the token:\n\n```bash\ncenvero-str-ctl config set api_bind_address 10.0.0.5   # bind to a management IP\ncenvero-str-ctl service rest off                        # stop serving REST\ncenvero-str-ctl service rest on                         # serve it again\ncenvero-str-ctl service status                          # show each service's state\n```\n\n### 2. Authenticate with a bearer token\n\nSend the token in the `Authorization` header on every protected call:\n\n```\nAuthorization: Bearer <token>\n```\n\nThree credential types are accepted, depending on the transport:\n\n| Credential | How you get it | Scope | Works on |\n|---|---|---|---|\n| **Node API token** | The `api_token` you configured on the node | Full (node-wide) | REST, WebSocket, gRPC |\n| **Operator API key** | `cenvero-str-ctl apikeys mint <label>` (secret shown once) | Full (node-wide) | REST only |\n| **Tenant-scoped key** | `POST /api/v1/tenant/{id}/keys` (see Multi-tenancy below) | Confined to one tenant | REST only |\n\nA **tenant-scoped key** may only act on its own tenant — i.e. `/tenant/{id}/...`\nand `/billing/tenants/{id}/...` where `{id}` is that key's tenant; any other path\nreturns **403**. The node API token and operator keys are never confined. The\n**WebSocket and gRPC** transports accept the **node API token only** (not operator\nor tenant-scoped keys). The local `cenvero-str-ctl` socket needs no token at all —\nit is the node's always-available lifeline and can never be disabled.\n\n### 3. Verify connectivity\n\nTwo endpoints need **no token** and are handy for a connectivity check. Examples\non this page use these shell variables:\n\n```bash\nexport NODE=https://<node-ip>:7070\nexport TOKEN=<your-api-token>\n```\n\n```bash\ncurl -k \"$NODE/api/v1/health\"\n```\n\n```json\n{\n  \"status\": \"healthy\",\n  \"uptime\": \"3h14m22s\"\n}\n```\n\nThis endpoint is **deliberately minimal**. It answers one question — is the agent\nup, and for how long — and nothing more. Because it is reachable without a token,\nanything it returned would be readable by anyone who can reach the port, so it\ndoes not report the build version, the hardware, or any configuration. Point a\nload balancer or an uptime monitor at it and treat a `200` as alive.\n\nFor the node's version and its network-acceleration detail, use the\n**authenticated** status endpoint:\n\n```bash\ncurl -k \"$NODE/api/v1/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"status\": \"healthy\",\n  \"version\": \"1.0.0\",\n  \"uptime\": \"3h14m22s\",\n  \"acceleration\": {\n    \"mode\": \"software\",\n    \"summary\": \"Software (CPU)\",\n    \"detail\": \"Software (CPU) — hardware acceleration not available on this network card\"\n  }\n}\n```\n\n### Licence\n\n`GET /api/v1/license` — the node's licence: who it was issued to, its plan and\nchannel, when it expires, the enforcement state, the plan's speed ceiling and the\ncapability map. Read-only — it never contacts the licence server and never\nchanges anything, so it is safe to poll on a schedule.\n\n```bash\ncurl -k \"$NODE/api/v1/license\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"installed\": true,\n  \"serial_number\": \"a3f9-...\",\n  \"plan\": \"enterprise\",\n  \"release_channel\": \"stable\",\n  \"valid_until\": \"2026-12-31T00:00:00Z\",\n  \"state\": \"active\",\n  \"days_remaining\": 152,\n  \"expired\": false,\n  \"mutations_allowed\": true,\n  \"hardware_grace\": false,\n  \"max_bandwidth_gbps\": 25,\n  \"features\": { \"bgp\": true, \"cluster\": true, \"ids\": false }\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `state` | `active`, `warning` (expiring soon), `grace` (just expired), `frozen` |\n| `mutations_allowed` | Whether state-changing requests are accepted **right now** |\n| `hardware_grace` | The licence is valid but bound to hardware that no longer matches |\n| `max_bandwidth_gbps` | The plan's aggregate node ceiling; `0` means uncapped |\n| `days_remaining` | Negative once expired |\n\n**Branch on `mutations_allowed`, not on `state`.** Licence enforcement never\nsevers traffic — a frozen node keeps forwarding packets and keeps its workloads\nrunning; what it refuses is *changes*. So this is the field that tells automation\nwhether a write will be accepted.\n\nWith no licence installed the answer is `{\"installed\": false}` with the identity\nfields absent, rather than a document full of blanks that reads like a licence.\n\n`GET /api/v1/docs` (also unauthenticated) returns a short machine-readable index\nof the endpoint paths — the same list published in this reference.\n\nBecause the node's certificate is privately managed, the `curl` examples below\nuse `-k` to skip system trust. To **pin** the certificate instead, fetch its\npublic key from the unauthenticated endpoint `GET /api/v1/tls/pubkey` (returns the\nPEM as `text/plain`).\n\n---\n\n## Conventions\n\n- **Base URL** — `https://<node-ip>:7070`; every REST path is under `/api/v1`.\n- **HTTPS only** — the agent refuses to serve plaintext; your client must trust\n  (or pin) the node's certificate.\n- **JSON only** — every request that carries a body must send\n  `Content-Type: application/json`; anything else is rejected with **415**.\n  Responses are always JSON.\n- **Body cap** — request bodies over **1 MiB** are rejected with **413**.\n- **Status codes** — successful reads/updates return **200**; resource creation\n  returns **201**.\n- **Timestamps** — UTC throughout (RFC 3339, e.g. `2026-06-30T14:05:00Z`), unless\n  a field is explicitly a Unix epoch.\n- **No pagination** — list endpoints return the full collection under a named key\n  (e.g. `{\"networks\": [ ... ]}`); filters are provided per-endpoint via query\n  parameters where noted.\n\n### Error responses\n\nErrors are returned as JSON `{ \"error\": \"...\" }` with a matching HTTP status:\n\n| Status | Meaning |\n|---|---|\n| `400` | Malformed request — bad/invalid body or parameter (e.g. an invalid CIDR). |\n| `401` | Missing or invalid bearer token: `{\"error\":\"unauthorized\"}`. |\n| `403` | Forbidden — see the cases below. |\n| `404` | Unknown resource (e.g. an unknown network or record id). |\n| `413` | Request body over the 1 MiB cap. |\n| `415` | Body sent without `Content-Type: application/json`. |\n| `429` | Rate-limited, or too many failed auth attempts (temporary IP block). |\n| `501` | The operation is intentionally not supported (noted per-endpoint). |\n| `503` | That subsystem is not enabled/wired on this node: `{\"error\":\"<name> service not available\"}`. |\n\nA **403** is returned in any of these situations:\n\n- **Source not allowed** — when an IP allowlist (`api_allowed_ips`) is configured\n  and your address is not on it: `{\"error\":\"forbidden: source address not allowed\"}`.\n- **License frozen** — a *mutating* call (POST/PUT/DELETE) while the license is\n  frozen: `{\"error\":\"license inactive: changes are frozen until the license is\n  renewed; existing workloads keep running\"}`. Reads (GET) are never blocked, and\n  running workloads are untouched.\n- **Feature not in your plan** — see plan-gated subsystems below.\n- **Tenant-scope violation** — a tenant-scoped key used outside its own tenant.\n\n### Rate limits\n\nThe API applies a **per-source-IP** token-bucket limit. The defaults are\n**1000 requests per minute** with a **burst of 100**; exceed it and you get\n**429** `{\"error\":\"rate limit exceeded\"}`. Tune them with `api_rate_limit`\n(requests/minute) and `api_rate_burst`.\n\n### Plan-gated subsystems\n\nSome subsystems are available only if your license plan includes them. For a\nplan-gated subsystem, **every** call — reads as well as writes — returns **403**\nwith a message naming the missing feature (or \"no license installed\" when no\nlicense is active).\n\n| Path prefix | Required feature |\n|---|---|\n| `/networks` | Private Networks |\n| `/dns/*` | DNS |\n| `/dhcp/*` | DHCP |\n| `/vxlan/*` | VXLAN |\n| `/lb`, `/lb/*` | Load Balancer |\n| `/bgp/*` | BGP |\n| `/gateway/*` | Gateway HA |\n| `/bandwidth`, `/bandwidth/*` | Bandwidth shaping (limits + pools + quotas) |\n| `/cluster`, `/cluster/*` | Cluster |\n\nEverything else — IPAM, firewall `/rules`, `/routes`, `/vlan`, `/bridges`,\n`/nics`, `/macbind`, `/forward`, `/bonds`, `/float`, `/flows`, `/accounting`,\n`/tenant`, `/billing`, `/containers`, and the operations endpoints — is available\non any active plan.\n\n---\n\n# REST endpoint reference\n\n## Private networks\n\nManaged private (SDN) networks. You create a network from a CIDR pool, and the\nagent automatically materializes **one endpoint profile per usable host IP** —\neach a fixed IP paired with a system-generated MAC. *Attaching* claims a free\nprofile and programs its address binding into the data plane; *detaching* frees\nit. *(Plan feature: Private Networks.)*\n\n**Network fields**\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | string | Server-assigned network id. |\n| `name` | string | **Required** on create, unique per node. |\n| `cidr` | string | **Required**, IPv4 only (e.g. `10.20.0.0/24`). |\n| `gateway` | string | Optional gateway IP. |\n| `vlan` | int | Optional VLAN id to tag the network. |\n| `tenant_id` | string | Optional — scopes the network to one of your tenants. |\n| `created_at` | string | UTC timestamp. |\n\n**Endpoint fields:** `id`, `network_id`, `ip`, `mac`, `state` (`free` or\n`bound`), and `bound_at` (set once bound).\n\n### List networks\n\n`GET /api/v1/networks`\n\n```bash\ncurl -k \"$NODE/api/v1/networks\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"networks\": [\n    {\n      \"id\": \"net-a1b2c3d4\",\n      \"name\": \"app-net\",\n      \"cidr\": \"10.20.0.0/24\",\n      \"gateway\": \"10.20.0.1\",\n      \"vlan\": 100,\n      \"tenant_id\": \"t-acme\",\n      \"created_at\": \"2026-06-30T12:00:00Z\"\n    }\n  ]\n}\n```\n\n### Create a network\n\n`POST /api/v1/networks` — body: `name` and `cidr` required; `gateway`, `vlan`,\n`tenant_id` optional. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/networks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"app-net\",\"cidr\":\"10.20.0.0/24\",\"gateway\":\"10.20.0.1\",\"vlan\":100}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"network\": {\n    \"id\": \"net-a1b2c3d4\",\n    \"name\": \"app-net\",\n    \"cidr\": \"10.20.0.0/24\",\n    \"gateway\": \"10.20.0.1\",\n    \"vlan\": 100,\n    \"created_at\": \"2026-06-30T12:00:00Z\"\n  }\n}\n```\n\n### Get a network\n\n`GET /api/v1/networks/{id}`\n\n```bash\ncurl -k \"$NODE/api/v1/networks/net-a1b2c3d4\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"network\": { \"id\": \"net-a1b2c3d4\", \"name\": \"app-net\", \"cidr\": \"10.20.0.0/24\", \"gateway\": \"10.20.0.1\" } }\n```\n\n### Delete a network\n\n`DELETE /api/v1/networks/{id}`\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/networks/net-a1b2c3d4\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"deleted\", \"id\": \"net-a1b2c3d4\" }\n```\n\n### List a network's endpoints\n\n`GET /api/v1/networks/{id}/endpoints`\n\n```bash\ncurl -k \"$NODE/api/v1/networks/net-a1b2c3d4/endpoints\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"endpoints\": [\n    { \"id\": \"ep-1111\", \"network_id\": \"net-a1b2c3d4\", \"ip\": \"10.20.0.1\", \"mac\": \"02:1a:4f:14:00:01\", \"state\": \"free\" },\n    { \"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\" }\n  ]\n}\n```\n\nAdd `?mac=52:54:00:ab:01:02` to narrow the listing to a single address. This is\nthe efficient way to answer \"which endpoint is this workload on\" when\nreconciling — a /24 otherwise returns 254 rows. Matching ignores case, and an\naddress that is not present returns an empty list rather than an error.\n\n```bash\ncurl -k \"$NODE/api/v1/networks/net-a1b2c3d4/endpoints?mac=52:54:00:ab:01:02\" \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Attach an endpoint\n\n`POST /api/v1/networks/{id}/attach` — claims an endpoint and binds it. Body is\noptional: send `{\"ip\":\"10.20.0.2\"}` to claim a specific address, or an empty body\nto take the next free one. Idempotent — attaching an already-bound IP returns that\nsame endpoint. Returns **200**.\n\nAdd `\"mac\"` to give the endpoint a specific hardware address instead of the one\nit was assigned. Use this when the workload already has a fixed address of its\nown — a virtual machine image, or an appliance whose licence is tied to one — so\nthe fabric accepts the address it will actually send from.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/networks/net-a1b2c3d4/attach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"ip\":\"10.20.0.2\"}'\n\n# ...or claiming that address for a workload that already has this MAC\ncurl -k -X POST \"$NODE/api/v1/networks/net-a1b2c3d4/attach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"ip\":\"10.20.0.2\",\"mac\":\"52:54:00:ab:01:02\"}'\n```\n\n```json\n{\n  \"status\": \"attached\",\n  \"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\" }\n}\n```\n\n### Change an endpoint's MAC address\n\n`PUT /api/v1/networks/{id}/endpoints/{eid}/mac` — replaces the endpoint's\nhardware address, keeping its IP. Returns **200**.\n\nOn a bound endpoint the anti-spoof binding moves to the new address as part of\nthe change, so the workload is never left able to send from an address the fabric\nwould reject. Setting the address it already has succeeds and changes nothing.\n\n```bash\ncurl -k -X PUT \"$NODE/api/v1/networks/net-a1b2c3d4/endpoints/ep-2222/mac\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"mac\":\"52:54:00:ab:01:02\"}'\n```\n\n```json\n{\n  \"status\": \"updated\",\n  \"endpoint\": { \"id\": \"ep-2222\", \"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\" }\n}\n```\n\n**400** is returned for an address that is not a MAC, for a multicast address\n(one can never be a source address, so traffic from it would be dropped), and for\nan address already held by another endpoint — the response names the endpoint\nholding it, since two endpoints sharing an address would collide in the\nanti-spoof binding.\n\n### Detach an endpoint\n\n`POST /api/v1/networks/{id}/endpoints/{eid}/detach` — frees the endpoint and\nreleases its binding.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/networks/net-a1b2c3d4/endpoints/ep-2222/detach\" \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"status\": \"detached\",\n  \"endpoint\": { \"id\": \"ep-2222\", \"network_id\": \"net-a1b2c3d4\", \"ip\": \"10.20.0.2\", \"mac\": \"02:1a:4f:14:00:02\", \"state\": \"free\" }\n}\n```\n\n---\n\n## IP address management (IPAM)\n\nAddress pools and the individual IP allocations drawn from them. Creating a\nprivate network registers a matching pool automatically; you can also manage\npools directly.\n\n**Pool fields:** `id`, `name` (**required**), `subnet` (**required** CIDR),\n`gateway`, `range_start`, `range_end`, `is_ipv6`, and an optional `tenant_id`.\n\n### List pools\n\n`GET /api/v1/ipam/pools` — optional `?tenant_id=` filter.\n\n```bash\ncurl -k \"$NODE/api/v1/ipam/pools\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"pools\": [\n    { \"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 }\n  ]\n}\n```\n\n### Create a pool\n\n`POST /api/v1/ipam/pools` — `name` and `subnet` required; `gateway`,\n`range_start`, `range_end`, `is_ipv6`, `tenant_id` optional. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/ipam/pools\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -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\"}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"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 }\n}\n```\n\n### List allocations\n\n`GET /api/v1/ipam/allocations` — every allocation across all pools; optional\n`?tenant_id=` filter.\n\n```bash\ncurl -k \"$NODE/api/v1/ipam/allocations\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"allocations\": [ { \"id\": 10, \"pool_id\": 1, \"ip\": \"10.20.0.5\", \"hostname\": \"web-1\" } ] }\n```\n\n### Allocate an IP\n\n`POST /api/v1/ipam/allocate` — body: `pool_id` (**required**), `hostname`\n(optional). Returns the assigned address.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/ipam/allocate\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"pool_id\":1,\"hostname\":\"web-2\"}'\n```\n\n```json\n{ \"status\": \"allocated\", \"id\": 11, \"pool_id\": 1, \"ip\": \"10.20.0.6\", \"hostname\": \"web-2\" }\n```\n\n### Release an IP\n\n`POST /api/v1/ipam/release` — body: `pool_id` and `ip` (**both required**).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/ipam/release\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"pool_id\":1,\"ip\":\"10.20.0.6\"}'\n```\n\n```json\n{ \"status\": \"released\" }\n```\n\n### Delete a pool\n\n`DELETE /api/v1/ipam/pools/{id}` — removes the pool **and every allocation in it**.\nAddresses handed out from the pool stop being reserved, so do this only once\nnothing is using them.\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/ipam/pools/1\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"deleted\", \"id\": 1 }\n```\n\nDeleting a pool that does not exist returns **404**.\n\n---\n\n## Bridges\n\nThe node's software bridges and their member ports. A node ships with two managed\nbridges — `cnv-mgmt-br0` (management) and `cnv-user-br0` (tenant/user traffic) —\nand you can create and wire additional ones.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/bridges` | List bridges and their member interfaces |\n| POST | `/api/v1/bridges` | Create a bridge (`name` required) |\n| DELETE | `/api/v1/bridges/{name}` | Delete a bridge |\n| POST | `/api/v1/bridges/{name}/ports` | Attach an interface (`interface` required) |\n| DELETE | `/api/v1/bridges/{name}/ports/{iface}` | Detach an interface |\n\n```bash\ncurl -k \"$NODE/api/v1/bridges\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"bridges\": [ { \"name\": \"cnv-user-br0\", \"interfaces\": [\"cnv-nic-1\"] } ] }\n```\n\n```bash\n# Create a bridge and attach a NIC\ncurl -k -X POST \"$NODE/api/v1/bridges\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"cnv-svc-br0\"}'\n\ncurl -k -X POST \"$NODE/api/v1/bridges/cnv-svc-br0/ports\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"interface\":\"cnv-nic-2\"}'\n```\n\n```json\n{ \"status\": \"added\", \"bridge\": \"cnv-svc-br0\", \"interface\": \"cnv-nic-2\" }\n```\n\nDetach a port → `DELETE /api/v1/bridges/cnv-svc-br0/ports/cnv-nic-2` returns\n`{ \"status\": \"removed\", \"bridge\": \"cnv-svc-br0\", \"interface\": \"cnv-nic-2\" }`.\nDelete a bridge → `{ \"status\": \"deleted\", \"name\": \"cnv-svc-br0\" }`.\n\n---\n\n## Network interfaces (NICs)\n\nRead-only inventory of the node's physical interfaces. Renaming is a privileged\nboot-time operation and is not exposed over the API.\n\n`GET /api/v1/nics` — pass `?refresh=1` to re-scan hardware before returning.\n\n```bash\ncurl -k \"$NODE/api/v1/nics\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"nics\": [\n    { \"original_name\": \"eth0\", \"stratum_name\": \"cnv-nic-0\", \"pci\": \"0000:01:00.0\", \"driver\": \"ixgbe\", \"speed_mbps\": 10000, \"status\": \"up\" }\n  ]\n}\n```\n\n---\n\n## MAC bindings\n\nTie a MAC address to an authorized port/VLAN for the node's anti-spoofing guard.\nManaged-network endpoints get their bindings automatically; use these endpoints to\nmanage bindings for addresses you bridge in from outside.\n\n**Binding fields:** `id`, `mac`, `port_id`, `vlan_id`, and `mode` — `hard` (drop\ntraffic from unbound MACs; the default) or `soft` (log but allow).\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/macbind` | List bindings |\n| POST | `/api/v1/macbind` | Create a binding (`mac` required) |\n| DELETE | `/api/v1/macbind/{mac}` | Remove a binding by MAC |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/macbind\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"mac\":\"02:1a:4f:14:00:09\",\"port_id\":1,\"vlan_id\":100,\"mode\":\"hard\"}'\n```\n\n```json\n{ \"status\": \"created\", \"binding\": { \"id\": 4, \"mac\": \"02:1a:4f:14:00:09\", \"port_id\": 1, \"vlan_id\": 100, \"mode\": \"hard\" } }\n```\n\nRemove → `DELETE /api/v1/macbind/02:1a:4f:14:00:09` returns\n`{ \"status\": \"deleted\", \"mac\": \"02:1a:4f:14:00:09\" }`.\n\n---\n\n## DHCP leases\n\nRead the live DHCP lease table. *(Plan feature: DHCP.)*\n\n`GET /api/v1/dhcp/leases` — optional `?state=` filter, one of `active`,\n`expired`, `revoked`. `expires_at` is a Unix UTC timestamp.\n\n```bash\ncurl -k \"$NODE/api/v1/dhcp/leases?state=active\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"leases\": [\n    { \"id\": 3, \"mac\": \"52:54:00:de:ad:01\", \"ip\": \"10.30.0.10\", \"state\": \"active\", \"hostname\": \"db-primary\", \"expires_at\": 1782825600 }\n  ]\n}\n```\n\n> Static reservations and early releases are managed from the CLI\n> (`cenvero-str-ctl dhcp reserve` / `dhcp release`).\n\n---\n\n## DNS\n\nThe authoritative DNS manager: managed zones and their records, plus DNSSEC\nmaterial. *(Plan feature: DNS.)*\n\n**Record fields:** `id`, `zone_id`, `name`, `type` (`A`, `AAAA`, `PTR`, `CNAME`,\n`MX`, `TXT`, `NS`, `SOA`, `SRV`), `value`, `ttl` (defaults to `300` when omitted),\nand an optional `source_subnet` for split-horizon answers.\n\n### List zones\n\n`GET /api/v1/dns/zones`\n\n```bash\ncurl -k \"$NODE/api/v1/dns/zones\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"zones\": [ { \"id\": 1, \"name\": \"app-net.internal.\", \"soa\": \"ns.app-net.internal.\", \"serial\": 2026063001 } ] }\n```\n\n### Create a zone\n\n`POST /api/v1/dns/zones` — body: `name` (**required**). Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/dns/zones\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"app-net.internal\"}'\n```\n\n```json\n{ \"status\": \"created\", \"zone\": { \"id\": 1, \"name\": \"app-net.internal.\", \"soa\": \"ns.app-net.internal.\", \"serial\": 2026063001 } }\n```\n\n### Delete a zone\n\n`DELETE /api/v1/dns/zones/{id}` — removes the zone **and every record in it**.\nNames under the zone stop resolving immediately.\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/dns/zones/1\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"deleted\", \"id\": 1 }\n```\n\nDeleting a zone that does not exist returns **404**.\n\n### List records\n\n`GET /api/v1/dns/records` — lists all records, or one zone's with `?zone_id=N`.\n\n```bash\ncurl -k \"$NODE/api/v1/dns/records?zone_id=1\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"records\": [ { \"id\": 5, \"zone_id\": 1, \"name\": \"api\", \"type\": \"A\", \"value\": \"10.20.0.55\", \"ttl\": 300 } ] }\n```\n\n### Create a record\n\n`POST /api/v1/dns/records` — body: `zone_id`, `name`, `type`, `value` required;\n`ttl` and `source_subnet` optional. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/dns/records\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"zone_id\":1,\"name\":\"db\",\"type\":\"A\",\"value\":\"10.20.0.10\",\"ttl\":300}'\n```\n\n```json\n{ \"status\": \"created\", \"record\": { \"id\": 6, \"zone_id\": 1, \"name\": \"db\", \"type\": \"A\", \"value\": \"10.20.0.10\", \"ttl\": 300 } }\n```\n\n### Update a record\n\n`PUT /api/v1/dns/records/{id}` — **in-place update is not supported.** The call\nreturns **501**; delete the record and recreate it instead.\n\n```json\n{ \"error\": \"updating a record in place is not supported; delete and recreate it\" }\n```\n\n### Delete a record\n\n`DELETE /api/v1/dns/records/{id}`\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/dns/records/6\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"deleted\", \"record_id\": 6 }\n```\n\n### DNSSEC material for a zone\n\n`GET /api/v1/dns/dnssec/{zone}` — returns the zone's DNSKEY set and the **DS**\nrecord to publish in the parent zone. Keys are generated and persisted on the\nfirst call, so this is also how you enable DNSSEC for a zone.\n\n```bash\ncurl -k \"$NODE/api/v1/dns/dnssec/app-net.internal\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"zone\": \"app-net.internal.\",\n  \"enabled\": true,\n  \"dnskey\": [\n    \"app-net.internal.\\t3600\\tIN\\tDNSKEY\\t257 3 15 <base64-public-key>\",\n    \"app-net.internal.\\t3600\\tIN\\tDNSKEY\\t256 3 15 <base64-public-key>\"\n  ],\n  \"ds\": \"app-net.internal.\\t3600\\tIN\\tDS\\t12345 15 2 <hex-digest>\"\n}\n```\n\n---\n\n## Static & policy routing\n\nTwo related surfaces: **routes** placed in the kernel forwarding table (optionally\na non-main table id), and the **policy rules** that steer matched traffic into a\ntable.\n\n**Route fields:** `destination` (**required** CIDR), `gateway`, `interface`,\n`metric`, `table` (`0` = the main table; use a positive id for policy routing).\n\n### List routes\n\n`GET /api/v1/routes` — optional `?table=N` filter.\n\n```bash\ncurl -k \"$NODE/api/v1/routes\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"routes\": [ { \"id\": 1, \"destination\": \"10.50.0.0/24\", \"gateway\": \"10.20.0.254\", \"interface\": \"\", \"metric\": 100, \"table\": 0 } ] }\n```\n\n### Create a route\n\n`POST /api/v1/routes` — `destination` required; `gateway`, `interface`, `metric`,\n`table` optional (`table` must be `>= 0`). Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/routes\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"destination\":\"0.0.0.0/0\",\"gateway\":\"203.0.113.1\",\"table\":100}'\n```\n\n```json\n{ \"status\": \"created\", \"route\": { \"id\": 2, \"destination\": \"0.0.0.0/0\", \"gateway\": \"203.0.113.1\", \"interface\": \"\", \"metric\": 0, \"table\": 100 } }\n```\n\n### Delete a route\n\n`DELETE /api/v1/routes/{id}` → `{ \"status\": \"deleted\", \"id\": 2 }`.\n\n### List policy rules\n\n`GET /api/v1/routes/rules` — the rules that direct matched traffic into a table.\n\n```bash\ncurl -k \"$NODE/api/v1/routes/rules\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"rules\": [ { \"id\": 1, \"priority\": 100, \"from\": \"10.20.0.0/24\", \"to\": \"\", \"fwmark\": 0, \"iif\": \"\", \"oif\": \"\", \"table\": 100 } ] }\n```\n\n### Create a policy rule\n\n`POST /api/v1/routes/rules` — `table` must be a **positive** id, and at least one\nselector (`from`, `to`, `fwmark`, `iif`, `oif`) is required. `priority` optional.\nReturns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/routes/rules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"priority\":100,\"from\":\"10.20.0.0/24\",\"table\":100}'\n```\n\n```json\n{ \"status\": \"created\", \"rule\": { \"id\": 1, \"priority\": 100, \"from\": \"10.20.0.0/24\", \"to\": \"\", \"fwmark\": 0, \"iif\": \"\", \"oif\": \"\", \"table\": 100 } }\n```\n\n### Delete a policy rule\n\n`DELETE /api/v1/routes/rules/{id}` → `{ \"status\": \"deleted\", \"id\": 1 }`.\n\n---\n\n## Port forwarding\n\nForward an inbound public address:port to an internal target (destination NAT).\nThis is how you publish an internal service on a public address.\n\n| Method | Path | Purpose |\n|---|---|---|\n| POST | `/api/v1/forward` | Create a port-forward rule |\n| DELETE | `/api/v1/forward/{id}` | Delete a port-forward rule |\n| GET | `/api/v1/forward`, `/api/v1/forward/{id}` | *Not supported* — returns `501` |\n\n**Create** — body: `dest_ip`, `dest_port` (the inbound match), `target_ip`,\n`target_port` (the internal target); all four required. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/forward\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"dest_ip\":\"203.0.113.10\",\"dest_port\":443,\"target_ip\":\"10.20.0.10\",\"target_port\":8443}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"port_forward\": { \"id\": 5, \"dest_ip\": \"203.0.113.10\", \"dest_port\": 443, \"target_ip\": \"10.20.0.10\", \"target_port\": 8443 }\n}\n```\n\nDelete → `DELETE /api/v1/forward/5` returns `{ \"status\": \"deleted\", \"id\": 5 }`.\nListing/fetching individual rules is not supported and returns **501**; track\nyour rule ids from the create response.\n\n---\n\n## VLAN lockdown\n\nA per-VLAN allow/deny table for the tenant network. An **empty table means every\nVLAN is allowed** (the default). Add a row with `allowed: false` to lock a VLAN\ndown; `allowed: true` records an explicit allow.\n\n### List VLAN policies\n\n`GET /api/v1/vlan`\n\n```bash\ncurl -k \"$NODE/api/v1/vlan\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"vlans\": [ { \"vlan_id\": 200, \"allowed\": false } ] }\n```\n\n### Set a VLAN policy\n\n`POST /api/v1/vlan` — body: `vlan_id` (**required**, 1–4094) and `allowed`.\nReturns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/vlan\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"vlan_id\":200,\"allowed\":false}'\n```\n\n```json\n{ \"status\": \"set\", \"vlan\": { \"vlan_id\": 200, \"allowed\": false } }\n```\n\n### Clear a VLAN policy\n\n`DELETE /api/v1/vlan/{id}` — removes the row, returning that VLAN to the default.\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/vlan/200\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"cleared\", \"vlan_id\": 200 }\n```\n\n---\n\n## VXLAN overlays\n\nLayer-2 overlay networks keyed by **VNI** (a 24-bit id, 1–16777215) plus their\nremote peers (VTEPs). *(Plan feature: VXLAN.)*\n\n### List overlay networks\n\n`GET /api/v1/vxlan/networks`\n\n```bash\ncurl -k \"$NODE/api/v1/vxlan/networks\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"vxlan_networks\": [\n    { \"vni\": 1001, \"subnet\": \"10.200.0.0/24\", \"peers\": [ { \"host\": \"node-b\", \"mac\": \"\", \"vtep_ip\": \"198.51.100.20\" } ] }\n  ]\n}\n```\n\n### Create an overlay network\n\n`POST /api/v1/vxlan/networks` — body: `vni` (**required**, 1–16777215) and\n`subnet`. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/vxlan/networks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"vni\":1001,\"subnet\":\"10.200.0.0/24\"}'\n```\n\n```json\n{ \"status\": \"created\", \"vni\": 1001, \"subnet\": \"10.200.0.0/24\" }\n```\n\n### Delete an overlay network\n\n`DELETE /api/v1/vxlan/networks/{vni}` → `{ \"status\": \"deleted\", \"vni\": 1001 }`.\n\n### List peers\n\n`GET /api/v1/vxlan/networks/{vni}/peers`\n\n```json\n{ \"vni\": 1001, \"peers\": [ { \"host\": \"node-b\", \"mac\": \"\", \"vtep_ip\": \"198.51.100.20\" } ] }\n```\n\n### Add a peer\n\n`POST /api/v1/vxlan/networks/{vni}/peers` — body: `host` and `vtep_ip`\n(**both required**), `mac` optional. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/vxlan/networks/1001/peers\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"host\":\"node-b\",\"vtep_ip\":\"198.51.100.20\"}'\n```\n\n```json\n{ \"status\": \"added\", \"vni\": 1001, \"host\": \"node-b\" }\n```\n\n### Remove a peer\n\n`DELETE /api/v1/vxlan/networks/{vni}/peers/{host}` →\n`{ \"status\": \"removed\", \"vni\": 1001, \"host\": \"node-b\" }`.\n\n### Forwarding database\n\n`GET /api/v1/vxlan/fdb` — the overlay MAC-to-VTEP forwarding table.\n\n```json\n{ \"fdb\": [ { \"mac\": \"02:1a:4f:c8:00:05\", \"vni\": 1001, \"vtep_ip\": \"198.51.100.20\" } ] }\n```\n\n---\n\n## Firewall rules & connection tracking\n\nManage the node's firewall rule table. Each rule matches on chain, protocol,\nsource/destination IP and port, ingress interface, and optionally a source MAC,\nand applies an action. Setting `stateful: true` on a rule enables connection\ntracking for that rule (return traffic of established connections is matched\nautomatically). To *view* tracked connections, see the **Traffic visibility →\nFlows** section below.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/rules` | List rules (optional `?chain=` filter) |\n| POST | `/api/v1/rules` | Create a rule |\n| GET | `/api/v1/rules/{id}` | Get one rule |\n| PUT | `/api/v1/rules/{id}` | Replace a rule (a new `id` is assigned) |\n| DELETE | `/api/v1/rules/{id}` | Delete a rule |\n\n**Rule fields**\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | integer | Assigned by the node (response only) |\n| `chain` | string | **Required.** `input`, `output`, `forward`, `prerouting`, `postrouting` |\n| `action` | string | **Required.** `accept`, `drop`, `reject`, `log` |\n| `priority` | integer | Evaluation priority |\n| `protocol` | string | e.g. `tcp`, `udp`, `icmp` (omit for any) |\n| `source_ip` / `dest_ip` | string | Address or CIDR |\n| `source_port` / `dest_port` | integer | |\n| `stateful` | boolean | Enable connection tracking for this rule |\n| `interface` | string | Bind the rule to one ingress device (e.g. `cnv-user-br0`) |\n| `mac` | string | Match a source MAC (`aa:bb:cc:dd:ee:ff`); omit for any |\n| `level` | string | Policy scope: `global` (default), `bridge`, `vlan`, `mac`, `flow`, `private_network` |\n\nEmpty/zero optional fields are omitted from responses; `id`, `chain`, `priority`,\n`action`, and `stateful` always appear.\n\n**Create a rule**\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/rules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"chain\": \"forward\",\n    \"action\": \"drop\",\n    \"priority\": 100,\n    \"protocol\": \"tcp\",\n    \"source_ip\": \"198.51.100.0/24\",\n    \"dest_port\": 22,\n    \"stateful\": true,\n    \"comment\": \"block ssh from that net\"\n  }'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"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 }\n}\n```\n\n**List rules** (optionally filter by chain):\n\n```bash\ncurl -k \"$NODE/api/v1/rules?chain=forward\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"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 } ] }\n```\n\n**Replace a rule.** There is no in-place edit; `PUT` deletes the old rule and\ninserts the replacement, which receives a **new** `id` (returned in the response).\nThe body uses the same fields as create (`chain` and `action` required).\n\n```bash\ncurl -k -X PUT \"$NODE/api/v1/rules/7\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"chain\":\"forward\",\"action\":\"reject\",\"priority\":100,\"protocol\":\"tcp\",\"source_ip\":\"198.51.100.0/24\",\"dest_port\":22}'\n```\n\n```json\n{ \"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 } }\n```\n\n**Delete a rule:** `DELETE /api/v1/rules/8` → `{ \"status\": \"deleted\", \"id\": 8 }`.\n\n---\n\n## Load balancer\n\nL4 virtual IPs (VIPs) with a pool of backends. Create a VIP, attach/detach\nbackends, override backend health, and optionally configure an active health\ncheck. *(Plan feature: Load Balancer.)*\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/lb` | List VIPs |\n| POST | `/api/v1/lb` | Create a VIP |\n| GET | `/api/v1/lb/{id}` | Get one VIP |\n| PUT | `/api/v1/lb/{id}` | *Not supported* — returns `501` |\n| DELETE | `/api/v1/lb/{id}` | Delete a VIP |\n| POST | `/api/v1/lb/{id}/backends` | Add a backend |\n| DELETE | `/api/v1/lb/{id}/backends/{bid}` | Remove a backend |\n| POST | `/api/v1/lb/{id}/backends/{bid}/health` | Manually set a backend up/down |\n\n**VIP fields** — `id`, `frontend_ip`, and `algorithm` are **required** on create.\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | string | Your VIP identifier |\n| `frontend_ip` | string | The virtual IP |\n| `frontend_port` | integer | |\n| `protocol` | string | `tcp` or `udp` |\n| `algorithm` | string | `round-robin`, `least-conn`, `source-hash`, `weighted`, `maglev`, `consistent-hash` |\n| `dsr_enabled` | boolean | Direct server return |\n| `health_check` | object | Optional active check (see below) |\n\n`health_check`: `type` (`tcp` or `http`; empty disables it), `interval_sec`,\n`timeout_sec`, `threshold`, `http_path`.\n\n**Create a VIP with an HTTP health check:**\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/lb\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"id\": \"web-vip\",\n    \"frontend_ip\": \"203.0.113.10\",\n    \"frontend_port\": 443,\n    \"protocol\": \"tcp\",\n    \"algorithm\": \"round-robin\",\n    \"dsr_enabled\": false,\n    \"health_check\": { \"type\": \"http\", \"interval_sec\": 5, \"timeout_sec\": 2, \"threshold\": 3, \"http_path\": \"/healthz\" }\n  }'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"load_balancer\": { \"id\": \"web-vip\", \"frontend_ip\": \"203.0.113.10\", \"frontend_port\": 443, \"protocol\": \"tcp\", \"algorithm\": \"round-robin\", \"dsr_enabled\": false, \"backends\": [] }\n}\n```\n\n**Add a backend** (`id` and `ip` required; `weight` drives the `weighted`\nalgorithm's share):\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/lb/web-vip/backends\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"app-1\",\"ip\":\"10.0.0.11\",\"port\":443,\"weight\":1}'\n```\n\n```json\n{ \"status\": \"added\", \"vip_id\": \"web-vip\", \"backend_id\": \"app-1\" }\n```\n\n**Get a VIP** (shows backends with live health and active connection counts):\n\n```json\n{\n  \"load_balancer\": {\n    \"id\": \"web-vip\", \"frontend_ip\": \"203.0.113.10\", \"frontend_port\": 443, \"protocol\": \"tcp\", \"algorithm\": \"round-robin\", \"dsr_enabled\": false,\n    \"backends\": [ { \"id\": \"app-1\", \"ip\": \"10.0.0.11\", \"port\": 443, \"weight\": 1, \"healthy\": true, \"active_conns\": 12 } ]\n  }\n}\n```\n\n**Override a backend's health** (a configured active check may flip it back on the\nnext probe):\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/lb/web-vip/backends/app-1/health\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"healthy\": false}'\n```\n\n```json\n{ \"status\": \"set\", \"vip_id\": \"web-vip\", \"backend_id\": \"app-1\", \"healthy\": false }\n```\n\n**Remove a backend** → `{ \"status\": \"removed\", \"vip_id\": \"web-vip\", \"backend_id\": \"app-1\" }`.\n**Delete a VIP** → `{ \"status\": \"deleted\", \"id\": \"web-vip\" }`.\n\nA VIP cannot be edited in place. `PUT /api/v1/lb/{id}` returns `501`:\n\n```json\n{ \"error\": \"updating a VIP in place is not supported; delete and recreate it\" }\n```\n\n---\n\n## BGP & route filtering\n\nPeer with upstream routers, advertise and withdraw prefixes, and shape what you\naccept/announce with prefix-lists, route-maps, and per-neighbor import/export\npolicy. *(Plan feature: BGP.)*\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/bgp/status` | Engine summary |\n| GET | `/api/v1/bgp/neighbors` | List peers |\n| POST | `/api/v1/bgp/neighbors` | Add a peer |\n| DELETE | `/api/v1/bgp/neighbors/{addr}` | Remove a peer |\n| GET | `/api/v1/bgp/routes` | Route table (`?family=ipv4/ipv6`) |\n| POST | `/api/v1/bgp/announce` | Advertise a prefix |\n| POST | `/api/v1/bgp/withdraw` | Withdraw a prefix |\n| GET | `/api/v1/bgp/prefix-lists` | List prefix-lists |\n| POST | `/api/v1/bgp/prefix-lists` | Create a prefix-list |\n| GET | `/api/v1/bgp/route-maps` | List route-maps |\n| POST | `/api/v1/bgp/route-maps` | Create a route-map |\n| GET | `/api/v1/bgp/policy` | Show import/export policy bindings |\n| POST | `/api/v1/bgp/policy/{dir}` | Bind a route-map (`{dir}` = `import` or `export`) |\n\n**Status:**\n\n```bash\ncurl -k \"$NODE/api/v1/bgp/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"running\", \"neighbors\": 2, \"routes\": 17, \"bfd_sessions_up\": 2 }\n```\n\n**Add a peer.** `peer_addr`, `peer_as`, and `local_as` are **required**. Optional:\n`hold_time`, `keepalive_interval`, `md5_key` (TCP-MD5 auth; never returned in\nreads), and BFD (`bfd_enabled`, `bfd_interval_ms`, `bfd_multiplier` — the\nfailure-detection time is interval × multiplier).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/neighbors\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"peer_addr\": \"192.0.2.1\", \"peer_as\": 64512, \"local_as\": 64513,\n    \"hold_time\": 90, \"keepalive_interval\": 30,\n    \"bfd_enabled\": true, \"bfd_interval_ms\": 250, \"bfd_multiplier\": 3\n  }'\n```\n\n**List peers** (`state` is one of `Idle`, `Connect`, `Active`, `OpenSent`,\n`OpenConfirm`, `Established`):\n\n```json\n{\n  \"neighbors\": [\n    {\n      \"id\": \"192.0.2.1\", \"peer_as\": 64512, \"local_as\": 64513, \"peer_addr\": \"192.0.2.1\",\n      \"state\": \"Established\", \"hold_time\": 90, \"keepalive_interval\": 30,\n      \"bfd_enabled\": true, \"bfd_interval_ms\": 250, \"bfd_multiplier\": 3,\n      \"last_state_change\": \"2026-06-30T11:58:02Z\", \"messages_in\": 412, \"messages_out\": 410,\n      \"graceful_restart\": false, \"bfd_status\": \"Up\", \"bfd_detect_time_ms\": 750, \"peer_graceful_restart\": false\n    }\n  ]\n}\n```\n\nRemove a peer → `DELETE /api/v1/bgp/neighbors/192.0.2.1` returns\n`{ \"status\": \"deleted\", \"addr\": \"192.0.2.1\" }`.\n\n**Advertise a prefix** (`prefix` required, valid CIDR; `next_hop` and\n`communities` optional):\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/announce\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"prefix\":\"203.0.113.0/24\",\"next_hop\":\"192.0.2.254\",\"communities\":[\"64512:100\"]}'\n```\n\n```json\n{ \"status\": \"announced\", \"prefix\": \"203.0.113.0/24\" }\n```\n\n**Withdraw a prefix:** `POST /api/v1/bgp/withdraw` with `{\"prefix\":\"203.0.113.0/24\"}`\nreturns `{ \"status\": \"withdrawn\", \"prefix\": \"203.0.113.0/24\" }`.\n\n`GET /api/v1/bgp/routes` returns the current route table as\n`{ \"routes\": [ ... ], \"family\": \"ipv4\" }`. Each route carries its next hop, AS\npath, communities, local-preference, MED, and origin.\n\n### Prefix-lists\n\nA named, ordered list of CIDR matchers used by route-maps. `name` is **required**;\neach entry has `prefix` (CIDR), `action` (`allow` or `deny`), and optional\n`ge`/`le` prefix-length bounds.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/prefix-lists\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"customer-routes\",\n    \"entries\": [\n      { \"prefix\": \"203.0.113.0/24\", \"action\": \"allow\", \"ge\": 24, \"le\": 32 },\n      { \"prefix\": \"0.0.0.0/0\",       \"action\": \"deny\" }\n    ]\n  }'\n```\n\n```json\n{ \"status\": \"created\", \"prefix_list\": \"customer-routes\" }\n```\n\n`GET /api/v1/bgp/prefix-lists` returns `{ \"prefix_lists\": [ ... ] }`, each with its\n`name` and `entries`.\n\n### Route-maps\n\nA named, sequenced policy that matches routes and sets attributes. `name` is\n**required**; each entry has `seq`, `action` (`allow`/`deny`), `match_prefix` (a\nprefix-list name), and optional `set_local_pref`, `set_community`, `set_med`.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/route-maps\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"name\": \"prefer-customer\",\n    \"entries\": [ { \"seq\": 10, \"action\": \"allow\", \"match_prefix\": \"customer-routes\", \"set_local_pref\": 200, \"set_community\": \"64512:100\" } ]\n  }'\n```\n\n```json\n{ \"status\": \"created\", \"route_map\": \"prefer-customer\" }\n```\n\n`GET /api/v1/bgp/route-maps`:\n\n```json\n{\n  \"route_maps\": [\n    { \"name\": \"prefer-customer\", \"entries\": [ { \"seq\": 10, \"action\": \"allow\", \"match_prefix\": \"customer-routes\", \"set_local_pref\": 200, \"set_community\": \"64512:100\", \"set_med\": 0 } ] }\n  ]\n}\n```\n\n### Apply import/export policy\n\nBind a route-map to a neighbor in a direction. `{dir}` in the path is `import` or\n`export`; the body needs `neighbor` and `route_map` (both **required**).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/policy/import\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"neighbor\":\"192.0.2.1\",\"route_map\":\"prefer-customer\"}'\n```\n\n```json\n{ \"status\": \"applied\", \"direction\": \"import\", \"neighbor\": \"192.0.2.1\", \"route_map\": \"prefer-customer\" }\n```\n\n`GET /api/v1/bgp/policy` returns the current bindings as neighbor → route-map maps:\n\n```json\n{ \"import\": { \"192.0.2.1\": \"prefer-customer\" }, \"export\": { \"192.0.2.1\": \"announce-only\" } }\n```\n\n---\n\n## NIC bonds\n\nAggregate physical NICs into a bond for redundancy or throughput. Create a bond,\nenslave/release member interfaces, and set a consistent MTU across the bond and\nits members.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/bonds` | List bonds |\n| POST | `/api/v1/bonds` | Create a bond |\n| GET | `/api/v1/bonds/{id}` | Get one bond |\n| DELETE | `/api/v1/bonds/{id}` | Delete a bond |\n| POST | `/api/v1/bonds/{id}/members` | Enslave a member NIC |\n| DELETE | `/api/v1/bonds/{id}/members/{iface}` | Release a member NIC |\n| PUT | `/api/v1/bonds/{id}/mtu` | Set the bond+members MTU |\n\n**Create a bond.** `name` and `mode` are **required**; `mode` is `active-backup`\nor `802.3ad` (LACP). `id` is optional — one is generated if omitted.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bonds\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"cnv-bond0\",\"mode\":\"active-backup\",\"mtu\":1500}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"bond\": { \"id\": \"bond-9f2a1c44d0e7b3a8\", \"name\": \"cnv-bond0\", \"mode\": \"active-backup\", \"mtu\": 1500, \"active_slave\": \"\", \"members\": [] }\n}\n```\n\n**Enslave a member** (member `state` is `active`, `standby`, or `failed`):\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bonds/bond-9f2a1c44d0e7b3a8/members\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"interface\":\"cnv-nic-0\"}'\n```\n\n```json\n{ \"status\": \"enslaved\", \"id\": \"bond-9f2a1c44d0e7b3a8\", \"interface\": \"cnv-nic-0\" }\n```\n\n**Get a bond:**\n\n```json\n{\n  \"bond\": {\n    \"id\": \"bond-9f2a1c44d0e7b3a8\", \"name\": \"cnv-bond0\", \"mode\": \"active-backup\", \"mtu\": 1500, \"active_slave\": \"cnv-nic-0\",\n    \"members\": [ { \"interface\": \"cnv-nic-0\", \"state\": \"active\" }, { \"interface\": \"cnv-nic-1\", \"state\": \"standby\" } ]\n  }\n}\n```\n\n**Set the MTU:**\n\n```bash\ncurl -k -X PUT \"$NODE/api/v1/bonds/bond-9f2a1c44d0e7b3a8/mtu\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"mtu\":9000}'\n```\n\n```json\n{ \"status\": \"mtu-set\", \"id\": \"bond-9f2a1c44d0e7b3a8\", \"mtu\": 9000 }\n```\n\nRelease a member → `DELETE /api/v1/bonds/{id}/members/cnv-nic-1` returns\n`{ \"status\": \"released\", \"id\": \"...\", \"interface\": \"cnv-nic-1\" }`.\nDelete a bond → `{ \"status\": \"deleted\", \"id\": \"...\" }`.\n\n---\n\n## Gateway HA & floating IPs\n\nFor two nodes paired active/standby, inspect HA state and trigger\nfailover/failback. Floating IPs are virtual addresses bound to a primary endpoint\nwith an optional standby for takeover.\n\n### Gateway HA\n\n*(Plan feature: Gateway HA.)*\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/gateway/status` | Current HA state |\n| POST | `/api/v1/gateway/failover` | Force this node to take over (become active) |\n| POST | `/api/v1/gateway/failback` | Trigger failback |\n\n### Source NAT (masquerade)\n\nHow a private tenant subnet reaches the internet through the gateway's public\naddress. Traffic leaving `source_cidr` via `interface` is rewritten to that\ninterface's address, and replies are translated back.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/gateway/snat` | List NAT rules |\n| POST | `/api/v1/gateway/snat` | Add a masquerade rule |\n| DELETE | `/api/v1/gateway/snat/{id}` | Remove a rule |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/gateway/snat\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"source_cidr\":\"10.20.0.0/24\",\"interface\":\"cnv-nic-1\"}'\n```\n\n```json\n{ \"status\": \"created\", \"rule\": { \"id\": 3, \"type\": \"snat\", \"source_ip\": \"10.20.0.0/24\", \"interface\": \"cnv-nic-1\" } }\n```\n\n`source` and `wan` are accepted as aliases for `source_cidr` and `interface`,\nmatching the names the CLI uses. These are the same rules `cenvero-str-ctl\ngateway snat` manages — both go through the same manager, so the two views\ncannot drift.\n\n```bash\ncurl -k \"$NODE/api/v1/gateway/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n`local_state`/`peer_state` are `active`, `standby`, or `solo`:\n\n```json\n{\n  \"local_state\": \"active\", \"peer_state\": \"standby\", \"vip\": \"203.0.113.1\",\n  \"last_heartbeat\": \"2026-06-30T12:00:01Z\", \"uptime\": \"72h3m12s\", \"active_conns\": 1024, \"peer_addr\": \"10.0.0.6\"\n}\n```\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/gateway/failover\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"status\": \"failover_triggered\" }\n```\n\n`POST /api/v1/gateway/failback` returns `{ \"status\": \"failback_triggered\" }`.\n\n### Floating IPs\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/float` | List floating IPs |\n| POST | `/api/v1/float` | Assign a floating IP |\n| GET | `/api/v1/float/{id}` | Get one floating IP |\n| DELETE | `/api/v1/float/{id}` | Release a floating IP |\n\n**Assign a floating IP.** `ip` is **required**; `primary` and `standby` name the\nendpoints. `state` is `active`, `standby`, or `failover`.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/float\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"ip\":\"203.0.113.7\",\"primary\":\"web-01\",\"standby\":\"web-02\"}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"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\" }\n}\n```\n\n**List:**\n\n```json\n{ \"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\" } ] }\n```\n\nRelease → `DELETE /api/v1/float/fip-7c1e` returns `{ \"status\": \"deleted\", \"id\": \"fip-7c1e\" }`.\n\n---\n\n## Traffic visibility\n\nRead-only views of live connections, usage accounting, and the bandwidth\nlimits/quotas you have configured.\n\n### Flows\n\nThe live connection table the node tracks, plus aggregate stats and a downloadable\nexport. All reads. With no traffic tracked these return empty results rather than\nerroring.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/flows` | List flows (`?state=new/active/closed`) |\n| GET | `/api/v1/flows/stats` | Aggregate statistics |\n| GET | `/api/v1/flows/export` | Download flows (`?format=csv/json`, `?state=`) |\n\n```bash\ncurl -k \"$NODE/api/v1/flows?state=active\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"flows\": [\n    {\n      \"id\": \"f-001\", \"src_ip\": \"10.0.0.11\", \"dst_ip\": \"203.0.113.5\", \"src_port\": 51000, \"dst_port\": 443, \"protocol\": \"tcp\",\n      \"bytes_in\": 12000, \"bytes_out\": 3400, \"packets_in\": 40, \"packets_out\": 28,\n      \"start_time\": \"2026-06-30T11:59:00Z\", \"last_seen\": \"2026-06-30T12:00:00Z\", \"state\": \"active\"\n    }\n  ],\n  \"count\": 1\n}\n```\n\n**Stats** (`per_protocol`/`per_state` are always present; `top_talkers` is ranked\nby packet volume):\n\n```json\n{\n  \"stats\": {\n    \"total_flows\": 128, \"active_flows\": 73, \"total_bytes\": 9120384, \"total_packets\": 21044,\n    \"per_protocol\": { \"tcp\": 110, \"udp\": 18 }, \"per_state\": { \"active\": 73, \"closed\": 55 },\n    \"top_talkers\": [ { \"ip\": \"10.0.0.11\", \"packets\": 8044, \"bytes\": 4011008, \"flows\": 12 } ]\n  }\n}\n```\n\n**Export** streams a file (`Content-Disposition: attachment`). `format` defaults to\n`json`; pass `format=csv` for a spreadsheet-friendly download:\n\n```bash\ncurl -k \"$NODE/api/v1/flows/export?format=csv&state=active\" \\\n  -H \"Authorization: Bearer $TOKEN\" -o flows.csv\n```\n\n### Accounting\n\nPer-period usage roll-ups derived from the node's traffic accounting. All three\naccept an optional window via `?start=` and `?end=` (RFC 3339); the default window\nis the current UTC calendar month to now.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/accounting/summary` | Totals for the period |\n| GET | `/api/v1/accounting/billing` | Per-source usage + cost (`?rate=` $/Mbps) |\n| GET | `/api/v1/accounting/95th` | 95th-percentile in/out Mbps |\n\n```bash\ncurl -k \"$NODE/api/v1/accounting/summary\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"summary\": {\n    \"total_bandwidth_gb\": 12.5, \"total_bytes\": 12500000000, \"total_packets\": 9000000,\n    \"total_vms\": 4, \"active_ips\": 4, \"period_start\": \"2026-06-01T00:00:00Z\", \"period_end\": \"2026-06-30T12:00:00Z\"\n  }\n}\n```\n\n**Billing.** `rate` is an optional $/Mbps figure for 95th-percentile billing; omit\nit (or pass `0`) to get per-source usage and P95 with zero cost.\n\n```bash\ncurl -k \"$NODE/api/v1/accounting/billing?rate=1.50\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"billing\": {\n    \"period\": \"2026-06-01/2026-06-30\", \"total_usd\": 42.00,\n    \"items\": [ { \"mac\": \"aa:bb:cc:dd:ee:ff\", \"bytes_total\": 8000000000, \"gb\": 8.0, \"p95_mbps\": 28.0, \"cost_usd\": 42.00 } ]\n  }\n}\n```\n\n**95th percentile:**\n\n```json\n{ \"percentile_95th\": { \"in_mbps\": 31.2, \"out_mbps\": 18.7, \"period_start\": \"2026-06-01T00:00:00Z\", \"period_end\": \"2026-06-30T00:00:00Z\" } }\n```\n\n### Bandwidth limits, pools & quotas\n\nPer-MAC rate limits, shared pools, and monthly usage quotas. *(Plan feature:\nBandwidth shaping.)*\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/bandwidth` | List limits + pools (or one limit via `?mac=`) |\n| POST | `/api/v1/bandwidth` | Create/update a per-MAC limit |\n| POST | `/api/v1/bandwidth/pools` | Create a shared pool |\n| POST | `/api/v1/bandwidth/pools/members` | Add a MAC to a pool |\n| GET | `/api/v1/bandwidth/quotas` | List quotas (or one via `?mac=`) |\n| POST | `/api/v1/bandwidth/quotas` | Create/update a quota |\n| GET | `/api/v1/bandwidth/quotas/{mac}` | Get one MAC's quota + status |\n| DELETE | `/api/v1/bandwidth/{id}` | Remove a limit — that MAC becomes unshaped |\n| DELETE | `/api/v1/bandwidth/pools/{id}` | Remove a shared pool |\n| DELETE | `/api/v1/bandwidth/pools/{id}/members/{mac}` | Remove one MAC from a pool |\n\nRemoving a limit stops shaping that MAC — it is not throttled at all afterwards,\nso it draws whatever the node's licensed ceiling allows. Removing a pool leaves\nits former members unshaped in the same way. Each DELETE returns **404** when the\nlimit, pool or member does not exist.\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/limit-1\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/pools/1\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/pools/1/members/aa:bb:cc:dd:ee:ff\" \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n**Limit fields** — `id` and `target_mac` are **required**. `rate_bps`,\n`burst_bytes`, and `guaranteed_bps` are in bits/bytes per second; `direction` is\n`up`, `down`, or `both` (default `both`). `pool_id` links the limit to a shared\npool.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bandwidth\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"vm-11-cap\",\"target_mac\":\"aa:bb:cc:dd:ee:ff\",\"rate_bps\":1000000000,\"burst_bytes\":125000,\"direction\":\"both\"}'\n```\n\n```json\n{\n  \"status\": \"updated\",\n  \"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\" }\n}\n```\n\n**List** (returns all limits and pools; pass `?mac=` to get a single limit, `404`\nif none). Note that pool objects use capitalized field names:\n\n```json\n{\n  \"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\" } ],\n  \"pools\": [ { \"ID\": \"tenant-a-pool\", \"Name\": \"Tenant A shared\", \"TotalBps\": 10000000000, \"AllocatedBps\": 2000000000, \"Members\": [\"aa:bb:cc:dd:ee:ff\"] } ]\n}\n```\n\n**Create a shared pool** — body: `id` and `total_bps` (**both required**), `name`\noptional. Several MACs can then draw from the pool's aggregate cap.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bandwidth/pools\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"tenant-a-pool\",\"name\":\"Tenant A shared\",\"total_bps\":10000000000}'\n```\n\n```json\n{ \"status\": \"created\", \"id\": \"tenant-a-pool\" }\n```\n\n**Add a MAC to a pool** — body: `pool_id` and `member_mac` (**both required**),\n`rate_bps` optional (the member's own ceiling within the pool).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bandwidth/pools/members\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"pool_id\":\"tenant-a-pool\",\"member_mac\":\"aa:bb:cc:dd:ee:ff\",\"rate_bps\":1000000000}'\n```\n\n```json\n{ \"status\": \"added\", \"pool_id\": \"tenant-a-pool\", \"member_mac\": \"aa:bb:cc:dd:ee:ff\" }\n```\n\n**Quotas.** A quota caps a MAC's monthly usage and resets at the start of each UTC\nmonth. `id`, `mac`, and `monthly_limit_bytes` (> 0) are **required**. `action` is\n`notify`, `throttle`, or `block`; `enforced: true` opts into a *hard* cap (default\nis advisory — alert and count only).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bandwidth/quotas\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"q-vm11\",\"mac\":\"aa:bb:cc:dd:ee:ff\",\"monthly_limit_bytes\":1000000000000,\"action\":\"throttle\",\"enforced\":true}'\n```\n\nThe response includes the derived `status` (`ok`, `warning`, `exceeded`),\n`remaining_bytes`, and whether the MAC is currently `throttled`:\n\n```json\n{\n  \"status\": \"updated\",\n  \"quota\": {\n    \"id\": \"q-vm11\", \"mac\": \"aa:bb:cc:dd:ee:ff\", \"monthly_limit_bytes\": 1000000000000, \"used_bytes\": 250000000000, \"reset_day\": 0,\n    \"action\": \"throttle\", \"enforced\": true, \"current_period\": \"2026-06\", \"status\": \"ok\", \"remaining_bytes\": 750000000000, \"throttled\": false\n  }\n}\n```\n\n**Get one MAC's quota:** `GET /api/v1/bandwidth/quotas/aa:bb:cc:dd:ee:ff` returns\n`{ \"quota\": { ... } }` with the same shape, or `404` `{ \"error\": \"no quota for MAC ...\" }`.\n`GET /api/v1/bandwidth/quotas` (no `?mac=`) returns `{ \"quotas\": [ ... ] }`.\n\n---\n\n## Cluster\n\nInspect and manage this node's clustering state. Cluster membership is normally\nprovisioned through the panel-delivered configuration; these endpoints let you\nread status and, when clustering is enabled, manage membership. Every member is\nthe same kind of node, so there is no node type to select.\n*(Plan feature: Cluster. See also the [Clustering guide](/docs/clustering/overview).)*\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/cluster` | Brief cluster summary |\n| GET | `/api/v1/cluster/status` | State, leader, and peers |\n| GET | `/api/v1/cluster/state` | Replicated-state counts |\n| POST | `/api/v1/cluster/join` | Join a peer (`node_id`, `address` required) |\n| POST | `/api/v1/cluster/leave` | Leave the cluster |\n\n```bash\ncurl -k \"$NODE/api/v1/cluster/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"state\": \"leader\", \"leader\": \"10.0.0.5\", \"is_leader\": true, \"peers\": [\"10.0.0.6\"] }\n```\n\n`state` is this node's role in the cluster — `leader` or `follower` — not a kind\nof node.\n\n`GET /api/v1/cluster/state` returns replicated-resource counts:\n\n```json\n{ \"blocklist_count\": 0, \"ip_allocations_count\": 12, \"vxlan_peers_count\": 2, \"floating_ips_count\": 1, \"tenants_count\": 3 }\n```\n\nJoin/leave return `{ \"status\": \"joined\", ... }` / `{ \"status\": \"left\" }`, or\n**503** when clustering is not enabled on the node.\n\n---\n\n## Multi-tenancy\n\nA **tenant** is one of your downstream customers on this node. Tenants have a\nlifecycle (`active` → `suspended` → `deleted`), a resource **quota**, and their own\n**scoped API keys**.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/tenant` | List tenants |\n| POST | `/api/v1/tenant` | Create a tenant (`name` required) |\n| GET | `/api/v1/tenant/{id}` | Get a tenant |\n| PUT | `/api/v1/tenant/{id}` | Update name and/or status |\n| DELETE | `/api/v1/tenant/{id}` | Delete a tenant (cascades its keys, quota, networks & IPAM) |\n| GET / PUT | `/api/v1/tenant/{id}/quota` | Read / set a tenant's quota |\n| GET / POST | `/api/v1/tenant/{id}/keys` | List / mint scoped API keys |\n| DELETE | `/api/v1/tenant/{id}/keys/{kid}` | Revoke a scoped key |\n\n### Create / list tenants\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/tenant\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"acme-corp\"}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"tenant\": { \"id\": \"t-9f3a21\", \"name\": \"acme-corp\", \"status\": \"active\", \"created_at\": \"2026-06-30T14:05:00Z\", \"updated_at\": \"2026-06-30T14:05:00Z\" }\n}\n```\n\nReturns **201**. `GET /api/v1/tenant` returns `{ \"tenants\": [ ... ] }`; `status` is\n`active`, `suspended`, or `deleted`.\n\n### Update a tenant\n\n`PUT /api/v1/tenant/{id}` — body: `name` and/or `status` (`active`, `suspended`,\n`deleted`).\n\n```bash\ncurl -k -X PUT \"$NODE/api/v1/tenant/t-9f3a21\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"acme-corp\",\"status\":\"suspended\"}'\n```\n\n```json\n{ \"status\": \"updated\", \"tenant\": { \"id\": \"t-9f3a21\", \"name\": \"acme-corp\", \"status\": \"suspended\", \"updated_at\": \"2026-06-30T14:06:00Z\" } }\n```\n\n**Delete a tenant.** `DELETE /api/v1/tenant/t-9f3a21` returns\n`{ \"status\": \"deleted\", \"id\": \"t-9f3a21\" }`. Deleting a tenant cascades cleanup of\neverything scoped to it: its scoped API keys (which stop authenticating\nimmediately), its quota, and its private networks and IPAM pools/allocations.\n\n### Tenant quota\n\nA quota caps how much bandwidth a tenant may use. **`0` means unlimited.**\n\n| Field | Type | Meaning |\n|---|---|---|\n| `max_bandwidth_bps` | int64 | Bandwidth cap in **bits/sec** (`0` = unlimited) |\n\n```bash\ncurl -k -X PUT \"$NODE/api/v1/tenant/t-9f3a21/quota\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"max_bandwidth_bps\":1000000000}'\n```\n\n```json\n{\n  \"status\": \"quota-set\",\n  \"quota\": { \"tenant_id\": \"t-9f3a21\", \"max_bandwidth_bps\": 1000000000 }\n}\n```\n\nThe cap is applied on the way out of the node and behaves as described in\n[Tenants & Bandwidth](/docs/tenants) — TCP settles at the rate, traffic over it is\ndropped rather than queued. Every plan is otherwise unlimited: there is no cap on\nhow many addresses, workloads or firewall rules a tenant may have.\n\n### Tenant-scoped API keys\n\nMint a key that authenticates as the tenant but is **confined to that tenant's\nresources** — ideal for handing to the tenant's own automation or your\nper-customer billing logic. **Create** body: `name` (label) and `ttl_hours`\n(`0`/omitted = no expiry).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/tenant/t-9f3a21/keys\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"acme-automation\",\"ttl_hours\":720}'\n```\n\n```json\n{\n  \"status\": \"created\",\n  \"key\": {\n    \"id\": \"k-7b1c44\", \"tenant_id\": \"t-9f3a21\", \"name\": \"acme-automation\",\n    \"created_at\": \"2026-06-30T14:07:00Z\", \"expires_at\": \"2026-07-30T14:07:00Z\",\n    \"secret\": \"tnk_3f8a...e91c\", \"note\": \"store this secret now; it is shown only once\"\n  }\n}\n```\n\n> The `secret` is returned **once** at creation and never again. Store it\n> immediately; list responses omit it.\n\nList → `GET /api/v1/tenant/t-9f3a21/keys` returns `{ \"keys\": [ ... ] }` (no\nsecrets). Revoke → `DELETE /api/v1/tenant/t-9f3a21/keys/k-7b1c44` returns\n`{ \"status\": \"revoked\", \"id\": \"k-7b1c44\" }`.\n\n---\n\n## Billing automation (operator hook)\n\nThese endpoints are your hook for gating downstream customers. Your billing system\ncalls them — typically with a **minted operator API key** (`apikeys mint`) — to\nsuspend a non-paying customer, resume them on payment, or apply/clear a bandwidth\nlimit. A tenant here is one of your customers. (A tenant-scoped key may also call\n`/billing/tenants/{id}/...` for its own tenant.) These are mutating calls and are\n**frozen** (`403`) while the node's license is inactive.\n\n| Method | Path | Purpose |\n|---|---|---|\n| POST | `/api/v1/billing/tenants/{id}/suspend` | Mark the tenant `suspended` |\n| POST | `/api/v1/billing/tenants/{id}/resume` | Mark the tenant `active` |\n| POST | `/api/v1/billing/tenants/{id}/limit` | Apply an aggregate rate cap |\n| POST | `/api/v1/billing/tenants/{id}/unlimit` | Remove the rate cap |\n| GET | `/api/v1/billing/tenants/{id}` | Read the customer's billing state |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/t-9f3a21/suspend\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"id\": \"t-9f3a21\", \"status\": \"suspended\" }\n```\n\n**Rate-limit** — body: `rate_mbps` (int64, **Mbps**). Negative is treated as `0`;\ncapped at `1000000` (1 Tbps). Stored as `rate_mbps × 1,000,000` bits/sec.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/t-9f3a21/limit\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"rate_mbps\":100}'\n```\n\n```json\n{ \"id\": \"t-9f3a21\", \"max_bandwidth_bps\": 100000000 }\n```\n\n`unlimit` returns `{ \"id\": \"t-9f3a21\", \"max_bandwidth_bps\": 0 }`; `GET` returns\n`{ \"id\": \"t-9f3a21\", \"name\": \"acme-corp\", \"status\": \"active\", \"max_bandwidth_bps\": 100000000 }`.\n\n> CLI equivalent: `cenvero-str-ctl billing suspend|resume|limit|unlimit|status <tenant-id>`.\n\n---\n\n## Container networking\n\nAttach a container's network namespace to one of your **managed private networks**.\nThe container claims a managed endpoint (IP + MAC) and is wired with a veth pair\ninto the network's bridge — the same attach path a VM uses, so containers share the\nnetwork's IP/MAC pool and firewall.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/containers` | List attached containers |\n| POST | `/api/v1/containers/attach` | Attach a container netns |\n| GET | `/api/v1/containers/{id}` | Get one container |\n| POST | `/api/v1/containers/{id}/detach` | Detach (frees the endpoint) |\n\n**Attach** body:\n\n| Field | Type | Required | Notes |\n|---|---|---|---|\n| `runtime` | string | yes | `lxc`, `docker`, or `podman` |\n| `network_id` | string | yes | A managed network you've already created |\n| `netns_pid` | int | yes | The container init PID whose network namespace is the target |\n| `container_id` | string | no | Your runtime's container id (for tracking) |\n| `ip` | string | no | Request a specific IP; omitted = next free in the network |\n| `firewall` | bool | no | Apply the managed per-container firewall rule |\n| `id` | string | no | Supply your own record id; omitted = generated |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/containers/attach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"runtime\":\"docker\",\"network_id\":\"net-7a\",\"netns_pid\":48213,\"container_id\":\"9c2f...\",\"firewall\":true}'\n```\n\n```json\n{\n  \"status\": \"attached\",\n  \"container\": {\n    \"id\": \"c-12ab\", \"runtime\": \"docker\", \"container_id\": \"9c2f...\", \"netns_pid\": 48213,\n    \"veth_host\": \"cnv-veth-12ab\", \"veth_container\": \"eth0\", \"bridge\": \"cnv-user-br0\",\n    \"mac\": \"02:42:0a:00:00:05\", \"ip\": \"10.0.0.5\", \"network_id\": \"net-7a\", \"endpoint_id\": \"ep-31\"\n  }\n}\n```\n\nReturns **201**. List → `GET /api/v1/containers` returns `{ \"containers\": [ ... ] }`.\nDetach → `POST /api/v1/containers/c-12ab/detach` tears down the veth, releases the\nendpoint, and returns `{ \"status\": \"detached\", \"id\": \"c-12ab\" }`.\n\n---\n\n## Operations\n\n### Alerting\n\nDefine **conditions** (a threshold on a metric), attach **actions** (notify on\nfire), and review/acknowledge fired alerts.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/alerts` | List fired alerts (`?state=firing/resolved/acknowledged`) |\n| GET | `/api/v1/alerts/conditions` | List conditions |\n| POST | `/api/v1/alerts/conditions` | Create a condition |\n| DELETE | `/api/v1/alerts/conditions/{id}` | Delete a condition |\n| POST | `/api/v1/alerts/conditions/{id}/actions` | Attach an action |\n| POST | `/api/v1/alerts/{id}/ack` | Acknowledge an alert |\n| GET | `/api/v1/alerts/history` | Full alert history |\n\n**Create a condition** — `metric_type` (`bandwidth`, `pps`, `connections`,\n`quota`, `threat`) and `operator` (`gt`, `lt`, `eq`) are required; `threshold`,\n`target`, `duration_secs`, `cooldown_secs` optional. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/alerts/conditions\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"metric_type\":\"bandwidth\",\"operator\":\"gt\",\"threshold\":900000000,\"target\":\"global\",\"duration_secs\":60,\"cooldown_secs\":300}'\n```\n\n```json\n{ \"condition\": { \"id\": \"cond-1\", \"metric_type\": \"bandwidth\", \"threshold\": 900000000, \"operator\": \"gt\", \"target\": \"global\", \"duration_secs\": 60, \"cooldown_secs\": 300 } }\n```\n\n**Attach an action** — `type` is `websocket`, `webhook`, or `log`; for `webhook`,\n`config` is the URL to POST to when the alert fires. Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/alerts/conditions/cond-1/actions\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"webhook\",\"config\":\"https://hooks.example.net/stratum\"}'\n```\n\n```json\n{ \"condition_id\": \"cond-1\", \"type\": \"webhook\" }\n```\n\n**Acknowledge an alert** — `by` defaults to `operator`:\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/alerts/a-55/ack\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"by\":\"oncall@example.net\"}'\n```\n\n```json\n{ \"acknowledged\": \"a-55\", \"by\": \"oncall@example.net\" }\n```\n\n`GET /api/v1/alerts` and `/alerts/history` return `{ \"alerts\": [ ... ] }` /\n`{ \"history\": [ ... ] }`; delete a condition returns `{ \"removed\": \"cond-1\" }`.\n\n> `POST /api/v1/alerts` is **not** a fire endpoint — alerts fire from configured\n> conditions. It returns `400` pointing you to the condition/ack subpaths above.\n\n### Self-healing\n\nThe node runs periodic health checks and repairs what it can.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/heal` | Latest check results |\n| POST | `/api/v1/heal/check` | Run the full sweep now (returns fresh results) |\n\n```bash\ncurl -k \"$NODE/api/v1/heal\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{\n  \"checks\": [\n    { \"name\": \"bridge-mgmt\", \"healthy\": true, \"last_checked\": \"2026-06-30T14:11:00Z\" },\n    { \"name\": \"bridge-user\", \"healthy\": false, \"error\": \"link down\", \"repaired\": true, \"last_checked\": \"2026-06-30T14:11:00Z\" }\n  ]\n}\n```\n\nEach check reports `name`, `healthy`, and `last_checked`; plus `error`,\n`repaired`, and `repair_error` when relevant.\n\n### Backups\n\nCreate config/full backups, list and restore them, and manage schedules.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/backups` | List backups |\n| POST | `/api/v1/backups` | Create a backup (`type`: `config` (default) or `full`) |\n| POST | `/api/v1/backups/restore` | Restore a backup (`ref`: id or path) |\n| GET | `/api/v1/backups/schedules` | List schedules |\n| POST | `/api/v1/backups/schedules` | Create a schedule |\n| DELETE | `/api/v1/backups/schedules/{id}` | Remove a schedule |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/backups\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"type\":\"config\"}'\n```\n\n```json\n{ \"backup\": { \"id\": \"bk-02\", \"type\": \"config\", \"size\": 20480, \"created_at\": \"2026-06-30T14:12:00Z\", \"retention\": 7 } }\n```\n\nReturns **201**. **Restore** — a `config` restore applies in place\n(`{ \"restored\": \"bk-01\", \"staged\": false }`); a `full` restore unpacks to a staging\narea and reports `staged: true` with a note describing the manual completion step\n(stop the agent, restore the staged snapshot, restart).\n\n**Create a schedule** — `expression` (e.g. `daily@03:00`, required), `type`\n(`config` (default) or `full`), `retention` (default `7`).\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/backups/schedules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"expression\":\"daily@03:00\",\"type\":\"config\",\"retention\":14}'\n```\n\n```json\n{ \"schedule\": { \"id\": \"sch-01\", \"expression\": \"daily@03:00\", \"type\": \"config\", \"retention_count\": 14, \"next_run\": \"2026-07-01T03:00:00Z\" } }\n```\n\nDelete → `{ \"removed\": \"sch-01\" }`.\n\n### Node dashboard\n\n`GET /api/v1/dashboard` — one aggregated snapshot of the node for a status\nscreen: counts and live figures pulled from the subsystems that are running,\nin a single request instead of polling a dozen endpoints.\n\n```bash\ncurl -k \"$NODE/api/v1/dashboard\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Not implemented\n\nThese paths exist on the router and return **501 Not Implemented** with an\nexplanation. They are listed here so you do not spend time discovering by\nexperiment that they return nothing useful.\n\n| Method | Path | Use instead |\n|---|---|---|\n| GET | `/api/v1/audit` | The audit record lives in the management panel |\n| GET | `/api/v1/topology` | Build it from `/networks`, `/bridges`, `/nics`, `/cluster/status` |\n| POST | `/api/v1/config/batch` | Apply settings individually |\n| POST | `/api/v1/cluster/profile` | Nothing to select — every node runs the same services |\n| PUT | `/api/v1/dns/records/{id}` | Delete the record and create it again |\n| PUT | `/api/v1/lb/{id}` | Delete the VIP and create it again |\n\nEach answers 501 with a message naming the alternative. They previously returned\na success-shaped body — an empty audit log, an empty topology, `\"applied\"` with\nzero changes — which is indistinguishable from a real, empty answer. A 501 you\ncan branch on is more useful than a success you cannot trust.\n\n### Event log (Server-Sent Events)\n\nStream system events as they happen over a long-lived HTTP connection. For a\npush-style socket see the **Real-time WebSocket** section below.\n\n`GET /api/v1/events?categories=<CSV>` — `categories` is an optional\ncomma-separated filter (uppercase). Omit it for **all** categories. Valid\ncategories:\n\n```\nTRAFFIC  SECURITY  BANDWIDTH  DHCP  DNS  NETWORK  ALERT  SYSTEM  LB  CLUSTER\n```\n\n```bash\ncurl -k -N \"$NODE/api/v1/events?categories=SECURITY,ALERT\" -H \"Authorization: Bearer $TOKEN\"\n```\n\nThe response is `text/event-stream`. Each event arrives as an SSE frame;\nheartbeats (`: keepalive`) hold the connection open:\n\n```\n: connected\n\nevent: SECURITY\ndata: {\"id\":\"b1c2...\",\"type\":\"firewall.block\",\"category\":\"SECURITY\",\"timestamp\":\"2026-06-30T14:13:00Z\",\"payload\":{\"src\":\"203.0.113.7\",\"rule\":\"deny-inbound\"}}\n\n: keepalive\n```\n\n---\n\n## Real-time WebSocket\n\nFor a bidirectional, push-style stream, connect to the node's WebSocket endpoint:\n\n```\nwss://<node-ip>:7072/ws\n```\n\nEnable it once with `cenvero-str-ctl service websocket on`. The WebSocket\nauthenticates with the **node API token only** (not operator-minted or\ntenant-scoped keys).\n\n### 1. Connect & authenticate\n\nProvide the token either as a query parameter or an `Authorization` header:\n\n```\nwss://<node-ip>:7072/ws?token=<api-token>\n```\n\nor `Authorization: Bearer <api-token>`.\n\n> Browser clients can't set request headers on a WebSocket, so use the `?token=`\n> form there. The `Origin` is restricted to the node's own host by default;\n> configure additional allowed origins on the node (`api_allowed_origins`) if you\n> connect from a different web origin.\n\n### 2. Subscribe\n\nAfter the socket opens, send a **subscribe** message naming the categories you want\n(uppercase, the same set as the SSE stream). Until you subscribe, **no events are\ndelivered**. Subscriptions are additive; there is no acknowledgement message —\nmatching events simply begin to flow.\n\n```json\n{ \"action\": \"subscribe\", \"categories\": [\"SECURITY\", \"ALERT\", \"NETWORK\"] }\n```\n\n### 3. Receive events\n\nEach event is delivered as a JSON **text** frame:\n\n```json\n{\n  \"id\": \"b1c2d3...\",\n  \"type\": \"alert.fired\",\n  \"category\": \"ALERT\",\n  \"timestamp\": \"2026-06-30T14:13:00Z\",\n  \"payload\": { \"condition_id\": \"cond-1\", \"value\": 950000000 }\n}\n```\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | string | Unique event id |\n| `type` | string | Event type, e.g. `alert.fired`, `firewall.block` |\n| `category` | string | One of the categories above |\n| `timestamp` | string | UTC RFC 3339 |\n| `payload` | object | Event-specific detail (omitted when empty) |\n\n### Example — `wscat`\n\n```bash\nwscat --no-check -c \"wss://node.example.net:7072/ws?token=$TOKEN\"\n# once connected:\n> {\"action\":\"subscribe\",\"categories\":[\"SECURITY\",\"ALERT\"]}\n< {\"id\":\"b1c2...\",\"type\":\"alert.fired\",\"category\":\"ALERT\",\"timestamp\":\"2026-06-30T14:13:00Z\",\"payload\":{...}}\n```\n\n(`--no-check` skips TLS verification for a privately-managed node certificate\nduring testing; in production, trust the node's certificate instead.)\n\n### Example — browser\n\n```js\nconst ws = new WebSocket(\"wss://node.example.net:7072/ws?token=YOUR_API_TOKEN\");\n\nws.onopen = () => {\n  ws.send(JSON.stringify({ action: \"subscribe\", categories: [\"ALERT\", \"SECURITY\"] }));\n};\n\nws.onmessage = (e) => {\n  const evt = JSON.parse(e.data);\n  console.log(evt.category, evt.type, evt.payload);\n};\n```\n\n> A client that can't keep up with the event rate is dropped to protect the node;\n> reconnect and re-subscribe to resume.\n\n---\n\n## Event webhooks\n\nRegister HTTP endpoints that the node **POSTs a signed event to** when something\nhappens — an out-of-band, push-style alternative to holding open the SSE\n`/api/v1/events` stream or a WebSocket. A webhook is a URL, an optional\nevent-category filter, and a per-webhook secret. Subscriptions are stored on the\nnode and **survive a restart**, and every delivery carries an **HMAC-SHA256**\nsignature so your receiver can verify it. Managing webhooks uses the standard\n`api_token` bearer, like the rest of this API.\n\nDelivery is **fail-safe**: events are handed to a bounded, out-of-band worker\npool, so a slow, hanging, or broken receiver can never block the node's event\nprocessing or the other webhooks. Each delivery is attempted with a per-attempt\ntimeout and a few retries with exponential backoff; if the delivery queue is ever\nfull, the event is dropped (and counted) rather than blocking.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/webhooks` | List subscriptions (never includes secrets) |\n| POST | `/api/v1/webhooks` | Register a subscription (returns the secret **once**) |\n| GET | `/api/v1/webhooks/{id}` | Get one subscription |\n| DELETE | `/api/v1/webhooks/{id}` | Delete a subscription |\n| POST | `/api/v1/webhooks/{id}/test` | Send a one-shot test delivery |\n\n**Subscription fields**\n\n| Field | Type | Notes |\n|---|---|---|\n| `id` | string | Server-assigned subscription id |\n| `url` | string | The `http`/`https` endpoint events are POSTed to |\n| `categories` | array | Event-category filter (uppercase); **empty means every category** |\n| `created_at` | string | UTC timestamp |\n| `delivered` | integer | Successful deliveries so far |\n| `failed` | integer | Deliveries that failed after all retries |\n| `dropped` | integer | Events dropped because the delivery queue was full |\n| `last_status` | integer | HTTP status of the most recent attempt (omitted until the first attempt) |\n| `last_error` | string | Most recent error, if any (omitted when the last attempt succeeded) |\n\nThe category filter uses the same uppercase categories as the event stream; an\nunknown category is rejected. Omit the filter (or send an empty list) to receive\nevery category:\n\n```\nTRAFFIC  SECURITY  BANDWIDTH  DHCP  DNS  NETWORK  ALERT  SYSTEM  LB  CLUSTER\n```\n\n### Register a webhook\n\n`POST /api/v1/webhooks` — body: `url` (**required**, `http` or `https`);\n`categories` (optional filter); `secret` (optional). Omit `secret` and the node\ngenerates a strong one (prefixed `whsec_`). Returns **201**.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/webhooks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"url\":\"https://hooks.example.com/stratum\",\"categories\":[\"SECURITY\",\"ALERT\"]}'\n```\n\n```json\n{\n  \"webhook\": {\n    \"id\": \"9f1c2d3e4a5b6c7d\",\n    \"url\": \"https://hooks.example.com/stratum\",\n    \"categories\": [\"ALERT\", \"SECURITY\"],\n    \"created_at\": \"2026-06-30T14:20:00Z\",\n    \"delivered\": 0,\n    \"failed\": 0,\n    \"dropped\": 0,\n    \"secret\": \"whsec_EXAMPLE_shown_once_store_it_now\"\n  }\n}\n```\n\n> The `secret` is returned **only here**, at registration — it is the HMAC key\n> your receiver needs to verify deliveries. Store it now; list and `GET` responses\n> never include it. Lost it? Delete the webhook and register a new one.\n\nList → `GET /api/v1/webhooks` returns `{ \"webhooks\": [ ... ] }` (secret-free). Get\none → `GET /api/v1/webhooks/9f1c2d3e4a5b6c7d` returns `{ \"webhook\": { ... } }`.\nDelete → `DELETE /api/v1/webhooks/9f1c2d3e4a5b6c7d` returns\n`{ \"deleted\": \"9f1c2d3e4a5b6c7d\" }`.\n\n### Delivery format\n\nEach delivery is an HTTP `POST` with `Content-Type: application/json`. The body is\nthe event itself — the **same JSON shape** delivered over the SSE and WebSocket\nstreams (`id`, `type`, `category`, `timestamp`, `payload`). These headers\naccompany every delivery:\n\n| Header | Value |\n|---|---|\n| `X-Stratum-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the **raw request body**, keyed by your webhook secret |\n| `X-Stratum-Event` | The event category (e.g. `SECURITY`) |\n| `X-Stratum-Delivery` | The event id (matches `id` in the body) |\n| `X-Stratum-Timestamp` | Send time, Unix seconds (UTC) |\n| `User-Agent` | `cenvero-stratum-webhook/1` |\n\nExample delivered body:\n\n```json\n{\n  \"id\": \"b1c2d3e4f5\",\n  \"type\": \"firewall.block\",\n  \"category\": \"SECURITY\",\n  \"timestamp\": \"2026-06-30T14:20:05Z\",\n  \"payload\": { \"src\": \"203.0.113.7\", \"rule\": \"deny-inbound\" }\n}\n```\n\n**Verify a delivery** by recomputing the signature over the exact bytes you\nreceived and comparing it (in constant time) to the `X-Stratum-Signature` header:\n\n```bash\n# body = the raw request body; SECRET = your webhook secret\nprintf '%s' \"$body\" | openssl dgst -sha256 -hmac \"$SECRET\"\n# prepend \"sha256=\" to the hex digest, then compare to X-Stratum-Signature\n```\n\n### Test a webhook\n\n`POST /api/v1/webhooks/{id}/test` sends one synchronous test delivery — a\n`webhook_test` event in the `SYSTEM` category — so you can confirm the receiver is\nreachable and verifies the signature.\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/webhooks/9f1c2d3e4a5b6c7d/test\" \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n```json\n{ \"tested\": \"9f1c2d3e4a5b6c7d\", \"status\": \"delivered\" }\n```\n\nIf the receiver is unreachable or returns a non-2xx status, the call reports\n**502** with `{ \"error\": \"...\", \"webhook_id\": \"...\" }` — the node is fine, the\ndownstream endpoint is not. An unknown id returns **404**.\n\n---\n\n## Bulk create\n\nTwo convenience endpoints create **many items in one request**, each item using\nthe same shape as the corresponding single-create call. Every item is applied\nindependently and the response reports **per-item** success or failure, so one bad\nitem never aborts the rest.\n\n| Method | Path | Per-item shape |\n|---|---|---|\n| POST | `/api/v1/rules/batch` | A firewall rule (as in `POST /api/v1/rules`) |\n| POST | `/api/v1/dns/records/batch` | A DNS record (as in `POST /api/v1/dns/records`) |\n\nSend the items as a JSON array under `rules` (firewall) or `records` (DNS). A\nbatch may hold up to **1000** items; an empty array, or one over the cap, is\nrejected with **400**. Firewall `/rules/batch` is available on any active plan;\n`/dns/records/batch` requires the DNS plan feature. Both are mutating calls, so\nthey are frozen (**403**) while the node's license is inactive.\n\nThe response carries `created` and `failed` counts and a `results` array — one\nentry per submitted item, in order, each with its `index`, an `ok` flag, and\neither the created object (`rule` / `record`) or an `error` string. The HTTP\nstatus reflects the batch as a whole:\n\n| Outcome | Status |\n|---|---|\n| Every item created | `201 Created` |\n| Some created, some failed | `207 Multi-Status` |\n| No item created | `400 Bad Request` |\n\n**Create several firewall rules:**\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/rules/batch\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"rules\": [\n      { \"chain\": \"forward\", \"action\": \"drop\", \"protocol\": \"tcp\", \"dest_port\": 23 },\n      { \"chain\": \"forward\", \"action\": \"drop\", \"protocol\": \"tcp\", \"dest_port\": 2323 }\n    ]\n  }'\n```\n\n```json\n{\n  \"created\": 2,\n  \"failed\": 0,\n  \"results\": [\n    { \"index\": 0, \"ok\": true, \"rule\": { \"id\": 11, \"chain\": \"forward\", \"priority\": 0, \"protocol\": \"tcp\", \"dest_port\": 23, \"action\": \"drop\", \"stateful\": false } },\n    { \"index\": 1, \"ok\": true, \"rule\": { \"id\": 12, \"chain\": \"forward\", \"priority\": 0, \"protocol\": \"tcp\", \"dest_port\": 2323, \"action\": \"drop\", \"stateful\": false } }\n  ]\n}\n```\n\n**A partial batch** (one item is missing a required field) returns **207**, with\nthe valid items created and the bad one reported in place:\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/dns/records/batch\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\n    \"records\": [\n      { \"zone_id\": 1, \"name\": \"a\", \"type\": \"A\", \"value\": \"10.20.0.1\" },\n      { \"zone_id\": 1, \"name\": \"b\", \"type\": \"A\" }\n    ]\n  }'\n```\n\n```json\n{\n  \"created\": 1,\n  \"failed\": 1,\n  \"results\": [\n    { \"index\": 0, \"ok\": true, \"record\": { \"id\": 20, \"zone_id\": 1, \"name\": \"a\", \"type\": \"A\", \"value\": \"10.20.0.1\", \"ttl\": 300 } },\n    { \"index\": 1, \"error\": \"zone_id, name, type and value are required\" }\n  ]\n}\n```\n\n---\n\n## Host addressing\n\nThe step between *a network reserved its gateway address* and *something on the\nhost answers on it*. Creating a network does not configure that address on an\ninterface; this is how you do it.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/nics/{iface}/addresses` | Addresses currently on an interface |\n| POST | `/api/v1/nics/{iface}/addresses` | Put an address on it |\n| DELETE | `/api/v1/nics/{iface}/addresses/{cidr}` | Remove one |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/nics/cnv-user-br0/addresses\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"address\":\"10.20.0.1/24\"}'\n```\n\n```json\n{ \"status\": \"configured\", \"interface\": \"cnv-user-br0\", \"address\": \"10.20.0.1/24\",\n  \"addresses\": [\"10.20.0.1/24\", \"fe80::.../64\"] }\n```\n\nThe prefix is **required** — a bare address is refused rather than guessed. Assuming\n`/32` where you meant `/24` produces an interface that looks correct and cannot\nreach its own subnet.\n\nAdding is **idempotent**, so you can reconcile toward a desired state instead of\ntracking what you have already done.\n\n**The management interface is refused.** It carries the address this node is\nreached on, and a call that strips it is not recoverable without console access.\nReads are allowed on every interface — seeing the node's own addressing is what a\npanel needs to render a network page, and reading cannot strand anything.\n\nTo delete, URL-escape the prefix:\n\n```bash\ncurl -k -X DELETE \"$NODE/api/v1/nics/cnv-user-br0/addresses/10.20.0.1%2F24\" \\\n  -H \"Authorization: Bearer $TOKEN\"\n```\n\n## DHCP scopes\n\nA network's address pool exists from the moment you create it. The DHCP server\nstill will not answer for that subnet until a **scope** binds serving to the\npool. Without one the server has nothing to offer and, per RFC 2131, stays\nsilent — the client retries with no reply, which looks exactly like a broken\nconnection.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/dhcp/scopes` | List scopes |\n| POST | `/api/v1/dhcp/scopes` | Bind serving for a subnet to a pool |\n| DELETE | `/api/v1/dhcp/scopes/{subnet}` | Remove a scope |\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/dhcp/scopes\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"subnet\":\"10.20.0.0/24\",\"pool_id\":16,\"gateway\":\"10.20.0.1\",\n       \"subnet_mask\":\"255.255.255.0\",\"dns\":[\"1.1.1.1\"],\"lease_seconds\":3600}'\n```\n\n`subnet` and `pool_id` are required. Find the pool with\n`GET /api/v1/ipam/pools` — a network's pool carries the network's name. A scope\nwith no pool is refused, because it would reproduce exactly the silent failure\nthis exists to prevent.\n\nDeleting takes the subnet URL-escaped: `.../dhcp/scopes/10.20.0.0%2F24`.\n\n## Subsystem reads\n\nThese subsystems are configured from the CLI and can now be **read** over the\nAPI, so a panel can show what a node actually has without shelling in. Each\nreturns the same view its CLI command shows — they call the same manager.\n\n| Method | Path | Purpose |\n|---|---|---|\n| GET | `/api/v1/l7lb/status` | Layer-7 balancer state |\n| GET | `/api/v1/l7lb/pools` | Backend pools |\n| GET | `/api/v1/l7lb/frontends` | Frontends (host/path routing, TLS) |\n| GET | `/api/v1/vrf` | Virtual routing & forwarding devices |\n| GET | `/api/v1/geneve` | Geneve overlay tunnels |\n| GET | `/api/v1/nat64/status` | NAT64 configuration + live bindings |\n| GET | `/api/v1/plugins` | Installed plugins |\n| GET | `/api/v1/apikeys` | Operator API keys — metadata only |\n\n```bash\ncurl -k \"$NODE/api/v1/l7lb/status\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/vrf\"        -H \"Authorization: Bearer $TOKEN\"\n```\n\nA subsystem that is not running on this node answers **503** naming it, rather\nthan an empty list — \"not available here\" and \"none configured\" are different\nanswers and a panel should be able to tell them apart.\n\n`/apikeys` returns id, label, scope and timestamps. It never returns key\nmaterial: a minted key is shown once at creation and is not recoverable.\n\n**Creating and changing these is still CLI-only.** Their mutating commands take\npositional arguments rather than a JSON body, and exposing them as a passthrough\nwould have locked in an awkward shape; they will get designed request bodies\nrather than a quick wrapper.\n\n## Metrics scrape\n\n`GET /api/v1/metrics` returns the node's metrics in **Prometheus text exposition\nformat**, behind the same `api_token` bearer as the rest of the API — so a\nPrometheus scrape config authenticates like any other client. It is **read-only**\n(a `GET`, never blocked by a license freeze) and simply renders already-collected\ncounters and gauges; it never touches the data plane.\n\n> This is **distinct** from the optional standalone metrics listener (the loopback\n> `metrics_bind_addr`, default `127.0.0.1:9090`, described in\n> [Configuration](/docs/configuration)). Both expose the **same** `stratum_*`\n> metric set; this endpoint surfaces it on the authenticated REST API so you can\n> scrape it over the management port without opening a second listener.\n\n```bash\ncurl -k \"$NODE/api/v1/metrics\" -H \"Authorization: Bearer $TOKEN\"\n```\n\nUnlike the JSON endpoints, the response is\n`Content-Type: text/plain; version=0.0.4; charset=utf-8` — the standard Prometheus\nexposition body, one `# HELP`/`# TYPE` header per metric family followed by its\nsamples:\n\n```\n# HELP stratum_uptime_seconds Agent uptime in seconds\n# TYPE stratum_uptime_seconds gauge\nstratum_uptime_seconds 86400\n# HELP stratum_active_tasks Active internal tasks\n# TYPE stratum_active_tasks gauge\nstratum_active_tasks 42\n# HELP stratum_nic_rx_bytes Total received bytes per NIC\n# TYPE stratum_nic_rx_bytes counter\nstratum_nic_rx_bytes{interface=\"cnv-nic-0\"} 481920512\n# HELP stratum_firewall_drops_total Total firewall drops\n# TYPE stratum_firewall_drops_total counter\nstratum_firewall_drops_total{chain=\"forward\",reason=\"acl\"} 1204\n```\n\nThe exposition covers the families the node already collects: runtime gauges\n(`stratum_uptime_seconds`, `stratum_active_tasks`), per-VM byte/packet counters,\nper-bridge connection gauges, per-NIC rx/tx byte counters, per-reason\nfirewall-drop counters, load-balancer active-connection gauges, BGP session-state\nand cluster-state gauges, and an alerts-fired counter.\n\nA minimal Prometheus scrape configuration:\n\n```yaml\nscrape_configs:\n  - job_name: cenvero-stratum\n    scheme: https\n    metrics_path: /api/v1/metrics\n    authorization:\n      credentials: <your-api-token>\n    tls_config:\n      insecure_skip_verify: true   # node cert is privately managed; pin it in production\n    static_configs:\n      - targets: [\"node.example.com:7070\"]\n```\n\n---\n\n## gRPC endpoint\n\nThe node also exposes a gRPC endpoint:\n\n```\n<node-ip>:7071\n```\n\nEnable it with `cenvero-str-ctl service grpc on`. It uses the **same TLS\ncertificate** as REST/WebSocket and requires the **node API token** in the\n`authorization` metadata on every call (`authorization: Bearer <api-token>`). The\nnode may optionally require a **client certificate (mTLS)** when configured.\n\nThe gRPC port serves the **standard gRPC Health Checking protocol**\n(`grpc.health.v1.Health`), so load balancers and orchestration systems can probe\nnode liveness over gRPC. The full management surface is the **REST API** documented\nabove — gRPC is for health/liveness probing, **not** a REST mirror.\n\n```bash\ngrpcurl -H \"authorization: Bearer $TOKEN\" \\\n  node.example.net:7071 grpc.health.v1.Health/Check\n```\n\n```json\n{ \"status\": \"SERVING\" }\n```\n\n> Server reflection is intentionally disabled; supply the standard\n> `grpc.health.v1` descriptor (or use a purpose-built health-probe client). Use the\n> empty service name for overall node health, or `cenvero.stratum` for the agent's\n> service.\n\n---\n\n## Every endpoint, ready to paste\n\nOne line per endpoint, generated from the routes the agent actually serves — so\nit cannot drift from the code. Set these once and the rest copies straight out:\n\n```bash\nNODE=https://your-node:7070\nTOKEN=your-api-token\n```\n\nPlaceholders in braces are yours to fill. Request bodies for the mutating calls\nare in the sections above; this is the shape and the address, not a replacement\nfor them.\n\n### Accounting & billing data\n\n```bash\ncurl -k \"$NODE/api/v1/accounting/95th\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/accounting/billing\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/accounting/summary\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Alerting\n\n```bash\ncurl -k \"$NODE/api/v1/alerts\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/alerts\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/alerts/conditions\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/alerts/conditions\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/alerts/conditions/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/alerts/conditions/{id}/actions\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/alerts/history\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/alerts/{id}/ack\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Operator API keys\n\n```bash\ncurl -k \"$NODE/api/v1/apikeys\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Audit\n\n```bash\ncurl -k \"$NODE/api/v1/audit\" -H \"Authorization: Bearer $TOKEN\"   # 501 — see Not implemented\n```\n\n### Backups\n\n```bash\ncurl -k \"$NODE/api/v1/backups\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/backups\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/backups/restore\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/backups/schedules\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/backups/schedules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/backups/schedules/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Bandwidth & quotas\n\n```bash\ncurl -k \"$NODE/api/v1/bandwidth\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bandwidth\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/bandwidth/pools\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/bandwidth/pools/members\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/pools/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/pools/{id}/members/{mac}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/bandwidth/quotas\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bandwidth/quotas\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/bandwidth/quotas/{mac}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/bandwidth/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### BGP\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/bgp/announce\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/bgp/neighbors\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bgp/neighbors\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bgp/neighbors/{addr}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/bgp/policy\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bgp/policy/{dir}\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/bgp/prefix-lists\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bgp/prefix-lists\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/bgp/route-maps\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bgp/route-maps\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/bgp/routes\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/bgp/status\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bgp/withdraw\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Billing automation\n\n```bash\ncurl -k \"$NODE/api/v1/billing/tenants/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/{id}/limit\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/{id}/resume\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/{id}/suspend\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/billing/tenants/{id}/unlimit\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### NIC bonds\n\n```bash\ncurl -k \"$NODE/api/v1/bonds\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bonds\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bonds/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/bonds/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bonds/{id}/members\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bonds/{id}/members/{iface}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/bonds/{id}/mtu\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Bridges\n\n```bash\ncurl -k \"$NODE/api/v1/bridges\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bridges\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bridges/{name}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/bridges/{name}/ports\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/bridges/{name}/ports/{iface}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Cluster\n\n```bash\ncurl -k \"$NODE/api/v1/cluster\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/cluster/join\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/cluster/leave\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/cluster/profile\" \\   # 501 — see Not implemented\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/cluster/state\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/cluster/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Configuration\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/config/batch\" \\   # 501 — see Not implemented\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Container networking\n\n```bash\ncurl -k \"$NODE/api/v1/containers\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/containers/attach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/containers/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/containers/{id}/detach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Dashboard\n\n```bash\ncurl -k \"$NODE/api/v1/dashboard\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### DHCP\n\n```bash\ncurl -k \"$NODE/api/v1/dhcp/leases\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/dhcp/scopes\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/dhcp/scopes\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/dhcp/scopes/{subnet}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### DNS\n\n```bash\ncurl -k \"$NODE/api/v1/dns/dnssec/{zone}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/dns/records\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/dns/records\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/dns/records/batch\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/dns/records/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/dns/records/{id}\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/dns/zones\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/dns/zones\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/dns/zones/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Endpoint index\n\n```bash\ncurl -k \"$NODE/api/v1/docs\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Event stream\n\n```bash\ncurl -k \"$NODE/api/v1/events\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Floating IPs\n\n```bash\ncurl -k \"$NODE/api/v1/float\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/float\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/float/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/float/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Flow tracking\n\n```bash\ncurl -k \"$NODE/api/v1/flows\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/flows/export\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/flows/stats\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Port forwarding\n\n```bash\ncurl -k \"$NODE/api/v1/forward\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/forward\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/forward/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/forward/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Gateway & NAT\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/gateway/failback\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/gateway/failover\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/gateway/snat\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/gateway/snat\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/gateway/snat/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/gateway/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Geneve tunnels\n\n```bash\ncurl -k \"$NODE/api/v1/geneve\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Self-healing\n\n```bash\ncurl -k \"$NODE/api/v1/heal\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/heal/check\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Health\n\n```bash\ncurl -k \"$NODE/api/v1/health\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Address pools\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/ipam/allocate\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/ipam/allocations\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/ipam/pools\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/ipam/pools\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/ipam/pools/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/ipam/release\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Layer-7 load balancer\n\n```bash\ncurl -k \"$NODE/api/v1/l7lb/frontends\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/l7lb/pools\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/l7lb/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Layer-4 load balancer\n\n```bash\ncurl -k \"$NODE/api/v1/lb\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/lb\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/lb/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/lb/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/lb/{id}\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/lb/{id}/backends\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/lb/{id}/backends/{bid}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/lb/{id}/backends/{bid}/health\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Licence\n\n```bash\ncurl -k \"$NODE/api/v1/license\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### MAC bindings\n\n```bash\ncurl -k \"$NODE/api/v1/macbind\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/macbind\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/macbind/{mac}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Metrics\n\n```bash\ncurl -k \"$NODE/api/v1/metrics\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### NAT64\n\n```bash\ncurl -k \"$NODE/api/v1/nat64/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Networks & endpoints\n\n```bash\ncurl -k \"$NODE/api/v1/networks\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/networks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/networks/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/networks/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/networks/{id}/attach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/networks/{id}/endpoints\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/networks/{id}/endpoints/{eid}/detach\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X PUT \"$NODE/api/v1/networks/{id}/endpoints/{eid}/mac\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Network interfaces & addressing\n\n```bash\ncurl -k \"$NODE/api/v1/nics\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/nics/{iface}/addresses\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/nics/{iface}/addresses\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/nics/{iface}/addresses/{cidr}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Plugins\n\n```bash\ncurl -k \"$NODE/api/v1/plugins\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Routing\n\n```bash\ncurl -k \"$NODE/api/v1/routes\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/routes\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/routes/rules\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/routes/rules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/routes/rules/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/routes/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Firewall rules\n\n```bash\ncurl -k \"$NODE/api/v1/rules\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/rules\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X POST \"$NODE/api/v1/rules/batch\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/rules/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/rules/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/rules/{id}\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### Status\n\n```bash\ncurl -k \"$NODE/api/v1/status\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Tenants\n\n```bash\ncurl -k \"$NODE/api/v1/tenant\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/tenant\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/tenant/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/tenant/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/tenant/{id}\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k \"$NODE/api/v1/tenant/{id}/keys\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/tenant/{id}/keys\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/tenant/{id}/keys/{kid}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/tenant/{id}/quota\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X PUT \"$NODE/api/v1/tenant/{id}/quota\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n\n### TLS\n\n```bash\ncurl -k \"$NODE/api/v1/tls/pubkey\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Topology\n\n```bash\ncurl -k \"$NODE/api/v1/topology\" -H \"Authorization: Bearer $TOKEN\"   # 501 — see Not implemented\n```\n\n### VLAN lockdown\n\n```bash\ncurl -k \"$NODE/api/v1/vlan\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/vlan\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/vlan/{id}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### VRF devices\n\n```bash\ncurl -k \"$NODE/api/v1/vrf\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### VXLAN overlays\n\n```bash\ncurl -k \"$NODE/api/v1/vxlan/fdb\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/vxlan/networks\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/vxlan/networks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/vxlan/networks/{vni}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/vxlan/networks/{vni}/peers\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/vxlan/networks/{vni}/peers\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/vxlan/networks/{vni}/peers/{host}\" -H \"Authorization: Bearer $TOKEN\"\n```\n\n### Webhooks\n\n```bash\ncurl -k \"$NODE/api/v1/webhooks\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/webhooks\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\ncurl -k -X DELETE \"$NODE/api/v1/webhooks/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k \"$NODE/api/v1/webhooks/{id}\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X POST \"$NODE/api/v1/webhooks/{id}/test\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" -d '{}'\n```\n## Feedback\n\nFound a gap, an inaccuracy, or something you wish this API did? We want to hear it.\nOpen a request from your account dashboard, or reach the team through the support\nchannel listed on your panel. Please include the agent version\n(`cenvero-str-ctl version`) and the endpoint in question.\n\n## See also\n\n- **[CLI Reference](/docs/cli)** — the same managers from the local `cenvero-str-ctl` command line.\n- **[Configuration](/docs/configuration)** — node config, ports, and the API settings.\n- **[Clustering](/docs/clustering/overview)** — joining nodes into a cluster.\n- **[Licensing](/docs/licensing)** — activation, renewal, and the enforcement states.\n"
        },
        {
            "slug": "tenants",
            "title": "Tenants & Bandwidth",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/tenants",
            "headings": [
                {
                    "level": 1,
                    "text": "Tenants & Bandwidth"
                },
                {
                    "level": 2,
                    "text": "What a tenant actually separates"
                },
                {
                    "level": 2,
                    "text": "Scoped API keys"
                },
                {
                    "level": 2,
                    "text": "The tenant quota"
                },
                {
                    "level": 2,
                    "text": "Your plan's speed ceiling"
                },
                {
                    "level": 2,
                    "text": "Bandwidth limits"
                },
                {
                    "level": 3,
                    "text": "Pools"
                },
                {
                    "level": 3,
                    "text": "What a limit does to traffic"
                },
                {
                    "level": 2,
                    "text": "Monthly usage quotas"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1323,
            "markdown": "# Tenants & Bandwidth\n\nA **tenant** is a named owner you attach resources to — networks, addresses, API keys and a bandwidth allowance. If you run workloads for several customers, teams or environments on the same hardware, tenants are how you keep them apart and how you cap what each one can consume.\n\nIf everything on your nodes belongs to you, you can ignore tenants entirely. Nothing requires one.\n\n## What a tenant actually separates\n\nCreating a tenant does not create an enclosure that traffic cannot leave. It creates an **ownership label** plus two things that are genuinely enforced:\n\n| What you get | What it does |\n|---|---|\n| **Ownership of resources** | Networks and addresses are recorded against the tenant, so you can see and delete everything one customer owns as a unit. |\n| **A scoped API key** | A key that can act only for that tenant — it cannot read or change another tenant's resources. |\n| **A bandwidth cap** | A real rate limit, applied in the traffic path. See below. |\n\nIsolation *between* tenants comes from the network design — separate networks and firewall policy — not from the tenant record itself. Two tenants whose networks you have connected can reach each other; the tenant label does not stop that. Put each tenant on its own network and set your firewall default action to deny, and the separation is real. See [Zero-Trust Firewall](/docs/networking/firewall).\n\n```bash\nsudo cenvero-str-ctl tenant create --name acme\ncenvero-str-ctl tenant list\n```\n\nDeleting a tenant is a **cascade** — it removes that tenant's API keys, quota, networks and address allocations together:\n\n```bash\nsudo cenvero-str-ctl tenant delete <tenant-id>\n```\n\n## Scoped API keys\n\nA tenant key lets you hand out API access that is confined to one tenant, instead of sharing an operator key that can change everything.\n\n```bash\n# Mint a key (optionally labelled, optionally time-limited)\nsudo cenvero-str-ctl tenant key-generate <tenant-id> --name \"acme-portal\" --ttl 720h\n\ncenvero-str-ctl tenant key-list <tenant-id>\nsudo cenvero-str-ctl tenant key-revoke <key-id>\n```\n\nThe key is shown once, when it is minted. Give it a TTL if it is going into a system you do not fully control — a key that expires on its own is one less thing to remember to revoke.\n\n## The tenant quota\n\nA tenant's quota is its **bandwidth cap**, and that is the only resource capped. `0` means unlimited.\n\n```bash\nsudo cenvero-str-ctl tenant quota-set <tenant-id> --max-bandwidth-bps 1000000000\ncenvero-str-ctl tenant quota <tenant-id>\n```\n\nEverything else is deliberately **unlimited on every plan** — there is no cap on how many addresses, workloads, networks or firewall rules a tenant may have. If you need those bounded for commercial reasons, bound them in your own provisioning layer, where you can refuse the request and tell the customer why.\n\n## Your plan's speed ceiling\n\nAbove every limit you set yourself sits one you do not: **your licence carries the maximum speed the node may use**, and it is applied to the node's uplink.\n\n| Plan | Maximum speed per node |\n|---|---|\n| **Lab** (free, non-commercial) | 1 Gbps |\n| **Core** | 10 Gbps |\n| **Cloud** | 25 Gbps |\n| **Enterprise** | 100 Gbps |\n\n> These are the current plans. The [pricing section of the site](/#pricing) is the\n> authoritative list — your own ceiling is whatever your licence carries, which you\n> can read with `cenvero-str-ctl license status`.\n\nFour things worth knowing:\n\n- **It is per node, not per account.** Ten Core nodes are ten separate 10 Gbps ceilings, matching how the plans are priced.\n- **It is the node's total, shared across all its network cards.** A licensed node behaves as though its cards have exactly that much capacity between them: a 2 Gbps licence on a single 10 Gbps card passes 2 Gbps, and **two cards share the same 2 Gbps** — you can distribute traffic across them however you like, but the total is still 2 Gbps. Adding a card does not give you more headroom. (Your management interface is deliberately left alone — throttling the control plane would take the node out of its cluster.)\n- **Speeds are bits per second**, the way link speed and the plans are quoted — not bytes.\n- **It applies on top of anything you configure.** A tenant limit of 5 Gbps on a Core node gives that tenant 5 Gbps; setting a tenant to 50 Gbps does not lift the node past 10.\n- **Changing plan takes effect without a restart.** The node re-reads its licence periodically, so an upgrade raises the ceiling within about a minute of the new licence landing.\n\nGoing over the ceiling does not fail — it **throttles**. Traffic above the rate is held to it, exactly as described for bandwidth limits below, so a transfer slows rather than breaking.\n\nThe ceiling behaves like any other cap here — a credit allowance refilling at the licensed rate, with burst headroom so ordinary traffic is not penalised. See what that means for TCP and UDP below.\n\n## Bandwidth limits\n\nBandwidth is the one resource control that is real, and it is applied on the way **out** of a node — egress shaping. A limit is a rate in **bits per second**, and `0` always means unlimited.\n\nThere are two ways to apply one, and they answer different questions.\n\n**Per tenant** — \"this customer gets 1 Gbps in total\":\n\n```bash\nsudo cenvero-str-ctl tenant quota-set <tenant-id> --max-bandwidth-bps 1000000000\n```\n\n**Per target** — \"this particular workload gets 100 Mbps\", regardless of who owns it:\n\n```bash\ncenvero-str-ctl bandwidth list\nsudo cenvero-str-ctl bandwidth delete <id>\n```\n\nA per-target limit is submitted as a JSON object describing what to limit and at what rate — see the [API Reference](/docs/api) for the field names, and [CLI Reference](/docs/cli) for the command form.\n\n### Pools\n\nA **pool** is a shared allowance that several targets draw from together, rather than each holding its own independent cap. Use it when a group should be limited collectively — \"these five workloads share 500 Mbps between them\" — instead of five separate 100 Mbps limits that cannot lend capacity to one another.\n\n```bash\ncenvero-str-ctl bandwidth pool --help\n```\n\n### What a limit does to traffic\n\nThis is the part to understand before you set one, because the behaviour is not what \"shaping\" usually implies.\n\nA limit is a **credit allowance that refills at the rate you set**. Every packet spends credit equal to its size. While there is credit, traffic passes untouched. When the credit runs out, **traffic over the rate is dropped — not queued and delivered late.**\n\nThat distinction matters:\n\n- **TCP copes well.** Loss is exactly the signal TCP uses to slow down, so a TCP transfer finds the rate and settles there. This is the normal case and it works.\n- **UDP does not.** Nothing tells a UDP sender to slow down, so anything over the rate is simply lost. Video, voice, game traffic and metrics streams degrade rather than slow down. Size the limit for the peak these need, not the average.\n\nUnused credit accumulates up to a ceiling, so a burst after a quiet period passes at full speed and only sustained traffic is held to the rate. A short spike is not punished.\n\nTwo more consequences worth planning around:\n\n- **The cap is per node.** A tenant with workloads on three nodes has the limit applied on each of them — it is not a single allowance summed across the cluster.\n- **It applies on the way out.** Limiting what a workload can *receive* means limiting whoever is sending to it, which you only control if the sender is also yours.\n\n## Monthly usage quotas\n\nSeparately from rate limiting, a node can track **how much a workload transfers per calendar month** and act when it crosses a threshold. This is a volume cap, not a speed cap — the two are independent, and you can use either or both.\n\n```bash\ncenvero-str-ctl quota list\ncenvero-str-ctl quota get <mac>\n```\n\nCounters reset at the start of each month. Use rate limits to control how fast a tenant can go, and monthly quotas to control how much they can move in total.\n\n## See also\n\n- [Zero-Trust Firewall](/docs/networking/firewall) — the policy that actually separates tenants from each other.\n- [Networking Overview](/docs/networking/overview) — networks, endpoints, and how a workload joins one.\n- [Monitoring & Observability](/docs/monitoring) — reading real throughput per workload.\n- [API Reference](/docs/api) — tenant, quota and bandwidth endpoints.\n"
        },
        {
            "slug": "tls-and-license",
            "title": "TLS/SSL & License Operations",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/tls-and-license",
            "headings": [
                {
                    "level": 1,
                    "text": "TLS/SSL & License Operations"
                },
                {
                    "level": 2,
                    "text": "Part A — TLS/SSL certificates"
                },
                {
                    "level": 3,
                    "text": "The private key never leaves the node"
                },
                {
                    "level": 3,
                    "text": "How a node gets its CA-signed certificate"
                },
                {
                    "level": 3,
                    "text": "Trusting your account root"
                },
                {
                    "level": 3,
                    "text": "Inspecting the certificate"
                },
                {
                    "level": 3,
                    "text": "Renewal"
                },
                {
                    "level": 3,
                    "text": "Offline / air-gapped install"
                },
                {
                    "level": 3,
                    "text": "Starting over"
                },
                {
                    "level": 3,
                    "text": "TLS command reference"
                },
                {
                    "level": 2,
                    "text": "Part B — License activation & recovery"
                },
                {
                    "level": 3,
                    "text": "Activating a node"
                },
                {
                    "level": 3,
                    "text": "Checking license status"
                },
                {
                    "level": 3,
                    "text": "Renewing"
                },
                {
                    "level": 3,
                    "text": "Recovery / reinstall"
                },
                {
                    "level": 3,
                    "text": "License command reference"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 1793,
            "markdown": "# TLS/SSL & License Operations\n\nThis guide covers two day-to-day operational flows for a Cenvero Stratum node:\n\n- **Part A — TLS/SSL certificates:** how each node\n  gets a trusted certificate for its management API (REST, gRPC and WebSocket all\n  share one cert), how you approve it, and how to operate it online or air-gapped.\n- **Part B — License activation & recovery:**\n  how to activate a license, confirm the machine, renew, and bring a reinstalled\n  node back online.\n\nAll commands are run on the node with `cenvero-str-ctl` (the agent's CLI). All\napprovals happen in your account in the management panel.\n\n---\n\n## Part A — TLS/SSL certificates\n\nThe agent serves its management API over TLS and manages the certificate\nlifecycle for you. There are two ways a node can be certified:\n\n- **Account CA (recommended):** every node gets a leaf certificate signed by\n  **your account's certificate authority**. Trust your one account root on each\n  machine that talks to the fleet and it then trusts *every* node — no per-node\n  cert wrangling. This is the mode a registered node uses automatically.\n- **Self-signed (bootstrap / no account CA):** until a node has obtained its\n  account-CA certificate it serves a temporary, locally-generated self-signed\n  certificate so the API is never offline. A registered node only ever uses\n  self-signed as a short-lived placeholder while its CA certificate is pending.\n\n### The private key never leaves the node\n\nIn every flow, the node generates its **own private key locally** and only ever\nsends a **CSR** (certificate signing request) to the panel. Your private key is\nnever transmitted, never stored in the panel, and never leaves the host.\n\n### How a node gets its CA-signed certificate\n\n**1. Request.** The node generates a key + CSR on disk and submits the CSR to the\npanel. This happens **automatically when the node registers** (in account-CA\nmode), or you can trigger it now with:\n\n```bash\nsudo cenvero-str-ctl tls request\n```\n\nWhile the request is outstanding, the node keeps serving a **temporary\nself-signed certificate** so its API stays up.\n\n**2. Approve.** Open your account and go to **Account → TLS** (`/account/tls`).\nYour pending request is listed there. Before approving, verify it is really\nyour machine — the page shows the bound **Hardware ID**, which you can compare\nagainst the node's own:\n\n```bash\ncenvero-str-ctl hardware\n```\n\nClick **Approve** when it matches. (You can also **Reject**; the node can\nresubmit later.) The first certificate for any node always needs this manual\napproval.\n\n**3. Collect.** The node pulls its signed certificate and hot-swaps it in with no\nrestart. It does this automatically — a node awaiting approval re-checks the\npanel roughly **every 10 minutes** — or you can collect it immediately after\napproving:\n\n```bash\nsudo cenvero-str-ctl tls pull\n```\n\nOnce a healthy CA-signed cert is held, the fast re-check stops; the agent then\njust renews on the normal schedule (and re-arms the fast pull automatically as\nexpiry approaches).\n\n> The certificate is **bound to your machine's hardware identity** and to the\n> node's registered IP. The panel sets the certificate's identity (SAN) itself\n> from the node's registered IP — values in the CSR are not trusted to widen it.\n\n### Trusting your account root\n\nDownload your **account root certificate** from **Account → TLS** (or the node\ntrusts its own account root automatically once it installs a CA-signed cert).\nInstall that one root on any machine or client that connects to your fleet, and\nit will trust every node certificate your account signs — and only those (the\nroot is scoped to your account, not a public CA). The TLS page shows the\ncross-OS trust instructions.\n\n### Inspecting the certificate\n\n```bash\ncenvero-str-ctl tls info\n```\n\n`tls info` reports the **live** certificate from the running agent:\n\n- **mode** — `ca-signed` (your account-CA leaf, what you want) or `self-signed`\n  (a temporary local cert).\n- **ca_pending** — `true` while the node is still serving the self-signed\n  fallback and waiting for its panel-approved CA certificate.\n- the certificate **expiry** and the configured **SAN domains**.\n\nTo see only outstanding requests:\n\n```bash\ncenvero-str-ctl tls pending-status\n```\n\nThis reports any **online CA request** awaiting approval in the panel, and\n(separately) any **offline CSR** written by `tls csr` that is still awaiting\n`tls install`. It is read-only and never touches the live certificate.\n\n### Renewal\n\nCA-signed certificates renew automatically before they expire — the node submits\na renewal CSR for its existing identity. By default a renewal still needs admin\napproval, but you can enable **auto-renew** for your account (a master switch\nunder your account settings): with it on, renewals for an **already-approved**\nidentity are signed automatically. The *first* certificate for any node always\nneeds manual approval regardless of this setting.\n\n### Offline / air-gapped install\n\nFor a node that cannot reach the panel, certify it out of band:\n\n**1. Generate a key + CSR on the node** (the key stays on the node):\n\n```bash\nsudo cenvero-str-ctl tls csr > node.csr\n```\n\nThe CSR is printed to stdout (the private key and CSR are also written under\n`/etc/cenvero-str/tls/`).\n\n**2. Get the CSR signed** against your account CA in the panel — paste it at\n**Account → TLS** (or have an admin sign it manually) — and download the\nsigned leaf (plus the chain, if offered).\n\n**3. Install the signed certificate** on the node, pairing it with the retained key\n(hot-reloaded, no restart):\n\n```bash\nsudo cenvero-str-ctl tls install node-leaf.pem chain.pem\n```\n\nThe chain file is optional. Installation **fails closed**: if the signed\ncertificate does not match the pending key, nothing is swapped in.\n\nIf a request was rejected or you abandoned the offline flow, clear the pending\noffline CSR/key (the live certificate is untouched) so you can start a fresh one:\n\n```bash\nsudo cenvero-str-ctl tls reset-pending\n```\n\n### Starting over\n\nTo completely re-do a node's certificate — clear the live cert/key **and** any\npending CSR, then obtain a fresh one:\n\n```bash\nsudo cenvero-str-ctl tls reset\nsudo cenvero-str-ctl tls pull\n```\n\nIn account-CA mode `tls reset` submits a **new** CSR that you must approve again\nin **Account → TLS**; without an account CA it produces a fresh self-signed\ncertificate. Run `tls pull` afterwards to submit/collect the new certificate\nimmediately, or wait for the next automatic pull.\n\n### TLS command reference\n\n| Command | What it does |\n|---------|--------------|\n| `tls request` | Submit a CA-signing request to the panel now (online). |\n| `tls info` | Show the live cert: mode, expiry, SANs, `ca_pending`. |\n| `tls pull` | Collect the CA-signed cert now (no-op if already held). |\n| `tls pending-status` | Report any pending online CA request or offline CSR. |\n| `tls csr` | Generate a key + CSR locally **without** contacting the panel (offline). |\n| `tls install <leaf> [chain]` | Install an out-of-band-signed cert, pairing it with the CSR's key. |\n| `tls reset-pending` | Discard a pending **offline** CSR/key (live cert untouched). |\n| `tls reset` | Clear the live cert + any pending CSR and re-obtain a fresh one. |\n\n> A single `cenvero-str-ctl pull` re-checks **both** the TLS certificate and the\n> license at once — handy right after you approve a node in the panel.\n\n---\n\n## Part B — License activation & recovery\n\nA license is a digitally signed document that binds your plan and an expiry to a\nspecific machine. The agent verifies it locally on every boot and continuously\nwhile running. For the plan/enforcement model and offline behaviour, see\n[Licensing](/docs/licensing).\n\n### Activating a node\n\n**1. Send the activation request** from the node with your license key:\n\n```bash\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\n```\n\nThis records a **pending per-machine activation** for your account — it never\nauto-issues. The command prints the machine's Hardware ID. (If you have\nalready confirmed this machine, the signed license is returned immediately and\ninstalled.)\n\n**2. Confirm the machine in your account.** Activation is per machine and you\nconfirm each one yourself — Stratum never silently binds a machine. Find the\npending activation in your account, verify the **Hardware ID** matches the\nnode's own (`cenvero-str-ctl hardware`), and confirm it.\n\n**3. Install the signed license.** Once confirmed, the running agent picks up and\ninstalls the license automatically. To fetch it immediately instead of\nwaiting:\n\n```bash\nsudo cenvero-str-ctl license fetch CNVR-XXXX-XXXX-XXXX-XXXX\n```\n\n(Or run `cenvero-str-ctl pull` to re-sync the license and the TLS cert at\nonce.)\n\n### Checking license status\n\n```bash\ncenvero-str-ctl license status\n```\n\nThis is **read-only** and works offline from the on-disk license. It shows who\nthe license was issued to/by, the serial, plan and release channel; validity\n(valid-until, the enforcement state, days remaining or days into grace); the full\nfeature catalogue with each capability enabled/disabled; that the license is\n**bound to this machine's hardware identity** (and whether this machine matches);\nand the signing key id.\n\n```text\nplan         enterprise\nserial       7F3A-21C9-...\nissued_to    acme-corp\nhardware_id  9b2c… (matches this host)\nvalid_until  2026-07-04T00:00:00Z\nstate        active   (27 days remaining)\n```\n\n### Renewing\n\nAfter you renew (extend) the license in the panel, pull the refreshed license to\nthe node:\n\n```bash\nsudo cenvero-str-ctl license refresh   # alias of `license renew`\n```\n\nConfirmed machines also **auto-renew silently** before expiry with no action from\nyou, as long as the license and the activation are still valid and within your\npurchased term. A node in Warning, Grace, or Frozen returns to Active the moment\nit receives a valid, longer-dated license. (See\n[Licensing → Enforcement](/docs/licensing) for the warn → grace → freeze model.)\n\n### Recovery / reinstall\n\nIf a node is **reinstalled** — fresh disk, restored snapshot, or otherwise — it\nloses its local state, including its **credentials** and any cached license. To\nbring it back online:\n\n**1. Re-activate with the same license key** on the reinstalled host:\n\n```bash\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\n```\n\nBecause the license is **bound to the machine's hardware identity** and the\nhardware is unchanged, the machine is recognised as the one you already\nconfirmed and the signed license is reinstated.\n\n**2. Re-register the node.** The reinstalled node re-registers automatically\nusing your **license key** and is re-issued fresh credentials — you do not\npaste anything by hand; it happens as the agent comes back up.\n\n- If the hostname is owned by a **different** license/customer, re-registration\n  is refused (this protects other tenants).\n- Once re-registered, the node can pull its CA-signed TLS certificate again.\n\n**3. Re-issue the TLS certificate.** The reinstalled node has no CA-signed cert\nyet. Once it has re-registered it submits a fresh CSR; approve it at\n**Account → TLS** (verify the Hardware ID first), then it collects the signed\ncert as in **Part A**. Run\n`cenvero-str-ctl pull` to do the TLS + license sync immediately.\n\n> **No re-purchase needed.** A reinstall reuses your existing license and node\n> slot — you re-activate and re-register the same machine; you do not buy a new\n> license.\n\n### License command reference\n\n| Command | What it does |\n|---------|--------------|\n| `license activate <key>` | Send this machine's activation request (confirm it in your account). |\n| `license fetch <key>` | Fetch the signed license once the machine is confirmed. |\n| `license status` | Show the full installed-license picture (read-only, offline). |\n| `license renew` / `license refresh` | Force a license renewal for this machine. |\n| `license load <file>` | Load a signed license XML manually. |\n\n---\n\n## Next steps\n\n- **[Installation](/docs/installation)** — activation during first-time setup.\n- **[Licensing](/docs/licensing)** — plans, enforcement, revocation, offline use.\n- **[Upgrades](/docs/upgrades)** — keeping the agent current.\n"
        },
        {
            "slug": "licensing",
            "title": "Licensing",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/licensing",
            "headings": [
                {
                    "level": 1,
                    "text": "Licensing"
                },
                {
                    "level": 2,
                    "text": "Plans"
                },
                {
                    "level": 2,
                    "text": "Pre-release (beta/RC) licenses"
                },
                {
                    "level": 2,
                    "text": "How a license is bound"
                },
                {
                    "level": 2,
                    "text": "Tamper-proof by design"
                },
                {
                    "level": 2,
                    "text": "Enforcement: warn → grace → freeze"
                },
                {
                    "level": 2,
                    "text": "Revocation"
                },
                {
                    "level": 2,
                    "text": "Offline and air-gapped nodes"
                },
                {
                    "level": 2,
                    "text": "Renewing"
                },
                {
                    "level": 2,
                    "text": "Coupons and invoices"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 853,
            "markdown": "# Licensing\n\nCenvero Stratum is licensed per node. A license is a digitally signed document\nthat binds a plan and an expiry to a specific machine. The agent verifies it\ncryptographically on every boot and re-checks it continuously while running.\n\n## Plans\n\nStratum is licensed **per node**, billed on the term you choose (monthly or\nlonger). The current plans, their features and rates are shown on the\n[pricing section of the site](/#pricing) — plans are configured in the management\npanel, so this page never carries stale numbers. For large-scale, air-gapped or\nbespoke arrangements, **contact sales**.\n\nThe self-service plans have **no minimum node count**. Large fleets, air-gapped,\nor custom-SLA deployments are arranged as bespoke agreements with sales.\n\nLicenses are **never issued automatically**. You place an order — which\nimmediately raises an invoice you can pay against — follow the payment\ninstructions, and submit your payment proof; an admin verifies it, the invoice is\nmarked paid, and your license is issued and appears in your account. There is no\ninstant self-service issuance: every license is tied to a verified order.\n\n## Pre-release (beta/RC) licenses\n\nBeta and release-candidate builds run on a separate **pre-release** track and\nrequire a special **pre-release license**. These are **issued directly by an\nadministrator** — there is no order or payment flow for them — and may be bound\nto a customer account or left internal (for your own testing), with an admin-set\nexpiry.\n\nThe gate is strict, both ways: a normal (stable) license can neither download nor\nrun a beta/RC build, and a pre-release license runs **only** beta/RC builds — not\nstable. The channel is written into the *signed* license, so the agent enforces\nit offline and **fails closed** (refuses to operate) on a mismatch. See\n[Upgrades → Release channels](/docs/upgrades) for the build/release side.\n\n## How a license is bound\n\nWhen you activate, the agent derives a stable **hardware ID** that is unique to\nthat physical server. Your license is cryptographically bound to it, so a license\ncopied to a different machine simply will not work there. Stratum is licensed for\nbare-metal servers.\n\n```bash\nsudo cenvero-str-ctl license activate CNVR-XXXX-XXXX-XXXX-XXXX\ncenvero-str-ctl license status\n```\n\n```text\nplan         enterprise\nserial       7F3A-21C9-...\nissued_to    acme-corp\nhardware_id  9b2c… (matches this host)\nvalid_until  2026-07-04T00:00:00Z\nstate        active   (27 days remaining)\n```\n\n## Tamper-proof by design\n\nEvery license is **cryptographically signed by Cenvero** and verified by the\nagent independently, on each node, both at boot and continuously while running.\nA license that has been altered, forged, or is not genuinely Cenvero-issued is\nrejected — there is nothing on the node you can edit to extend or fake a license.\nVerification happens locally, so it works even when the node is offline.\n\n## Enforcement: warn → grace → freeze\n\nStratum **never hard-stops your network.** Existing endpoints, forwarding, DHCP,\nDNS, and the firewall keep working regardless of license state. Enforcement only\ngoverns *changes*, and it escalates gently:\n\n| State | When | Effect |\n|-------|------|--------|\n| **Active** | License valid | Everything works normally |\n| **Warning** | ≤ 7 days to expiry | Full functionality; renewal reminders logged |\n| **Grace** | ≤ 14 days *after* expiry | Full functionality continues; renew now |\n| **Frozen** | Past grace, or revoked, or a license that fails verification | Existing traffic keeps running; **new or changing operations are blocked** until you renew |\n\nIn the Frozen state, commands that mutate state (creating networks, attaching\nendpoints, editing the firewall) return exit code `4`. Read-only commands and the\ndata plane are unaffected.\n\n## Revocation\n\nA license can be **revoked** by Cenvero — for example after a refund or a\nterms violation. The agent checks revocation status periodically, and a node\nwhose license has been revoked moves to **Frozen** (existing traffic keeps\nrunning; changes are blocked until the license is restored or replaced).\nRevocation is honoured even offline, and a revocation cannot be quietly undone\nby a tampered or spoofed server.\n\n## Offline and air-gapped nodes\n\nNodes do not need continuous connectivity. The signed license is cached locally,\nso an isolated node keeps running on its cached license. Air-gapped operation\nwith offline activation is available for bespoke deployments — contact sales.\n\n## Renewing\n\n```bash\n# After renewing in the panel, pull the refreshed license\nsudo cenvero-str-ctl license refresh\n```\n\nA node in Warning, Grace, or Frozen returns to Active the moment it receives a\nvalid, longer-dated license.\n\n## Coupons and invoices\n\nIf you have a **coupon code**, enter it when you place your order — the discount\nis applied to the order total once the code is validated (it must be active,\nin date, within its usage limits, and applicable to your plan). Each code can be\npercentage- or fixed-amount.\n\nAn **invoice is raised as soon as you place an order**, so you always have a\nnumbered document to pay against and to give your finance team. You will find it\nunder **Invoices** in your account, marked *awaiting payment*, with a **Pay**\nbutton that takes you to the payment instructions for that order.\n\nOnce we verify your payment the same invoice is marked **paid** — there is one\ninvoice per order, and it keeps its number throughout. You can download the PDF\nat any time, whether it is still outstanding or already settled.\n\n## Next steps\n\n- **[Installation](/docs/installation)** — activation during setup.\n- **[Upgrades](/docs/upgrades)** — keeping the agent current.\n"
        },
        {
            "slug": "billing-integration",
            "title": "Billing Integration",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/billing-integration",
            "headings": [
                {
                    "level": 1,
                    "text": "Billing Integration"
                },
                {
                    "level": 2,
                    "text": "How enforcement actually works"
                },
                {
                    "level": 2,
                    "text": "Get an API key"
                },
                {
                    "level": 2,
                    "text": "Endpoints"
                },
                {
                    "level": 3,
                    "text": "A worked flow"
                },
                {
                    "level": 2,
                    "text": "From the CLI"
                },
                {
                    "level": 2,
                    "text": "What this is not"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 877,
            "markdown": "# Billing Integration\n\nEndpoints **in the agent** so your billing system can suspend, resume, or rate-limit one of your customers — a **tenant** — when their invoice changes. It is part of the agent's normal API (REST plus the `cenvero-str-ctl` CLI); you don't build anything beyond the HTTP calls.\n\nThe intended shape is: your billing system already knows who has paid. When that answer changes, it makes one call per affected tenant. Nothing polls, and the agent never talks to your billing system.\n\n## How enforcement actually works\n\nWorth understanding before you wire it up, because it determines what \"suspended\" means for a customer and what you can promise them.\n\n**Suspending is a firewall action, not a switch.** When you suspend a tenant, the agent looks up every endpoint belonging to that tenant's networks and installs a **drop rule for each endpoint's address**. Traffic from those addresses stops being forwarded. Resuming removes exactly those rules and nothing else — they are tagged with the tenant's id, so a resume can never disturb a rule you wrote yourself.\n\nTwo properties follow, and both matter in practice:\n\n- **It is idempotent.** Suspending an already-suspended tenant is harmless — an endpoint that already has its drop rule is skipped. Your billing system can retry a failed call, or re-send the current state on a schedule, without special-casing anything.\n- **Existing connections are cut, not drained.** The rule applies to traffic, so a transfer in progress stops. There is no grace period. If you want one, delay the call in your billing system.\n\n**Rate-limiting is the bandwidth cap**, the same mechanism described in [Tenants & Bandwidth](/docs/tenants). It is a credit allowance that refills at the rate you set: TCP finds the new rate and settles there, UDP over the rate is dropped. Applying a limit to a running tenant takes effect immediately without disturbing established connections.\n\n**State is durable and cluster-wide.** The tenant's status and cap are persisted, so they survive an agent restart, and in a cluster the change is replicated to the other members. You call one node, not every node.\n\n**Suspension holds for endpoints created later.** Suspending a tenant that owns nothing yet is fine: there is no address to drop traffic from at that moment, but the suspension is remembered, and **any endpoint attached to that tenant afterwards is covered as it is attached.** So the order you do things in does not matter — you can suspend an empty tenant, provision it later, and its traffic still does not flow. You do not need to re-send the call.\n\n## Get an API key\n\nThe operator mints a key on the node; your billing system sends it as a bearer token.\n\n```bash\ncenvero-str-ctl apikeys mint \"billing\"     # → csk_xxxxxxxxxxxxxxxx  (shown once)\ncenvero-str-ctl apikeys list\ncenvero-str-ctl apikeys revoke <id>\n```\n\n```\nAuthorization: Bearer csk_xxxxxxxxxxxxxxxx\n```\n\nThe key is shown **once**, when it is minted — store it then. Mint a separate key for your billing system rather than reusing an operator key, so revoking it cannot lock you out of anything else.\n\n## Endpoints\n\nA tenant is addressed by its id. All return JSON.\n\n| Method · Path | What happens |\n|---|---|\n| `POST /api/v1/billing/tenants/{id}/suspend` | Installs a drop rule per tenant endpoint address. Traffic from the tenant stops. |\n| `POST /api/v1/billing/tenants/{id}/resume` | Removes exactly the rules the suspend installed. |\n| `POST /api/v1/billing/tenants/{id}/limit` | Caps the tenant's bandwidth. Body: `{\"rate_mbps\": 50}`. |\n| `POST /api/v1/billing/tenants/{id}/unlimit` | Removes the cap (unlimited). |\n| `GET  /api/v1/billing/tenants/{id}` | Current state: `{id, name, status, max_bandwidth_bps}`. |\n\n```bash\ncurl -X POST https://node:7070/api/v1/billing/tenants/t-abc123/suspend \\\n     -H \"Authorization: Bearer csk_xxxxxxxxxxxxxxxx\"\n```\n\nUnknown tenant → `404`.\n\n### A worked flow\n\nWhat a billing system typically does, in order:\n\n```bash\n# Invoice went unpaid — cut the customer off\nPOST /api/v1/billing/tenants/t-abc123/suspend\n\n# Confirm what the node now believes\nGET  /api/v1/billing/tenants/t-abc123\n# → {\"id\":\"t-abc123\",\"name\":\"acme\",\"status\":\"suspended\",\"max_bandwidth_bps\":0}\n\n# Payment arrived — restore them\nPOST /api/v1/billing/tenants/t-abc123/resume\n\n# Downgraded to a slower plan instead of being cut off\nPOST /api/v1/billing/tenants/t-abc123/limit    {\"rate_mbps\": 50}\n```\n\n**Read back after writing.** `GET` is the node's own view, and it is the answer to \"did that actually apply?\" — more reliable than inferring it from a `200`.\n\n## From the CLI\n\nThe same actions, for an operator or a script on the node:\n\n```bash\ncenvero-str-ctl billing suspend  <tenant-id>\ncenvero-str-ctl billing resume   <tenant-id>\ncenvero-str-ctl billing limit    <tenant-id> --rate-mbps 50\ncenvero-str-ctl billing unlimit  <tenant-id>\ncenvero-str-ctl billing status   <tenant-id>\n```\n\n## What this is not\n\n- **Not invoicing.** The agent has no concept of an invoice, a price, or a due date. It enforces what you tell it, and your billing system remains the source of truth.\n- **Not the customer panel's billing.** Orders, invoices and payment in the management panel are a separate thing entirely — see [Your account](/docs/your-account). These endpoints are for *your* system controlling *your* customers on *your* nodes.\n- **Not a usage meter.** For how much a tenant actually transferred, use the accounting and metrics surfaces — see [Monitoring & Observability](/docs/monitoring).\n\n## See also\n\n- [Tenants & Bandwidth](/docs/tenants) — what a tenant is, and how the bandwidth cap behaves.\n- [Monitoring & Observability](/docs/monitoring) — usage figures to bill against.\n- [API Reference](/docs/api) — the full endpoint surface and authentication.\n"
        },
        {
            "slug": "upgrades",
            "title": "Upgrades",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/upgrades",
            "headings": [
                {
                    "level": 1,
                    "text": "Upgrades"
                },
                {
                    "level": 2,
                    "text": "Releases"
                },
                {
                    "level": 2,
                    "text": "Release channels (stable, beta, RC)"
                },
                {
                    "level": 2,
                    "text": "The update manifest"
                },
                {
                    "level": 2,
                    "text": "How a node updates"
                },
                {
                    "level": 2,
                    "text": "Configuration an update migrates for you"
                },
                {
                    "level": 2,
                    "text": "Downgrade protection"
                },
                {
                    "level": 2,
                    "text": "Self-rollback on a failed apply"
                },
                {
                    "level": 2,
                    "text": "Checking versions"
                },
                {
                    "level": 2,
                    "text": "Relationship to licensing"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 884,
            "markdown": "# Upgrades\n\nStratum upgrades are **pull-based** and cryptographically verified. The\nmanagement panel publishes releases; each agent polls a signed manifest, and\nwhen a newer, complete release is available it downloads, verifies, and applies\nthe artifacts itself. There is no push, no heartbeat-driven rollout, and no\ncentral scheduler — every node updates on its own poll loop.\n\n## Releases\n\nA release bundles the agent (and the CLI) at a pinned version. The agent ships\nas a single self-contained binary, so a release advertises the binaries CI\nuploaded for it.\n\nA release becomes visible to agents only when it is **published** and\n**complete** — every required (component, architecture) artifact is present and\ncarries a publisher signature. A half-published or unsigned release is never\nadvertised, so an agent never pulls an incomplete set. Once published, a\nrelease is **immutable**: CI cannot replace the artifacts of a release that has\nalready gone out.\n\n## Release channels (stable, beta, RC)\n\nEvery release ships on one of three channels: `stable`, `beta`, or `rc` (beta and\nRC together form the **pre-release** track). The channel comes from the release\ntag — `vX.Y.Z` is stable, `vX.Y.Z-beta.N` is beta, `vX.Y.Z-rc.N` is rc.\n\nPre-release builds require a **pre-release license** (see\n[Licensing](/docs/licensing)), and the gate is strict in both directions:\n\n- a **stable** license is served — and can run — **only stable** builds;\n- a **pre-release** license is served — and can run — **only beta/RC** builds, never stable.\n\nThis is enforced at every layer: the manifest only advertises releases on the\nchannel your license is entitled to, the `/download` route returns `404` for any\nout-of-channel build (so a stable key cannot even fetch a beta binary), and the\nchannel is baked into the signed license, so the agent **refuses to run** a\nmismatched build even offline.\n\n## The update manifest\n\nAgents read `/update/manifest.json` from the panel. The manifest is\n**license-gated** (the same valid-license check as the installer), and it\nadvertises the **highest complete published release** on the channel your license\nis entitled to (see *Release channels* above), by semantic version — not simply\nthe most recently published one. Each artifact entry carries:\n\n- a license-gated **download URL** on the panel,\n- the artifact's **sha256** checksum, and\n- a **publisher signature** that proves the binary was released by Cenvero and\n  has not been altered.\n\n## How a node updates\n\nEach agent runs a long-interval poll loop (it checks infrequently; every tick\nis a no-op unless a newer, fully verified build exists):\n\n1. The agent fetches `/update/manifest.json` and compares the advertised\n   version against what it is running and against its **monotonic version\n   floor** (see below).\n2. If a newer version applies, it downloads the artifact for its component and\n   architecture over HTTPS.\n3. It checks the download's **sha256** against the manifest, then verifies the\n   binary's **publisher signature**. Anything that fails verification is\n   discarded — nothing unsigned or altered is ever installed.\n4. It applies the new binary with an **atomic temp-file write + `rename()`** in\n   place, then restarts under the watchdog.\n\nBecause the data plane runs in the kernel and the interfaces stay held across\nthe swap, the brief agent restart does not tear down existing flows.\n\n## Configuration an update migrates for you\n\nAn update can carry a settings change the agent applies to itself, so a node\ndoes not need an operator to keep working. There is one of those in this\nversion.\n\n**Compute mode has been removed, and nodes on it migrate automatically.** Older\nversions asked a node to be either a Compute node or a Gateway node; every node\nnow routes. A node updating from an older version has its configuration\nrewritten for it as part of the update — no reinstall, nothing to set, and no\ninterruption beyond the usual restart. Its networks, endpoints, firewall rules,\ntenants and addressing carry over untouched. See\n[Nodes and Interfaces](/docs/nodes).\n\n## Downgrade protection\n\nThe agent keeps a **monotonic version floor** persisted on disk. After a\nsuccessful apply it raises the floor to the\nversion it just installed, and it **refuses any update at or below the floor**.\nA panel that is tampered with (or rolled back) to advertise an older version\ncannot walk an agent backwards. The panel enforces its own minimum-version floor\nas well, so both sides agree on the lowest acceptable version.\n\n## Self-rollback on a failed apply\n\nApplying an update is local to the node and self-healing: before swapping the\nbinary the agent keeps a backup of the previous one. If the post-restart health\ncheck does not pass, the agent restores the previous binary and brings it back\ninto service. This is a **single-node** safeguard around its own apply step —\nthere is no fleet-wide automatic revert and no canary/percentage rollout\norchestrated from the panel.\n\n## Checking versions\n\n```bash\n# What's installed now\ncenvero-str-ctl status\n\n# Ask the agent to check the manifest for a newer release\ncenvero-str-ctl update check\n\n# Apply an available update now (otherwise it applies on the next poll)\ncenvero-str-ctl update apply\n```\n\n## Relationship to licensing\n\nThe update manifest and the artifact downloads require a valid license, just\nlike the initial install. A node in the **Frozen** license state keeps running\nits current version but cannot pull new updates until the license is renewed —\nsee [Licensing](/docs/licensing).\n\n## Next steps\n\n- **[Installation](/docs/installation)** — the initial install flow.\n- **[Plugins Overview](/docs/plugins/overview)** — extending a node beyond the core.\n"
        },
        {
            "slug": "networking/overview",
            "title": "Networking Overview",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/overview",
            "headings": [
                {
                    "level": 1,
                    "text": "Networking Overview"
                },
                {
                    "level": 2,
                    "text": "The two bridges"
                },
                {
                    "level": 2,
                    "text": "Networks as L2 segments"
                },
                {
                    "level": 3,
                    "text": "What it does not do"
                },
                {
                    "level": 2,
                    "text": "Worked example: two servers talking over a private network"
                },
                {
                    "level": 3,
                    "text": "1. Create the network"
                },
                {
                    "level": 3,
                    "text": "2. Claim an endpoint for each server"
                },
                {
                    "level": 3,
                    "text": "3. Put each server on the workload bridge with its MAC"
                },
                {
                    "level": 3,
                    "text": "4. Give each server its address"
                },
                {
                    "level": 3,
                    "text": "5. They can talk"
                },
                {
                    "level": 3,
                    "text": "Removing it"
                },
                {
                    "level": 3,
                    "text": "What this network is and is not"
                },
                {
                    "level": 2,
                    "text": "Endpoints — how a workload joins a network"
                },
                {
                    "level": 2,
                    "text": "Addresses, networks and endpoints — which one do you want?"
                },
                {
                    "level": 3,
                    "text": "The address pool is a ledger"
                },
                {
                    "level": 3,
                    "text": "The endpoint is an identity"
                },
                {
                    "level": 3,
                    "text": "You do not create pools for networks"
                },
                {
                    "level": 3,
                    "text": "So, in practice"
                },
                {
                    "level": 2,
                    "text": "What runs on the network"
                },
                {
                    "level": 2,
                    "text": "Stretching a network across nodes with VXLAN"
                },
                {
                    "level": 3,
                    "text": "How a frame crosses the overlay"
                },
                {
                    "level": 2,
                    "text": "VRF & Geneve"
                },
                {
                    "level": 2,
                    "text": "Firewall and routing"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 3002,
            "markdown": "# Networking Overview\n\nEvery network you define is a managed segment with its own subnet, addressing and DNS, carried on the host's workload bridge — and every usable address in it becomes an endpoint you can attach a workload to. This page describes that model: what a network gives you, how a workload joins one, and how a network is stretched across several hosts. Follow the links in each section for deeper coverage.\n\n## The two bridges\n\nEvery node owns two Linux bridges:\n\n| Bridge | Purpose |\n|--------|---------|\n| Management bridge | Carries agent control traffic, cluster communication, and the Gateway HA channel. Keep this reachable at all times. |\n| Workload bridge | Carries all workload traffic. This is where your networks live. |\n\nThe split is deliberate: workload saturation or a misconfigured network policy cannot starve the control plane.\n\n## Networks as L2 segments\n\nA *network* is a named managed L2 segment on the workload bridge.\n\n```bash\nsudo cenvero-str-ctl network create \\\n  --name app-net \\\n  --cidr 10.20.0.0/24 \\\n  --gateway 10.20.0.1\n```\n\nCreating one gives you:\n\n- The segment itself — subnet, VLAN id if you gave one, and owning tenant.\n- An **address pool** covering the usable range, which `network delete` removes again.\n- An **endpoint profile** (IP ↔ generated MAC) for every usable host address, which\n  you claim with `network attach`.\n- The gateway address **reserved** out of that range, so no endpoint is ever handed it.\n\n### What it does not do\n\nWorth knowing before you automate against it, because the difference is invisible\nuntil a workload fails to come up.\n\n`network create` does **not**:\n\n- assign the gateway address to any interface — nothing on the host answers on it;\n- create a DHCP scope;\n- create a DNS zone.\n\nThe gateway address matters if you expect workloads to route *off* the segment.\nTwo workloads on the same network talk to each other without it; reaching\nanything else needs something actually holding that address.\n\nThe DHCP point is the one that bites. The address pool exists, but **the DHCP\nserver will not hand out addresses for the network until a scope is bound to that\npool** — see [DHCP and DNS](/docs/networking/dhcp-dns). Until then the server has\nnothing to offer, and per RFC 2131 a server with nothing to offer stays silent:\nthe client retries with no reply, which looks exactly like a broken L2 path. The\nagent logs a warning naming the client when this happens. Give the workload a\nstatic address and it works immediately.\n\nNone of this depends on how a node is deployed. Every node runs the same DHCP and\nDNS servers, and every node routes — there is one kind of node.\n\nA single node can host many independent networks with non-overlapping subnets. In a cluster, the VXLAN overlay stretches each network across every node so an endpoint can move hosts without changing its IP — see [Clustering Overview](/docs/clustering/overview).\n\n## Worked example: two servers talking over a private network\n\nEnd to end, with nothing assumed. Every command here was run on a live node and\nthe output is what it actually printed.\n\n### 1. Create the network\n\n```bash\nsudo cenvero-str-ctl network create --name doctest --cidr 10.77.0.0/24 --gateway 10.77.0.1\n```\n\n```json\n{ \"status\": \"created\", \"network\": { \"id\": \"net-953e5ae21abd5029\", \"cidr\": \"10.77.0.0/24\", \"gateway\": \"10.77.0.1\" } }\n```\n\nKeep the `id` — the next step needs it. The address pool and one endpoint profile\nper usable address are created with it.\n\n### 2. Claim an endpoint for each server\n\n```bash\nsudo cenvero-str-ctl network attach net-953e5ae21abd5029\n```\n\n```json\n{ \"status\": \"attached\", \"endpoint\": { \"ip\": \"10.77.0.2\", \"mac\": \"02:ce:0a:4d:00:02\", \"state\": \"bound\" } }\n```\n\nRun it once per server. The second returns `10.77.0.3` / `02:ce:0a:4d:00:03`.\n\n**Write down the IP and MAC pairs.** They are the whole configuration — the MAC\nidentifies the workload to the fabric and the IP is what it is allowed to use.\nAttaching also registers the pairing in the packet path, which is what permits\nthat workload's traffic; an unregistered MAC is dropped.\n\n### 3. Put each server on the workload bridge with its MAC\n\nConfigure the virtual machine's network interface to use the **workload bridge**\nand the **MAC from step 2**. On a hypervisor this is a per-guest setting; the\nexact field name varies, but it is the one that sets the guest's hardware address.\n\nThe MAC must match exactly. A guest presenting any other address is refused —\nthat is the anti-spoofing working, not a fault.\n\n### 4. Give each server its address\n\nConfigure the address from step 2 inside the guest, with prefix `/24` and gateway\n`10.77.0.1`:\n\n```\n10.77.0.2/24   on the first server\n10.77.0.3/24   on the second\n```\n\nUse a static address. DHCP will not answer for this network until a scope is\nbound to its pool, and a client that gets no answer looks exactly like a broken\nconnection — see [DHCP and DNS](/docs/networking/dhcp-dns).\n\n### 5. They can talk\n\n```bash\n# from the first server\nping 10.77.0.3\n```\n\nTraffic between them stays on this node and never touches your uplink.\n\n### Removing it\n\n```bash\nsudo cenvero-str-ctl network delete net-953e5ae21abd5029\n```\n\nThis removes the network, its endpoints and its address pool together, so a\ndeleted network leaves nothing behind.\n\n### What this network is and is not\n\n- **Private, and private between customers.** Traffic between the private\n  networks of two *different* tenants is refused in the packet path. Those\n  addresses are not reachable from outside either, so there is no way around it.\n- **On one node.** Two workloads on the same node talk over it directly. To\n  stretch a network across several nodes, see the overlay section below.\n- **Not routed off the segment by itself.** Nothing holds the gateway address\n  until you configure something to. Workloads on the network reach each other\n  regardless.\n\n## Endpoints — how a workload joins a network\n\nThis is the model to understand before automating anything.\n\nCreating a network does not create workloads; it creates **endpoints**. An\nendpoint is one address on that network paired with a hardware address, and one\nis prepared for every usable host address the moment the network exists. They sit\nunused until something claims them.\n\n```\nnetwork 10.20.0.0/24  ─┬─ endpoint 10.20.0.2   free\n                       ├─ endpoint 10.20.0.3   free\n                       └─ ...\n```\n\n**Attaching** claims one. You can name the address you want or take the next\nfree one, and you can supply the hardware address if the workload already has one\nof its own:\n\n```bash\n# take the next free address\nsudo cenvero-str-ctl network attach <network-id>\n\n# or claim a specific address for a workload that already has a MAC\nsudo cenvero-str-ctl network attach <network-id> --ip 10.20.0.50 --mac 52:54:00:ab:01:02\n```\n\n**Detaching** returns the endpoint to the free pool, and the address becomes\navailable again.\n\nTwo properties follow, and they are the reason the model works this way:\n\n> **The address and hardware address are decided before the workload exists.**\n> You can build your network layout, firewall policy and DNS records first, then\n> attach machines into a plan that is already in place.\n\n> **The pairing is enforced on every packet.** Traffic claiming to come from an\n> endpoint must carry that endpoint's address and hardware address, so one\n> workload cannot impersonate another. This is not a convention — it is checked\n> in the packet path.\n\nBecause the endpoint is the unit, moving a workload between hosts is a matter of\nattaching the same endpoint elsewhere; the address travels with it.\n\n## Addresses, networks and endpoints — which one do you want?\n\nThree things sound alike and are not, and picking the wrong one is the most common\nway to get stuck. Here is the whole distinction.\n\n| | **Address pool** (`ipam`) | **Network** | **Endpoint** (`network attach`) |\n|---|---|---|---|\n| What it is | A ledger of addresses | An L2 segment | One address bound to one hardware address |\n| Gives you | An address | A place for workloads to live | An address **and** a MAC |\n| Enforced on packets | No | — | **Yes** |\n| Use it for | Tracking addresses you own | Building a segment | Putting a workload on that segment |\n\n### The address pool is a ledger\n\n`ipam` answers one question: *which addresses are taken, and by whom.*\n\n```bash\nsudo cenvero-str-ctl ipam allocate 15 testhost\n```\n\n```json\n{ \"id\": 9, \"ip\": \"161.248.163.3\", \"pool_id\": 15, \"hostname\": \"testhost\" }\n```\n\nAn address and a label. **No hardware address, and nothing enforced.** Nothing\nstops a machine using that address anyway — the pool records that you assigned\nit, it does not police it. That is the right tool for keeping track of addresses\nyou own, such as the public addresses your provider routed to you.\n\n### The endpoint is an identity\n\n`network attach` answers a different question: *which workload may use this\naddress.*\n\n```bash\nsudo cenvero-str-ctl network attach net-953e5ae21abd5029\n```\n\n```json\n{ \"ip\": \"10.77.0.2\", \"mac\": \"02:ce:0a:4d:00:02\", \"state\": \"bound\" }\n```\n\nAn address **and** a hardware address, paired — and that pairing is registered in\nthe packet path. Traffic must carry both or it is refused. A workload cannot take\nan address it was not given, and cannot impersonate one that was given to another.\n\n**That enforcement is the entire difference.** A pool entry is a note; an endpoint\nis a rule.\n\n### You do not create pools for networks\n\n`network create` makes the pool for you, covering the network's usable range, and\n`network delete` removes it again. You will see it in `ipam pools` under the\nnetwork's name. There is no step where you build one by hand.\n\nYou create a pool directly only for addresses that are **not** a network's — a\nblock of public addresses, for instance, that you want to track allocation of.\n\n### So, in practice\n\n- **Two workloads that need to talk?** Create a network, attach an endpoint for\n  each. You get the addresses and the hardware addresses, and the fabric enforces\n  them.\n- **Just recording which addresses are spoken for?** Use a pool directly.\n- **Wondering where the MAC comes from?** Attaching generates one, unless the\n  workload already has a hardware address of its own — pass `--mac` and it will be\n  bound to the address instead.\n\n## What runs on the network\n\nOnce a network exists it comes with services already attached, rather than\nneeding separate ones configured:\n\n- **Addresses handed out automatically**, or reserved to a specific workload —\n  see [DHCP & DNS](/docs/networking/dhcp-dns).\n- **A DNS zone** the network answers for, so workloads can find each other by\n  name.\n- **Firewall policy**, applied wherever traffic enters — see\n  [Zero-Trust Firewall](/docs/networking/firewall).\n- **Bandwidth limits** per tenant.\n- **Load balancing** for published services — see\n  [Load Balancer](/docs/networking/load-balancer).\n\nFor how a packet actually travels through a node and where it can be stopped,\nsee [How Stratum Works](/docs/architecture).\n\n## Stretching a network across nodes with VXLAN\n\nAn overlay carries a network between nodes, so two endpoints on different hosts share one subnet. Creating it is a two-part job, and both parts are needed before a single packet moves.\n\n### How a frame crosses the overlay\n\nUnderstanding this explains why the setup has two halves, and why a missing peer produces silence rather than an error.\n\nA workload on node A sends a frame to a workload on node B. As far as both are concerned they are on the same flat network — same subnet, no router in between. What actually happens:\n\n1. **Node A wraps the frame.** The original frame — addresses, contents and all — is placed inside an ordinary UDP packet addressed from node A to node B. The workload's addresses are now payload; the outer packet carries only the two nodes' addresses.\n2. **That packet crosses your existing network** like any other traffic. Every switch and router between the nodes sees a normal UDP packet between two hosts. They neither know nor care that a whole frame is inside.\n3. **Node B unwraps it** and delivers the original frame to the destination workload, which sees it as though it arrived over a local switch port.\n\nTwo consequences you will actually run into:\n\n> **A node can only deliver to peers it has been told about.** The wrapping step needs a destination — node A must know that this workload's address lives behind node B. That is what a peer entry is. Without it, node A has nowhere to send the wrapped packet, so the frame is dropped **silently**: no error, just traffic that never arrives. This is the single most common overlay problem, and it is why peers must be registered on *every* node, in both directions.\n\n> **The overlay travels on your existing network, so that network must let it through.** The wrapped packets are UDP on port 4789 between the nodes' own addresses. On a cloud provider that means the security group or firewall in front of each node, not just the node's own firewall — and it must be open in **both** directions.\n\nWrapping also adds bytes to every packet. If your underlying network only just accommodates a standard-size packet, a full-size frame plus its wrapper can exceed it, and the symptom is characteristic: small packets work perfectly, large transfers stall. If you see that, the overlay is fine and the size limit is the problem.\n\nThe **VNI** is the overlay's identifier. It must match on every participating node, and it is what keeps overlays separate — two overlays on the same nodes with different VNIs cannot see each other's traffic even though they share the same underlying network.\n\n**Create the overlay on every node that participates.** The VNI identifies the overlay and must match; the subnet is the address range the overlay carries.\n\n```bash\nsudo cenvero-str-ctl vxlan create 4711 10.211.0.0/24\n```\n\nThe response names the tunnel device it created, `cnv-vx<vni>`:\n\n```json\n{\"data\":{\"vni\":4711,\"subnet\":\"10.211.0.0/24\",\"kernel_device\":\"cnv-vx4711\",\"status\":\"created\"},\"status\":\"ok\"}\n```\n\nIf it reports `\"kernel_device\": false` instead, the node has no address to send encapsulated traffic from and the overlay will carry nothing — the message explains what is missing.\n\n**Then tell each node about the others.** A node only delivers to peers it has been given, so every node needs an entry for every other node. A peer is its hostname, the address of its tunnel device, and the public address its traffic arrives from:\n\n```bash\n# Read the peer's tunnel-device address ON THAT PEER\ncat /sys/class/net/cnv-vx4711/address        # e.g. ce:f0:ef:6b:18:f1\n\n# ...then register it here, with the peer's underlay address\nsudo cenvero-str-ctl vxlan peer-add 4711 node-b ce:f0:ef:6b:18:f1 203.0.113.9\n```\n\nRepeat in the other direction on `node-b`, pointing back at this node. Then confirm both sides agree:\n\n```bash\ncenvero-str-ctl vxlan peers 4711\ncenvero-str-ctl vxlan fdb\n```\n\nPeers persist and are re-applied when the agent restarts, so an overlay comes back on its own after a reboot. Removing a peer stops traffic to it immediately; deleting the overlay removes the tunnel device and every peer with it.\n\nThe underlay must allow UDP port 4789 between the nodes' addresses in both directions — on a cloud provider that means the firewall or security group in front of each node, not just the node's own firewall.\n\n## VRF & Geneve\n\nAlongside the VXLAN overlay, Stratum manages two more kernel networking constructs directly from `cenvero-str-ctl`. Both are control-plane device management: the agent creates the kernel devices, tracks them, persists them in its local database, and recreates them on startup — nothing is created at boot until you define it. The devices are ordinary kernel objects, visible with tools like `ip link show`.\n\n**VRF (virtual routing and forwarding)** gives a node more than one independent routing table.\n\n*Why you would want that:* normally a host has one routing table, so one destination has one answer — and two customers who both use `10.0.0.0/24` internally cannot both be routed correctly, because the address is ambiguous. A VRF removes the ambiguity by giving each its own table: the same address can route to different places depending on which VRF the traffic arrived in. It is also how you keep two upstream providers' routes from mixing on one box.\n\nYou create a VRF bound to a routing-table id and attach interfaces to it; traffic on those interfaces is then routed in that VRF's table instead of the main one. The kernel device is named `cnv-vrf-<name>`.\n\n```bash\n# Create a VRF \"red\" on routing table 100 and enslave a NIC to it\nsudo cenvero-str-ctl vrf create red --table 100\nsudo cenvero-str-ctl vrf attach red --iface cnv-nic-1\n\n# Inspect, then release the interface and delete the VRF\ncenvero-str-ctl vrf list\ncenvero-str-ctl vrf show red\nsudo cenvero-str-ctl vrf detach red --iface cnv-nic-1\nsudo cenvero-str-ctl vrf delete red\n```\n\nDeleting a VRF releases every enslaved interface first, so none is left orphaned.\n\n**Geneve** is an overlay tunnel type (RFC 8926) — a sibling of VXLAN that wraps frames the same way, described above.\n\n*When to reach for it instead of VXLAN:* almost never, unless something you must interoperate with requires it. Geneve's advantage is an extensible header that can carry additional metadata alongside the frame, which matters to some hardware and to some other platforms. For stretching a Stratum network across your own nodes, **use VXLAN** — it is the path that clustering uses and the one that is proven end-to-end across hosts. Choose Geneve when you are terminating a tunnel from equipment that speaks Geneve and not VXLAN.\n\nA Geneve device carries a 24-bit VNI to a remote endpoint IP, with an optional outer UDP port (default 6081) and TTL; the kernel device is named `cnv-gnv-<name>`.\n\n```bash\nsudo cenvero-str-ctl geneve create blue --vni 42 --remote 10.0.0.2\nsudo cenvero-str-ctl geneve create green --vni 7 --remote 10.0.0.3 --port 6081 --ttl 64\n\ncenvero-str-ctl geneve list\ncenvero-str-ctl geneve show blue\nsudo cenvero-str-ctl geneve delete blue\n```\n\nCreating a Geneve device configures the tunnel endpoint on this host. Carrying packets across the overlay also requires the remote endpoint to be configured to match; validate cross-host Geneve forwarding in your own environment before relying on it.\n\n## Firewall and routing\n\nThe firewall is a dual-stack L3/L4 ACL operating at the network level. For a zero-trust posture, set its default action to `deny` so all inter-network and external traffic is blocked unless you add an explicit allow rule. See [Zero-Trust Firewall](/docs/networking/firewall).\n\nTraffic that exits the fabric hits the BGP speaker for north-south routing, and published services can be fronted by the L4 or L7 load balancer. See [BGP Edge Routing](/docs/networking/bgp) and [Load Balancer](/docs/networking/load-balancer).\n\n## See also\n\n- [DHCP & DNS](/docs/networking/dhcp-dns) — pool configuration, leases, zones, and forwarders.\n- [Zero-Trust Firewall](/docs/networking/firewall) — policy model and rule examples.\n- [Load Balancer](/docs/networking/load-balancer) — L4 VIPs plus the Layer-7 HTTP proxy.\n- [BGP Edge Routing](/docs/networking/bgp) — announcing networks upstream.\n- [Clustering Overview](/docs/clustering/overview) — VXLAN overlay and multi-host networking.\n"
        },
        {
            "slug": "networking/firewall",
            "title": "Zero-Trust Firewall",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/firewall",
            "headings": [
                {
                    "level": 1,
                    "text": "Zero-Trust Firewall"
                },
                {
                    "level": 2,
                    "text": "How a packet is judged"
                },
                {
                    "level": 3,
                    "text": "A note on `chain`"
                },
                {
                    "level": 3,
                    "text": "What `stateful` gives you"
                },
                {
                    "level": 2,
                    "text": "The default action"
                },
                {
                    "level": 2,
                    "text": "How a rule is written"
                },
                {
                    "level": 2,
                    "text": "Adding allow rules"
                },
                {
                    "level": 2,
                    "text": "Listing and removing rules"
                },
                {
                    "level": 2,
                    "text": "Explicit deny rules"
                },
                {
                    "level": 2,
                    "text": "Scheduled rules"
                },
                {
                    "level": 2,
                    "text": "Connection tracking"
                },
                {
                    "level": 3,
                    "text": "What it is for"
                },
                {
                    "level": 3,
                    "text": "Applying a rule change to connections that are already open"
                },
                {
                    "level": 2,
                    "text": "Per-source connection limits"
                },
                {
                    "level": 2,
                    "text": "Intrusion detection"
                },
                {
                    "level": 2,
                    "text": "Anti-spoof enforcement on the bridge"
                },
                {
                    "level": 2,
                    "text": "RA-guard & DHCP snooping"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 2782,
            "markdown": "# Zero-Trust Firewall\n\nStratum's firewall filters IPv4 and IPv6 traffic on addresses, protocols and ports. Rules are checked in order and the first match wins; when nothing matches, the default action applies. You write policy in terms of addresses and CIDRs, optionally narrowed to one interface, one workload's hardware address, or one scope.\n\nFiltering happens in the kernel, on the path the packet is already taking, so it costs nothing extra in round trips and applies before a packet reaches a workload. The same rules are applied wherever traffic enters — at the workload bridge and at a Gateway node's forwarding interfaces — so one rule is in force everywhere rather than per location. A blocked-address list is checked alongside them.\n\n## How a packet is judged\n\nFour things decide the verdict, in this order. Most surprises come from the\nsecond one.\n\n**1. Where rules are applied.** Rules are evaluated on traffic **entering** —\nat the workload bridge, and at a Gateway node's forwarding interfaces. The same\nrule set is applied at every one of those points, so a rule you add is in force\neverywhere traffic arrives, not only on one interface. Narrow it with\n`interface` when you want it to apply in just one place.\n\n**2. The order rules are checked — broad first, then specific.** This is the\nopposite of what most people expect, and it decides everything:\n\n| Order | `level` | Scope |\n|---|---|---|\n| 1st | `global` (default) | Everything |\n| 2nd | `bridge` | One bridge |\n| 3rd | `vlan` | One VLAN |\n| 4th | `private_network` | One network |\n| 5th | `mac` | One workload |\n| 6th | `flow` | One conversation |\n\nWithin the same level, lower `priority` is checked first; rules that tie are\nsettled by creation order so the result is always deterministic.\n\n**The first rule that matches wins, and nothing after it is consulted.** So a\nbroad rule is consulted *before* a narrow one:\n\n> If you allow `10.20.0.0/24` at the default `global` level and then deny one\n> address at `mac` level, **the allow wins** — it is checked first and the deny\n> is never reached. To carve an exception out of a broad rule, give the exception\n> a **lower priority number** so it is checked first, or write the broad rule at\n> a narrower level.\n\n**3. What the rule matches.** Any field you leave out matches anything. A rule\nwith only `chain` set matches every packet — which is why an over-broad rule is\nusually a forgotten field rather than a wrong one.\n\n**4. The default action**, if no rule matched at all.\n\n### A note on `chain`\n\n`chain` is required and recorded on every rule, but it does **not** currently\nchange where the rule is evaluated: rules are applied to traffic as it enters,\nwhichever chain they name. Writing a rule with `chain: \"output\"` does not create\noutbound filtering — it is evaluated on entry like every other rule. Filter\negress by writing the rule against the traffic's entry point instead.\n\n### What `stateful` gives you\n\nA stateful rule means the **reply** to a permitted connection is allowed back\nwithout you writing a second rule for the return direction. Without it you would\nneed a mirrored rule for every service.\n\nThe consequence people hit is at the other end of a connection's life:\n\n> **Tightening a rule does not close connections that are already open.** A new\n> rule governs new connections; conversations already established keep running\n> until they end on their own. This is usually what you want — you are not\n> cutting off live sessions by editing policy. When you *do* need a change to\n> take effect immediately, such as during an incident, clear the tracked\n> connections explicitly (see [Connection tracking](#connection-tracking)).\n\n## The default action\n\nThe firewall has a single no-match (default) action that applies when no rule matches a packet. It defaults to **allow** and is persisted, so a configured default survives a reboot. For a zero-trust posture, set the default to `deny` so traffic is isolated until you add an explicit allow rule.\n\n> Setting the default to `deny` is strongly recommended. An explicit allowlist is much easier to audit than a denylist. Because the default action is global to the firewall, plan your allow rules before flipping it to `deny`.\n\n## How a rule is written\n\n`firewall allow` and `firewall deny` each take **one argument**: a JSON rule object. Writing the rule as JSON keeps one spelling for every field across the CLI, the REST API, and the panel, and it lets a rule carry matchers that a flag-per-option syntax could not express cleanly.\n\nOnly `chain` is required — every other field is a matcher that you leave out when you want it to match anything.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `chain` | string | **Required.** Where the rule is evaluated: `input`, `output`, `forward`, `prerouting`, or `postrouting`. |\n| `protocol` | string | `tcp`, `udp`, `icmp`. Omit to match any protocol. |\n| `source_ip` | string | Source address or CIDR, e.g. `10.20.0.50` or `10.20.0.0/24`. |\n| `dest_ip` | string | Destination address or CIDR. |\n| `source_port` | number | Single source port. |\n| `dest_port` | number | Destination port. With `dest_port_max`, the start of a range. |\n| `dest_port_max` | number | Optional. Makes `dest_port` the start of an inclusive port range, so one rule can cover many ports. |\n| `priority` | number | Evaluation order; lower is evaluated first. |\n| `stateful` | boolean | Use connection tracking so return traffic is admitted automatically. |\n| `interface` | string | Bind the rule to one ingress device, e.g. `cnv-user-br0`. Omit to match any interface. |\n| `mac` | string | Match only frames from this source MAC, e.g. `52:54:00:ab:cd:01`. |\n| `level` | string | Policy scope: `global` (default), `bridge`, `interface`, `mac`, `flow`, `vlan`, or `private_network`. |\n| `comment` | string | Free-text note, shown in `firewall list`. |\n\nField names are matched exactly and an unrecognized key is **rejected with an error**, so a typo fails loudly instead of silently widening the rule. Note that ports are single numeric values — to allow both 80 and 443 you add two rules.\n\n## Adding allow rules\n\nThe `firewall allow` command adds an allow rule. Set `\"stateful\": true` to have the connection-tracking table admit return traffic for established flows, so you only need a rule in one direction.\n\nAllow inbound HTTPS to a specific endpoint:\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"dest_port\":443,\"stateful\":true,\"comment\":\"https to app\"}'\n```\n\nPorts are matched one at a time, so add HTTP as its own rule:\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"dest_port\":80,\"stateful\":true,\"comment\":\"http to app\"}'\n```\n\nA range covers many ports in a single rule, rather than one rule per port:\n\n```bash\n# Allow the whole 8000-9000 range to an application server\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"dest_port\":8000,\"dest_port_max\":9000,\"stateful\":true,\"comment\":\"app port range\"}'\n```\n\nAllow an endpoint to initiate outbound connections (to anywhere):\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"source_ip\":\"10.20.0.50\",\"stateful\":true,\"comment\":\"app egress\"}'\n```\n\nAllow one endpoint to reach a database on another:\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"source_ip\":\"10.20.0.50\",\"dest_ip\":\"10.30.0.60\",\"protocol\":\"tcp\",\"dest_port\":5432,\"stateful\":true}'\n```\n\nBecause the address matchers take CIDRs, a whole-subnet rule is the same command with a prefix instead of a host address — this is how you express \"any host on the app subnet may reach the database subnet\":\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"source_ip\":\"10.20.0.0/24\",\"dest_ip\":\"10.30.0.0/24\",\"protocol\":\"tcp\",\"dest_port\":5432,\"stateful\":true}'\n```\n\nTo confine a rule to traffic arriving on one device, add `interface`:\n\n```bash\nsudo cenvero-str-ctl firewall allow '{\"chain\":\"forward\",\"interface\":\"cnv-user-br0\",\"source_ip\":\"10.20.0.0/24\",\"protocol\":\"tcp\",\"dest_port\":443,\"stateful\":true}'\n```\n\n## Listing and removing rules\n\n```bash\ncenvero-str-ctl firewall list\n```\n\n```text\n{\n  \"data\": {\n    \"rules\": [\n      {\n        \"ID\": 1,\n        \"Chain\": \"forward\",\n        \"Action\": \"accept\",\n        \"Protocol\": \"tcp\",\n        \"SourceIP\": \"\",\n        \"DestIP\": \"10.20.0.50\",\n        \"SourcePort\": 0,\n        \"DestPort\": 443,\n        \"Priority\": 0,\n        \"Stateful\": true,\n        \"Interface\": \"\",\n        \"MAC\": \"\",\n        \"Level\": \"\",\n        \"Comment\": \"https to app\"\n      }\n    ]\n  },\n  \"status\": \"ok\"\n}\n```\n\nRemove a rule by its ID — the `ID` field above:\n\n```bash\nsudo cenvero-str-ctl firewall delete 3\n```\n\nChanges apply to new flows immediately. Established flows already in the connection table continue until they close.\n\n## Explicit deny rules\n\nUse `firewall deny` to add a block rule. It takes the same JSON rule object as `firewall allow`. Give it a lower `priority` number than your allow rules so it is evaluated first — useful for incident response:\n\n```bash\nsudo cenvero-str-ctl firewall deny '{\"chain\":\"forward\",\"source_ip\":\"198.51.100.44\",\"dest_ip\":\"10.20.0.50\",\"protocol\":\"tcp\",\"priority\":10,\"comment\":\"block abusive source\"}'\n```\n\nTo block a source outright, leave the destination and protocol matchers out:\n\n```bash\nsudo cenvero-str-ctl firewall deny '{\"chain\":\"forward\",\"source_ip\":\"198.51.100.0/24\",\"priority\":10}'\n```\n\nAllow and deny rules share the same ordered ACL; the first match wins, so a higher-priority deny is evaluated before the allow rules below it.\n\n## Scheduled rules\n\nAny firewall rule can carry an optional activation window so it is enforced only during certain days and hours. A rule with no schedule is always active — scheduling is opt-in and changes nothing until you set one — and all windows are evaluated in **UTC**.\n\nAttach a schedule to an existing rule by its ID (as shown in `firewall list`). The `--days` flag takes comma-separated day numbers, `0` for Sunday through `6` for Saturday (omit it to mean every day), and `--start`/`--end` take `HH:MM` in UTC:\n\n```bash\n# Enforce rule 7 only on weekdays, 09:00 to 17:00 UTC\nsudo cenvero-str-ctl firewall schedule set 7 --days 1,2,3,4,5 --start 09:00 --end 17:00\n\n# Enforce rule 12 all day, but only on weekends\nsudo cenvero-str-ctl firewall schedule set 12 --days 0,6\n```\n\nAn end time earlier than the start time wraps past midnight (for example `--start 22:00 --end 02:00`). While a rule's window is closed the rule is not enforced; when the window opens it is applied automatically. Window transitions take effect within about 30 seconds, and any change you make applies immediately.\n\nList scheduled rules, with whether each is active right now:\n\n```bash\ncenvero-str-ctl firewall schedule list\n```\n\nClear a rule's schedule to make it always-active again:\n\n```bash\nsudo cenvero-str-ctl firewall schedule clear 7\n```\n\n## Connection tracking\n\n### What it is for\n\nConnection tracking is what lets you write a one-directional rule and still get a working two-way conversation.\n\nWithout it, allowing a workload to reach a database would also require a rule allowing the database's replies back in — and since replies come from an unpredictable port, that second rule would have to be uselessly broad. Instead, the node remembers each conversation it has allowed. When a reply arrives, it is recognised as belonging to a permitted conversation and let through, without any rule permitting it on its own.\n\nSo your policy describes **who may start a conversation with whom**, and the return traffic follows automatically. That is why the examples on this page only ever allow one direction.\n\nThe consequence is the one in the next section: because a conversation is remembered, changing a rule does not affect conversations already under way.\n\nThe agent exposes the connection table for inspection. The command takes no arguments and dumps every tracked flow:\n\n```bash\ncenvero-str-ctl firewall conntrack\n```\n\n### Applying a rule change to connections that are already open\n\nA new or tightened rule only affects **new** connections. A connection that is\nalready established stays in the table and keeps being allowed through until it\nfinishes or ages out. After changing a rule, flush the table so the next packet\nof each flow is checked against your current rules:\n\n```bash\n# Re-evaluate every established connection\nsudo cenvero-str-ctl firewall conntrack-flush\n\n# Only connections to or from one address\nsudo cenvero-str-ctl firewall conntrack-flush 198.51.100.7\n```\n\nFlushing does not close anything by itself — each connection is simply re-checked,\nso the ones your rules still permit carry on.\n\n```text\n{\n  \"data\": {\n    \"flows\": [\n      {\n        \"proto\": 6,\n        \"src\": \"10.20.0.50\",\n        \"sport\": 54321,\n        \"dst\": \"10.30.0.10\",\n        \"dport\": 5432,\n        \"state\": \"established\",\n        \"packets\": 128,\n        \"snat\": false,\n        \"dnat\": false\n      }\n    ]\n  },\n  \"status\": \"ok\"\n}\n```\n\nThe `snat` and `dnat` flags show whether the gateway is translating that flow. If the data plane is not loaded, the dump returns an empty list together with a `note` explaining why rather than failing.\n\n> The table itself is not editable — you cannot delete one specific flow or rewrite an entry. What you can do is **flush**, either everything or everything touching one address, as shown above; each affected connection is then re-checked against your current rules. Flows you do not flush age out on their own.\n\n## Per-source connection limits\n\nYou can cap how many concurrent established connections a single source IPv4 address may hold. A source that exceeds the cap is added to the source-IP blocklist for a self-expiring cooldown — never a permanent ban — and is admitted again automatically once the cooldown elapses. The count comes from the live connection-tracking table and includes only the connections that source initiated. This feature is off by default and is IPv4-only.\n\nEnable it with a maximum, and optionally a cooldown:\n\n```bash\nsudo cenvero-str-ctl firewall connlimit set --max 200 --cooldown 10m\n```\n\n| Flag | Description |\n|------|-------------|\n| `--max` | Maximum concurrent established connections per source IPv4 (required, must be greater than 0) |\n| `--cooldown` | How long an over-limit source stays blocked, e.g. `10m` or `1h` (default `10m`) |\n\nShow the current configuration and any sources blocked right now:\n\n```bash\ncenvero-str-ctl firewall connlimit status\n```\n\nDisable per-source connection limiting:\n\n```bash\nsudo cenvero-str-ctl firewall connlimit clear\n```\n\nThis is a cap on *concurrent* connections. It is a different control from intrusion detection below, which watches the *rate* at which a source starts new connections but does not cap anything by itself.\n\n## Intrusion detection\n\nIntrusion detection watches one interface's incoming traffic and keeps a per-source tally of the behaviour that precedes an attack: connection attempts that are never completed, the same source touching many different ports, and an unusual rate of new connections. Those are the signatures of port scanning and flooding.\n\nIt is **off until you name an interface to watch** — typically your uplink, or the workload bridge if you want to see lateral scanning between your own workloads.\n\n**Detection never drops traffic.** This is the important property. The detector only counts and reports; a packet that trips every signal still passes. What you get from detection alone is an event telling you a source is behaving like a scanner — which is what you want, because the alternative is a false positive silently cutting off a legitimate client.\n\nActing on a detection is a separate, opt-in decision:\n\n| Mode | Behaviour |\n|---|---|\n| **Alert only** (default) | A confirmed scanner or flooder raises an event. Traffic is untouched. |\n| **Auto-block** | A confirmed IPv4 source is added to the firewall blocklist, with an expiry so the block lifts on its own. |\n\nAuto-block is off by default deliberately: it turns a detection into a connectivity outage for whoever tripped it. Run in alert-only first, look at what it actually catches on your network, and enable blocking once you trust the signal. The block is time-limited rather than permanent, so a mistake heals without you intervening.\n\nBoth settings are delivered in the node's configuration — see [Configuration → Intrusion detection](/docs/configuration). Detections surface as events alongside everything else; see [Monitoring](/docs/monitoring).\n\n## Anti-spoof enforcement on the bridge\n\nIndependently of the ACL, the data plane enforces anti-spoofing on the workload bridge so an endpoint cannot impersonate another:\n\n- **MAC binding** — the source MAC of every frame must be a MAC the agent bound to that port; an unknown source MAC is dropped (default-deny on MACs).\n- **IPv4 source guard + Dynamic ARP Inspection** — an IPv4 source address bound to a MAC must arrive from its bound MAC, and an ARP sender hardware address must match the frame's source MAC and the IP↔MAC binding.\n- **IPv6 source guard + ND inspection** — the 16-byte IPv6 source is guarded the same way, and Neighbor Discovery messages must carry the frame's real source MAC (anti-ND-spoofing).\n\nThese checks run before the ACL, so spoofed frames never reach the rule evaluation.\n\n## RA-guard & DHCP snooping\n\nOn top of the MAC and IP source guards above, the workload bridge blocks two more ways a rogue endpoint could hijack its neighbors. Because a tenant workload is never a legitimate router or DHCP server, the data plane drops traffic that claims those roles when it originates from a tenant bridge port:\n\n- **IPv6 RA-guard** — ICMPv6 Router Advertisement and Redirect messages sent from a tenant port are dropped, defeating a rogue-default-gateway or man-in-the-middle attempt. Router Solicitation and Neighbor Discovery (neighbor solicit/advertise) messages are still allowed, and remain subject to the neighbor-discovery checks above.\n- **DHCP snooping** — a DHCP server or relay reply from a tenant port is dropped: an IPv4 DHCP reply (UDP source port 67) or a DHCPv6 reply (UDP source port 547). Clients send from ports 68 and 546, so ordinary DHCP requests (discover, solicit, request) are unaffected; only forged server replies are blocked.\n\nLike the source guards, these drops are counted among the anti-spoof drops and are applied before the ACL is evaluated.\n\n## See also\n\n- [Networking Overview](/docs/networking/overview) — where firewall enforcement sits in the data plane.\n- [Load Balancer](/docs/networking/load-balancer) — VIP addresses need their own allow rules for external access.\n- [BGP Edge Routing](/docs/networking/bgp) — north-south traffic from Gateway nodes also passes through the firewall.\n- [Quick Start](/docs/quickstart) — basic policy example.\n"
        },
        {
            "slug": "networking/dhcp-dns",
            "title": "DHCP & DNS",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/dhcp-dns",
            "headings": [
                {
                    "level": 1,
                    "text": "DHCP & DNS"
                },
                {
                    "level": 2,
                    "text": "How a workload gets its address"
                },
                {
                    "level": 2,
                    "text": "How a name is resolved"
                },
                {
                    "level": 2,
                    "text": "DHCP pools"
                },
                {
                    "level": 3,
                    "text": "Reservations (static leases)"
                },
                {
                    "level": 3,
                    "text": "Inspecting leases"
                },
                {
                    "level": 2,
                    "text": "Relayed clients"
                },
                {
                    "level": 2,
                    "text": "Authoritative DNS"
                },
                {
                    "level": 3,
                    "text": "Adding manual DNS records"
                },
                {
                    "level": 3,
                    "text": "Upstream forwarders"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1591,
            "markdown": "# DHCP & DNS\n\nStratum runs its own DHCP server and authoritative DNS inside the agent process — there are no external daemons to configure or keep in sync. Both serve whatever scopes and zones you configure; creating a network does not configure them for you. Addresses are handed to a network's endpoints (an endpoint is an IP ↔ MAC profile). This page covers pool configuration, static leases, DHCP relay, and DNS zone management.\n\n## How a workload gets its address\n\nFour things are consulted, in this order, and the first that applies wins:\n\n1. **An address it already holds.** A workload that comes back before its lease\n   expires gets the same address, so a reboot does not renumber anything.\n2. **A reservation for its hardware address**, if you have made one.\n3. **An offer it was already given**, so a client that asks repeatedly is not\n   handed a different address each time.\n4. **The next free address in the network's range.**\n\n> **Adding a reservation does not move a workload that already has a lease.**\n> Step 1 is checked first, so the workload keeps its current address until that\n> lease expires or you release it. If you need the reservation to take effect\n> immediately, release the existing lease after making it.\n\nOffering and committing are separate: the address is held tentatively when\noffered and only becomes a lease once the client accepts it. An offer a client\nnever takes up is returned to the pool automatically rather than leaking.\n\nReservations require a DHCP scope covering that subnet. Creating a network does\n**not** create one — see [DHCP pools](#dhcp-pools) below. If you see *\"no address\npool configured for this network\"* when reserving, either no scope covers that\naddress or none is configured at all.\n\n## How a name is resolved\n\nA query is answered by the first of these that applies:\n\n1. **Blocked names** are refused outright, whatever else would have answered.\n2. **Your own zones.** If the name is in a zone this node is authoritative for,\n   the answer comes from your records — and if it is *not* there, the answer is\n   an authoritative \"no such name\". The query is never forwarded, because this\n   node is the authority for that zone and the correct answer is that it does not\n   exist.\n3. **Upstream resolvers**, for everything else — but only if the asking client is\n   permitted to use recursion. A client outside the permitted set is refused\n   rather than served, so the node cannot be used as an open resolver by\n   strangers.\n4. With no upstreams configured and no local answer, the reply is \"no such name\".\n\n> The practical consequence of step 2: once you create a zone, this node owns\n> **every** name under it. A name you have not added returns \"does not exist\"\n> rather than falling through to the public internet. That is what makes an\n> internal zone trustworthy — but it means creating `example.com` internally\n> stops you resolving the real one.\n\nRecords can also be scoped to a source subnet, so the same name answers\ndifferently depending on who is asking — an internal client gets the internal\naddress while everyone else gets the public one, from one zone.\n\n## DHCP pools\n\n`network create` **does** create the address pool for the subnet. What it does not\ncreate is the **DHCP scope** that binds serving to that pool — and until a scope\nexists the DHCP server has no address to offer for that subnet, pool or no pool.\n\nThat failure is quiet by design. RFC 2131 says a server with nothing to offer\nmust not reply, so the client simply retries: its interface shows DHCP going out\nand nothing coming back, which looks identical to a broken L2 path. The agent\nlogs a warning naming the client — *\"no address pool for this client's\nnetwork\"* — the first time it happens, then holds it down so a fresh node is not\nburied in noise. If a workload never gets an address, search the agent log for\nthat line before you go looking at the bridge.\n\nOnce a pool exists and a scope is bound to it, leases are allocated from the IPAM\npool and released back to it on expiry.\n\n```bash\nsudo cenvero-str-ctl network create \\\n  --name db-net \\\n  --cidr 10.30.0.0/24 \\\n  --gateway 10.30.0.1\n```\n\n`network create` accepts `--name`, `--cidr`, and optionally `--gateway`, `--vlan`, and `--tenant`. (Only IPv4 CIDRs are supported.)\n\nThe DHCP server tells clients:\n\n| Option | Value |\n|--------|-------|\n| Subnet mask | derived from `--cidr` |\n| Default gateway | `--gateway` |\n| DNS server | the agent's gateway IP (same as `--gateway`) |\n| Lease time | the server's lease TTL |\n\nNo domain-name or domain-search option is offered, so configure a search domain on the workload itself if you need short names to resolve.\n\n### Reservations (static leases)\n\nPin a specific IP to a MAC address so an endpoint always gets the same address. The address is matched to its pool from the IP itself, so the reservation does not name a network:\n\n```bash\nsudo cenvero-str-ctl dhcp reserve \\\n  --mac 52:54:00:de:ad:01 \\\n  --ip 10.30.0.10 \\\n  --hostname db-primary\n```\n\nThe MAC and IP may also be given positionally, which is handy for one-liners:\n\n```bash\nsudo cenvero-str-ctl dhcp reserve 52:54:00:de:ad:01 10.30.0.10 --hostname db-primary\n```\n\nA reservation pins the address only — it does not create a DNS record. To make the hostname resolve, add a DNS record with `dns record add` (or the API); see **Adding manual DNS records** below.\n\nList the reservations you have made:\n\n```bash\ncenvero-str-ctl dhcp reservations\n```\n\n### Inspecting leases\n\n`dhcp leases` dumps every active lease the server has handed out:\n\n```bash\ncenvero-str-ctl dhcp leases\n```\n\nTo release a reservation early (e.g. before re-provisioning a workload), identify it by MAC:\n\n```bash\nsudo cenvero-str-ctl dhcp release --mac 52:54:00:ab:01:02\n```\n\nThe MAC may be positional here too — `dhcp release 52:54:00:ab:01:02` is equivalent.\n\n## Relayed clients\n\nClients on a segment that has no directly-attached agent — for example a physical VLAN reached through an external DHCP relay — are supported. Point your existing relay agent at the node, and the relayed request is matched to the right scope by the relay's `giaddr` (the gateway/relay address the relay stamps into the request). Add a scope whose `subnet` covers that segment (see [Network Services Control → Scopes](/docs/networking/services)) and the built-in server answers the relayed request from that scope's pool.\n\n## Authoritative DNS\n\nThe agent runs a DNS server bound to the bridge addresses (the management and user bridge IPv4 addresses) rather than every interface, so the resolver is never exposed on an untrusted NIC.\n\nZones are explicit: creating a network does not create a zone for it, so you choose the domain and create it yourself. Pick any name you like — a `.internal` suffix is a good convention for names that should never resolve publicly.\n\n```bash\nsudo cenvero-str-ctl dns zone add app-net.internal\n```\n\n```text\n{\n  \"data\": {\n    \"id\": 1,\n    \"name\": \"app-net.internal.\",\n    \"serial\": 1,\n    \"status\": \"created\"\n  },\n  \"status\": \"ok\"\n}\n```\n\nThe `id` in that response is the **zone id**, and every record command refers to the zone by that numeric id rather than by name. List your zones at any time to look one up:\n\n```bash\ncenvero-str-ctl dns zones\n```\n\nA DHCP lease does not by itself create a DNS record. To make a hostname resolve to a leased (or reserved) address, add an A record for it — it then resolves inside the zone:\n\n```text\nweb-01.app-net.internal    →  10.20.0.50\ndb-primary.db-net.internal →  10.30.0.10\n```\n\n### Adding manual DNS records\n\n`dns record add` takes positional arguments: the zone id, the record name, its type, its value, and optionally a TTL and a source subnet.\n\n```bash\n# dns record add <zone_id> <name> <type> <value> [ttl] [source_subnet]\nsudo cenvero-str-ctl dns record add 1 api A 10.20.0.55 300\n\nsudo cenvero-str-ctl dns record add 1 services CNAME api.app-net.internal\n```\n\nThe name is relative to the zone, so `api` in zone `app-net.internal` becomes `api.app-net.internal`. When you omit the TTL a default is applied. `dns add` is a shorthand alias for `dns record add` and takes exactly the same arguments.\n\nList records for a zone by its id:\n\n```bash\ncenvero-str-ctl dns list 1\n```\n\n```text\n{\n  \"data\": {\n    \"records\": [\n      {\n        \"id\": 3,\n        \"zone_id\": 1,\n        \"name\": \"api.app-net.internal.\",\n        \"type\": \"A\",\n        \"value\": \"10.20.0.55\",\n        \"ttl\": 300\n      }\n    ]\n  },\n  \"status\": \"ok\"\n}\n```\n\nOmit the zone id (`dns list`), or use `dns records`, to list records across every zone.\n\nRemove a record by its **record id** — the `id` field above, not its name:\n\n```bash\nsudo cenvero-str-ctl dns record delete 3\n```\n\nDelete a whole zone by its zone id with `dns zone delete 1`.\n\n### Upstream forwarders\n\nQueries for names outside the local zones are forwarded to configurable upstream resolvers. Manage them at runtime with the `dns forwarder` commands:\n\n```bash\n# Show the active forwarders\ncenvero-str-ctl dns forwarder list\n\n# Replace the whole list\nsudo cenvero-str-ctl dns forwarder set 1.1.1.1 8.8.8.8\n\n# Add or remove one\nsudo cenvero-str-ctl dns forwarder add 9.9.9.9\nsudo cenvero-str-ctl dns forwarder remove 8.8.8.8\n```\n\nEach forwarder is an IP or `IP:port` (a bare IP defaults to `:53`). The agent tries forwarders in order and falls back to the next on timeout. `dns forwarder` changes take effect on the running agent; to persist them across restarts, set the `dns_upstreams` key:\n\n```bash\nsudo cenvero-str-ctl config set dns_upstreams \"1.1.1.1,8.8.8.8\"\n```\n\nThe agent falls back to public defaults when the list is empty.\n\n> **Not an open resolver.** Recursion (forwarding a query upstream) is gated by a client ACL. When the ACL is unset it defaults to the private and loopback ranges (`127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`), so only clients inside those ranges have their queries forwarded; a query from outside the allowed set is refused rather than silently forwarded. Authoritative answers for local zones are always returned regardless of the ACL.\n\n## See also\n\n- [Networking Overview](/docs/networking/overview) — how DHCP and DNS fit into the data plane.\n- [Zero-Trust Firewall](/docs/networking/firewall) — DNS traffic on port 53 must be explicitly allowed for cross-network queries.\n- [Configuration](/docs/configuration) — how settings reach a node and what lives in the node config.\n"
        },
        {
            "slug": "networking/services",
            "title": "Network Services Control",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/services",
            "headings": [
                {
                    "level": 1,
                    "text": "Network Services Control"
                },
                {
                    "level": 2,
                    "text": "The `service` command group"
                },
                {
                    "level": 3,
                    "text": "Seeing what is running"
                },
                {
                    "level": 3,
                    "text": "Turning a service on or off"
                },
                {
                    "level": 2,
                    "text": "Configuring DNS"
                },
                {
                    "level": 3,
                    "text": "Listen address"
                },
                {
                    "level": 3,
                    "text": "Allowed clients (recursion ACL)"
                },
                {
                    "level": 3,
                    "text": "Zones, records, and forwarders"
                },
                {
                    "level": 2,
                    "text": "Configuring DHCP"
                },
                {
                    "level": 3,
                    "text": "Scopes"
                },
                {
                    "level": 3,
                    "text": "Reservations and leases"
                },
                {
                    "level": 2,
                    "text": "Quick reference"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1437,
            "markdown": "# Network Services Control\n\nThe agent runs a small set of **network services** alongside the data plane — among them the built-in DNS resolver and the DHCP server. Each one is independently switchable, so an operator can turn DNS or DHCP on or off, see whether it is actually listening, and tune how it binds and who may talk to it. This page covers the `service` command group, the DNS and DHCP configuration knobs, and the `dns …` / `dhcp …` operator commands. For zone and pool concepts, see [DHCP & DNS](/docs/networking/dhcp-dns).\n\n## The `service` command group\n\n`cenvero-str-ctl service` shows and toggles the agent's switchable network services: the REST, gRPC and WebSocket management APIs, the Prometheus metrics endpoint, **DNS**, and **DHCP**. The local control socket that `cenvero-str-ctl` itself rides is always up and is deliberately *not* a toggleable service — it cannot be turned off.\n\n### Seeing what is running\n\n```bash\ncenvero-str-ctl service status\n```\n\n```text\nNetwork services\n================\nSERVICE        ENABLED  ADDRESS            STATE      NOTE\nREST API       on       0.0.0.0:7070       listening\ngRPC API       on       0.0.0.0:7071       listening\nWebSocket API  on       0.0.0.0:7072       listening\nMetrics        on       127.0.0.1:9090     listening\nDNS            on       10.0.0.1:53        listening\nDHCP           on       :67                listening\n\nENABLED reflects the operator switch (applied at next restart); STATE is a live listen probe.\nApply a change with:  systemctl restart cenvero-stratum\n```\n\nThe two columns mean different things:\n\n- **ENABLED** is the operator switch — what the agent *would* start on its next restart.\n- **STATE** is a live probe of the address: `listening` (something is bound there now), `down` (nothing is), or `unknown` (the address could not be probed).\n\n`status` also answers to `list`, `top`, `ls`, and `ps`, and a bare `cenvero-str-ctl service` prints the same table. Add `--format json` (or `yaml`) for machine-readable output.\n\n### Turning a service on or off\n\nBoth word orders work, as do the `enable`/`disable` aliases:\n\n```bash\n# Turn the DNS resolver off\nsudo cenvero-str-ctl service dns off\n\n# Turn the DHCP server back on (these are equivalent)\nsudo cenvero-str-ctl service dhcp on\nsudo cenvero-str-ctl service on dhcp\nsudo cenvero-str-ctl service enable dhcp\n```\n\nToggling a service records its enable flag in the agent's local override store — the same mechanism `config set` uses — so the change **persists across reboots**. It is *not* applied to the running process immediately: it takes effect on the next agent restart. The command reminds you how:\n\n```text\nService dns (DNS) disabled.\nVerify with:  cenvero-str-ctl service status\n```\n\n```bash\nsudo systemctl restart cenvero-stratum\n```\n\n> **DNS and DHCP only switch their network listeners.** With DHCP off, the agent stops binding UDP `:67` (it hands out no addresses) but the lease table and its expiry bookkeeping keep running, so existing leases are still tracked. With DNS off, the resolver does not bind a port; zone and record data is untouched and still served over the management API. Turning the service back on and restarting re-binds it.\n\nThe IPC control socket cannot be disabled — asking to turn off `ipc` (or `socket`) is refused with an explanation rather than silently accepted, because it is the channel this very command travels over.\n\n## Configuring DNS\n\nThe agent is **authoritative** for each network's internal zone (e.g. `app-net.internal`) and acts as a **recursive forwarder** for everything else. Two settings control where it listens and who may use it for recursion.\n\n### Listen address\n\nBy default the resolver binds the **management bridge's** IPv4 address (falling back to the user bridge, then loopback `127.0.0.1:53` before a bridge has an address). It is **never** bound to a wildcard such as `0.0.0.0:53`, so it is not exposed as an open forwarder on an untrusted NIC.\n\nTo pin it to a specific address, set `dns_listen_addr` to a bare IP or a `host:port`. `config set` takes the key and value positionally:\n\n```bash\nsudo cenvero-str-ctl config set dns_listen_addr 10.0.0.1\n```\n\nSetting it back to empty reverts to the bridge-address default:\n\n```bash\nsudo cenvero-str-ctl config set dns_listen_addr \"\"\n```\n\nThe address change is applied on the next agent restart; confirm the resulting bind with `service status`.\n\n### Allowed clients (recursion ACL)\n\nRecursion — forwarding a query for a name *outside* the local zones to an upstream resolver — is gated by a client ACL so the agent is never an open resolver. Set the allowed clients as a comma-separated list of IPs and/or CIDRs:\n\n```bash\nsudo cenvero-str-ctl config set dns_allowed_clients \"10.20.0.0/24,10.30.0.0/24\"\n```\n\nWhen the list is empty (the default) it falls back to the loopback and private ranges (`127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`). A query from a client outside the allowed set is **refused**, not silently forwarded. Authoritative answers for the local zones are always returned regardless of the ACL.\n\n### Zones, records, and forwarders\n\nDay-to-day zone and record management uses the `dns` command group:\n\n| Command | Purpose |\n|---------|---------|\n| `dns zones` | List the authoritative zones with their ids |\n| `dns zone add <name>` | Create a zone |\n| `dns zone delete <zone_id>` | Delete a zone |\n| `dns records` / `dns list [zone_id]` | List records (all zones, or one zone) |\n| `dns record add <zone_id> <name> <type> <value> [ttl]` | Add a record to a zone |\n| `dns record delete <record_id>` | Remove a record |\n\nZones and records are addressed by their numeric ids, which `dns zones` and `dns list` report:\n\n```bash\n# List zones to find the zone id, then add and remove a record\ncenvero-str-ctl dns zones\nsudo cenvero-str-ctl dns record add 1 api A 10.20.0.55 300\ncenvero-str-ctl dns list 1\nsudo cenvero-str-ctl dns record delete 3\n```\n\n`dns add` and `dns delete` are shorthand aliases for `dns record add` and `dns record delete`.\n\n**Upstream forwarders** (the resolvers recursion is sent to) are managed live with the `dns forwarder list/set/add/remove` commands and take effect on the running agent; persist them across restarts with `config set dns_upstreams <ip,...>`. See [DHCP & DNS → Upstream forwarders](/docs/networking/dhcp-dns).\n\n## Configuring DHCP\n\nThe DHCP server leases addresses to each network's endpoints from that network's IPAM pool and binds UDP `:67` while it is enabled.\n\n### Scopes\n\nA **scope** binds a client subnet to its own IPAM pool and the reply parameters advertised to clients on that subnet — the server identity, gateway, subnet mask, DNS servers, and an optional per-scope lease length. A relayed request is matched to its scope by the relay's `giaddr`; a directly-attached one by the interface it arrived on. A network created with `network create` gets a scope automatically; multiple scopes let one agent serve several subnets (including relayed ones).\n\nScopes (`dhcp_scopes`) are part of the node's configuration rather than a live `config set` field — they are provisioned with the node config, not set on the node. Each scope carries:\n\n| Field | Meaning |\n|-------|---------|\n| `subnet` | The client subnet the scope serves (CIDR) |\n| `pool_id` | The IPAM pool addresses are leased from |\n| `server_ip` | The DHCP server identity advertised to clients |\n| `gateway` | The default gateway offered to clients |\n| `subnet_mask` | The subnet mask offered to clients |\n| `dns` | The DNS server(s) offered to clients |\n| `lease_seconds` | Optional per-scope lease length (0 = the server default) |\n\n### Reservations and leases\n\nUse the `dhcp` command group to inspect leases and to pin or release an address:\n\n| Command | Purpose |\n|---------|---------|\n| `dhcp leases` | List current leases (MAC, IP, hostname, expiry) |\n| `dhcp reserve` | Pin a specific IP to a MAC (a static lease) |\n| `dhcp release` | Release a lease/reservation early |\n\nThe reserved IP selects its own pool, so these commands identify a client by MAC rather than by network:\n\n```bash\n# Always hand db-primary the same address\nsudo cenvero-str-ctl dhcp reserve \\\n  --mac 52:54:00:de:ad:01 \\\n  --ip 10.30.0.10 \\\n  --hostname db-primary\n\n# Inspect what is leased, and what is pinned\ncenvero-str-ctl dhcp leases\ncenvero-str-ctl dhcp reservations\n\n# Release a reservation before re-provisioning a workload\nsudo cenvero-str-ctl dhcp release --mac 52:54:00:ab:01:02\n```\n\nA reservation pins the address only — it does not create a DNS record. To make the hostname resolve to the reserved IP, add a DNS record for it with `dns record add` (or the API). See [DHCP & DNS → Reservations](/docs/networking/dhcp-dns) for how reservations and the lease table behave in more depth.\n\n## Quick reference\n\n| Task | Command |\n|------|---------|\n| See every service's switch + live state | `cenvero-str-ctl service status` |\n| Turn DNS off / on | `sudo cenvero-str-ctl service dns off` / `… dns on` |\n| Turn DHCP off / on | `sudo cenvero-str-ctl service dhcp off` / `… dhcp on` |\n| Apply a service toggle | `sudo systemctl restart cenvero-stratum` |\n| Set the DNS listen address | `sudo cenvero-str-ctl config set dns_listen_addr <ip>` |\n| Set the DNS recursion allow-list | `sudo cenvero-str-ctl config set dns_allowed_clients <cidrs>` |\n| Reserve / release a DHCP address | `sudo cenvero-str-ctl dhcp reserve …` / `dhcp release …` |\n| Manage a DNS record | `sudo cenvero-str-ctl dns add …` / `dns delete …` |\n\n## See also\n\n- [DHCP & DNS](/docs/networking/dhcp-dns) — pools, leases, zones, and forwarders in depth.\n- [Networking Overview](/docs/networking/overview) — where DNS and DHCP sit in the data plane.\n- [Zero-Trust Firewall](/docs/networking/firewall) — DNS on port 53 must be explicitly allowed for cross-network queries.\n- [Configuration](/docs/configuration) — how settings reach a node and what lives in the node config.\n- [CLI Reference](/docs/cli) — the full command surface.\n"
        },
        {
            "slug": "networking/gateway-nat",
            "title": "Gateway NAT & Internet Access",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/gateway-nat",
            "headings": [
                {
                    "level": 1,
                    "text": "Gateway NAT & Internet Access"
                },
                {
                    "level": 2,
                    "text": "Which nodes can do this"
                },
                {
                    "level": 2,
                    "text": "The two directions"
                },
                {
                    "level": 3,
                    "text": "Where the firewall fits"
                },
                {
                    "level": 2,
                    "text": "Publishing a service (inbound)"
                },
                {
                    "level": 2,
                    "text": "Masquerade (shared public IP)"
                },
                {
                    "level": 2,
                    "text": "Hairpin NAT (reaching your own public IP)"
                },
                {
                    "level": 3,
                    "text": "Two workloads on the same node stay on that node"
                },
                {
                    "level": 2,
                    "text": "NAT64 (IPv6-only clients)"
                },
                {
                    "level": 2,
                    "text": "ICMP / ping"
                },
                {
                    "level": 2,
                    "text": "Command reference"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1766,
            "markdown": "# Gateway NAT & Internet Access\n\nA node connects your private fabric to the public internet. Tenant workloads sit on a private subnet whose addresses are not routable on the internet, while the node holds an uplink with a public IP. NAT rewrites addresses as traffic crosses that boundary, so a whole tenant subnet can reach the internet over one shared public IP and so you can publish internal services on it.\n\nTranslation happens in the kernel, in the same pass as the firewall and routing decisions, so nothing is proxied and no extra hop is added. Throughput depends on your traffic profile and hardware — measure it for your workload rather than assuming a figure.\n\nGateway NAT is opt-in. With no rules in place, source NAT is off and the data path is unchanged, so you enable internet access one subnet at a time.\n\n## Which nodes can do this\n\nAll of them. There is one kind of node: every node hosts workloads, and every\nnode can sit at the edge, translate addresses and exchange routes with your\nupstream routers.\n\nEarlier versions asked you to pick Compute or Gateway when the node was\ninstalled, and the choice could not be changed afterwards. That is gone —\nsee [Nodes](/docs/nodes). Nodes running an older version are migrated on update\nwith nothing for you to do.\n\nWhat still varies is your topology, not the software: a node needs an uplink to\nthe outside world before it can be an edge for anything, and NAT is opt-in per\nsubnet either way.\n\n## The two directions\n\nEverything on this page is one of two movements, and they use different rules.\n\n**Outbound — many workloads, one public address.** A workload opens a\nconnection to the internet. The Gateway replaces the workload's private source\naddress with its own public one, remembers the conversation, and puts the\noriginal address back on the replies. The internet sees only your public address.\nThis is **masquerade**, below.\n\n**Inbound — one public port, one internal service.** Something on the internet\nconnects to your public address on a chosen port, and the Gateway sends it to a\nspecific workload. This is a **port forward**, and it is the only way traffic\nstarts from outside; masquerade alone does not let anyone in.\n\n```\noutbound   workload ─► Gateway (source becomes public) ─► internet\ninbound    internet ─► Gateway (destination becomes the workload) ─► workload\n```\n\nThey are independent. A subnet with masquerade can reach out without publishing\nanything, and a published service works whether or not that subnet also has\nmasquerade.\n\n### Where the firewall fits\n\nNAT decides *where* a packet goes; the firewall decides *whether* it may. Both\napply — publishing a service does not exempt it from firewall policy, so an\ninbound port forward still needs a rule permitting the traffic if your default\naction is deny. Plan the two together: a forward with no matching allow rule is a\nservice that appears configured and answers nothing.\n\n## Publishing a service (inbound)\n\nA port forward maps **one public address and port** to **one workload address and\nport**. It is available through the API:\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/forward\" \\\n  -H \"Authorization: Bearer $TOKEN\" -H \"Content-Type: application/json\" \\\n  -d '{\"dest_ip\":\"203.0.113.10\",\"dest_port\":443,\"target_ip\":\"10.20.0.50\",\"target_port\":8443}'\n```\n\n`dest_ip` and `dest_port` are what the outside world connects to; `target_ip` and\n`target_port` are the workload that should receive it. The ports do not have to\nmatch — publishing 443 to an application listening on 8443 is normal.\n\nList and remove them the same way:\n\n```bash\ncurl -k \"$NODE/api/v1/forward\" -H \"Authorization: Bearer $TOKEN\"\ncurl -k -X DELETE \"$NODE/api/v1/forward/<id>\" -H \"Authorization: Bearer $TOKEN\"\n```\n\nOne public port goes to one target. To publish several services, add a forward\nfor each, using a different public port or a different public address.\n\n## Masquerade (shared public IP)\n\nMasquerade (source NAT) lets an entire private LAN subnet reach the internet through a single public IP. When a tenant on the subnet opens an outbound connection, the Gateway rewrites the packet's source address to the WAN interface's IP and tracks the flow; replies arriving at that public IP are translated back to the original tenant. TCP, UDP, and ICMP echo (ping) are all masqueraded.\n\nAdd a rule by giving the LAN subnet as a CIDR and the WAN egress interface:\n\n```bash\nsudo cenvero-str-ctl gateway snat add 10.50.0.0/24 --wan cnv-nic-0\n```\n\nThe public IP is taken from the WAN interface itself, so you name the interface rather than an address. The response confirms the stored rule:\n\n```json\n{\n  \"status\": \"added\",\n  \"id\": 1,\n  \"type\": \"snat\",\n  \"source_cidr\": \"10.50.0.0/24\",\n  \"interface\": \"cnv-nic-0\"\n}\n```\n\nList the NAT rules in place — this shows both masquerade rules and any published port-forwards:\n\n```bash\ncenvero-str-ctl gateway snat list\n```\n\n```json\n{\n  \"nat_rules\": [\n    {\n      \"id\": 1,\n      \"type\": \"snat\",\n      \"source_ip\": \"10.50.0.0/24\",\n      \"interface\": \"cnv-nic-0\"\n    }\n  ]\n}\n```\n\nRemove a rule by its id:\n\n```bash\nsudo cenvero-str-ctl gateway snat remove 1\n```\n\nWith no masquerade rule, outbound tenant traffic is not translated. Add one rule per LAN subnet you want to give internet access.\n\n## Hairpin NAT (reaching your own public IP)\n\nHairpin NAT (also called NAT loopback) handles the case where a tenant on the LAN reaches one of your own published services by its public address. Suppose you publish an internal service on the Gateway's public IP, and a client on the same LAN connects to that public IP instead of the internal address. Without hairpin, the server's reply would return straight to the client without passing back through the Gateway, and the connection would break.\n\nWith hairpin, the Gateway rewrites both ends of the forward packet: the destination is translated to the internal host (as with any published service), and the source is translated to the Gateway's own LAN address. The reply then comes back to the Gateway, which reverses both translations, so the client sees a consistent, working conversation.\n\nHairpin is automatic — there is no separate command. For IPv4 it engages when:\n\n- a masquerade rule covers the LAN subnet (this is what tells the Gateway its own address on that subnet), and\n- the client and the published service's target host are both on that subnet.\n\nIf there is no masquerade rule for the subnet, a published service still works normally from outside; only the same-subnet loopback case relies on hairpin. Hairpin NAT is IPv4 only. See the [API reference](/docs/api) for publishing a service with a port-forward rule.\n\n### Two workloads on the same node stay on that node\n\nThis is the case worth knowing about if you host several customers on one server.\n\nOne workload calls another's **public** address — a VPS calling an API next door,\nsay. Both addresses are on your node. That traffic is turned around **inside the\nnode**. It does not go out to your provider's router and come back.\n\nWhat that buys you:\n\n| | |\n|---|---|\n| Latency | a local hop instead of a round trip off the box |\n| Your uplink | untouched, so it stays free for traffic that genuinely leaves |\n| Provider bandwidth | not consumed — the packets never reach the provider |\n\n**It is still fully accounted.** Usage is measured in the packet path, not at the\nuplink, so both workloads are metered exactly as they would be for external\ntraffic. Bandwidth limits, shared pools, monthly quotas and the node's licensed\nspeed ceiling all apply to it. Keeping traffic local speeds it up; it does not\nmake it invisible or free.\n\nThis is not a mode you turn on — it is what happens when both addresses are on\nthe same node.\n\n> **Private addresses behave differently, on purpose.** Traffic between the\n> private networks of two *different* tenants is refused, because private\n> addresses are not reachable from outside and blocking them is real separation.\n> Public addresses are reachable from the internet by definition, so refusing the\n> local shortcut between two of them would not separate anything — it would only\n> make the same conversation slower.\n\n## NAT64 (IPv6-only clients)\n\nNAT64 lets an IPv6-only tenant reach an IPv4-only service on the internet. The client sends traffic to an IPv4 destination embedded in the well-known NAT64 prefix `64:ff9b::/96`; the Gateway translates the IPv6 packet to IPv4 (sourced from a shared IPv4 pool address) and translates the reply back to IPv6. This covers TCP and UDP.\n\nNAT64 is off by default. Turn it on by first configuring an IPv4 pool address, then enabling it:\n\n```bash\nsudo cenvero-str-ctl nat64 configure 203.0.113.10\nsudo cenvero-str-ctl nat64 enable\n```\n\nThe pool address is the public IPv4 address that translated flows are sourced from. To use a prefix other than the well-known one, pass it as a second argument:\n\n```bash\nsudo cenvero-str-ctl nat64 configure 203.0.113.10 64:ff9b::/96\n```\n\nCheck the current state and the number of active flows:\n\n```bash\ncenvero-str-ctl nat64 status\n```\n\n```json\n{\n  \"enabled\": true,\n  \"prefix\": \"64:ff9b::/96\",\n  \"v4_pool\": \"203.0.113.10\",\n  \"active_bindings\": 0,\n  \"scope\": \"TCP/UDP (ICMP + fragmentation not translated)\"\n}\n```\n\nDisable it to return the Gateway to its previous behavior:\n\n```bash\nsudo cenvero-str-ctl nat64 disable\n```\n\nNAT64 translates TCP and UDP. ICMP and fragmented datagrams are passed through untranslated rather than dropped. IPv6-only clients typically rely on a DNS64 resolver to synthesize `64:ff9b::` addresses for IPv4-only names; DNS64 is a resolver function and is configured separately from the Gateway.\n\n## ICMP / ping\n\nTenants behind a masquerade rule can ping IPv4 hosts on the internet. ICMP echo requests are source-NATed to the WAN IP alongside TCP and UDP, and the matching echo replies are translated back to the originating tenant, so ordinary `ping` connectivity checks work through the Gateway.\n\nThis covers ICMP echo (ping) only. It rides on the same masquerade rule — there is no separate command to enable it — so as soon as a subnet has a masquerade rule, its tenants can ping out. ICMP error messages are not translated.\n\n## Command reference\n\nMutating commands require root, so prefix them with `sudo`; the read-only `list` and `status` commands do not.\n\n| Command | Action |\n|---------|--------|\n| `gateway snat add <lan-cidr> --wan <iface>` | Masquerade a private LAN subnet out the WAN interface's public IP |\n| `gateway snat list` | List NAT rules (masquerade and published port-forwards) |\n| `gateway snat remove <id>` | Remove a NAT rule by id |\n| `nat64 configure <v4-pool> [prefix]` | Set the IPv4 pool address (and optional `/96` prefix) |\n| `nat64 enable` | Enable NAT64 (requires a configured pool) |\n| `nat64 disable` | Disable NAT64 (the Gateway path reverts to unchanged) |\n| `nat64 status` | Show NAT64 state, prefix, pool, and active flow count |\n\n## See also\n\n- [Networking Overview](/docs/networking/overview) — where the Gateway sits in the data plane.\n- [BGP Edge Routing](/docs/networking/bgp) — announce your fabric prefixes to upstream routers.\n- [Zero-Trust Firewall](/docs/networking/firewall) — outbound and published traffic still passes the ACL.\n- [Gateway High Availability](/docs/clustering/high-availability) — redundant Gateways and VIP failover.\n- [API Reference](/docs/api) — publish internal services with the port-forward endpoint.\n"
        },
        {
            "slug": "networking/load-balancer",
            "title": "Load Balancer",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/load-balancer",
            "headings": [
                {
                    "level": 1,
                    "text": "Load Balancer"
                },
                {
                    "level": 2,
                    "text": "Two load balancers, and which one you want"
                },
                {
                    "level": 3,
                    "text": "What the L4 balancer requires: its own interface"
                },
                {
                    "level": 3,
                    "text": "Switching a node's mode"
                },
                {
                    "level": 2,
                    "text": "How the L4 balancer moves a packet"
                },
                {
                    "level": 3,
                    "text": "One VIP is one address, one port, one protocol"
                },
                {
                    "level": 3,
                    "text": "TLS at layer 4"
                },
                {
                    "level": 3,
                    "text": "Direct server return"
                },
                {
                    "level": 2,
                    "text": "Creating a VIP"
                },
                {
                    "level": 2,
                    "text": "Algorithms"
                },
                {
                    "level": 3,
                    "text": "Setting weights"
                },
                {
                    "level": 2,
                    "text": "Health checks"
                },
                {
                    "level": 3,
                    "text": "Overriding a backend's health manually"
                },
                {
                    "level": 2,
                    "text": "Adding and removing backends live"
                },
                {
                    "level": 3,
                    "text": "Retiring a backend without dropping traffic"
                },
                {
                    "level": 2,
                    "text": "North-south VIPs on Gateway nodes"
                },
                {
                    "level": 2,
                    "text": "Deleting a VIP"
                },
                {
                    "level": 2,
                    "text": "Layer-7 HTTP load balancer"
                },
                {
                    "level": 3,
                    "text": "How a request travels"
                },
                {
                    "level": 3,
                    "text": "Terminating TLS"
                },
                {
                    "level": 3,
                    "text": "Serving several ports"
                },
                {
                    "level": 3,
                    "text": "Backend pools"
                },
                {
                    "level": 3,
                    "text": "TLS-terminating frontends"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 3037,
            "markdown": "# Load Balancer\n\nStratum ships an L4 load balancer designed to run in the in-kernel data plane: it distributes TCP and UDP flows across a set of backends using a per-VIP algorithm, maintains a stateful connection table so established flows stay pinned to their backend, and performs health checks to take unhealthy backends out of rotation automatically.\n\n> **Status:** the L4 load balancer's control surface (`lb` commands, VIP definitions, backend pools, health checks) and its in-kernel data plane are both in place, and the data plane loads and attaches on a real Linux kernel. Validate the L4 balancer against your own workload profile before production rollout. The Layer-7 HTTP proxy described below is a separate userspace load balancer and does not depend on it.\n\nA VIP can serve traffic from outside the fabric or stay internal for service-mesh style balancing between endpoints — that is a matter of how you route and announce it, not of what kind of node you are on.\n\n## Two load balancers, and which one you want\n\nStratum has two, and they solve different problems. Choosing the wrong one is the\nmost common mistake, so start here.\n\n| | **L4 (`lb`)** | **L7 HTTP (`l7lb`)** |\n|---|---|---|\n| Works on | TCP and UDP — any protocol | HTTP and HTTPS only |\n| Decides using | Addresses and ports | Hostname, URL path, headers |\n| Handles TLS | No — traffic passes through untouched | Yes — terminates it for you |\n| Use it for | Databases, game servers, SMTP, gRPC, anything not HTTP, or HTTPS you want passed straight through | Websites and APIs where you want one address serving several apps, or TLS handled centrally |\n\nThey are independent. You can run both — an L4 VIP in front of a database and an\nL7 frontend for your web tier — and neither depends on the other.\n\n### What the L4 balancer requires: its own interface\n\nThis is the requirement people actually hit, and it has nothing to do with node\nmode.\n\nThe L4 balancer must be given a **dedicated interface** to receive VIP traffic on\n— one it does not share with your workload bridge or with a Gateway node's WAN or\nLAN interface. Each interface can carry only one of these fast-path attachments,\nso they cannot be stacked on the same NIC.\n\n> **Until that interface is configured, VIPs are configuration only.** Every `lb`\n> command works, your VIPs and backends are stored and listed back correctly, and\n> health checks report — but no traffic is balanced, because nothing is attached\n> to receive it. If your VIP looks perfectly configured and simply does not pass\n> traffic, this is the first thing to check.\n\nThe interface is named in the node's configuration and applied at startup — see\n[Configuration](/docs/configuration). The **L7** balancer has no such\nrequirement: it is an ordinary listener, so it needs a free port and nothing else.\n\n### Switching a node's mode\n\nMode is not a runtime switch. It is set in the node's **signed configuration** —\nat install, or delivered from the panel — and applied when the agent starts.\nThere is deliberately no command that flips it live, because changing which\ninterfaces forward traffic mid-flight would cut the traffic already crossing the\nnode.\n\nTo change it: update the node's configuration, let the node pick it up, and\nrestart the agent. To read the current mode:\n\n```bash\ncenvero-str-ctl node info\n```\n\nEvery node can host workloads *and* forward traffic — one node doing both is a\nnormal setup. What cannot be shared is an *interface*, not a role.\n\n## How the L4 balancer moves a packet\n\nUnderstanding this matters because one deployment detail depends on it.\n\n1. **A client connects to the VIP.** As far as the client is concerned it is\n   talking to one address, and it will keep talking to that address for the\n   lifetime of the connection.\n2. **A backend is chosen**, once, using the VIP's algorithm.\n3. **The destination address is rewritten** to that backend, and the packet is\n   forwarded on. The backend can be anywhere the node can route to — it does not\n   have to be on the same subnet or the same machine.\n4. **The connection is remembered**, so every later packet in that conversation\n   goes to the same backend. The choice is made once, not per packet.\n5. **The reply comes back through the node**, which puts the VIP address back on\n   it before it reaches the client — so the client only ever sees the address it\n   connected to.\n\nTwo consequences follow, and they surprise people:\n\n> **Your backends see the real client address.** Only the destination is\n> rewritten, never the source. Your application logs show actual client IPs with\n> no extra configuration and no `X-Forwarded-For` to parse.\n\n> **Replies must come back through the balancing node.** Step 5 is where the VIP\n> address is restored. If a backend answers the client directly — because it has\n> its own route out — the client receives a reply from an address it never\n> contacted and discards it. The usual symptom is connections that hang instead\n> of failing. Make sure your backends route back through the node holding the\n> VIP.\n\n### One VIP is one address, one port, one protocol\n\nA VIP carries a single `frontend_port` and a single `protocol`. To serve a\nservice on two ports, create two VIPs:\n\n```bash\n# HTTP and HTTPS for the same service = two VIPs, same backends\nsudo cenvero-str-ctl lb create '{\"id\":\"web-80\",\"frontend_ip\":\"10.0.0.100\",\"frontend_port\":80,\"protocol\":\"tcp\",\"algorithm\":\"round-robin\"}'\nsudo cenvero-str-ctl lb create '{\"id\":\"web-443\",\"frontend_ip\":\"10.0.0.100\",\"frontend_port\":443,\"protocol\":\"tcp\",\"algorithm\":\"round-robin\"}'\n```\n\nEach VIP has its own backends, its own algorithm and its own health checks, so\nthey can differ where that is useful — for example checking a different health\npath per port.\n\n### TLS at layer 4\n\nThe L4 balancer does not read or terminate TLS. Encrypted traffic passes through\nexactly as it arrives, and each backend must present its own certificate. That is\nthe right choice when you want end-to-end encryption with no middle box, or when\nthe protocol is not HTTP at all.\n\nIf you want certificates handled in one place instead, that is the L7 balancer —\nsee [Terminating TLS](#terminating-tls) below.\n\n### Direct server return\n\n`dsr_enabled` is accepted on a VIP but **is not yet in effect**: a VIP with it set\nis served through the normal path described above, with replies returning through\nthe node. Plan capacity on that basis.\n\n## Creating a VIP\n\n`lb create` takes **one argument**: a JSON object describing the virtual IP. A VIP and its backends are created separately — first the VIP, then each backend with `lb add-backend`.\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `id` | string | **Required.** The VIP's name, used by every other `lb` command. |\n| `frontend_ip` | string | **Required.** The virtual IP address clients connect to. |\n| `algorithm` | string | **Required.** One of the algorithms below. |\n| `frontend_port` | number | The port clients connect to. |\n| `protocol` | string | `tcp` or `udp`. |\n| `dsr_enabled` | boolean | Enable direct server return. |\n| `health_check` | object | Optional active health check — see **Health checks** below. |\n\n```bash\nsudo cenvero-str-ctl lb create '{\"id\":\"web-lb\",\"frontend_ip\":\"10.20.0.10\",\"frontend_port\":80,\"protocol\":\"tcp\",\"algorithm\":\"least-conn\"}'\n```\n\nList your VIPs:\n\n```bash\ncenvero-str-ctl lb list\n```\n\nShow detail for one VIP, including per-backend health and connection counts:\n\n```bash\ncenvero-str-ctl lb show web-lb\n```\n\n```text\n{\n  \"data\": {\n    \"load_balancer\": {\n      \"id\": \"web-lb\",\n      \"frontend_ip\": \"10.20.0.10\",\n      \"frontend_port\": 80,\n      \"protocol\": \"tcp\",\n      \"algorithm\": \"least-conn\",\n      \"dsr_enabled\": false,\n      \"backends\": [\n        {\n          \"id\": \"web-1\",\n          \"ip\": \"10.20.0.50\",\n          \"port\": 80,\n          \"weight\": 1,\n          \"healthy\": true,\n          \"active_conns\": 0\n        }\n      ]\n    }\n  },\n  \"status\": \"ok\"\n}\n```\n\n## Algorithms\n\nSet one of these as the `algorithm` field. An unrecognized value is rejected when the VIP is created.\n\n| Algorithm | Value | Behaviour |\n|-----------|-------|-----------|\n| Round-robin | `round-robin` | Distributes new connections evenly in turn. Good default for stateless services. |\n| Least connections | `least-conn` | Sends each new connection to the backend with the fewest active connections. Handles variable-cost requests well. |\n| Weighted round-robin | `weighted` | Like round-robin but each backend has a relative weight (see below). Use when backends have unequal capacity. |\n| Source-hash persistence | `source-hash` | Hashes the client source IP to a backend. The same client always reaches the same backend as long as the backend is healthy — useful for session-affinity without shared state. |\n| Maglev | `maglev` | Consistent hashing with Maglev's lookup table, which keeps flow-to-backend mapping stable when the backend set changes. |\n| Consistent hash | `consistent-hash` | Hash-ring backend selection, minimizing remapping when backends are added or removed. |\n\n### Setting weights\n\nWeight is a property of each backend, so choose the `weighted` algorithm on the VIP and give each backend its relative weight as you add it:\n\n```bash\nsudo cenvero-str-ctl lb create '{\"id\":\"api-lb\",\"frontend_ip\":\"10.20.0.20\",\"frontend_port\":443,\"protocol\":\"tcp\",\"algorithm\":\"weighted\"}'\n\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"api-lb\",\"id\":\"api-1\",\"ip\":\"10.20.0.50\",\"port\":443,\"weight\":3}'\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"api-lb\",\"id\":\"api-2\",\"ip\":\"10.20.0.51\",\"port\":443,\"weight\":1}'\n```\n\nThe weights `3` and `1` are relative — `api-1` receives three times the new connections of `api-2`.\n\n## Health checks\n\nThe agent probes backends at a configurable interval. A backend that fails enough consecutive checks is marked unhealthy and removed from the connection pool. It re-enters the pool automatically when it passes the same number of consecutive checks.\n\nA health check is configured as part of the VIP, in the optional `health_check` object passed to `lb create`:\n\n```bash\nsudo cenvero-str-ctl lb create '{\"id\":\"api-lb\",\"frontend_ip\":\"10.20.0.20\",\"frontend_port\":443,\"protocol\":\"tcp\",\"algorithm\":\"round-robin\",\"health_check\":{\"type\":\"http\",\"interval_sec\":5,\"timeout_sec\":2,\"threshold\":3,\"http_path\":\"/healthz\"}}'\n```\n\n| Field | Description | Notes |\n|-------|-------------|-------|\n| `type` | `tcp` (connection probe) or `http` (expects a 2xx response) | Omit the whole object, or leave `type` empty, for no active check |\n| `interval_sec` | Seconds between probes | |\n| `timeout_sec` | Probe timeout in seconds | |\n| `threshold` | Consecutive checks before flipping a backend's state | One value covers both directions |\n| `http_path` | Path to request for an `http` check, e.g. `/healthz` | Ignored for `tcp` checks |\n\n### Overriding a backend's health manually\n\n`lb set-health` is a manual override, not the check configurator — it forces one backend up or down, which is useful for draining a backend before maintenance. It takes three positional arguments: the VIP id, the backend id, and `up` or `down`.\n\n```bash\n# Take a backend out of rotation\nsudo cenvero-str-ctl lb set-health web-lb web-1 down\n\n# Put it back\nsudo cenvero-str-ctl lb set-health web-lb web-1 up\n```\n\nIf an active health check is configured for the VIP, the next probe result will overwrite a manual override.\n\n## Adding and removing backends live\n\nBackend changes take effect immediately without dropping established connections. The connection table keeps existing flows on their current backend until the flow closes naturally.\n\n`lb add-backend` takes a JSON object identifying the VIP (`vip_id`) plus the backend's own `id`, `ip`, `port`, and `weight`. `vip_id`, `id`, and `ip` are required.\n\n```bash\n# Add a backend\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"web-lb\",\"id\":\"web-3\",\"ip\":\"10.20.0.52\",\"port\":80,\"weight\":1}'\n```\n\n### Retiring a backend without dropping traffic\n\n`lb remove-backend` takes effect immediately, which cuts the connections currently\non that backend. To retire one cleanly, drain it first: the load balancer stops\nsending it **new** connections while the existing ones finish, and reports how many\nare still active.\n\n```bash\n# Stop new connections; existing ones keep being served\nsudo cenvero-str-ctl lb drain web-lb web-1\n\n# Re-run to watch the count fall, then remove it once it reaches zero\nsudo cenvero-str-ctl lb remove-backend web-lb web-1\n```\n\nDraining the only healthy backend of a load balancer is refused, since that would\ntake the service down.\n\n`lb remove-backend` takes two positional arguments — the VIP id and the backend id:\n\n```bash\n# Remove a backend\nsudo cenvero-str-ctl lb remove-backend web-lb web-1\n```\n\nTo drain a backend gracefully, mark it down first and let its existing flows finish before removing it:\n\n```bash\nsudo cenvero-str-ctl lb set-health web-lb web-1 down\n# ...wait for active_conns on that backend to reach 0 in `lb show web-lb`...\nsudo cenvero-str-ctl lb remove-backend web-lb web-1\n```\n\nList a VIP's backends at any time:\n\n```bash\ncenvero-str-ctl lb backends web-lb\n```\n\n## North-south VIPs on Gateway nodes\n\nOn a Gateway node, VIPs in the fabric subnet are reachable from outside the cluster automatically because the Gateway node announces those routes via BGP. If you want a VIP to have a publicly routable address, assign it from a prefix your BGP session announces:\n\n```bash\nsudo cenvero-str-ctl lb create '{\"id\":\"external-web\",\"frontend_ip\":\"203.0.113.10\",\"frontend_port\":443,\"protocol\":\"tcp\",\"algorithm\":\"round-robin\"}'\n\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"external-web\",\"id\":\"web-1\",\"ip\":\"10.20.0.50\",\"port\":443,\"weight\":1}'\nsudo cenvero-str-ctl lb add-backend '{\"vip_id\":\"external-web\",\"id\":\"web-2\",\"ip\":\"10.20.0.51\",\"port\":443,\"weight\":1}'\n```\n\nSee [BGP Edge Routing](/docs/networking/bgp) for how prefixes are advertised.\n\n## Deleting a VIP\n\n```bash\nsudo cenvero-str-ctl lb delete web-lb\n```\n\nActive connections are torn down immediately. Drain traffic to backends first if zero disruption is required.\n\n## Layer-7 HTTP load balancer\n\nSeparately from the in-kernel L4 balancer above, Stratum ships a **userspace Layer-7 (HTTP/HTTPS) reverse proxy**. Where the L4 balancer distributes raw TCP/UDP flows, the L7 proxy understands HTTP: it terminates TLS, routes by `Host` header and URL path prefix, and load-balances across backend pools. The two are orthogonal — reach for the L4 balancer for raw flow distribution and the L7 proxy when you need HTTP-aware routing or TLS termination. The L7 proxy stays **off until you configure a frontend**.\n\nIts two building blocks are:\n\n- **Pools** — a named set of HTTP backends with a selection algorithm (`round-robin` or `least-connections`) and health checks.\n- **Frontends** — a listener, optionally terminating TLS, that routes each request to a pool by `Host` and path prefix, with a default pool as fallback.\n\n### How a request travels\n\n1. **A request arrives at a frontend** — a listener on one address and port.\n2. **If the frontend has TLS, it is decrypted here.** From this point on the\n   proxy is working with a plain HTTP request.\n3. **The routes are checked in order and the first match wins.** A route matches\n   on hostname, on a path prefix, or both. Anything matching no route goes to the\n   frontend's `default_pool`; a frontend with no default and no match returns an\n   error rather than guessing.\n4. **The pool picks a backend** — round-robin, or the one with the fewest active\n   requests.\n5. **The request is forwarded over plain HTTP** and the response passed back.\n\nBecause the proxy is the one talking to the client, it adds `X-Forwarded-For`\n(the real client address), `X-Forwarded-Proto` and `X-Forwarded-Host` so your\napplication can still see who asked and how. WebSocket and other upgrade\nconnections pass through unchanged.\n\nRouting is per **request**, not per connection — two requests on the same\nkeep-alive connection can land on different backends, and on different pools.\n\n### Terminating TLS\n\nThis is where most questions come up, so plainly:\n\n> **The certificate goes on the frontend, once. Your backends do not need one.**\n\nA frontend with a `tls` block accepts HTTPS from clients, decrypts, and then\nspeaks **plain HTTP to the backends**. So:\n\n- **You need one certificate per frontend**, not one per backend and not one per\n  port. Ten backends behind an HTTPS frontend need zero certificates between\n  them.\n- **The backend link is unencrypted.** That is fine when the backends are on a\n  network you control — the usual case, since they are your own endpoints on your\n  own fabric. If you need encryption all the way to the application, do not\n  terminate here: use an L4 VIP and let each backend hold its own certificate.\n- **A frontend with no `tls` block serves plain HTTP.** Mixing is normal — run a\n  plain frontend on `:80` and a TLS one on `:443`.\n\nThe certificate itself is either a PEM certificate and key you supply, or the\nnode's own managed certificate. The minimum accepted version is TLS 1.2.\n\n### Serving several ports\n\nUnlike an L4 VIP, one frontend is one listener — but you can add as many\nfrontends as you need, and they can share pools:\n\n```bash\n# Plain HTTP on 80 and TLS on 443, both serving the same backends\nsudo cenvero-str-ctl l7lb frontend add '{\"name\":\"http\",\"listen\":\":80\",\"default_pool\":\"web\"}'\nsudo cenvero-str-ctl l7lb frontend add '{\"name\":\"https\",\"listen\":\":443\",\"tls\":{\"use_managed\":true},\"default_pool\":\"web\"}'\n```\n\nOne frontend can also serve **many sites** on a single port by routing on\nhostname — that is the usual reason to choose L7 over L4:\n\n```bash\nsudo cenvero-str-ctl l7lb frontend add '{\"name\":\"edge\",\"listen\":\":443\",\"tls\":{\"use_managed\":true},\n  \"routes\":[\n    {\"host\":\"api.example.com\",\"pool\":\"api\"},\n    {\"host\":\"www.example.com\",\"path_prefix\":\"/static\",\"pool\":\"assets\"},\n    {\"host\":\"www.example.com\",\"pool\":\"web\"}\n  ],\"default_pool\":\"web\"}'\n```\n\nOrder matters: the `/static` rule sits before the general `www` rule, because the\nfirst match wins.\n\n### Backend pools\n\nA pool is created from a single JSON object:\n\n```bash\nsudo cenvero-str-ctl l7lb pool add '{\"name\":\"web\",\"algorithm\":\"round-robin\",\"backends\":[\"10.20.0.50:8080\",\"10.20.0.51:8080\"]}'\n```\n\nList pools, and add or remove a pool's backends live:\n\n```bash\ncenvero-str-ctl l7lb pool list\ncenvero-str-ctl l7lb backend list web\nsudo cenvero-str-ctl l7lb backend add web 10.20.0.52:8080\nsudo cenvero-str-ctl l7lb backend remove web 10.20.0.50:8080\n```\n\nThe proxy runs active health checks (periodic HTTP probes with configurable healthy and unhealthy thresholds) and also ejects a backend passively when it returns transport errors while proxying, re-admitting it once it recovers.\n\n### TLS-terminating frontends\n\nA frontend binds a listener and routes to pools. This one terminates TLS with an operator-provided certificate and routes one host and path prefix to the `web` pool, falling back to it by default:\n\n```bash\nsudo cenvero-str-ctl l7lb frontend add '{\"name\":\"https\",\"listen\":\":8443\",\"tls\":{\"cert_file\":\"/etc/cenvero-str/tls/lb.crt\",\"key_file\":\"/etc/cenvero-str/tls/lb.key\"},\"default_pool\":\"web\",\"routes\":[{\"host\":\"api.example.com\",\"path_prefix\":\"/v1\",\"pool\":\"web\"}]}'\n```\n\nTLS termination accepts either an operator-supplied PEM certificate and key (as above) or the node's own managed certificate; the minimum TLS version is 1.2. A frontend with no `tls` block serves plain HTTP. The proxy sets the `X-Forwarded-For`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers on proxied requests and passes WebSocket and other upgrade connections through unchanged.\n\nList or remove frontends, and show the full picture — every frontend and pool with per-backend health and active connection counts:\n\n```bash\ncenvero-str-ctl l7lb frontend list\nsudo cenvero-str-ctl l7lb frontend remove https\ncenvero-str-ctl l7lb status\n```\n\nWhen a pool has no healthy backend the proxy returns `503`; when a chosen backend fails mid-request it returns `502` without leaking internal error detail.\n\n## See also\n\n- [Networking Overview](/docs/networking/overview) — where the load balancer sits in the data plane.\n- [Zero-Trust Firewall](/docs/networking/firewall) — VIP addresses also need firewall allow rules for external access.\n- [BGP Edge Routing](/docs/networking/bgp) — advertising VIP prefixes upstream.\n- [Quick Start](/docs/quickstart) — end-to-end example including an L4 VIP.\n"
        },
        {
            "slug": "networking/bgp",
            "title": "BGP Edge Routing",
            "category": "Networking",
            "url": "https://www.stratum.cenvero.com/docs/networking/bgp",
            "headings": [
                {
                    "level": 1,
                    "text": "BGP Edge Routing"
                },
                {
                    "level": 2,
                    "text": "How it fits together"
                },
                {
                    "level": 3,
                    "text": "What travels in each direction"
                },
                {
                    "level": 3,
                    "text": "How a session comes up"
                },
                {
                    "level": 2,
                    "text": "Configuring a peer session"
                },
                {
                    "level": 2,
                    "text": "Announcing prefixes"
                },
                {
                    "level": 2,
                    "text": "Importing external prefixes"
                },
                {
                    "level": 2,
                    "text": "Checking RIB and FIB"
                },
                {
                    "level": 2,
                    "text": "RPKI route-origin validation"
                },
                {
                    "level": 2,
                    "text": "EVPN address family"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1597,
            "markdown": "# BGP Edge Routing\n\nGateway nodes run an integrated BGP speaker that connects your fabric to the outside world. You configure peer sessions, announce your managed-network prefixes upstream, optionally import external routes, and filter routes with prefix-lists and route-maps — all through `cenvero-str-ctl`.\n\n## How it fits together\n\nA node at the edge of the fabric speaks BGP upstream. It speaks eBGP to one or more upstream routers or route reflectors, announcing the subnets of your internal networks so external hosts can reach them. Inbound prefixes learned from peers are installed into the kernel routing table and become reachable from within the fabric.\n\n### What travels in each direction\n\nTwo separate flows, and confusing them is the usual cause of \"the session is up\nbut nothing works\":\n\n**Out — what you announce.** Nothing leaves automatically. A prefix is advertised\nonly once you announce it, so bringing a session up publishes nothing on its own.\nUntil you announce, your peer has no idea your networks exist and traffic will not\ncome to you.\n\n**In — what you learn.** Prefixes your peers advertise are accepted, subject to\nany filters you set, and installed as routes so workloads inside the fabric can\nreach them. A peer that sends you nothing leaves you with a working session and\nno new routes, which is normal if it is only meant to receive.\n\nA session being **Established** means the two speakers are talking. It does not\nmean any route has been exchanged in either direction — check what is actually\nbeing carried rather than inferring it from the session state.\n\n### How a session comes up\n\nA peer moves through a sequence of states, and knowing them makes the difference\nbetween waiting and debugging:\n\n| State | Meaning |\n|---|---|\n| **Idle** | Not trying yet, or backing off after a failure before the next attempt. |\n| **Connect** / **Active** | Trying to reach the peer. Sitting here means the peer is not answering — check reachability and that it is expecting you. |\n| **OpenSent** / **OpenConfirm** | Connected; the two sides are agreeing terms. Sitting here usually means a mismatch, most often the AS number each side expects of the other. |\n| **Established** | Working. Routes can now be exchanged. |\n\nBoth ends may try to connect to each other at the same time — normal when two\nStratum nodes peer directly. Two connections briefly exist and one is discarded\nautomatically, which is expected and needs no configuration.\n\nOnce established, the two exchange keepalives (every 30 seconds by default). If\nnothing is heard for the hold time (90 seconds by default) the session is\nconsidered dead and torn down, and the routes learned through it are withdrawn.\nLower both if you need failure detected faster, at the cost of more chatter —\nthey are negotiated, so the peer must agree.\n\n> Peers that keep returning to Idle are usually being rejected rather than\n> failing to connect. Check the AS numbers on both sides first; that is the most\n> common cause.\n\nFor redundant deployments with a pair of Gateways, both nodes can establish their own peer sessions and announce the fabric prefixes. The shared VIP is held by exactly one Gateway at a time (priority-based active/standby); Stratum's HA mechanism moves the VIP to the surviving node if the active Gateway goes down. See [Gateway High Availability](/docs/clustering/high-availability).\n\n## Configuring a peer session\n\n```bash\nsudo cenvero-str-ctl bgp peer add '{\"peer_addr\":\"198.51.100.1\",\"peer_as\":65001,\"local_as\":65100,\"md5_key\":\"mysecretpass\"}'\n```\n\nThe neighbour is a single JSON object. `peer_addr`, `peer_as`, and `local_as` are required; the rest are optional:\n\n| Field | Description |\n|-------|-------------|\n| `peer_addr` | Upstream router's IP address |\n| `peer_as` | Upstream router's AS number |\n| `local_as` | This node's AS number |\n| `md5_key` | BGP TCP-MD5 authentication key (optional but recommended; never returned in reads) |\n| `hold_time` | BGP hold timer in seconds (default `90`) |\n| `keepalive_interval` | BGP keepalive interval in seconds (default `30`) |\n| `bfd_enabled` | Enable BFD fast failure detection for this peer (`true`/`false`) |\n| `bfd_interval_ms` | BFD min-tx/min-rx interval in milliseconds |\n| `bfd_multiplier` | BFD detect multiplier (failure-detection time is interval × multiplier) |\n\nList configured peers and their session state:\n\n```bash\ncenvero-str-ctl bgp peer list\n```\n\n```text\nPEER            PEER-ASN  LOCAL-ASN  STATE        PREFIXES-RX  PREFIXES-TX\n198.51.100.1    65001     65100      Established  12           3\n198.51.100.2    65002     65100      Established  8            3\n```\n\nRemove a peer session:\n\n```bash\nsudo cenvero-str-ctl bgp peer remove 198.51.100.1\n```\n\n## Announcing prefixes\n\nTell Stratum which internal prefixes to advertise to peers:\n\n```bash\nsudo cenvero-str-ctl bgp announce 10.20.0.0/24\nsudo cenvero-str-ctl bgp announce 10.30.0.0/24\n```\n\nAnnouncements take effect as soon as they are added. You can also announce a summary prefix that covers multiple networks:\n\n```bash\nsudo cenvero-str-ctl bgp announce 10.20.0.0/20\n```\n\nList what you are announcing:\n\n```bash\ncenvero-str-ctl bgp announcements\n```\n\nWithdraw a prefix (peers are notified immediately):\n\n```bash\nsudo cenvero-str-ctl bgp withdraw 10.20.0.0/24\n```\n\n## Importing external prefixes\n\nBy default, prefixes learned from peers are installed in the kernel routing table and are reachable from within the fabric. You can filter what gets imported.\n\nFiltering is expressed as **prefix-lists** and **route-maps**, then bound to a neighbour as an import (or export) policy — each is a single JSON object. First define a prefix-list of the CIDRs you care about (entries are matched in order; each has an `action` of `allow` or `deny` and optional `ge`/`le` length bounds):\n\n```bash\nsudo cenvero-str-ctl bgp prefix-list add '{\"name\":\"customer-routes\",\"entries\":[{\"prefix\":\"172.16.0.0/12\",\"action\":\"allow\",\"le\":24},{\"prefix\":\"0.0.0.0/0\",\"action\":\"deny\"}]}'\n```\n\nReference that prefix-list from a route-map, which decides what to do with a matching route and can set attributes such as local-preference:\n\n```bash\nsudo cenvero-str-ctl bgp route-map add '{\"name\":\"import-filter\",\"entries\":[{\"seq\":10,\"action\":\"allow\",\"match_prefix\":\"customer-routes\",\"set_local_pref\":200}]}'\n```\n\nBind the route-map to a neighbour as its import policy (use `export` for the outbound direction):\n\n```bash\nsudo cenvero-str-ctl bgp policy import 198.51.100.1 import-filter\n```\n\n## Checking RIB and FIB\n\nInspect the BGP routing information base:\n\n```bash\ncenvero-str-ctl bgp rib\n```\n\n```text\nPREFIX            NEXT-HOP       AS-PATH    MED  LOCAL-PREF  SOURCE\n10.20.0.0/24      0.0.0.0        -          -    -           local\n0.0.0.0/0         198.51.100.1   65001      0    100         ebgp\n172.16.0.0/12     198.51.100.1   65001      0    100         ebgp\n```\n\nShow only routes installed in the kernel forwarding table:\n\n```bash\ncenvero-str-ctl bgp fib\n```\n\n## RPKI route-origin validation\n\nRoute-Origin Validation (ROV, RFC 6811) checks each prefix a peer advertises against Resource Public Key Infrastructure (RPKI) data, so you can tell whether the AS that originated a route is actually authorized to announce it. Stratum learns the set of validated prefix-to-origin records (the VRP set) from an external RPKI-to-Router (RTR, RFC 8210) cache — such as Routinator, rpki-client, or StayRTR — and classifies every received route as:\n\n- **valid** — a matching record authorizes this prefix from this origin AS.\n- **invalid** — a record covers the prefix but the origin AS or prefix length does not match, a likely mis-origination or hijack.\n- **not-found** — no record covers the prefix.\n\nROV is **off by default and fail-safe.** With no RTR configured, an unreachable cache, or stale data, the VRP set is empty, every route resolves to *not-found*, and BGP behaves exactly as it would without ROV — an RTR outage can never cause a route to be rejected. By default ROV only **marks** each route with its validation state and does not change route selection; the opt-in reject-invalid policy additionally drops RPKI-invalid routes before they enter the routing table.\n\nPoint Stratum at your RTR cache and start validating:\n\n```bash\nsudo cenvero-str-ctl bgp rpki configure 192.0.2.10:323 --reject-invalid\n```\n\n| Argument / flag | Description |\n|-----------------|-------------|\n| `<rtr-addr>` | Address (`host:port`) of the RTR cache to connect to |\n| `--reject-invalid` | Drop RPKI-invalid routes instead of only marking them (optional) |\n\nCheck the RTR session state, VRP count, and per-outcome validation tallies:\n\n```bash\ncenvero-str-ctl bgp rpki status\n```\n\nTest how a given prefix and origin AS would classify against the current VRP set:\n\n```bash\ncenvero-str-ctl bgp rpki validate 192.0.2.0/24 64500\n```\n\nStart or stop ROV using the configured RTR address; disabling clears the VRP set and reverts to plain BGP:\n\n```bash\nsudo cenvero-str-ctl bgp rpki enable\nsudo cenvero-str-ctl bgp rpki disable\n```\n\n## EVPN address family\n\nStratum's BGP speaker can carry the **EVPN** (Ethernet VPN, RFC 7432) address family — L2VPN/EVPN over MP-BGP — integrated with the VXLAN overlay (EVPN-VXLAN, RFC 8365). It exchanges two route types with EVPN-capable peers:\n\n- **Type 2 (MAC/IP)** — advertises the MAC (and optional IP) of a local overlay endpoint, so peers learn which VTEP a MAC lives behind.\n- **Type 3 (Inclusive Multicast)** — advertises the local VTEP so peers know where to send broadcast, unknown-unicast, and multicast traffic for a VNI.\n\nEach route carries a Route Distinguisher and Route Target, and the 24-bit VXLAN VNI is encoded in the MPLS label field.\n\n> **Scope — control plane only.** EVPN in Stratum is a route-exchange feature: it learns remote MAC-to-VTEP and VTEP bindings over BGP and maintains the EVPN route tables, but it does not program those learned routes into the overlay forwarding path. Use it to exchange and inspect MAC/VTEP reachability, and configure VXLAN peers explicitly for forwarding (see [Clustering Overview](/docs/clustering/overview)).\n\nEVPN is **off by default** and backward-compatible: while disabled, the EVPN address family is not advertised and BGP behaves exactly like the plain IPv4/IPv6-unicast speaker.\n\nEnable EVPN, advertising a local VTEP and route distinguisher:\n\n```bash\nsudo cenvero-str-ctl bgp evpn enable --vtep 10.0.0.1 --rd 65000:100\n```\n\n| Flag | Description |\n|------|-------------|\n| `--vtep` | Local VTEP source IP advertised as the EVPN next hop |\n| `--rd` | Route distinguisher, e.g. `65000:100` or `10.0.0.1:100` |\n\nRegister a local MAC (and optional IP) under a VNI so it is advertised as a Type 2 route:\n\n```bash\nsudo cenvero-str-ctl bgp evpn local-mac add 10100 aa:bb:cc:dd:ee:ff 192.0.2.10\n```\n\nInspect EVPN state and the local plus learned routes:\n\n```bash\ncenvero-str-ctl bgp evpn status\ncenvero-str-ctl bgp evpn show\n```\n\nDisable EVPN — it stops advertising the address family and withdraws the routes it announced:\n\n```bash\nsudo cenvero-str-ctl bgp evpn disable\n```\n\n## See also\n\n- [Gateway High Availability](/docs/clustering/high-availability) — redundant Gateway BGP speakers and failover.\n- [Load Balancer](/docs/networking/load-balancer) — combine BGP with L4 VIPs for published external services.\n- [Zero-Trust Firewall](/docs/networking/firewall) — north-south traffic from BGP peers passes through the firewall.\n"
        },
        {
            "slug": "clustering/overview",
            "title": "Clustering Overview",
            "category": "Clustering",
            "url": "https://www.stratum.cenvero.com/docs/clustering/overview",
            "headings": [
                {
                    "level": 1,
                    "text": "Clustering Overview"
                },
                {
                    "level": 2,
                    "text": "Which node do you talk to?"
                },
                {
                    "level": 2,
                    "text": "Ports used by clustering"
                },
                {
                    "level": 2,
                    "text": "High-availability cluster state"
                },
                {
                    "level": 2,
                    "text": "VXLAN overlay mesh"
                },
                {
                    "level": 2,
                    "text": "Forming a cluster"
                },
                {
                    "level": 2,
                    "text": "Leader election"
                },
                {
                    "level": 2,
                    "text": "Joining and leaving"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1127,
            "markdown": "# Clustering Overview\n\nA Stratum cluster is a group of nodes that share network state and run a VXLAN overlay mesh so that every managed network is present on every host. Shared state is **replicated across the cluster** for high availability: one node is the elected leader and accepts writes, the others replicate it. The VXLAN overlay stretches L2 segments across physical hosts, so a workload's endpoint keeps its IP and MAC when it moves between nodes.\n\nEvery node in a cluster is the same kind of node: each hosts workloads and each can handle north-south traffic.\n\n## Which node do you talk to?\n\nThis is the first question when building anything against a cluster, and the\nanswer is not \"any of them\".\n\n**Reads work anywhere.** Every member holds the replicated state, so listing\nnetworks, endpoints, rules or leases gives the same answer on any node.\n\n**Changes belong to the node that owns the thing you are changing.** Two kinds of\nstate exist and they behave differently:\n\n| What you are changing | Where to send it |\n|---|---|\n| Something on one machine — its interfaces, its local services, its own attachments | That node |\n| Shared cluster state | The leader |\n\nA change to shared state sent to a follower is not silently swallowed, but it\ndoes not replicate either — the leader is what writes. Ask `cluster status` which\nnode is the leader before making one, and expect the answer to change over time,\nbecause leadership moves when a node restarts or becomes unreachable.\n\n> For anything automated, look up the leader rather than hard-coding a node.\n> A cluster that has failed over once will otherwise start silently dropping\n> your changes while still answering every read normally.\n\n**Changes are not instant across the cluster.** Replication is quick but not\nsimultaneous, so a write on the leader followed immediately by a read on another\nnode can miss it. If you write then verify, read back from the same node you\nwrote to.\n\n## Ports used by clustering\n\nCluster communication happens only on the management bridge. Open these ports between all cluster members on the management network:\n\n| Port | Protocol | Purpose |\n|------|----------|---------|\n| 7071 | TCP | Node-to-node control plane |\n| 7073 | TCP | Cluster state replication |\n| 7074 | UDP | Gateway HA |\n\nDo not expose these ports to untrusted networks. The management bridge is separate from the workload bridge for exactly this reason — see [Networking Overview](/docs/networking/overview).\n\n## High-availability cluster state\n\nStratum replicates cluster state across every member: the list of networks, endpoint bindings, firewall rules, load balancer VIPs, and cluster membership. State replication is persisted on each node so a restarted node rejoins with the latest state.\n\nClustering runs only when it is enabled (`cluster.enabled: true`) and **requires mutual TLS** between members — the transport refuses to run unauthenticated and there is no silent plaintext downgrade. Provision the cluster member certificates before enabling clustering.\n\nKey properties:\n\n- A cluster of N nodes tolerates `(N-1)/2` simultaneous failures and still makes progress.\n- A **3-node cluster** (2 Compute + 1 Gateway, for example) tolerates 1 failure.\n- A **5-node cluster** tolerates 2 simultaneous failures.\n- A cluster of 2 nodes has **no fault tolerance** — losing one node stalls writes.\n\n> A 1-node deployment runs in single-node mode: the agent is always the leader and there is no replication. This is fine for development and testing.\n\n## VXLAN overlay mesh\n\nEach network you create is stretched across all cluster members via a VXLAN overlay, anchored on the management interface. When an endpoint on node A sends a frame to an endpoint on node B, the overlay encapsulates the frame and sends it to node B's management IP. The receiving node decapsulates it and delivers it to the destination endpoint — transparently, as if both endpoints were on the same physical switch.\n\nEach Stratum network gets its own isolated overlay segment, so networks remain isolated even though they share the same underlay. MAC-to-node mappings are maintained in the cluster's shared state and updated in real time as endpoints attach, detach, and move between nodes. Validate cross-host forwarding against your own topology before production rollout.\n\n## Forming a cluster\n\nClustering is provisioned centrally, not with an ad-hoc CLI join. Enable it through the **panel-delivered signed configuration** (the `cluster_enabled` / `cluster_bind_addr` / `cluster_bootstrap` fields and the mutual-TLS material under `cluster_cert_dir`), read by each agent at boot. The first node bootstraps as leader; each additional node joins once its config is applied and its member certificate is trusted.\n\nA node can also be added over the node's REST API — `POST /api/v1/cluster/join` with `{ \"node_id\": \"<id>\", \"address\": \"<mgmt-ip:7073>\" }` — pointing the joining node at an existing member. The joining node syncs the full cluster state and applies it before participating in elections.\n\nCheck the cluster from any member:\n\n```bash\ncenvero-str-ctl cluster status\n```\n\n```text\nROLE      NODE-ID   PEER              STATE\nleader    cmp-01    10.0.0.11:7073    healthy\nfollower  cmp-02    10.0.0.12:7073    healthy\nfollower  gw-01     10.0.0.13:7073    healthy\n```\n\n## Leader election\n\nIf the current leader becomes unreachable, the remaining members start an election after a short timeout elapses with no signal from the leader. The node with the most up-to-date state and a majority of votes becomes the new leader. During the election window, writes are paused — existing traffic continues uninterrupted: forwarding does not depend on the leader being available, so an election is felt only by whoever is trying to make a change at that moment.\n\nYou can see the current leader at any time — `cluster status` reports the leader's id along with an `is_leader` flag for the node you ran it on:\n\n```bash\ncenvero-str-ctl cluster status\n```\n\n## Joining and leaving\n\nAdd a new node at any time by provisioning it into the cluster — through the panel-delivered config or `POST /api/v1/cluster/join`. The cluster rebalances: if the new node is a Gateway, it begins participating in HA once it has caught up with the current cluster state.\n\nTo remove a node gracefully — for maintenance or decommission — have it leave over the node's REST API:\n\n```bash\ncurl -k -X POST \"$NODE/api/v1/cluster/leave\" -H \"Authorization: Bearer $TOKEN\"\n```\n\nThis notifies the leader, which records the membership change across the cluster and adjusts the quorum size. The leaving node shuts down its cluster participation cleanly. Workloads on a node being removed should be moved to another node first — see [Moving Workloads Between Nodes](/docs/clustering/live-migration).\n\n> Do not hard-power-off a node without letting it leave the cluster first. The cluster will continue to function (assuming quorum remains), but it will count the node as a failed member until it is explicitly removed.\n\n## See also\n\n- [Gateway High Availability](/docs/clustering/high-availability) — redundant Gateways and sub-second failover.\n- [Moving Workloads Between Nodes](/docs/clustering/live-migration) — keeping an endpoint's IP/MAC when it moves between nodes.\n- [Networking Overview](/docs/networking/overview) — how the VXLAN overlay extends L2 networks.\n- [Configuration](/docs/configuration) — the cluster fields in the node config reference.\n"
        },
        {
            "slug": "clustering/high-availability",
            "title": "Gateway High Availability",
            "category": "Clustering",
            "url": "https://www.stratum.cenvero.com/docs/clustering/high-availability",
            "headings": [
                {
                    "level": 1,
                    "text": "Gateway High Availability"
                },
                {
                    "level": 2,
                    "text": "How it works"
                },
                {
                    "level": 2,
                    "text": "Forming an HA pair"
                },
                {
                    "level": 2,
                    "text": "Heartbeat timing"
                },
                {
                    "level": 2,
                    "text": "VIP and route takeover"
                },
                {
                    "level": 2,
                    "text": "Split-brain on a full partition"
                },
                {
                    "level": 2,
                    "text": "Monitoring"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 926,
            "markdown": "# Gateway High Availability\n\nStratum supports a redundant pair of Gateway nodes in a priority-based **active/standby** arrangement. One node is the active VIP owner; the other stands by, monitoring the active node over a dedicated, encrypted health channel. When the active Gateway fails, the standby promotes itself, assumes the VIP, and announces the new owner — without manual intervention.\n\n## How it works\n\nThe two Gateways are members of the same cluster and exchange a health signal at a short interval (default 100 ms). Each message is authenticated and replay-protected, so a forged or replayed signal is rejected.\n\nVIP ownership is decided by **priority**: while both peers are alive, the higher-priority node owns the VIP and the lower-priority node stays in standby. A standby that stops hearing its partner promotes itself regardless of priority — so the VIP is never orphaned — and yields the VIP back to a higher-priority peer once that peer returns. Equal priorities are broken deterministically by node identity so the pair can never both stay active under normal operation.\n\nOn promotion, the new owner brings the VIP up on its own interface and sends a **gratuitous ARP**, so neighbours update their ARP caches and start sending to it within milliseconds rather than waiting for an entry to age out. On demotion it removes **only the VIP** — every other address on that interface is left untouched, so a node losing the VIP never loses its own addressing with it. Failover detection takes at most `interval × missed-threshold`; with the defaults that is 100 ms × 3 = 300 ms.\n\n## Forming an HA pair\n\nAn HA pair is two Gateway nodes in the same cluster configured to monitor each other. Both nodes must already be joined to the cluster.\n\n**HA settings are provisioned from the panel, not from the node.** Because the pair's shared heartbeat key is a secret that both nodes must agree on, HA is configured in the management panel and delivered to each node inside its signed configuration. The node applies it on the next configuration sync — there is no local command that sets it, and `ha set-peer` and `ha configure` exist only to tell you so and point you at the panel.\n\nThe four settings the panel delivers are:\n\n| Config key | Description |\n|------------|-------------|\n| `gateway_peer_addr` | The partner Gateway's address on the management network |\n| `gateway_vip` | The virtual IP the active node owns |\n| `gateway_priority` | This node's priority; the higher-priority peer owns the VIP while both are alive |\n| `gateway_shared_key` | The shared secret authenticating the heartbeat; identical on both nodes |\n\nOnce both nodes have synced their configuration, confirm the pair from either node:\n\n```bash\ncenvero-str-ctl ha status\n```\n\n```text\n{\n  \"data\": {\n    \"local_state\": \"active\",\n    \"peer_state\": \"standby\",\n    \"peer_addr\": \"10.0.0.13\",\n    \"vip\": \"10.0.0.100\",\n    \"last_heartbeat\": \"2026-07-24T18:10:40Z\",\n    \"active_conns\": 0,\n    \"uptime\": \"2h44m47s\"\n  },\n  \"status\": \"ok\"\n}\n```\n\n`local_state` and `peer_state` are each one of `active` (owns the VIP), `standby` (monitoring, ready to promote), or `solo` (no peer configured). A `peer_state` of `solo` together with an empty `peer_addr` means this node has not received HA settings yet.\n\n## Heartbeat timing\n\nThe heartbeat interval and the failover threshold are **fixed** and not operator-tunable:\n\n| Parameter | Value | Description |\n|-----------|-------|-------------|\n| Heartbeat interval | `100ms` | Time between heartbeat probes |\n| Failover threshold | `3` | Consecutive missed probes before declaring the peer dead |\n\nFailover detection therefore takes at most `100ms × 3 = 300 ms`.\n\n## VIP and route takeover\n\nWhen the standby detects the active node has failed (missed-threshold heartbeats), it:\n\n1. Promotes itself to active and assumes the VIP on its own interface.\n2. Sends a gratuitous ARP for the VIP so upstream neighbours redirect traffic to it.\n3. Marks the partner as unavailable in its local state.\n\nVIPs on the surviving node begin accepting new connections immediately. Existing connections that were being handled by the failed node are dropped (the client must reconnect); this is inherent to a stateless L4 failover.\n\nWhen the failed node returns, arbitration over the heartbeat converges the pair back to a single owner: if the returning node has higher priority it preempts and reclaims the VIP; otherwise it stays in standby.\n\n## Split-brain on a full partition\n\nA 2-node HA pair has no third arbiter, witness, or quorum. If the two Gateways stop hearing each other's heartbeats simultaneously — for example a management-network partition where each side is otherwise up — **both nodes will promote and assume the VIP**, because each believes its partner is dead. This residual split-brain window is an accepted limitation of a 2-node pair without a third arbiter.\n\nWhen the partition heals, the heartbeat resumes and arbitration converges the pair back to a single owner: the higher-priority node keeps the VIP and the other releases it. If you need to eliminate the split-brain window entirely, front the pair with a third arbiter at the network layer (the platform does not provide one for the 2-node case).\n\n## Monitoring\n\n```bash\n# On either Gateway\ncenvero-str-ctl ha status\n```\n\nThe two fields worth alerting on are `local_state` — a node that unexpectedly reports `active` on both sides of the pair indicates the partition case described above — and `last_heartbeat`, which should stay within a few hundred milliseconds of now while the peer is healthy. `active_conns` reports the connections the local node is currently handling, and `uptime` is how long its HA manager has been running.\n\n## See also\n\n- [Clustering Overview](/docs/clustering/overview) — cluster membership and the management network ports.\n- [BGP Edge Routing](/docs/networking/bgp) — configuring the BGP peer sessions that HA relies on.\n- [Moving Workloads Between Nodes](/docs/clustering/live-migration) — moving an endpoint to another node before maintenance.\n- [Configuration](/docs/configuration) — port reference and the management network.\n"
        },
        {
            "slug": "clustering/live-migration",
            "title": "Moving Workloads Between Nodes",
            "category": "Clustering",
            "url": "https://www.stratum.cenvero.com/docs/clustering/live-migration",
            "headings": [
                {
                    "level": 1,
                    "text": "Moving Workloads Between Nodes"
                },
                {
                    "level": 2,
                    "text": "Why the network identity survives a move"
                },
                {
                    "level": 2,
                    "text": "Moving an endpoint"
                },
                {
                    "level": 3,
                    "text": "If the workload has its own hardware address, name it again"
                },
                {
                    "level": 2,
                    "text": "Pre-move checklist"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 599,
            "markdown": "# Moving Workloads Between Nodes\n\nStratum is a software-defined networking platform, not a hypervisor — it manages\nthe *network identity* of a workload, not the workload's compute or memory. There\nis no built-in VM live-migration. What Stratum does provide is a network model\nthat lets a workload **keep its IP and MAC when it moves between nodes**,\nbecause a network's endpoints live in the cluster's shared state and are reachable\non every host through the VXLAN overlay.\n\nIf you run your own hypervisor (QEMU/KVM, etc.) and migrate a guest's memory\nbetween hosts yourself, Stratum keeps the network side consistent — you simply\nmove the endpoint binding to the destination node.\n\n## Why the network identity survives a move\n\nEach managed network is stretched across all cluster members via VXLAN, and every\nendpoint profile (IP ↔ MAC) is stored in the cluster's replicated state. The\noverlay forwards frames for an endpoint's MAC to whichever node currently has that\nendpoint bound. Because the IP and MAC never change, ARP and NDP caches on peer\nendpoints stay valid and active TCP connections are not reset by the move itself.\nValidate cross-host forwarding against your own topology before production rollout.\n\n## Moving an endpoint\n\nMoving a workload's network identity from one node to another is a detach\non the source followed by an attach on the destination, claiming the *same* IP:\n\n```bash\n# On the source node — free the endpoint\nsudo cenvero-str-ctl network detach <endpoint-id>\n\n# On the destination node — re-claim the same IP\nsudo cenvero-str-ctl network attach <network-id> --ip 10.20.0.50\n```\n\nThe destination node programs the data plane for the endpoint (including the\nIP↔MAC anti-spoof binding), and the cluster's shared state updates the\nMAC-to-node mapping. The VXLAN overlay then delivers frames for that MAC to the\nnew node. Sequence the detach/attach close together — and cut over your actual\ncompute workload at the same time — to minimize the window where the endpoint is\nunbound.\n\n### If the workload has its own hardware address, name it again\n\nThis is the detail that catches people out.\n\nWhen you let Stratum choose the hardware address, it is **derived from the IP**.\nRe-claiming the same address on another node therefore reproduces the identical\nhardware address on its own, which is exactly why the identity survives the move\nwith nothing extra from you.\n\nBut if you originally attached the endpoint with a hardware address **of your\nown** — because the workload already had one — then claiming just the IP on the\ndestination gives it the *derived* address instead, not the one it had. The\nworkload's identity changes mid-move: peer ARP caches point at an address that no\nlonger answers, and the anti-spoof binding now expects a different address than\nthe workload actually sends, so its traffic is dropped as spoofed.\n\nPass the same hardware address again and the move is clean:\n\n```bash\n# The workload has its own hardware address — supply it on the destination too\nsudo cenvero-str-ctl network attach <network-id> --ip 10.20.0.50 --mac 52:54:00:ab:01:02\n```\n\nIf you are unsure which case you are in, read the endpoint back before detaching\nit and use whatever address it reports.\n\n## Pre-move checklist\n\n- Both nodes are healthy cluster members: `cenvero-str-ctl cluster status`\n- The destination node has capacity for the workload: `cenvero-str-ctl node info`\n- The management network between the nodes has sufficient bandwidth\n- Firewall rules referencing the endpoint by network and address remain valid\n  after the move — the IP is preserved, so no rule changes are needed\n\n## See also\n\n- [Clustering Overview](/docs/clustering/overview) — VXLAN overlay and cluster membership.\n- [Gateway High Availability](/docs/clustering/high-availability) — failing over Gateway nodes.\n- [CLI Reference](/docs/cli) — the `network` command group (`attach` / `detach` / `endpoints`).\n"
        },
        {
            "slug": "plugins/overview",
            "title": "Building Plugins",
            "category": "Plugins",
            "url": "https://www.stratum.cenvero.com/docs/plugins/overview",
            "headings": [
                {
                    "level": 1,
                    "text": "Building Plugins"
                },
                {
                    "level": 2,
                    "text": "How plugins run (sandboxed, out-of-process)"
                },
                {
                    "level": 3,
                    "text": "What your plugin can and cannot reach"
                },
                {
                    "level": 2,
                    "text": "1. Get the packer"
                },
                {
                    "level": 2,
                    "text": "2. Lay out your plugin"
                },
                {
                    "level": 2,
                    "text": "3. Become a developer + get your certificate"
                },
                {
                    "level": 2,
                    "text": "4. Pack & sign"
                },
                {
                    "level": 2,
                    "text": "5. Ship it"
                },
                {
                    "level": 2,
                    "text": "Capabilities"
                },
                {
                    "level": 2,
                    "text": "Scopes"
                },
                {
                    "level": 2,
                    "text": "Dependencies & licensing"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 1551,
            "markdown": "# Building Plugins\n\nPlugins extend a Stratum node with extra capabilities — custom protocol handlers, integrations, observability exporters, or specialised data-plane rules — without touching the core agent. This page is the practical guide to **building, signing, and shipping your own plugin**. To install one you already have, see [Installing Plugins](/docs/plugins/installing).\n\nEvery plugin is a **signed `.cenvero-plugin` package**. A node will only load a plugin signed with a **developer certificate that Cenvero issued to you** — so you don't manage any signing infrastructure yourself; you just sign with your own key and the certificate we give you. You build and sign packages with the **`cnvstrpack`** tool.\n\n## How plugins run (sandboxed, out-of-process)\n\nA plugin is **a signed executable, not a shared library**. The agent runs it as a **supervised, unprivileged child process** — never inside the agent and never as root:\n\n- The agent **verifies the package signature, your certificate, its scope, and that it has not been revoked _before_ it launches your executable**. A bad package is rejected and the process is never started.\n- Your plugin then runs as a **separate process under a dedicated unprivileged user** (`cenvero-str-plugin`), in its own process group, with CPU / memory / open-file limits applied. It has **no access to the agent's memory, private keys, configuration secrets, or root-owned files**.\n- The only thing your plugin can do is talk to the agent over a small JSON protocol on its stdin/stdout, calling the **capability-scoped host API** the agent grants it (see **Capabilities** below). Everything is **deny-by-default**: a host call you weren't granted is rejected and your plugin is terminated.\n\nThe sandbox is designed so an over-scoped or misbehaving plugin is contained: it runs unprivileged, capability-gated, and is restarted or killed when it misbehaves.\n\n### What your plugin can and cannot reach\n\nThese are the constraints to design against. They are properties of the sandbox, not settings you can relax, and each one has caught someone out:\n\n| Your plugin… | What that means for your code |\n|---|---|\n| **has no network of its own** | It cannot open sockets, call an external API, or reach the internet — it gets a private, empty network view. A plugin that phones home will fail. Anything network-facing must go through a granted host-API call. |\n| **cannot see other processes** | It has a private process table: it cannot list, inspect, or signal the agent or anything else on the host. |\n| **cannot affect the host filesystem** | It has a private filesystem view; changes it makes do not propagate back to the host. |\n| **may only make ordinary syscalls** | The kernel refuses anything outside a fixed allowlist — mounting, loading modules, and similar privileged operations are denied even if something in the package tries. |\n| **is restarted if it dies** | A crashed child is relaunched automatically on a backoff that widens with repeated failures, and resets once it stays up. Don't build your own restart loop. |\n| **must answer a hook promptly** | Each hook invocation carries a deadline (5 seconds by default). A plugin that overruns it is **killed**, not merely timed out — do slow work in the background and answer quickly. |\n\nThe practical shape of a plugin follows from the first row: it is an **event handler that reacts and reports**, not a service that goes and fetches. If your design needs to reach something off-box, that belongs in your own service, with the plugin reporting to it via the host API.\n\n> **A signed plugin is code you have chosen to trust.** The signature proves *who* wrote the package and that it has not been altered — it is an authenticity guarantee. The sandbox above is what limits what that code can do. Treat installing a plugin as the decision it is, and install only plugins from a developer you trust.\n\n## 1. Get the packer\n\nDownload `cnvstrpack` from the [Releases](/releases) page (it's listed alongside the agent). It's a single self-contained binary — no install needed.\n\n```bash\ncnvstrpack --help\n```\n\n## 2. Lay out your plugin\n\nA plugin is a directory containing your **entrypoint executable** plus a `manifest.json`:\n\n```\nmy-plugin/\n├── manifest.json\n└── entrypoint            # your compiled binary (any language) — must be executable\n```\n\n```json\n{\n  \"name\": \"my-plugin\",\n  \"version\": \"1.0.0\",\n  \"author\": \"Acme Corp\",\n  \"description\": \"What this plugin does\",\n  \"min_agent_version\": \"1.0.0\",\n  \"entrypoint\": \"entrypoint\",\n  \"requested_capabilities\": [\"log\", \"emit_event\", \"register_hook\"],\n  \"hooks\": [\"on:flow.new\"],\n  \"dependencies\": []\n}\n```\n\n- **`entrypoint`** is the executable the agent launches as your sandboxed child (defaults to `entrypoint` if omitted). It can be written in any language; it just speaks the JSON protocol on stdin/stdout.\n- **`requested_capabilities`** is the list of host-API functions your plugin needs. You only get the **intersection** of what you request and what your certificate's **scope** allows — request the minimum (see **Capabilities** below).\n- **`hooks`** are the data-plane hook points your plugin registers for. You may only register hooks you declared here.\n\nOn startup your entrypoint must: (1) send a `handshake` advertising its name/version/API and declared capabilities, (2) handle `hook_invoke` messages within the per-hook deadline, and (3) make host-API calls only for capabilities it was granted.\n\nThe declarable hook points are:\n\n| Hook | Fires on |\n|------|----------|\n| `on:flow.new` | A new connection is seen. |\n| `on:dns.query` | A DNS query is received. |\n| `before:packet.forward` | A packet is about to be forwarded. |\n| `on:alert.trigger` | An alert fires. |\n\n> **Scope — hook delivery is not live yet.** Packaging, signing, installing, launching, supervision, the host API and hook *registration* all work today, and a plugin that registers for a hook loads and runs normally. What is not wired yet is the last step: the agent's data-plane subsystems do not currently dispatch to registered hooks, so a handler for the points above **will not be invoked on live traffic**. Build and ship plugins against this interface by all means — it is stable and is what dispatch will use — but do not design a deployment that depends on a hook firing today. This page will say so plainly when that changes.\n\n## 3. Become a developer + get your certificate\n\nFirst, **apply for developer access**: in your account, choose *Become a developer*. Once an admin approves you, open your **Developer portal** at `/account/developer`.\n\nGenerate your signing keypair locally and keep the `.key` private:\n\n```bash\ncnvstrpack keygen -o mydev          # writes mydev.key (secret) and mydev.pub\n```\n\nIn the Developer portal, paste the contents of **`mydev.pub`**, choose a **scope** (see **Scopes** below), and request a certificate — it is **issued instantly**. From the same page, download the two certificate files you'll sign with: your **`developer.cert`** and the accompanying **`plugin-intermediate.cert`** bundle. Your private key never leaves your machine — you only ever handle your own `.key`; Cenvero manages all signing infrastructure on our side.\n\n## 4. Pack & sign\n\n```bash\ncnvstrpack pack ./my-plugin \\\n  --key mydev.key \\\n  --devcert developer.cert \\\n  --intcert plugin-intermediate.cert \\\n  -o my-plugin-1.0.0.cenvero-plugin\n```\n\n`cnvstrpack` validates the manifest, archives the directory, signs it with your key, and embeds your developer certificate — producing a ready-to-ship `my-plugin-1.0.0.cenvero-plugin`.\n\nSanity-check it before sending:\n\n```bash\ncnvstrpack verify my-plugin-1.0.0.cenvero-plugin --pub mydev.pub\n```\n\n## 5. Ship it\n\nHand the `.cenvero-plugin` file to the node operator — they install it with one command (see [Installing Plugins](/docs/plugins/installing)). The agent re-verifies the signature on load; a tampered or out-of-scope package is rejected with a clear error and never partially loaded.\n\n## Capabilities\n\nYour plugin can only call host functions it was **granted**. You request capabilities in `requested_capabilities`; the agent grants the **intersection** of your request and what your certificate's scope permits — it can never exceed the certificate. Any call to an ungranted function is rejected and your plugin is terminated.\n\nThe fixed set of host-API capabilities:\n\n| Capability | What it lets your plugin do |\n|------------|-----------------------------|\n| `log` | Write structured log lines to the agent log. |\n| `emit_event` | Publish an event onto the agent's event bus (tagged with your plugin name). |\n| `get_config_value` | Read a small, **read-only** subset of non-secret config values (e.g. node id, agent version). Never exposes API tokens or keys. |\n| `register_hook` | Register for a hook point — but only one you also declared in `hooks`. |\n| `kv_get` / `kv_put` | A small key/value scratch store **scoped to your plugin** — you cannot read or write another plugin's keys. |\n\nCapability availability by scope:\n\n- **`any`** (public distribution): `log`, `emit_event`, `get_config_value`, `register_hook`.\n- **`license:<serial>` / `hardware:<id>`** (bound to a specific deployment): the above **plus** `kv_get` / `kv_put`.\n\nRequest the least you need: a smaller capability set is easier to get approved and reduces blast radius.\n\n## Scopes\n\nWhen Cenvero issues your developer certificate, it carries a **scope** that decides which nodes will accept your plugins. Pick the one that matches how you distribute:\n\n| Scope | Use it for |\n|-------|------------|\n| `any` | A general-purpose plugin you distribute publicly — runs on any licensed node. |\n| `license:<serial>` | A customer- or enterprise-specific plugin — runs only on nodes activated with that license. |\n| `hardware:<id>` | A one-off plugin pinned to a single machine. Get the node's id with `cenvero-str-ctl node info`. |\n\nScope is fixed at issuance; to change it, request a new certificate.\n\n## Dependencies & licensing\n\n- If your `manifest.json` declares dependencies, the agent installs in dependency order automatically and rejects circular or unsatisfiable version constraints at install time — you don't manage load order.\n- Plugin **installs** are blocked while a node's license is in the frozen state; already-running plugins keep going. See [Licensing](/docs/licensing).\n\n## See also\n\n- [Installing Plugins](/docs/plugins/installing) — install, list, verify, and remove on a node.\n- [CLI Reference](/docs/cli) — the `plugin` command group.\n"
        },
        {
            "slug": "plugins/installing",
            "title": "Installing Plugins",
            "category": "Plugins",
            "url": "https://www.stratum.cenvero.com/docs/plugins/installing",
            "headings": [
                {
                    "level": 1,
                    "text": "Installing Plugins"
                },
                {
                    "level": 2,
                    "text": "Installing a plugin"
                },
                {
                    "level": 2,
                    "text": "Verifying a plugin before installing"
                },
                {
                    "level": 2,
                    "text": "Listing installed plugins"
                },
                {
                    "level": 2,
                    "text": "Disabling and re-enabling"
                },
                {
                    "level": 2,
                    "text": "Removing a plugin"
                },
                {
                    "level": 2,
                    "text": "Updating a plugin"
                },
                {
                    "level": 2,
                    "text": "See also"
                }
            ],
            "word_count": 791,
            "markdown": "# Installing Plugins\n\nThis page covers the full plugin lifecycle on a node: installing a package, verifying its signature, listing what is loaded, and removing a plugin cleanly. All plugin commands require `root` for mutations.\n\n## Installing a plugin\n\nPlugins are distributed as `.cenvero-plugin` files. To install one:\n\n```bash\nsudo cenvero-str-ctl plugin install /path/to/my-plugin-1.2.0.cenvero-plugin\n```\n\n**Plugins run sandboxed.** A plugin is a signed executable that the agent launches as a **supervised, unprivileged child process** under a dedicated `cenvero-str-plugin` user — never inside the agent and never as root. The agent verifies everything below **before it launches the executable**, so a bad package never starts a process. See [Building Plugins → How plugins run](/docs/plugins/overview) for the security model.\n\nBefore launching, the agent performs these checks in order:\n\n1. **Signature** — verifies that the package is intact and was genuinely signed with the developer certificate Cenvero issued, and that the certificate is authentic. A package that is tampered with, or signed with a key Cenvero never certified, is rejected.\n2. **Certificate validity** — checks that the developer certificate has not expired and has not been revoked.\n3. **Scope** — checks that the developer certificate's scope (`any`, `license:<serial>`, or `hardware:<id>`) covers this node.\n4. **Manifest** — validates that `manifest.json` declares a compatible agent API version, a valid entrypoint, and only known requested capabilities.\n5. **Dependencies** — checks that all declared dependencies are installed and satisfy the version constraints.\n\nOnly after all checks pass does the agent write the files and **launch the sandboxed child**. The capabilities the plugin is actually granted are the intersection of its requested capabilities and what its certificate's scope allows. If any check fails, nothing is written, no child is launched, and the agent prints a specific rejection reason. The agent state is unchanged.\n\n```text\nerror: plugin install failed\n  reason: developer certificate scope mismatch\n  scope:  license:ENT-2024-00042\n  node:   license ENT-2024-00099\n  action: obtain a plugin package signed with a certificate scoped to your license serial\n```\n\nOn success, the agent loads the plugin and confirms:\n\n```text\nplugin my-plugin 1.2.0 installed and loaded\n  signed by: Acme Corp Developer Certificate\n  scope:      any\n  api:        stratum/plugin/v2\n```\n\n## Verifying a plugin before installing\n\nInspect a `.cenvero-plugin` file without installing it:\n\n```bash\ncenvero-str-ctl plugin verify /path/to/my-plugin-1.2.0.cenvero-plugin\n```\n\n```text\nFIELD            VALUE\nName             my-plugin\nVersion          1.2.0\nAPI version      stratum/plugin/v2\nDeveloper        Acme Corp\nCertificate      valid (expires 2026-12-31)\nScope            any\nSignature        valid\nDependencies     (none)\nCompatible       yes\n```\n\nThis is a read-only operation and does not require root. Use it to check a plugin before distribution or before installing on a production node.\n\n## Listing installed plugins\n\n```bash\ncenvero-str-ctl plugin list\n```\n\n```text\nNAME           VERSION  STATE   SCOPE               DEVELOPER\nmy-plugin      1.2.0    loaded  any                 Acme Corp\naudit-export   0.8.1    loaded  license:ENT-2024-00042  Cenvero\ndebug-tools    1.0.0    loaded  hardware:a3f2...    Cenvero\n```\n\n| State | Meaning |\n|-------|---------|\n| `loaded` | Plugin's sandboxed child process is running normally (and is restarted with backoff if it crashes). |\n| `error` | Plugin failed to start or its child kept crashing — check `cenvero-str-ctl plugin show <name>` for the error and the captured child stderr. |\n| `disabled` | Plugin was manually disabled; its child is stopped but its files are kept. |\n\nShow full detail for one plugin:\n\n```bash\ncenvero-str-ctl plugin show my-plugin\n```\n\nThis prints the manifest, certificate detail, load time, and any runtime log lines the plugin has emitted.\n\n## Disabling and re-enabling\n\nDisable a plugin without removing it. The plugin binary and its configuration stay on disk; it is simply not loaded on the next agent start:\n\n```bash\nsudo cenvero-str-ctl plugin disable my-plugin\n\n# Re-enable later\nsudo cenvero-str-ctl plugin enable my-plugin\n```\n\nA disabled plugin's signature and certificate are re-verified when it is re-enabled, so an expired developer certificate will cause a re-enable to fail even if the plugin was installed successfully before the certificate expired.\n\n## Removing a plugin\n\n```bash\nsudo cenvero-str-ctl plugin remove my-plugin\n```\n\nThe agent unloads the plugin cleanly, removes its files, and updates the plugin registry.\n\n> Removal is unconditional: the agent does not track dependencies between plugins, so nothing warns you if another plugin expected this one to be present. If your plugins depend on each other, check that yourself before removing one, and disable it first with `plugin disable` if you want to test the effect reversibly.\n\n## Updating a plugin\n\nInstall the new version over the existing one:\n\n```bash\nsudo cenvero-str-ctl plugin install /path/to/my-plugin-1.3.0.cenvero-plugin\n```\n\nThe agent runs all the same verification steps as a fresh install. On success it unloads the old version and loads the new one with no agent restart. The old version's files are removed.\n\nTo roll back to a previous version, install the older `.cenvero-plugin` file.\n\n## See also\n\n- [Building Plugins](/docs/plugins/overview) — create, scope, sign, and ship your own plugin with `cnvstrpack`.\n- [CLI Reference](/docs/cli) — the full `plugin` command surface.\n- [Licensing](/docs/licensing) — how license enforcement affects plugin installs when a node is frozen.\n"
        },
        {
            "slug": "your-account",
            "title": "Your account",
            "category": null,
            "url": "https://www.stratum.cenvero.com/docs/your-account",
            "headings": [
                {
                    "level": 1,
                    "text": "Your account"
                },
                {
                    "level": 2,
                    "text": "The pages"
                },
                {
                    "level": 2,
                    "text": "Signing in"
                },
                {
                    "level": 2,
                    "text": "Common tasks"
                },
                {
                    "level": 2,
                    "text": "The order of things"
                },
                {
                    "level": 2,
                    "text": "Next steps"
                }
            ],
            "word_count": 661,
            "markdown": "# Your account\n\nA map of the customer console: what each page is for, and where to go for the\nthing you actually want to do.\n\n## The pages\n\n| Page | What lives there |\n|---|---|\n| **Dashboard** | Everything needing your attention, in one list — machines waiting to be approved, invoices awaiting payment, licenses expiring soon, node SSL requests. If nothing needs you, it says so. |\n| **Licenses** | Every license on your account: its plan, key, expiry, and status. Open one to see its machines and to renew it. |\n| **Machines & Activations** | The machines your licenses are activated on, and any activation waiting for you to confirm. This is where you approve a new machine, revoke one, or ask for a key to be regenerated. |\n| **TLS / SSL Certificates** | Your certificate authority: download your root certificate, and approve certificate requests coming from your nodes. |\n| **Billing › Orders** | Every order you have placed and its state — awaiting payment, submitted, verified, or rejected. Open an order for its payment instructions and to upload your proof. |\n| **Billing › Invoices** | Your invoices. An unpaid one shows *awaiting payment* with a **Pay** button; a settled one is marked *paid*. Download the PDF at any time. |\n| **Billing › Wallet** | Your balance and automatic renewal. |\n| **Settings** | Your profile, company details and password. |\n\n## Signing in\n\nAn email address and password works for every account. If your organisation has\nsingle sign-on set up, the sign-in page will also offer a **Sign in with SSO**\nbutton, and you use your existing company account instead of a separate\npassword.\n\nA few things to expect if you sign in that way:\n\n- **Your identity comes from your provider.** Changing your name or email is\n  done there, not here — an update flows through the next time you sign in.\n- **Your password here becomes irrelevant.** Access is granted and revoked by\n  whoever administers your company directory, so removing someone there removes\n  their access to this console too.\n- **Signing in with SSO never changes what you can do.** It authenticates you;\n  it does not grant any additional permissions.\n\nIf you do not see an SSO button, it is not enabled for your organisation — ask\nwhoever administers it. There is nothing to configure from your side.\n\n## Common tasks\n\n**\"Where is my license key?\"** — **Licenses**. Open the license; the key is on\nits page, along with the plan, expiry and the machines using it.\n\n**\"I installed the agent and it is waiting to be activated.\"** — **Machines &\nActivations**. A machine that has requested activation appears there for you to\nconfirm. The Dashboard also flags it.\n\n**\"I need to pay.\"** — **Billing › Invoices**, or the Dashboard, which shows the\ntotal due. Both have a **Pay** button that takes you to the order carrying the\npayment instructions and the proof upload.\n\n**\"I paid — where is my license?\"** — Once we verify the payment, the invoice is\nmarked paid and the license is issued automatically. It then appears under\n**Licenses**, and you can activate machines against it.\n\n**\"My license expires soon.\"** — The Dashboard flags it, and **Licenses** has a\nrenew action. With a funded wallet and automatic renewal enabled, renewal happens\non its own.\n\n**\"My hardware changed and the node is in its grace period.\"** — Open the license\nunder **Licenses** and request a hardware reset. Your node keeps running during\nthe grace period while we rebind it.\n\n## The order of things\n\nAn order and a license are connected but not the same thing:\n\n1. You place an **order** for a plan and a number of nodes.\n2. An **invoice** is raised straight away so you have something to pay against.\n3. You pay and submit your proof; we verify it and the invoice is marked paid.\n4. A **license** is issued and appears under Licenses.\n5. You install the agent on a machine; it requests **activation**.\n6. You confirm the activation, and the machine starts running under that license.\n\n## Next steps\n\n- [Licensing and activation](licensing) — plans, statuses, renewals, coupons\n- [Installation](installation) — getting the agent onto a server\n- [TLS and licensing](tls-and-license) — certificates for your nodes\n"
        }
    ]
}