Zero-Trust 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 one interface, one workload's hardware address, or one scope.
Filtering 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.
How a packet is judged
Four things decide the verdict, in this order. Most surprises come from the second one.
1. Where rules are applied. Rules are evaluated on traffic entering —
at the workload bridge, and at a Gateway node's forwarding interfaces. The same
rule set is applied at every one of those points, so a rule you add is in force
everywhere traffic arrives, not only on one interface. Narrow it with
interface when you want it to apply in just one place.
2. 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 | One bridge |
| 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.
3. What the rule matches. Any field you leave out matches anything. A rule
with only chain set matches every packet — which is why an over-broad rule is
usually a forgotten field rather than a wrong one.
4. The default action, if no rule matched at all.
A note on chain
chain is required and recorded on every rule, but it does not currently
change where the rule is evaluated: rules are applied to traffic as it enters,
whichever chain they name. Writing a rule with chain: "output" does not create
outbound filtering — it is evaluated on entry like every other rule. Filter
egress by writing the rule against the traffic's entry point instead.
What stateful gives you
A stateful rule means the reply to a permitted connection is allowed back without you writing a second rule for the return direction. Without it you would need a mirrored rule for every service.
The consequence people hit is at the other end of a connection's life:
Tightening a rule does not close connections that are already open. A new rule governs new connections; conversations already established keep running until they end on their own. This is usually what you want — you are not cutting off live sessions by editing policy. When you do need a change to take effect immediately, such as during an incident, clear the tracked connections explicitly (see Connection tracking).
The default action
The 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.
Setting the default todenyis 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 todeny.
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.
Only chain is required — every other field is a matcher that you leave out when you want it to match anything.
| Field | Type | Description |
|---|---|---|
chain | string | Required. Where the rule is evaluated: input, output, forward, prerouting, or postrouting. |
protocol | string | tcp, udp, icmp. Omit to match any protocol. |
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. |
source_port | number | 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. |
stateful | boolean | Use connection tracking so return traffic is admitted automatically. |
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: global (default), bridge, interface, mac, flow, vlan, or private_network. |
comment | string | Free-text note, shown in firewall list. |
Field 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.
Adding allow rules
The 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.
Allow inbound HTTPS to a specific endpoint:
sudo cenvero-str-ctl firewall allow '{"chain":"forward","dest_ip":"10.20.0.50","protocol":"tcp","dest_port":443,"stateful":true,"comment":"https to app"}'
Ports are matched one at a time, so add HTTP as its own rule:
sudo cenvero-str-ctl firewall allow '{"chain":"forward","dest_ip":"10.20.0.50","protocol":"tcp","dest_port":80,"stateful":true,"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 '{"chain":"forward","dest_ip":"10.20.0.50","protocol":"tcp","dest_port":8000,"dest_port_max":9000,"stateful":true,"comment":"app port range"}'
Allow an endpoint to initiate outbound connections (to anywhere):
sudo cenvero-str-ctl firewall allow '{"chain":"forward","source_ip":"10.20.0.50","stateful":true,"comment":"app egress"}'
Allow one endpoint to reach a database on another:
sudo 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}'
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 '{"chain":"forward","source_ip":"10.20.0.0/24","dest_ip":"10.30.0.0/24","protocol":"tcp","dest_port":5432,"stateful":true}'
To confine a rule to traffic arriving on one device, add interface:
sudo 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}'
Listing and removing rules
cenvero-str-ctl firewall list
{
"data": {
"rules": [
{
"ID": 1,
"Chain": "forward",
"Action": "accept",
"Protocol": "tcp",
"SourceIP": "",
"DestIP": "10.20.0.50",
"SourcePort": 0,
"DestPort": 443,
"Priority": 0,
"Stateful": true,
"Interface": "",
"MAC": "",
"Level": "",
"Comment": "https to app"
}
]
},
"status": "ok"
}
Remove a rule by its ID — the ID field above:
sudo cenvero-str-ctl firewall delete 3
Changes apply to new flows immediately. Established flows already in the connection table continue until they close.
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 '{"chain":"forward","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 '{"chain":"forward","source_ip":"198.51.100.0/24","priority":10}'
Allow 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.
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 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.
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.
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 from Gateway nodes also passes through the firewall.
- Quick Start — basic policy example.