From c78d0a01342c6c83608c3ae9e6737d7247c79893 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 11:14:24 +0200 Subject: [PATCH] fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238) fail2ban reads the ignoreip entry file:/var/lib/... and prints it back as file://var/lib/..., in get ignoreip and in its "Ignore ... by ..." log lines alike. The watch compared the text, so on ace it said every jail lacked the list while each held it, failing fail2ban's health and its walk; and it would never have counted a failure from the operator's own address. Compare the path instead, with a test on fail2ban's real form. --- modules/fail2ban/cmd/fail2ban-tools/own.go | 14 +++++++++ .../fail2ban/cmd/fail2ban-tools/own_test.go | 31 ++++++++++++++++--- modules/fail2ban/cmd/fail2ban-tools/watch.go | 13 ++++++-- 3 files changed, 52 insertions(+), 6 deletions(-) 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)