A module declares its fail2ban jail; the mesh composes them per node (to-be 31)
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.
This commit is contained in:
@@ -345,6 +345,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
"content": filtering, "mode": "0600",
|
"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 c := m.Certificate; c != nil {
|
||||||
if with.Certificate == "" {
|
if with.Certificate == "" {
|
||||||
// Asked for and not issued. Refused rather than skipped: a module that serves TLS
|
// Asked for and not issued. Refused rather than skipped: a module that serves TLS
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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 <HOST>", 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 <HOST>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -366,6 +366,14 @@ type Manifest struct {
|
|||||||
// that could only see its own ports would write a rule set that closed everything else.
|
// that could only see its own ports would write a rule set that closed everything else.
|
||||||
Filtering *Filtering `json:"filtering,omitempty"`
|
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
|
// 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
|
// 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
|
// 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
|
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 [<name>] 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.
|
// Filtering says where a module wants the computed rule set.
|
||||||
type Filtering struct {
|
type Filtering struct {
|
||||||
// Into is the path to write it to. Whatever loads it is this module's own business — an
|
// Into is the path to write it to. Whatever loads it is this module's own business — an
|
||||||
|
|||||||
Reference in New Issue
Block a user