A module declares its fail2ban jail; mesh composes per node (to-be 31 mechanism) #85
@@ -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
|
||||
|
||||
@@ -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.
|
||||
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 [<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.
|
||||
type Filtering struct {
|
||||
// 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