{
    "product": "Cenvero Stratum",
    "generated_at": "2026-08-03T07:18:08+00:00",
    "format": "cenvero-docs-v1",
    "document_count": 1,
    "documents": [
        {
            "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"
        }
    ]
}