diff --git a/internal/catalogue/jails_into.go b/internal/catalogue/jails_into.go index 3b5bf36..34305bc 100644 --- a/internal/catalogue/jails_into.go +++ b/internal/catalogue/jails_into.go @@ -1,6 +1,8 @@ package catalogue import ( + "crypto/sha256" + "encoding/hex" "fmt" "sort" "strings" @@ -43,8 +45,16 @@ func jailsInto(modules []Manifest, j *Jailing) []map[string]any { 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's digest rides in the jail file.** fail2ban is restarted when this file + // changes, and the filter is a file of its own: a module that changed only what a failure + // looks like rewrote the filter on disk and left the running jail on the old pattern, with + // nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's + // digest here makes a changed pattern a changed jail file, so the restart the service + // already takes on it covers the filter too. + sum := sha256.Sum256([]byte(d.jail.Failregex)) + fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n", + d.module, hex.EncodeToString(sum[:])[:12], 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, diff --git a/internal/catalogue/jails_into_test.go b/internal/catalogue/jails_into_test.go index 9ddb083..9586458 100644 --- a/internal/catalogue/jails_into_test.go +++ b/internal/catalogue/jails_into_test.go @@ -44,3 +44,29 @@ func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) { t.Fatalf("the empty composed jail file was not written alone: %v", files) } } + +// A changed pattern restarts fail2ban (novox/hq issue 191's rollout): the service restarts when the +// composed jail file changes, and the filter is a file of its own, so the jail file names the +// filter's digest. Changing only the failregex must change the jail file; the same pattern must not. +func TestAChangedFilterChangesTheJailFile(t *testing.T) { + jailFile := func(failregex string) string { + modules := []Manifest{ + {Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}}, + {Module: "gitea", Jails: []Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}}, + } + for _, f := range jailsInto(modules, modules[0].Jailing) { + if f["id"] == ComposedJailsID() { + return f["content"].(string) + } + } + t.Fatal("no composed jail file") + return "" + } + before := jailFile("web login failed from ") + if again := jailFile("web login failed from "); again != before { + t.Errorf("the same pattern composed a different jail file, which would restart fail2ban for nothing") + } + if after := jailFile("web login failed from \n Invalid user .* from "); after == before { + t.Errorf("a changed pattern left the jail file as it was, so fail2ban keeps the old filter:\n%s", after) + } +}