Firewall
Ze manages nftables packet filter and NAT rules from a single firewall { } YANG
section. The abstract data model describes matches (from) and actions (then);
the nft backend lowers them to nftables kernel expressions.
Backend
| Backend | Platform | Default | Mechanism |
|---|---|---|---|
nft |
Linux | ✓ | google/nftables netlink library |
vpp |
Linux with VPP | ✕ | GoVPP ACL classify pipeline and NAT44-ED |
firewall {
backend nft;
}
Clean-shutdown teardown
flush-on-shutdown controls whether stopping the ze process removes ze-owned
tables from the kernel. It defaults to true: an orderly stop (SIGTERM, e.g.
systemctl stop ze) tears the tables down so a stopped daemon leaves no rules
behind. It keys off how the process exits, and is unrelated to BGP graceful
restart -- that concerns only RIB/FIB route retention while a peer re-establishes
and never restarts the daemon, so it never reaches this path.
Set it to false to use ze as a one-shot provisioner: apply the firewall
(and interface) configuration, let the process exit, and leave the rules running
in the kernel -- the way nft -f programs a ruleset and returns. Nothing is torn
down when ze quits.
firewall {
backend nft;
flush-on-shutdown false; # one-shot: program the rules, exit, leave them running
}
A crash (SIGKILL, panic, power loss) never runs the shutdown path, so ze-owned
tables always persist across a crash regardless of this setting. The option
governs every ze table producer that shares the backend -- the firewall
component, control-plane-protection, policy-routes, and ddos-local -- and
the firewall component performs the teardown as a single ordered actor so
plugin table removal never races the backend close.
The reconcile deadline
Ze serializes the whole firewall reconcile: a snapshot of every owner's tables
followed by one Backend.Apply, under one process-wide lock. The owners are the
firewall component, control-plane-protection, policy-routes, and ddos-local.
A dataplane call that never returns therefore does not stall one owner, it stalls
all of them.
Each backend bounds one dataplane round-trip. Neither bound can be turned off. A
value below the floor or above the ceiling clamps, and an unparseable value falls
back to the default, because an unbounded call is the failure this guard exists to
prevent. The ceiling is one shared constant, firewall.MaxBackendDeadline, which
the latency histogram's last finite bucket is also derived from.
| Backend | Bounds | Environment variable | Default | Range |
|---|---|---|---|---|
nft |
One nftables netlink round-trip | ze.firewall.nft.netlink-timeout |
10s | 1s to 60s |
vpp |
One VPP binary-API round-trip | ze.firewall.vpp.reply-timeout |
10s | 1s to 60s |
The VPP backend previously ran with no bound at all. govpp's default reply timeout
is 0, which govpp documents as disabling the timeout, and Channel.ReceiveReply
has no context arm, so a wedged VPP held the process-wide reconcile lock
indefinitely.
When the deadline expires the apply fails, and ze logs an error saying the dataplane did not answer within the backend deadline and its ruleset is now behind the registry. That log sits outside the metrics path, so it is written whether or not telemetry is enabled.
A dataplane that is ABSENT is a different condition and is deliberately not reported as a timeout: VPP not running, or a connect wait that ran out, means the reconcile failed for a reason with a different fix, and there is no ruleset to be behind.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
ze_firewall_apply_duration_seconds |
histogram | result |
Time spent in Backend.Apply. result is ok, timeout, error, or panic. |
ze_firewall_apply_timeout_total |
counter | Reconciles that failed because the dataplane did not answer within the backend deadline. | |
ze_flowspec_rules_refused_total |
counter | reason |
FlowSpec routes received from a peer that did not become a firewall rule. reason is unknown-protocol, unsupported-component, unsupported-action, parse, or max-rules. |
The result label is what separates a healthy-but-slow apply from one that gave
up: a backend deadline of 10s and a 10s successful reconcile land in the same
latency bucket, and only the label tells them apart. Both signals derive from one
result value, so they cannot drift apart. A backend that panics is recorded as
panic rather than lost or filed as healthy.
A refused FlowSpec route is otherwise invisible: it is never registered, so no
reconcile fails and show firewall has nothing to render. The peer believes ze
filters the traffic and ze does not, which is what the counter reports.
Tables and Chains
Ze owns all tables whose kernel name starts with ze_. A table contains one or
more chains; a base chain has a type, hook, priority, and default policy.
firewall {
backend nft;
table wan {
family inet;
chain input {
type filter;
hook input;
priority 0;
policy drop;
term allow-ssh {
from {
destination-port 22;
protocol tcp;
}
then {
accept;
}
}
}
}
}
Tables another feature installs
A ze_ table is not always one the firewall { } section declared. A feature
that needs kernel state of its own publishes a table through the same backend,
so it appears beside yours in show firewall ruleset <name> and is withdrawn
with the configuration that asked for it.
One example: a BGP peer configured
with connection { ttl { max N; } } (RFC 5082 GTSM) gets ze_gtsm, an input
chain that drops an ICMP error which quotes that peer's BGP session and arrives
below the peer's TTL floor. The chain names the session by the header the error
quotes (the peer as the quoted destination, and the BGP port), whoever sent the
error. Its terms therefore read the quoted header as a raw payload compare
(@th,192,32 0xc0000202 is 192.0.2.2) and carry no ip saddr.
show firewall ruleset gtsm lists it, and removing
the peer's ttl block removes it. The full list of table owners is in
table ownership.
Table Families
inet (dual-stack), ip, ip6, arp, bridge, netdev.
Chain Types
filter, nat, route.
Hooks
input, output, forward, prerouting, postrouting, ingress, egress.
Match Types (from block)
| Config key | Match | Example |
|---|---|---|
source-address |
IP prefix | source-address 10.0.0.0/8; |
destination-address |
IP prefix | destination-address 192.168.1.0/24; |
source-port |
Port or range | source-port 1024-65535; |
destination-port |
Port or range | destination-port 22; or destination-port 80,443; |
protocol |
L4 protocol name (see below) | protocol tcp; |
input-interface |
Interface name | input-interface eth0; |
output-interface |
Interface name | output-interface "l2tp*"; |
icmp-type |
ICMP type (name or number) | icmp-type echo-request; |
icmpv6-type |
ICMPv6 type (name or number) | icmpv6-type nd-neighbor-solicit; |
connection-state |
Conntrack states | connection-state established,related; |
connection-mark |
Mark value/mask | connection-mark 0x10/0xff; |
Protocol names
protocol takes one of ten names. The set is the same everywhere a protocol is
matched: the firewall config, policy routes, DDoS mitigation terms and FlowSpec
routes learned from a peer all resolve against one table, so a name that commits
is a name every backend can program.
| Name | IANA number |
|---|---|
icmp |
1 |
tcp |
6 |
udp |
17 |
gre |
47 |
esp |
50 |
ah |
51 |
icmpv6 |
58 |
ospf |
89 |
vrrp |
112 |
sctp |
132 |
A protocol outside this set is refused where it enters. The config editor refuses it at commit; a FlowSpec route that names one is refused at translation, counted, and logged with the protocol number and the route key. Ze does not enforce a version of the rule with the protocol condition removed, because that rule is wider than the one that was asked for.
| mark | Packet mark value/mask | mark 0x10/0xff; |
| dscp | DSCP value (name or number) | dscp ef; |
| tcp-flags | TCP header flags | tcp-flags syn; |
| source-address @set | Named set lookup | source-address @blocked; |
ICMP Type Names
Symbolic names for icmp-type: echo-reply, destination-unreachable,
source-quench, redirect, echo-request, router-advertisement,
router-solicitation, time-exceeded, parameter-problem,
timestamp-request, timestamp-reply, info-request, info-reply,
address-mask-request, address-mask-reply. Numeric values (0-255)
are also accepted.
Symbolic names for icmpv6-type: destination-unreachable, packet-too-big,
time-exceeded, parameter-problem, echo-request, echo-reply,
mld-listener-query, mld-listener-report, mld-listener-done,
nd-router-solicit, nd-router-advert, nd-neighbor-solicit,
nd-neighbor-advert, nd-redirect, mld2-listener-report.
Numeric values (0-255) are also accepted.
Interface Wildcard
A trailing * on an interface name produces a prefix match. For example,
input-interface "l2tp*" matches any interface whose name starts with l2tp
(l2tp0, l2tp1, l2tp-peer42, etc.). Without the *, the match is exact.
Action Types (then block)
| Config key | Action | Example |
|---|---|---|
accept |
Accept packet | accept; |
drop |
Drop packet | drop; |
reject |
Reject with ICMP | reject { with icmp; code 3; } |
jump |
Jump to chain | jump helper; |
goto |
Goto chain | goto cleanup; |
return |
Return from chain | return; |
snat |
Source NAT | snat { to "10.0.0.1"; } |
dnat |
Destination NAT | dnat { to "10.1.1.1:8080"; } |
masquerade |
Masquerade | masquerade; or masquerade { port-range "1024-65535"; } or masquerade { random; } |
redirect |
Redirect to port | redirect { to 8080; } |
notrack |
Disable conntrack | notrack; |
flow-offload |
Hardware offload | flow-offload { flowtable ft0; } |
mark-set |
Set packet mark | mark-set { value 0x10; } |
connection-mark-set |
Set connmark | connection-mark-set { value 0x20/0xff; } |
dscp-set |
Set DSCP | dscp-set 46; |
tcp-mss-set |
Clamp TCP MSS | tcp-mss-set 1400; |
counter |
Count packets/bytes | counter; |
log |
Log packet | log { prefix "DROPPED"; } |
limit-rate |
Rate limit | limit-rate { rate 10/second; burst 5; } |
exclude |
Skip NAT (Return) | exclude; |
NAT Exclude
In a NAT chain, exclude emits a Return verdict so matched traffic skips
the NAT translation. This replaces the VyOS nat destination rule N exclude
pattern.
firewall {
backend nft;
table nat-rules {
family ip;
chain prerouting {
type nat;
hook prerouting;
priority -100;
policy accept;
term skip-local {
from {
destination-address 10.0.0.0/8;
}
then {
exclude;
}
}
term dnat-web {
from {
destination-port 80;
}
then {
dnat { to "10.1.1.1"; }
}
}
}
}
}
SNAT Address Ranges
SNAT and DNAT accept address ranges for pool-based NAT:
then {
snat { to "10.0.0.1-10.0.0.10"; }
}
Named Sets
firewall {
backend nft;
table wan {
family inet;
set blocked {
type ipv4;
element 10.0.0.1;
element 10.0.0.2 { timeout 3600; }
}
chain input {
type filter;
hook input;
priority 0;
policy drop;
term block-list {
from {
source-address @blocked;
}
then {
drop;
}
}
}
}
}
Global Options
The global-options container provides keyword toggles for common network
security defaults. Each keyword maps to a kernel sysctl. At
config apply time, the firewall component emits these as sysctl defaults via
EventBus. Explicit sysctl { setting { ... } } entries always override
global-options (three-layer priority: config > transient > default).
firewall {
backend nft;
global-options {
all-ping enable;
syn-cookies enable;
source-validation strict;
log-martians enable;
}
}
| Keyword | Sysctl | enable | disable |
|---|---|---|---|
all-ping |
net.ipv4.icmp_echo_ignore_all |
0 (allow) | 1 (ignore) |
broadcast-ping |
net.ipv4.icmp_echo_ignore_broadcasts |
0 (allow) | 1 (ignore) |
syn-cookies |
net.ipv4.tcp_syncookies |
1 | 0 |
receive-redirects |
net.ipv4.conf.all.accept_redirects |
1 | 0 |
send-redirects |
net.ipv4.conf.all.send_redirects |
1 | 0 |
source-validation |
net.ipv4.conf.all.rp_filter |
disable=0, strict=1, loose=2 | - |
log-martians |
net.ipv4.conf.all.log_martians |
1 | 0 |
ipv6-receive-redirects |
net.ipv6.conf.all.accept_redirects |
1 | 0 |
ipv6-src-route |
net.ipv6.conf.all.accept_source_route |
1 | 0 |
Note: all-ping and broadcast-ping have inverted semantics because the
underlying sysctl controls "ignore" behavior.
IRR Prefix-List Filtering
Firewall rules can match traffic by ASN or AS-SET using IRR-resolved prefix
lists. The firewall-irr plugin resolves references via the IRR whois client,
caches results in zefs, and populates nftables interval sets.
Operator Workflow
- Fetch prefix data:
update firewall irr asn 13335 - Inspect cached data:
show firewall irr - Commit a term whose from-block names the ASN:
firewall {
backend nft;
table wan {
family inet;
chain input {
type filter;
hook input;
priority 0;
policy drop;
term from-cloudflare {
from {
source-asn 13335;
}
then {
accept;
}
}
}
}
}
- Refresh all cached entries:
update firewall irr all
Fetch the data first. The commit is refused while the ASN or AS-SET has no
cached prefixes, and the refusal names the entry and the command that fetches
it. A term reaches the kernel as two rules, one for the IPv4 prefixes and one
for the IPv6 prefixes, so the table family must be inet.
The prefix sets belong to the firewall-irr plugin, not to the table that names
them. The two arrive at different times, and that one table waits: while a rule
names a set no owner has registered, the table holding it is not programmed and
the daemon logs table held back. Every other table is programmed as usual. The
plugin registers the sets moments later and the table lands.
That line staying in the log means the prefixes never arrived. show firewall irr says which entry is missing, and update firewall irr asn <N> or update firewall irr as-set <name> fetches it and programs the table. Use it after a
fresh install or a restored appliance, where the prefix cache starts empty:
refresh-interval defaults to 0, so no timer fetches the data for you.
Config Leaves
| Leaf | Type | Description |
|---|---|---|
source-asn |
uint32 (1-4294967294) | Match source address against IRR-resolved prefixes for this ASN |
source-as-set |
string | Match source address against IRR-resolved prefixes for this AS-SET |
destination-asn |
uint32 (1-4294967294) | Match destination address against IRR-resolved prefixes for this ASN |
destination-as-set |
string | Match destination address against IRR-resolved prefixes for this AS-SET |
IRR Policy
firewall {
irr {
server whois.radb.net;
peeringdb-url https://www.peeringdb.com;
refresh-interval 0; /* 0 = manual only; 60-86400 = auto-refresh seconds */
}
}
Config commit rejects if a referenced ASN/AS-SET has no cached prefix data, or has an entry holding no prefixes, with an actionable error naming the entry and the command to run:
firewall irr: no cached prefix data for as-set AS-CUSTOMER-A; run 'update firewall irr as-set AS-CUSTOMER-A' first
A daemon START reads its configuration without that check, because a commit is what runs it. The same sentence is logged as a warning instead, once per reference, and the rules naming the entry filter nothing until its prefixes are fetched.
Auto-refresh (when refresh-interval > 0) is fail-closed, and so is a refresh
that succeeds without returning prefixes. A failed query and an empty answer both
preserve the last-good cache, log the reason, and count under the
ze_firewall_irr_refresh_outcomes_total labels error and empty. An empty
answer never replaces a cached prefix list, because a filter built from an empty
list drops everything the operator wrote it to accept.
A scheduled refresh that panics is caught, so it costs that tick rather than the
plugin process, and counts under the same metric's panic label. Alert on it:
error and empty report an upstream condition, panic reports a defect in
ze, and the cached prefixes stay in force until it is fixed.
One firewall set holds 500000 prefixes, and each family has that bound of its
own. A reference whose IPv4 or IPv6 list is longer is refused: the commit fails,
the refresh fails, and the sets already programmed stay in force. Ze does not
program a part of the list, because a set that holds part of it drops traffic an
accept rule was written to pass, and passes traffic a drop rule was written
to block. Narrow the reference, or clear it.
An apply refused for that reason counts under the
ze_firewall_irr_refresh_outcomes_total label apply-failed.
show firewall irr reports each entry as ok, stale, oversized, or
missing, and gives data-age-seconds for cached entries and stale-since for
stale ones. ze doctor reports the same conditions with three codes:
doctor-firewall-irr-stale-data: a referenced entry is enforcing prefixes the IRR has stopped confirming.doctor-firewall-irr-no-data: a referenced entry has no prefixes, so it filters nothing.doctor-firewall-irr-oversized-data: a referenced entry is longer than a firewall set holds, so the rules naming it are not programmed.
Run ze explain <code> for the full description. ze_firewall_irr_data_age_seconds is the
age of the oldest data being enforced, and
ze_firewall_irr_last_refresh_timestamp moves only when a refresh learned
prefixes.
Prefixes are removed only on purpose: clear firewall irr asn <N> and
clear firewall irr as-set <name> drop the entry from memory and from zefs and
re-apply the tables. Use them when an AS-SET is deregistered upstream.
Per-Interface Source Validation
Bind an AS-SET to a customer-facing interface. Packets arriving on that interface with source addresses not in the AS-SET's IRR-resolved prefixes are dropped (ingress source validation, BCP 38).
firewall {
irr {
interface eth1 {
source-as-set AS-CUSTOMER-A;
}
interface eth2 {
source-as-set AS-CUSTOMER-B;
}
}
}
The plugin generates a ze_irr_iface table with a prerouting base chain.
For each bound interface, accept terms match input-interface + source-address in set for both IPv4 and IPv6, followed by a drop term for that interface.
Unconfigured interfaces pass through unfiltered (chain policy accept).
A binding whose AS-SET has no prefixes produces no terms at all, not a lone drop term. A lone drop term would drop every packet arriving on the interface while the apply reported success, so one unhelpful answer from an IRR server would take a customer-facing port down. The plugin logs the skipped binding, and config commit rejects the binding before it gets that far.
The same reasoning covers one family. A refresh that returns IPv4 prefixes and nothing for IPv6 keeps the last known good IPv6 list, so the IPv6 accept term stays and the drop term does not become the only rule for that family.
Same fail-closed semantics apply: config commit rejects if any bound AS-SET has no cached prefix data. Removing an interface binding removes its filter on the next apply.
DNS-Sourced Address Groups
A domain group holds one or more DNS names, and its nftables set holds the
addresses those names resolve to. The firewall-domain plugin asks the daemon's
resolver for each name, programs the answers, and asks again when that answer
expires. An operator who wants to permit or deny a service that publishes its
addresses in DNS writes the name once instead of transcribing addresses and
re-transcribing them each time they move.
A permit rule keyed on a DNS name trusts whoever controls that name. Ze caps
what one name may contribute at 64 addresses per family and honors the hub's
DNSSEC setting, and it does not otherwise constrain what a name resolves to.
That is an operator decision, and dnssecDecision does no signature validation
of its own: it sets the EDNS0 DO bit and trusts an upstream validating resolver.
Operator Workflow
- Resolve the group:
update firewall domain-group cdn - Inspect what it holds:
show firewall domain-group cdn - Commit a term whose from-block names the group:
firewall {
backend nft;
domain-group cdn {
domain-names [ api.example.com cdn.example.com ];
ttl-floor 300;
}
table wan {
family inet;
chain input {
type filter;
hook input;
priority 0;
policy drop;
term from-cdn {
from {
source-domain-group cdn;
}
then {
accept;
}
}
}
}
}
Resolve the group first. The commit is refused while its names have never
resolved, and the refusal names the group and the command that resolves it. A
term reaches the kernel as two rules, one for the IPv4 addresses and one for the
IPv6 addresses, so the table family must be inet.
The sets belong to the firewall-domain plugin, not to the table that names
them, so the same hold-back behavior the IRR section describes applies here.
Config Leaves
| Leaf | Type | Description |
|---|---|---|
domain-group <name> |
list | An address group whose members are what its DNS names resolve to |
domain-names |
leaf-list of string (1-255) | The DNS names whose addresses fill the group |
ttl-floor |
uint32 (1-86400), units seconds | Shortest interval Ze waits before resolving a name again. Default 60 |
source-domain-group |
string (1-64) | Match source address against the group's addresses |
destination-domain-group |
string (1-64) | Match destination address against the group's addresses |
Refresh Schedule
Each name is re-resolved on its OWN answer TTL, and each family keeps its own
schedule: a name with an A record at 60 seconds and an AAAA record at an hour is
asked for on both clocks, and a change in one family does not reprogram the
other. ttl-floor is the shortest Ze will wait whatever the answer says. A TTL
of 0 means "do not cache" rather than "expired now", so it waits the floor too
and does not spin.
The set is reprogrammed only when the addresses actually CHANGED. A name that keeps answering the same addresses costs one query and nothing else, so a 60-second TTL does not rewrite the kernel's sets 1440 times a day.
What a Failed Lookup Does
The response code decides, and the four outcomes are different:
| The answer | What Ze does |
|---|---|
| NOERROR | The addresses are what the group holds. An empty answer is an empty set for that family |
| NXDOMAIN | The name is gone. Its addresses are emptied and the change is recorded |
| SERVFAIL, REFUSED, any other code | The server could not answer. The last good addresses stay |
| ✕ | The query never reached a server. The last good addresses stay |
Only NXDOMAIN empties a set, because only NXDOMAIN says something about the NAME. Emptying on a SERVFAIL would enforce a DNS outage: for a permit rule that blocks every source the rule was written to allow.
Addresses are removed on purpose with clear firewall domain-group <name>,
which drops them from memory and from zefs and re-applies the tables. Use it
when a name is gone upstream for good.
The Change Log
Every change in what a name resolves to is appended to
<config-dir>/firewall-domain-group.dns.jsonl, one JSON object per line,
carrying the group, the name, the family, the addresses before, the addresses
after, and the time. It answers "what did this name point at last week".
Only a CHANGE is recorded. A refresh that confirmed the same addresses writes nothing. The log keeps its most recent 10000 records and drops the oldest past that, so a name whose addresses rotate cannot fill the disk.
It is deliberately not the operator audit log (<config>.audit.jsonl). That log
records operator actions and is bounded by how often a person acts; a rotating
name is neither, and sharing the file would evict commit history.
Restart and Recovery
Resolved addresses are kept in zefs, so a box that reboots with its upstream DNS
unreachable programs its domain-group sets from what it learned last time and
resumes filtering without waiting for a query. The plugin runs as its own
process: a panic while handling an answer costs one refresh cycle rather than
the process, and a crash starts the plugin again with the cache intact: the
plugin declares failure-policy: restart, and the cache is in the shared zefs
store rather than in the process.
Observability
show firewall domain-group [<name>] reports each group with its names, the
addresses being enforced per family, when each last resolved, and what the last
answer said. A family reads missing until it has been asked for once, which is
a different fact from a family that answered and holds nothing.
ze doctor reports two codes:
doctor-firewall-domain-group-no-data: a referenced group has no addresses, so the rules naming it filter nothing.doctor-firewall-domain-group-stale-data: a name in a referenced group has not resolved for more than a day, and Ze is still enforcing the addresses it learned before.
Run ze explain <code> for the full description. Four metrics carry the same
state: ze_firewall_domain_group_addresses_cached,
ze_firewall_domain_group_refresh_outcomes_total (labels unchanged,
changed, nxdomain, error, panic),
ze_firewall_domain_group_last_refresh_timestamp, which moves only when a
refresh changed the addresses, and ze_firewall_domain_group_data_age_seconds.
CLI
| Command | Description |
|---|---|
ze firewall show |
Display all firewall tables and rules |
ze firewall counters |
Show per-term packet and byte counters |
show firewall irr |
Show IRR filter status for all cached entries |
show firewall irr prefix <name> |
List cached prefixes for an ASN or AS-SET |
update firewall irr asn <N> |
Fetch/refresh IRR prefix-list for an ASN |
update firewall irr as-set <name> |
Fetch/refresh IRR prefix-list for an AS-SET |
update firewall irr all |
Refresh all cached IRR entries |
clear firewall irr asn <N> |
Remove an ASN's cached prefixes |
clear firewall irr as-set <name> |
Remove an AS-SET's cached prefixes |
show firewall domain-group [<name>] |
Show what each domain group's DNS names resolve to |
update firewall domain-group <name> |
Resolve a domain group's names now and program its set |
clear firewall domain-group <name> |
Remove a domain group's cached addresses |
Lifecycle
The firewall component registers with ze's engine via registry.Register.
On boot and config reload, the reactor parses the firewall { } section,
loads the selected backend, and calls Apply([]Table). On failure,
sdk.Journal triggers a rollback to the previous state.
On a clean shutdown the component removes every ze-owned table (unless
flush-on-shutdown false; see Clean-shutdown teardown),
then closes the backend. Because the firewall component owns the shared backend,
it is the single actor that performs this teardown -- plugins that register
tables (control-plane-protection, policy-routes, ddos-local) do not run
their own shutdown withdrawal, so table removal never races the backend close.
A crash bypasses this path entirely, leaving tables in place.