diff --git a/modules/fail2ban/cmd/fail2ban-tools/own.go b/modules/fail2ban/cmd/fail2ban-tools/own.go index 3c80a508..24ab9ef4 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/own.go +++ b/modules/fail2ban/cmd/fail2ban-tools/own.go @@ -44,6 +44,20 @@ const ( IgnoredBy = "file:" + ListFile ) +// isOurList says whether an entry of a jail's ignore list, or the source the daemon's log names, is the list of +// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads +// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1), +// in `get ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own +// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list +// and the watch said none did). +func isOurList(word string) bool { + path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:") + if !ok { + return false + } + return "/"+strings.TrimLeft(path, "/") == ListFile +} + // Own is the setting read: its entries, and each one that is not an address or a range, with why. type Own struct { Entries []netip.Prefix `json:"-"` diff --git a/modules/fail2ban/cmd/fail2ban-tools/own_test.go b/modules/fail2ban/cmd/fail2ban-tools/own_test.go index d1733978..293257b0 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/own_test.go +++ b/modules/fail2ban/cmd/fail2ban-tools/own_test.go @@ -15,10 +15,15 @@ import ( "time" ) +// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get ignoreip` and in its log: it reads +// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on +// 2026-10-13. +const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses" + // ignoring is a jail's ignore list as fail2ban-client 1.1 prints it, with the base the module writes. const ignoring = "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n|- 10.0.0.0/8\n" + "|- 172.16.0.0/12\n|- 192.168.0.0/16\n|- 169.254.0.0/16\n|- fc00::/7\n|- fe80::/10\n" + - "|- file:/var/lib/mesh-fail2ban/own-addresses\n`- ::1\n" + "|- " + asPrinted + "\n`- ::1\n" // home is the operator's home uplink in these tests (RFC 5737). const home = "203.0.113.5" @@ -153,7 +158,7 @@ func appendLog(t *testing.T, root, text string) { func daemon(missing bool) Runner { gitea := ignoring if missing { - gitea = strings.Replace(ignoring, "|- file:/var/lib/mesh-fail2ban/own-addresses\n", "", 1) + gitea = strings.Replace(ignoring, "|- "+asPrinted+"\n", "", 1) } return fake(map[string]string{"status": "Status\n|- Number of jail:\t2\n`- Jail list:\tgitea, recidive\n", "get gitea ignoreip": gitea, "get recidive ignoreip": ignoring}, nil) @@ -168,7 +173,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t clock := now w := &Watcher{F: Fail2ban{Run: daemon(false)}, Root: root, Now: func() time.Time { return clock }} - appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, IgnoredBy)+ + appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, asPrinted)+ logAt(now.Add(-time.Minute), "sshd", "192.168.1.1", "ip")) s := w.Look(context.Background()) if err := WriteState(root, s); err != nil { @@ -183,7 +188,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t var loop strings.Builder for i := 0; i < 40; i++ { - loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, IgnoredBy)) + loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, asPrinted)) } appendLog(t, root, loop.String()[:loop.Len()-10]) // the daemon is still writing the last line clock = now.Add(10 * time.Minute) @@ -303,3 +308,21 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) { t.Errorf("tools %v", m.Tools) } } + +// The list is known by its path, in the form the module writes it and in the form fail2ban prints it back. +func TestTheListIsKnownInTheFormFail2banPrintsItBack(t *testing.T) { + for _, word := range []string{IgnoredBy, asPrinted, "file:///var/lib/mesh-fail2ban/own-addresses"} { + if !isOurList(word) { + t.Errorf("%q is the list and was not known", word) + } + } + for _, word := range []string{"ip", "/var/lib/mesh-fail2ban/own-addresses", "file://var/lib/mesh-fail2ban/other", + "file:/var/lib/mesh-fail2ban/own-addresses.new", "192.168.0.0/16"} { + if isOurList(word) { + t.Errorf("%q was taken for the list", word) + } + } + if !namesOurList(listed(ignoring)) { + t.Errorf("the jail's list as the daemon prints it was read as lacking the list") + } +} diff --git a/modules/fail2ban/cmd/fail2ban-tools/watch.go b/modules/fail2ban/cmd/fail2ban-tools/watch.go index 81bbf80e..2fa2d3fd 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/watch.go +++ b/modules/fail2ban/cmd/fail2ban-tools/watch.go @@ -103,7 +103,7 @@ func (w *Watcher) Look(ctx context.Context) State { s.Unread = append(s.Unread, fmt.Sprintf("the jail %s's ignore list cannot be read: %v", j, err)) continue } - if !contains(listed(out), IgnoredBy) { + if !namesOurList(listed(out)) { s.Missing = append(s.Missing, j) } } @@ -115,6 +115,15 @@ func (w *Watcher) Look(ctx context.Context) State { return s } +func namesOurList(entries []string) bool { + for _, e := range entries { + if isOurList(e) { + return true + } + } + return false +} + func contains(list []string, want string) bool { for _, l := range list { if l == want { @@ -162,7 +171,7 @@ func (w *Watcher) readLog(now time.Time) error { // take keeps one line of the log when it is a failure ignored because of the operator's own addresses. func (w *Watcher) take(line string, now time.Time) { m := ignoredLine.FindStringSubmatch(line) - if m == nil || m[4] != IgnoredBy { + if m == nil || !isOurList(m[4]) { return } at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)