Files
hq/03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
jschoubben 225dfa9451 to-be 31: a module declares its fail2ban jail, mesh composes them per node
A node's intrusion filter should be composed from its assigned modules, like
its firewall (the Filtering mechanism): a service module (postgres, mssql,
mailu) declares its jail in its manifest (filter + stanza, no node/path per ADR
0112), and the mesh writes the jails of a node's modules into the fail2ban
holder's jail.d. The base (sshd, recidive, ignoreip=mesh-range) stays the
fail2ban module's. Records the model after novox's HAL per-module jails were
lost as dangling symlinks; the ignoreip is now safe on disk, the service jails
need this to be restored.
2026-09-27 16:59:56 +02:00

3.9 KiB

layer, status, code, updated, decisions
layer status code updated decisions
to-be proposed
2026-09-27
02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md

31 — A module declares its fail2ban jail, and the mesh composes them per node

A node's intrusion filter should be composed from the modules it runs, the same way its firewall is. The mesh already derives a node's nftables ruleset from every assigned module's listens and guards (the Filtering mechanism). fail2ban is the same shape and is not modelled: a module that runs an authenticating service — postgres, mssql, mailu — has a jail (a filter that reads its log and a jail stanza that bans on it), and which jails a node's fail2ban runs should be exactly the jails of the modules assigned to that node.

The predecessor did this with per-module files: postgres shipped postgres-auth.conf, mssql shipped mssql-auth.conf, mailu shipped mailu.conf, and the node's fail2ban read whichever were present. When HAL retired on novox those became dangling symlinks — fail2ban ran the jails only from memory, and a restart would have dropped them. The base was salvaged (the fail2ban module now ships sshd, recidive, and the ignoreip that spares the mesh's own range), but the service jails are gone, because no nox module declares one yet.

The shape

  • A module declares its jail in its manifest, naming no node and no path (ADR 0112): the filter (the failregex, or a stock filter it uses) and the jail stanza (port, logpath, maxretry, bantime). The postgres module says what a postgres brute-force looks like and how to ban it; it does not say on which machine, because it does not know.
  • The mesh composes them per node. For each node, the jails of its assigned modules are gathered and written into the fail2ban holder's jail.d/ (and filters into filter.d/), exactly as listens/guards are gathered into the node's firewall. So a node running postgres gets the postgres jail; a node not running it does not. The node-intrusion-prevention holder receives them the way a provider receives its consumers' contributions.
  • The base stays the fail2ban module's: sshd, recidive, and the ignoreip naming ${machine:mesh-range} so a tunnel peer is never banned.

Why this, and not the module writing the file itself

A module could declare a file resource at /etc/fail2ban/jail.d/<x>.conf directly. Rejected: the path is the fail2ban holder's to own (one module owns jail.d, as one module owns the firewall table), the jail's logpath and defaults want the mesh's composition (the ignoreip, the ban action the node uses), and two modules writing into one directory is the collision the seat/holder model exists to prevent. The module declares what its jail is; the holder's composition decides how it lands — the same split as listens (the module says the port; the mesh says the rule).

Why now

fail2ban on novox currently runs the service jails from memory only; the next restart drops them (the ignoreip is safe on disk, so the mesh-partition risk is closed, but postgres/mssql/mailu auth-banning would be lost). This is the mechanism that restores them properly, and it is needed as each of those modules migrates to the other nodes — ace running postgres should get the postgres jail, composed from the postgres module's manifest, without anyone editing a node.

References

  • ADR 0112 — a module names no node or path; its jail is declared the same way its listens are
  • mesh-controller internal/catalogue/adoption.go (Filtering — the firewall composition this mirrors), internal/catalogue/manifest.go (Listens/Guards, the fields a jail field sits beside)
  • mesh-catalog modules/fail2ban (the base: sshd, recidive, ignoreip); the service modules (postgres, mssql, mailu) that will declare jails