Repository navigation
firewall
Pre-Alpha. This page describes behavior that may change.
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 backend lowers them to kernel expressions.
| Backend | Platform | Mechanism |
|---|---|---|
nft |
Linux | google/nftables netlink library |
vpp |
Linux + VPP | GoVPP ACL classify pipeline |
firewall {
backend nft;
}
backend vpp needs the VPP component, which only a build with the ze_vpp tag carries. Selecting it starts that component with the firewall and orders the firewall after it. In a build without ze_vpp, ze config validate, a commit and daemon boot all refuse firewall backend vpp before anything is applied, naming the leaf and the tag.
The ze:backend YANG extension provides commit-time rejection of unsupported features. If a feature is not supported by the configured backend, the commit fails with a descriptive error.
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. Within a chain, terms are evaluated first-match-wins, in the order the operator wrote them; a chain with two or more terms is rejected at load if that order cannot be delivered to the backend, rather than silently applied in a different order.
show firewall reports every applied table under that ownership, not only the tables this firewall { } block defines. Tables registered by other plugins (copp, ddos-local, policy-routes, and the firewall-irr per-interface tables) are included in the same readback.
Four table owners (copp, ddos-local, the FlowSpec bridge, and anomaly-shape) once wrote their tables under a bare name, before every owner carried the ze_ prefix. A box upgraded from a build that predates the prefix could keep the old, unprefixed table in the kernel, still filtering, because a newer build reconciles only the tables it owns. On the first reconcile after startup, the nft backend sweeps those legacy names: it checks the table's name, family, and that its chains match exactly what the old producer wrote, so a foreign table sharing the same name is left alone.
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;
}
}
term allow-established {
from {
connection-state established,related;
}
then {
accept;
}
}
}
}
}
inet (dual-stack), ip, ip6, arp, bridge, netdev.
An inet table sees both IPv4 and IPv6 traffic in the same chain, and a few lowerings write directly into the packet at a fixed header offset. Each of those is now guarded to the family it was written for:
- An address match (
source-address,destination-address) on aninettable carries a family guard, so an IPv4 prefix no longer also matches an IPv6 packet whose header happens to hold the same bytes at that offset. -
dscp-setwrites the IPv4 TOS byte and its checksum fixup, and is scoped to IPv4 traffic inside aninettable. Without the guard it could write those bytes into an IPv6 packet's traffic class and flow label instead, and the packet's ECN bits would read as congested even though nothing was. -
icmp-typeandicmpv6-typenow check the transport protocol before comparing the type number, since the same number means a different ICMP type in each protocol.
ip and ip6 tables need no guard: a config referencing one of those families is already split by family before it reaches the backend.
filter, nat, route.
input, output, forward, prerouting, postrouting, ingress, egress.
| 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, range, or list | destination-port 80,443; |
protocol |
L4 protocol | 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 | icmpv6-type nd-neighbor-solicit; |
connection-state |
Conntrack states | connection-state established,related; |
connection-mark |
Mark value/mask | connection-mark 0x10/0xff; |
mark |
Packet mark value/mask | mark 0x10/0xff; |
dscp |
DSCP value | dscp ef; |
tcp-flags |
TCP header flags | tcp-flags syn; |
match-set |
Named set lookup | match-set blocked source-address; |
source-domain-group |
Source against a DNS-sourced group | source-domain-group cdn; |
destination-domain-group |
Destination against a DNS-sourced group | destination-domain-group cdn; |
A trailing * on an interface name produces a prefix match ("l2tp*" matches l2tp0, l2tp1, etc.).
A PPPoE client is matched by its configured name: the session device of pppoe-client pppoe0 is the kernel device pppoe0 on every session and reconnect, so output-interface "pppoe0" matches the WAN, as in a masquerade rule. Do not write ppp* for the client: the kernel names pppN only the subscriber sessions of an L2TP LNS or a PPPoE access concentrator, so on such a box ppp* matches the subscribers and never the client.
| Config key | Action | Example |
|---|---|---|
accept |
Accept packet | accept; |
drop |
Drop packet | drop; |
reject |
Reject with ICMP | reject { with icmp; code 3; } |
jump |
Jump to a chain of the same table; the editor completes the table's chains and a commit naming another is refused | jump helper; |
goto |
Goto a chain of the same table, checked like jump
|
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 to at most the value | tcp-mss-set 1400; |
tcp-mss-clamp-to-pmtu |
Clamp TCP MSS to the route MTU less the IP and TCP headers | tcp-mss-clamp-to-pmtu; |
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; |
tcp-mss-set writes a fixed upper bound. tcp-mss-clamp-to-pmtu takes the bound from the route: the kernel computes it per packet as the smaller of the MTU of the route the packet takes and of the route back to its source, less the fixed headers (40 bytes for IPv4, 60 for IPv6, RFC 6691). It is nftables tcp option maxseg size set rt mtu, and what VyOS writes for adjust-mss clamp-mss-to-pmtu.
Both are clamps: an MSS already at or below the bound is left alone, and a SYN without the option is not changed. A term carries one of the two, never both. The kernel computes the route MSS only at the forward, output and postrouting hooks, so verify refuses tcp-mss-clamp-to-pmtu in a chain of another hook, or in a regular chain such a chain reaches by jump or goto. The table family must be ip, ip6 or inet. The vpp backend refuses both actions.
A home CPE behind PPPoE clamps every SYN it forwards onto the session. With the session MTU at 1492, a SYN offering 1460 leaves with 1452 over IPv4 and 1432 over IPv6:
firewall {
backend nft;
table cpe {
family inet;
chain forward {
type filter;
hook forward;
priority 0;
policy accept;
term clamp {
from {
output-interface "pppoe*";
protocol tcp;
}
then { tcp-mss-clamp-to-pmtu; }
}
}
}
}
SNAT, DNAT, masquerade, and redirect are available in nat chains. exclude emits a Return verdict so matched traffic skips NAT translation. SNAT and DNAT accept address ranges for pool-based NAT: snat { to "10.0.0.1-10.0.0.10"; }.
masquerade accepts an optional port-range "lo-hi" to constrain the source port pool, or the flags random (random { full; } for fully-random port allocation) and persistent. A port range and the flags are mutually exclusive: configuring both is rejected at commit.
When backend vpp is configured and VPP is running, the firewall uses VPP's ACL classify pipeline instead of nftables:
- NAT44-ED: SNAT, DNAT, and masquerade via VPP's NAT44-ED plugin.
- ACL classify: SetMark and Limit actions through VPP classify tables.
- Hardened: patterns discovered in VyOS VPP integration are handled defensively.
The VPP backend supports the same YANG config surface. Features not yet supported by VPP are rejected at commit time via the backend gate.
Ze serializes the whole firewall reconcile: a snapshot of every owner's tables
followed by one Apply call, 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.
Both backends bound their dataplane round-trip. Neither bound can be turned off: a value below the floor or above the ceiling is clamped, and an unparseable value falls back to the default.
| 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 own default disables the reply timeout, so a wedged VPP held the reconcile lock indefinitely.
When the deadline expires, the apply fails, and Ze logs an error saying the dataplane did not answer and its ruleset is now behind the registry. A dataplane that is absent (VPP not running, no netlink permission) is a different condition and is not reported as a timeout.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
ze_firewall_apply_duration_seconds |
histogram | result |
Time spent in the reconcile. result is ok, timeout, error, or panic. |
ze_firewall_apply_timeout_total |
counter | Reconciles that failed because the dataplane did not answer within the deadline. |
The result label is what separates a healthy-but-slow apply from one that gave
up: a 10 second success and a 10 second timeout land in the same latency bucket.
firewall {
backend nft;
table wan {
family inet;
set blocked {
type ipv4_addr;
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 {
match-set blocked source-address;
}
then {
drop;
}
}
}
}
}
A rule's counter counts the packets its term matched. Earlier builds placed the counter ahead of the term's matches, so show firewall counters reported every packet the chain saw as the term's own.
A domain-group is an address set filled from DNS. Ze resolves the A and AAAA records of each name, programs the addresses into two nftables sets (domain_v4_<name> and domain_v6_<name>), and resolves each name again when its own answer expires. A set is rewritten only when the addresses changed.
firewall {
domain-group cdn {
domain-names [ cdn.example.com static.example.net ];
ttl-floor 60;
}
table wan {
chain forward {
term allow-cdn {
from { destination-domain-group cdn; }
then { accept; }
}
}
}
}
| Leaf | Default | Meaning |
|---|---|---|
domain-names |
none | The DNS names whose addresses are the members. At most 64 addresses are taken from one name and one family, with a warning for the rest. |
ttl-floor |
60 seconds | The shortest wait before a name is resolved again, whatever its TTL. A TTL of 0 also waits the floor. |
Each name and family keeps its own schedule, so one short TTL does not pull the others. A commit that names a group Ze has never resolved is refused: run update firewall domain-group <name> first. A permit rule keyed on a DNS name trusts whoever controls that name.
| Command | Purpose |
|---|---|
show firewall domain-group [<name>] |
What each group's names resolve to, and why a cache entry is missing |
update firewall domain-group <name> |
Resolve the group's names now and program its sets |
clear firewall domain-group <name> |
Remove the addresses Ze cached for a group |
Set elements support optional per-element timeouts for dynamic blocklists. A timeout on any element marks the whole set as timeout-capable, which is what makes the kernel accept the per-element value. Earlier builds populated the element timeout but not the set flag, and nftables silently dropped every one, so elements never expired. If you are relying on timeouts to age out a blocklist, this is the fix that made them take effect.
system conntrack module <helper> publishes ze_conntrack_helpers (family inet) or ze_conntrack_helpers_ip (family ip, for helpers the kernel registers for IPv4 only), with one ct helper object per kernel helper and transport, and prerouting and output chains at priority -5 that assign it by destination port. The assignment runs before your own chains at priority 0; a chain of yours below -5 sees the connection before the helper is attached.
GTSM (BGP ttl security) owns a ze_gtsm table with two kinds of term per peer: dangerous-<peer>-listen drops TCP from the peer to the listen port below the peer's TTL or hop-limit floor, so a spoofed SYN never costs a SYN-ACK, and dangerous-<peer>-type<N>-quoted-<side> (IPv4 only) drops an ICMP error that quotes the peer's BGP session and arrives below the floor (RFC 5082 Section 6.1). A GTSM dynamic group gets one listen term per range. A session source inside a wider GTSM range on the same port gets a claimed-<source>-listen accept term, so the range never judges it by the range's floor. Every term carries a counter, so show firewall ruleset gtsm shows how many below-floor SYNs each term dropped. The table goes with the last GTSM peer.
control-plane-protection bgp builds its own table, inet ze_copp, an access list on TCP to the protected ports (179 unless protected-port says otherwise), as RFC 7454 Section 4 recommends:
- an established or related connection is accepted;
- a connection from a
trusted-sourceprefix is accepted; - a connection from a configured BGP neighbor is accepted: each static peer's
remote ipand each dynamic group'srange, so adding or removing a peer updates the list on reload; - TCP to a protected port from any other source is counted and dropped.
set control-plane-protection bgp rate 100/second
set control-plane-protection bgp trusted-source 203.0.113.10/32
rate is optional and limits new connections from the neighbors only. over-limit-policy defaults to drop; set accept to observe a new rate before enforcing it. Neither admits an unknown source. Without the container no access list is built. The chain policy stays accept, so SSH, ICMP and every port the table does not name stay reachable. ze doctor names any part of ze_copp the kernel does not hold.
Changed from earlier builds: rate was mandatory; over-limit-policy defaulted to accept; a trusted term's name carried the prefix as written (trusted-192.0.2.0/24) and is now trusted-192.0.2.0-24; and any source could open a connection to a protected port.
The global-options container provides sysctl convenience mappings for common firewall-related kernel tunables, so you do not need to duplicate them in the sysctl { } block.
firewall {
global-options {
all-ping enable;
broadcast-ping disable;
ip-src-route disable;
ipv6-src-route disable;
log-martians enable;
receive-redirects disable;
send-redirects disable;
syn-cookies enable;
}
}
These map to their corresponding net.ipv4.* / net.ipv6.* sysctls and are applied through the sysctl plugin for three-layer precedence.
The firewall-irr plugin matches traffic by ASN or AS-SET using prefix lists resolved from Internet Routing Registry (IRR) data. It expands a reference into the set of registered prefixes, caches the result, and populates nftables interval sets that firewall terms match against.
Expansion is dual-stack: each ASN or AS-SET resolves to both an IPv4 and an IPv6 prefix list, lowered into separate irr_v4_<name> and irr_v6_<name> interval sets.
The plugin reuses the same shared IRR prefix store (the resolve IRR client plus the on-disk cache) as the bgp-filter-irr plugin, so a prefix list fetched for BGP filtering is also available to the firewall and vice versa.
- Fetch prefix data:
update firewall irr asn 13335 - Inspect cached data:
show firewall irr - Commit config referencing the ASN or AS-SET in a term's
fromblock. - Refresh all cached entries:
update firewall irr all
These leaves are used inside a term from block:
| Leaf | Type | Match |
|---|---|---|
source-asn |
uint32 | Source address against the ASN's IRR prefixes |
source-as-set |
string | Source address against the AS-SET's IRR prefixes |
destination-asn |
uint32 | Destination address against the ASN's IRR prefixes |
destination-as-set |
string | Destination address against the AS-SET's IRR prefixes |
firewall {
irr {
server whois.radb.net;
peeringdb-url "https://www.peeringdb.com";
refresh-interval 0; // 0 = manual only; 60 to 86400 = auto-refresh seconds
}
table wan {
family inet;
chain forward {
type filter;
hook forward;
priority 0;
policy drop;
term from-cloudflare {
from {
source-asn 13335;
}
then {
accept;
}
}
}
}
}
Config commit is rejected if a referenced ASN or AS-SET has no cached prefix data. The error names the missing entry and the exact update firewall irr ... command to run.
Auto-refresh (when refresh-interval > 0) is fail-closed: a failed IRR query keeps the last-good cache and logs an error rather than emptying the set.
A prefix list larger than one set holds (500,000 prefixes in one family) is refused, never cut short. Earlier builds truncated it and programmed a partial filter with only a log line. Now the commit is refused, or, for data that reached the cache through a refresh, a restart or the BGP IRR filter, the apply is refused and the working filter stays in place. show firewall irr reports such a reference with status oversized. A scheduled refresh that panics is recovered and logged rather than stopping the refresh loop.
Reconfiguring the IRR server or the PeeringDB URL keeps every already-fetched prefix. The cache is re-pointed at the new resolvers rather than rebuilt from scratch, so an edit that only changes where lookups go cannot silently drop prefixes a commit had already accepted.
A term needs both its IPv4 and IPv6 set, or neither. An ASN or AS-SET announcing prefixes in only one family used to leave the other set undeclared, and the whole table was silently dropped rather than just that term; a term now gets an empty set for the family with no data, which matches nothing, instead of losing the table.
A table naming a set no owner has registered yet is held back on its own; every other table, from every owner, still applies. Earlier builds held back the entire firewall ruleset behind one unresolved set.
Bind an AS-SET to a customer-facing interface to enforce ingress source validation (BCP 38). Packets arriving on that interface whose source address is not in the AS-SET's IRR-resolved prefixes are dropped.
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 it emits accept terms matching input-interface plus source address in the set (IPv4 and IPv6), followed by a drop term for that interface. Unconfigured interfaces pass through unfiltered (chain policy accept). The same fail-closed commit check applies: a bound AS-SET with no cached prefix data rejects the commit. Removing an interface binding removes its filter on the next apply.
The flowspec-firewall plugin converts BGP FlowSpec rules into nftables firewall entries. When a peer announces FlowSpec routes, the plugin translates the FlowSpec match/action into an nftables rule in a dedicated ze_flowspec table.
The plugin reads the FlowSpec rules the RIB has selected and validated, so it is not attached to a peer. Declare it once and enable a FlowSpec family on the peer. The same peer must also send the unicast routes that authorise each rule.
plugin {
internal flowspec-firewall { use flowspec-firewall; }
}
bgp {
peer upstream {
session {
family ipv4/flow { mode enable; }
family ipv4/unicast { mode enable; }
}
}
}
A plugin that does need a peer's messages is attached with attach process <name> { ... } inside the peer. The older bare process <name> { ... } form is refused when the configuration is parsed.
| Command | Description |
|---|---|
show firewall |
Display all firewall tables and rules. |
show firewall counters |
Per-term packet and byte counters. |
clear firewall counters |
Reset match counters. |
show firewall irr |
IRR filter status for all cached entries, including oversized for a list too large to program. |
show firewall domain-group [<name>] |
Addresses each domain group resolved to. |
update firewall domain-group <name> |
Resolve a domain group now and program its sets. |
clear firewall domain-group <name> |
Drop a domain group's cached addresses. |
show firewall irr prefix <asn-or-as-set> |
List cached prefixes for an ASN or AS-SET. |
update firewall irr asn <asn> |
Fetch or refresh the IRR prefix-list for an ASN. |
update firewall irr as-set <as-set> |
Fetch or refresh the IRR prefix-list for an AS-SET. |
update firewall irr all |
Refresh all cached IRR entries. |
- bgp-filter-irr for the BGP import filter sharing the same IRR prefix store.
- Traffic Control for per-interface queueing disciplines.
- Policy Routing for nftables-based PBR.
- VPP Data Plane for the VPP integration.
- Plugins for the plugin list.
Adapted from main/docs/guide/firewall.md.
Unreviewed draft. This wiki was authored in bulk and has not been reviewed. File corrections on the issue tracker.
- Overview
- YANG Model
- Editor Workflow
- Archive and Rollback
- Backup and Restore
- System
- Interfaces
- VRRP
- BFD
- FIB
- OSPF
- IS-IS
- MPLS / LDP / RSVP-TE
- RSVP-TE
- SRv6
- Static Routes
- Policy Routing
- Firewall
- Traffic Control
- Class of Service
- L2TP/PPP
- PPPoE
- VPP Data Plane
- RPKI
- IPsec VPN
- Path MTU
- TACACS+ AAA
- RADIUS AAA
- AS112 DNS
- DNS
- Authorization
- Fleet
- BGP
- Starting and Stopping
- Show Commands
- Monitoring
- Telemetry
- Flow Export
- DDoS Mitigation
- Anomaly Detection
- Health Checks
- Audit Trail
- Production Diagnostics
- Logging
- Operational Reports
- Healthcheck
- Self-Update
- Zero-Touch Provisioning
- MRT Analysis
- Upgrade and Restart
- Storage
- Policy
- Core
- Resilience
- Validation
- Link State
- Capabilities
- Address Families
- Protocol
- Subsystems
- Infrastructure
- Route Server at an IXP
- Transit Edge with RPKI
- Public Looking Glass
- ExaBGP Migration Walkthrough
- FlowSpec Injection
- Chaos-Tested Peering
- AS Path Topology