Firewall
Stratum'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 a source or destination port, one interface, one workload's hardware address, or one scope.
Filtering happens 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 the node's forwarding interfaces — so one rule is in force everywhere rather than per location. A blocked-address list is checked alongside them.
Every setting a rule accepts is enforced exactly as written. A value the firewall cannot honour is refused with an error when you add the rule, rather than stored and quietly ignored.
How a packet is judged
Five things decide the verdict, in this order. Most surprises come from the second and third.
1. Where rules are applied. Rules are evaluated on traffic arriving at
the node — at the workload bridge, and at the node's forwarding interfaces —
before the node decides where to send it and before any address translation.
That covers both traffic for the node itself and traffic the node routes
onwards; one rule applies to both. Outgoing traffic is not filtered: to control
what a workload may reach, write the rule against the traffic as it arrives from
that workload (its source address). Narrow a rule with interface when you want
it to apply in just one place.
Because rules see a packet before address translation, they match the addresses the packet arrived with: a port forward is matched on the node's public address and port, not on the inside address it is sent on to.
2. Connections already allowed are not checked again. Every rule is stateful. Once a rule allows a connection, its replies and its further packets are admitted without consulting the rules again, until the connection ends. Adding or tightening a rule therefore affects new connections; see Applying a rule change to connections that are already open.
This covers connections whose first packet arrives at the node — from a
workload or from outside. A connection the node itself opens (to a DNS
resolver, a time server, your licensing and update server, a BGP peer) is not
remembered, so its replies are judged by the rules like any other arriving
traffic. With the default action at allow that changes nothing unless a deny
rule matches those replies; with deny, allow them explicitly (see below).
3. The order rules are checked — broad first, then specific. This is the opposite of what most people expect, and it decides everything:
| Order | level | Scope |
|---|---|---|
| 1st | global (default) | Everything |
| 2nd | bridge (or interface, the same level) | One bridge or interface |
| 3rd | vlan | One VLAN |
| 4th | private_network | One network |
| 5th | mac | One workload |
| 6th | flow | One conversation |
Within the same level, lower priority is checked first; rules that tie are
settled by creation order so the result is always deterministic.
The first rule that matches wins, and nothing after it is consulted. So a broad rule is consulted before a narrow one:
If you allow10.20.0.0/24at the defaultgloballevel and then deny one address atmaclevel, the allow wins — it is checked first and the deny is never reached. To carve an exception out of a broad rule, give the exception a lower priority number so it is checked first, or write the broad rule at a narrower level.
4. What the rule matches. Any field you leave out matches anything. A rule with no matchers at all matches every packet — which is why an over-broad rule is usually a forgotten field rather than a wrong one.
5. The default action, if no rule matched at all.
The default action
The firewall has a single no-match (default) action that applies when no rule matches a packet. It starts as allow, so a new node lets through everything no rule denies, and it is kept across restarts. To deny by default, set it to deny: from then on traffic is refused unless a rule allows it.
# Show the current default action
cenvero-str-ctl firewall default
# Refuse everything no rule allows
sudo cenvero-str-ctl firewall default set deny
# Back to allowing unmatched traffic
sudo cenvero-str-ctl firewall default set allow
The same setting is available over the API at GET and PUT /api/v1/rules/default — see API Reference.
Setting the default to>denyis strongly recommended. An explicit allowlist is much easier to audit than a denylist. Because the default action applies to all traffic arriving at the node — including your own access to it — add the allow rules for the connections you need before switching todeny:
- the addresses you manage the node from (for example>{"source_ip":"203.0.113.10","protocol":"tcp","dest_port":22});
- the replies to connections the node opens itself, which are not remembered (see How a packet is judged): allow each service by its address and source port, for example your DNS resolver with
{"source_ip":"192.0.2.53","protocol":"udp","source_port":53}, and likewise your time servers, BGP peers and your licensing and update server.
If you lock yourself out, set the default back from the node's console with sudo cenvero-str-ctl firewall default set allow.
A blocked packet is discarded: nothing is sent back to the sender, so a blocked connection times out rather than being refused.
How a rule is written
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.
No field is required: every field is a matcher or a setting that you leave out when you want the default. The command sets the action — allow or deny — so leave action out of the JSON; a rule whose action says otherwise is refused.
| Field | Type | Description |
|---|---|---|
protocol | string | tcp, udp or icmp. Omit to match any protocol. icmp matches IPv4 ICMP only, so it cannot be combined with an IPv6 address or with ports. |
source_ip | string | Source address or CIDR, e.g. 10.20.0.50 or 10.20.0.0/24. |
dest_ip | string | Destination address or CIDR. When both addresses are given they must be the same family (both IPv4 or both IPv6). |
source_port | number | A single source port. |
dest_port | number | Destination port. With dest_port_max, the start of a range. |
dest_port_max | number | Optional. Makes dest_port the start of an inclusive port range, so one rule can cover many ports. |
priority | number | Evaluation order; lower is evaluated first. |
interface | string | Bind the rule to one ingress device, e.g. cnv-user-br0. Omit to match any interface. |
mac | string | Match only frames from this source MAC, e.g. 52:54:00:ab:cd:01. |
level | string | Policy scope, which sets the order rules are checked in (see the table above): global (default), bridge or its synonym interface, vlan, private_network, mac, or flow. An unrecognised value is treated as global. |
comment | string | Free-text note, shown in firewall list. |
chain | string | Optional. The only value is prerouting — traffic as it arrives, before routing and address translation, which is where every rule applies. Leave it out. |
stateful | boolean | Optional. Every rule is stateful, so the only value is true. Leave it out. |
A rule with a port matches TCP and UDP traffic on that port only; traffic without ports, such as ICMP, never matches it. Packets that are fragments after the first carry no ports, so a port rule does not match them either.
The firewall refuses, with an error that names the field:
- an unrecognised key — a typo fails loudly instead of silently widening the rule to every address;
- an address, MAC or protocol it cannot read;
chainset to anything other thanprerouting: the firewall cannot restrict a rule to traffic for the node only (input) or passing through it only (forward), and it does not filter outgoing traffic (output,postrouting);statefulset tofalse: replies to an allowed connection are always admitted;- the action
reject(over the API): a blocked packet is always discarded without a refusal being sent back. Usedrop.
Adding allow rules
The firewall allow command adds an allow rule. The rule describes who may start a conversation; replies are admitted automatically, so you only need a rule in one direction.
Allow inbound HTTPS to a specific endpoint:
sudo cenvero-str-ctl firewall allow '{"dest_ip":"10.20.0.50","protocol":"tcp","dest_port":443,"comment":"https to app"}'
Add HTTP as its own rule:
sudo cenvero-str-ctl firewall allow '{"dest_ip":"10.20.0.50","protocol":"tcp","dest_port":80,"comment":"http to app"}'
A range covers many ports in a single rule, rather than one rule per port:
# Allow the whole 8000-9000 range to an application server
sudo cenvero-str-ctl firewall allow '{"dest_ip":"10.20.0.50","protocol":"tcp","dest_port":8000,"dest_port_max":9000,"comment":"app port range"}'
Allow an endpoint to initiate connections (to anywhere):
sudo cenvero-str-ctl firewall allow '{"source_ip":"10.20.0.50","comment":"app egress"}'
Allow one endpoint to reach a database on another:
sudo cenvero-str-ctl firewall allow '{"source_ip":"10.20.0.50","dest_ip":"10.30.0.60","protocol":"tcp","dest_port":5432}'
Because 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":
sudo cenvero-str-ctl firewall allow '{"source_ip":"10.20.0.0/24","dest_ip":"10.30.0.0/24","protocol":"tcp","dest_port":5432}'
A source port narrows a rule to traffic sent from that port — for example, answers from one DNS server, which come from port 53:
sudo cenvero-str-ctl firewall allow '{"source_ip":"198.51.100.53","protocol":"udp","source_port":53}'
To confine a rule to traffic arriving on one device, add interface:
sudo cenvero-str-ctl firewall allow '{"interface":"cnv-user-br0","source_ip":"10.20.0.0/24","protocol":"tcp","dest_port":443}'
Listing and removing rules
cenvero-str-ctl firewall list
{
"data": {
"rules": [
{
"id": 1,
"chain": "prerouting",
"priority": 0,
"protocol": "tcp",
"source_ip": "",
"dest_ip": "10.20.0.50",
"source_port": 0,
"dest_port": 443,
"action": "accept",
"comment": "https to app",
"stateful": true,
"interface": "",
"mac": "",
"level": ""
}
]
},
"status": "ok"
}
Remove a rule by its id:
sudo cenvero-str-ctl firewall delete 1
Changes apply to new connections immediately. Connections that are already open continue until they close.
Rules saved before these checks
Rules added before the firewall refused the settings listed above may still hold one: a chain other than prerouting, stateful set to false, the action reject, or an address, MAC or protocol that does not read as one. Those settings were never enforced as written. Each such rule is listed with a not_enforced entry per setting that says what the firewall does instead, a warning at the top of the listing counts them, and the node logs one warning per rule when it starts:
{
"id": 4,
"chain": "output",
"action": "accept",
"dest_port": 443,
"stateful": false,
...
"not_enforced": [
{ "field": "chain", "detail": "\"output\" is not enforced: outgoing traffic is not filtered; this allow rule is not applied" },
{ "field": "stateful", "detail": "false is not enforced: every rule is stateful, so replies to connections this rule allows are admitted" }
],
"not_applied": true
}
How such a rule is applied is chosen so that it never lets through more than it says, without cutting the traffic it was written for:
| The saved rule has | A deny rule | An allow rule |
|---|---|---|
chain input or forward | Applied to all arriving traffic (it drops more, never less) | Applied to all arriving traffic, as before |
chain output or postrouting | Applied to arriving traffic | Not applied (not_applied): outgoing traffic is not filtered, and applying it to arriving traffic would admit traffic nobody asked for |
stateful false | Applied; connections already open are not re-checked | Applied; replies to what it allows are admitted, as before |
action reject | Applied as a drop | — |
| An address, MAC or protocol that does not read | Applied with that matcher ignored (it drops more) | Not applied: the matcher used to match everything |
An allow rule for incoming traffic keeps working because removing it would cut the connections it was written to admit; the extra traffic it admits is traffic arriving at the node that matches every one of its own matchers, or replies to connections it allowed. Review each flagged rule and replace it with one the firewall enforces as written — over the API, PUT /api/v1/rules/{id} swaps it in one step; on the command line, add the corrected rule and delete the old one. The flag goes away once no rule carries such a setting.
Explicit deny rules
Use 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:
sudo cenvero-str-ctl firewall deny '{"source_ip":"198.51.100.44","dest_ip":"10.20.0.50","protocol":"tcp","priority":10,"comment":"block abusive source"}'
To block a source outright, leave the destination and protocol matchers out:
sudo cenvero-str-ctl firewall deny '{"source_ip":"198.51.100.0/24","priority":10}'
Allow and deny rules share the same ordered rule list; the first match wins, so a higher-priority deny is evaluated before the allow rules below it. A new deny rule does not close connections that are already open — flush them if it must take effect at once (see Connection tracking).
Scheduled rules
Any 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.
Attach 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:
# Enforce rule 7 only on weekdays, 09:00 to 17:00 UTC
sudo cenvero-str-ctl firewall schedule set 7 --days 1,2,3,4,5 --start 09:00 --end 17:00
# Enforce rule 12 all day, but only on weekends
sudo cenvero-str-ctl firewall schedule set 12 --days 0,6
An 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.
List scheduled rules, with whether each is active right now:
cenvero-str-ctl firewall schedule list
Clear a rule's schedule to make it always-active again:
sudo cenvero-str-ctl firewall schedule clear 7
Connection tracking
What it is for
Connection tracking is what lets you write a one-directional rule and still get a working two-way conversation.
Without 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.
So 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.
The consequence is the one in the next section: because a conversation is remembered, changing a rule does not affect conversations already under way.
The agent exposes the connection table for inspection. The command takes no arguments and dumps every tracked flow:
cenvero-str-ctl firewall conntrack
Applying a rule change to connections that are already open
A new or tightened rule only affects new connections. A connection that is already established stays in the table and keeps being allowed through until it finishes or ages out. After changing a rule, flush the table so the next packet of each flow is checked against your current rules:
# Re-evaluate every established connection
sudo cenvero-str-ctl firewall conntrack-flush
# Only connections to or from one address
sudo cenvero-str-ctl firewall conntrack-flush 198.51.100.7
Flushing does not close anything by itself — each connection is simply re-checked, so the ones your rules still permit carry on.
{
"data": {
"flows": [
{
"proto": 6,
"src": "10.20.0.50",
"sport": 54321,
"dst": "10.30.0.10",
"dport": 5432,
"state": "established",
"packets": 128,
"snat": false,
"dnat": false
}
]
},
"status": "ok"
}
The snat and dnat flags show whether the node 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.
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.
Per-source connection limits
You 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.
Enable it with a maximum, and optionally a cooldown:
sudo cenvero-str-ctl firewall connlimit set --max 200 --cooldown 10m
| Flag | Description |
|---|---|
--max | Maximum concurrent established connections per source IPv4 (required, must be greater than 0) |
--cooldown | How long an over-limit source stays blocked, e.g. 10m or 1h (default 10m) |
Show the current configuration and any sources blocked right now:
cenvero-str-ctl firewall connlimit status
Disable per-source connection limiting:
sudo cenvero-str-ctl firewall connlimit clear
This 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.
Intrusion detection
Intrusion 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.
It 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.
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.
Acting on a detection is a separate, opt-in decision:
| Mode | Behaviour |
|---|---|
| Alert only (default) | A confirmed scanner or flooder raises an event. Traffic is untouched. |
| Auto-block | A confirmed IPv4 source is added to the firewall blocklist, with an expiry so the block lifts on its own. |
Auto-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.
Both settings are delivered in the node's configuration — see Configuration → Intrusion detection. Detections surface as events alongside everything else; see Monitoring.
Automatic blocks in a cluster
On a member of a cluster, an address that intrusion detection or a per-source connection limit blocks is blocked on every member:
- The block ends at the same moment on every member, even after a member restarts or a node joins. On the other members it lasts at most 7 days.
- A cluster member's address is never blocked — the address a member uses in the cluster, or for its API. A forged scan "from" a member therefore cannot cut the members off from each other. The detection is still reported.
- A node that joins has any block of its addresses lifted, on every member.
- A rule that drops or rejects a member's address (
cenvero-str-ctl rules addorrules batch) is refused, and the answer says to take that member out withcluster remove <node-id>first. A rule made before the address became a member's is not enforced against that member while it is one. - A deny rule whose source is a member's address (
cenvero-str-ctl firewall deny, orPOST/PUTon/api/v1/rules) is refused the same way, with409from the API, when it could drop the cluster's own traffic. One limited to UDP or ICMP, to a destination that is not a member's address, or to destination ports other than 7073 and the ports the node's own outgoing connections use is accepted.
See Members' addresses are never blocked.
Anti-spoof enforcement on the bridge
Independently of the ACL, the data plane enforces anti-spoofing on the workload bridge so an endpoint cannot impersonate another:
- 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).
- 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.
- 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).
These checks run before the ACL, so spoofed frames never reach the rule evaluation.
RA-guard & DHCP snooping
On 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:
- 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.
- 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.
Like the source guards, these drops are counted among the anti-spoof drops and are applied before the ACL is evaluated.
See also
- Networking Overview — where firewall enforcement sits in the data plane.
- Load Balancer — VIP addresses need their own allow rules for external access.
- BGP Edge Routing — north-south traffic routed through a node also passes through the firewall.
- Quick Start — basic policy example.