5 Enforcement
Robin edited this page 2026-08-22 23:15:50 +00:00

Enforcement

A ban is an element in an nftables set with its own timeout. That one sentence explains most of the behaviour on this page.

The table

The agent owns table inet shieldlist and rebuilds it at startup. Six sets:

Set Holds
banned4 / banned6 single addresses, all ports — plain sets (no interval flag): nft dumps every interval set of the ruleset before any element command, so a hundred thousand bans in an interval set cost every nft call a second
banned4_net / banned6_net ranges, all ports — interval sets, few elements
banned4_ports / banned6_ports address . port pairs, for service-scoped bans

and one chain hooked at prerouting, priority -150, which matters:

  • Traffic DNAT-ed into containers is filtered before the translation, so a container cannot be reached around the ban.
  • Rules match ct direction original only. A banned address's inbound connections die with the ban, while packets belonging to connections this machine initiated are never dropped — replies to your own outbound traffic keep working whatever their source.
  • A port-scoped ban closes the service the offender attacked, not the whole machine.

The drop rules feed one named counter, dropped, which is where blocked_packets and blocked_bytes in shield status come from — the only figures there about traffic rather than about decisions. (Named, and read with reset counter: on nftables before 1.0.7 every listing of a table dumps its sets first — seconds, with hundreds of thousands of bans.)

Published lists (feeds)

A list the controller fetches from a URL (the console's Published blocklists, or one of your own) reaches an enrolled agent as data: lists/<name>.set in the received bundle — a header with the count and the hash, one prefix a line. The agent holds it in sets of its own, feed4_<name> / feed4n_<name> (addresses / ranges) and their IPv6 twins, behind one drop rule each in the feeds chain, with a named counter feed_<name>. Nothing of a feed enters the store: the kernel is the only copy, rebuilt from the files at boot, and a refresh is a diff. Before anything reaches the kernel, an entry covering the machine's own addresses, its controller, or anything you exempted is left out — the rest of the list stands — and a file that cannot be read whole (a bad line, a count or a hash that does not match) is refused whole. shield status has one line per feed (entries held, packets dropped, entries left out); shield why <address> names the feed that blocks it. A test agent holds no feed.

Expiry belongs to the kernel

Every element carries its own timeout, so a ban ends on time whether or not the agent is alive. Kill the agent and the bans stay. That is the central failure property:

Losing the agent is a detection gap, never an unban.

It also means nobody has to ask the kernel when a ban ended: the agent wrote the same deadline into its store and into the element, and nftables rounds a timeout up and never down, so if the two ever disagree the ban outlives its record rather than the reverse.

Restarting

The table is recreated empty and refilled from the store, in batches, on its own goroutines while the agent is already reading logs. Five million sanctions come back in about a minute and a half, and the agent is watching throughout — it does not go blind while it copies.

Progress appears in the log every fifteen seconds:

restoring enforcement bans=1315000 elapsed=35s
enforcement restored bans=5099267 refused=0 took=1m27s

If the kernel refuses a batch, those elements are remembered and offered again on the next round rather than lost, and shield status shows unenforced until they land. Zero is the only good answer there.

Order: written down, then enforced

A sanction is persisted before it reaches the kernel, and both happen in batches — one store transaction and one nftables transaction per round. That order is what keeps the failure mode a gap and never an opening: a batch lost to a crash is a batch the next start rebuilds from the store.

Batching is not a detail. Every nftables write is a fork, an exec and a netlink transaction — fifteen milliseconds to add an element, nearly forty to delete one — and a flood extends hundreds of sanctions a second. One transaction a round costs what one sanction used to.

Looking at the table

Never list a set without -t. Any nft listing that reaches the elements walks and prints every one of them; at five million that is over ten minutes and a burnt core. The trap is a single letter — nft list sets dumps contents, nft -t list sets does not.

nft -t list table inet shieldlist    # chains, rules, counters, set declarations
nft -t list sets                     # set declarations only

There is no cheap way to count elements through nft. shield status answers that from the store.

Kernel guardrails

The table can also carry connection and rate caps as dynamic sets the kernel maintains alone — they keep working even when userspace is drowning. They are off by default.

Collection

Elements carrying timeouts are swept periodically by the kernel to reclaim what expired, and that sweep grows with the set. The agent declares gc-interval 1h on its sets, which on a five-million-element table took the machine from 17% busy to 1%. It costs nothing that matters: an expired element stops matching traffic the moment its timeout passes, collected or not — collection only frees the memory afterwards.

See Sizing and limits for what a large set costs in kernel memory, which is the one budget no setting here can bound.