Skip to content

firewall

Thomas Mangin edited this page Nov 9, 2026 · 11 revisions

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

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.

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. 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;
                }
            }
        }
    }
}

Table families

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 an inet table 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-set writes the IPv4 TOS byte and its checksum fixup, and is scoped to IPv4 traffic inside an inet table. 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-type and icmpv6-type now 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.

Chain types

filter, nat, route.

Hooks

input, output, forward, prerouting, postrouting, ingress, egress.

Match types

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.

Action types

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 clamping

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; }
            }
        }
    }
}

NAT

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.

VPP backend

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.

The reconcile deadline

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.

Named sets

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.

Domain groups

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.

Tables other features install

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 (CoPP)

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-source prefix is accepted;
  • a connection from a configured BGP neighbor is accepted: each static peer's remote ip and each dynamic group's range, 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.

Global options

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.

IRR prefix-list filtering

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.

Operator workflow

  1. Fetch prefix data: update firewall irr asn 13335
  2. Inspect cached data: show firewall irr
  3. Commit config referencing the ASN or AS-SET in a term's from block.
  4. Refresh all cached entries: update firewall irr all

Match leaves

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.

Per-interface source validation

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.

FlowSpec-to-firewall bridge

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.

CLI

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.

See also

Adapted from main/docs/guide/firewall.md.

Home

About

First Steps

Configuration

Operation

Interfaces

Plugins

Plugin Development

Chaos Testing

Blueprints

Development

Reference

Clone this wiki locally