From 47d412e13fc646b677959dfb6dca710e7b534a2b Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 17:20:50 +0200 Subject: [PATCH] A module declares its fail2ban jail; the mesh composes them per node (to-be 31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism, mirroring Filtering: a module declares Jails (name, failregex, jail stanza) naming no node/path (ADR 0112); the intrusion-prevention holder declares Jailing (where composed jails go); the mesh gathers every assigned module's jails into one jail.d file (a fixed id the fail2ban service restarts on) plus a filter.d file per jail. A node not running a module has none of its jails. Tested. Behaviour-neutral until a service module declares a jail — the per-service content (postgres/mssql/mailu failregex+logpath) is authored next, against how each container actually logs. --- internal/catalogue/declaration.go | 6 +++ internal/catalogue/jails_into.go | 62 +++++++++++++++++++++++++++ internal/catalogue/jails_into_test.go | 46 ++++++++++++++++++++ internal/catalogue/manifest.go | 38 ++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 internal/catalogue/jails_into.go create mode 100644 internal/catalogue/jails_into_test.go diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index cb8b070..eb2e990 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -345,6 +345,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri "content": filtering, "mode": "0600", }) } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } if c := m.Certificate; c != nil { if with.Certificate == "" { // Asked for and not issued. Refused rather than skipped: a module that serves TLS diff --git a/internal/catalogue/jails_into.go b/internal/catalogue/jails_into.go new file mode 100644 index 0000000..3b5bf36 --- /dev/null +++ b/internal/catalogue/jails_into.go @@ -0,0 +1,62 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A node's fail2ban jails, composed from the modules it runs (novox/hq to-be 31). +// +// **The same shape as the firewall.** Every module's `listens` become the node's rule set; every +// module's `jails` become the node's fail2ban config. A module that runs an authenticating service +// declares what a break-in on it looks like and how to ban it, naming no node and no path (ADR +// 0112); the intrusion-prevention holder — the one module with `jailing` — gathers them and writes +// them where it owns. A node not running a module has none of its jails. + +// jailsInto composes every jail declared by the modules on a node into the files the holder writes: +// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter +// file per jail (its failregex, which fail2ban references by the jail's name). +// +// Owned by the holder, because the directory is: two modules writing into one fail2ban is the +// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file +// is written empty rather than absent, so removing the last jail is an ordinary change the service +// restarts on rather than a file that vanishes. +func jailsInto(modules []Manifest, j *Jailing) []map[string]any { + type declared struct { + module string + jail Jail + } + var jails []declared + for _, m := range modules { + for _, jail := range m.Jails { + jails = append(jails, declared{m.Module, jail}) + } + } + // A stable order the host applies as given (ADR 0005), and so the same set composes byte for + // byte every time rather than differing by map iteration. + sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name }) + + var composed strings.Builder + composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n") + composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n") + + out := make([]map[string]any, 0, len(jails)+1) + for _, d := range jails { + fmt.Fprintf(&composed, "\n# from %s\n[%s]\nenabled = true\nfilter = %s\n%s\n", + d.module, d.jail.Name, d.jail.Name, strings.TrimRight(d.jail.Jail, "\n")) + // The filter is a file of its own, named as the jail's filter= references it. + out = append(out, map[string]any{ + "id": "filter-" + d.jail.Name, + "type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf", + "mode": "0644", + "content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" + + "[Definition]\nfailregex = " + d.jail.Failregex + "\n", + }) + } + // The one jail file, first, with the fixed id the fail2ban service names in its restart-on. + return append([]map[string]any{{ + "id": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644", + "content": composed.String(), + }}, out...) +} diff --git a/internal/catalogue/jails_into_test.go b/internal/catalogue/jails_into_test.go new file mode 100644 index 0000000..9ddb083 --- /dev/null +++ b/internal/catalogue/jails_into_test.go @@ -0,0 +1,46 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder +// (jailing) gathers every module's declared jail into one jail file and a filter file per jail. +func TestJailsAreComposedFromTheNodesModules(t *testing.T) { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "postgres", Jails: []Jail{{Name: "postgres-auth", Failregex: "auth failed from ", Jail: "port = 5432\nmaxretry = 5"}}}, + } + files := jailsInto(modules, modules[0].Jailing) + + by := map[string]map[string]any{} + for _, f := range files { + by[f["id"].(string)] = f + } + jail := by[ComposedJailsID()] + if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" { + t.Fatalf("the composed jail file was not written: %v", jail) + } + body := jail["content"].(string) + if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") || + !strings.Contains(body, "port = 5432") { + t.Fatalf("the postgres jail stanza was not composed in:\n%s", body) + } + filter := by["filter-postgres-auth"] + if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" { + t.Fatalf("the jail's filter file was not written: %v", filter) + } + if !strings.Contains(filter["content"].(string), "failregex = auth failed from ") { + t.Fatalf("the failregex was not written: %v", filter["content"]) + } +} + +// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the +// last jail is a change the service restarts on, not a file that vanishes. +func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) { + files := jailsInto([]Manifest{{Module: "fail2ban"}}, &Jailing{Into: "/x", FilterInto: "/f"}) + if len(files) != 1 || files[0]["id"] != ComposedJailsID() { + t.Fatalf("the empty composed jail file was not written alone: %v", files) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 85800d9..fca2fa8 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -366,6 +366,14 @@ type Manifest struct { // that could only see its own ports would write a rule set that closed everything else. Filtering *Filtering `json:"filtering,omitempty"` + // Jails are the fail2ban jails this module declares for its own service (novox/hq to-be 31). + // Written into whichever node runs the module, the same way `listens` become that node's rules. + Jails []Jail `json:"jails,omitempty"` + + // Jailing marks the module that composes the node's fail2ban jails — the intrusion-prevention + // holder. Like Filtering: one module per node gathers what every module declared and writes it. + Jailing *Jailing `json:"jailing,omitempty"` + // Guards are ports of this module's the mesh refuses on an adopted node except from the // private network and from the machine itself (novox/hq ADR 0100) — the store's port and the // broker's management port. The ports the software uses; the mesh guards where the machine @@ -615,6 +623,36 @@ func (l Listening) At() string { return l.Protocol } +// Jail is a fail2ban jail a module declares for its own service (novox/hq to-be 31). +// +// **The module names no node and no path** (ADR 0112): it says what a break-in on its service looks +// like — the failregex — and the jail's own keys (the port it watches, where it logs, how many +// tries, how long to ban). The mesh writes it into whichever node's fail2ban runs the module, the +// same way a module's `listens` become that node's firewall rules. A node not running the module +// has no such jail. +type Jail struct { + // Name is the jail and its filter, e.g. "postgres-auth". One holder of the name per node. + Name string `json:"name"` + // Failregex is what a failed authentication looks like in the service's log — the filter. + Failregex string `json:"failregex"` + // Jail is the body of the jail's stanza: the keys under [] the module knows and the mesh + // does not — the port it watches, its logpath and backend, maxretry, bantime. + Jail string `json:"jail"` +} + +// Jailing says a module composes the node's fail2ban jails — the intrusion-prevention holder. Like +// Filtering for the firewall: one module gathers what every other module declared and writes it +// where it owns. Into is the one jail file the stanzas are composed into (so the fail2ban service +// can restart on a single resource); FilterInto is the directory each jail's filter file goes in. +type Jailing struct { + Into string `json:"into"` + FilterInto string `json:"filter-into"` +} + +// ComposedJailsID is the single jail file the mesh composes every declared jail into, so the +// fail2ban service names one resource in its restart-on and a jail added or removed reaches it. +func ComposedJailsID() string { return "composed-jails" } + // Filtering says where a module wants the computed rule set. type Filtering struct { // Into is the path to write it to. Whatever loads it is this module's own business — an