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 originalonly. 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.