package main // The operator's own addresses (novox/hq ADR 0339, issue 238): the setting read, every jail's ignore list // naming it, the ban verb refusing them, the watch keeping what a jail ignored, and the health saying it. import ( "context" "encoding/json" "net/netip" "os" "path/filepath" "reflect" "strings" "testing" "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" + "|- " + asPrinted + "\n`- ::1\n" // home is the operator's home uplink in these tests (RFC 5737). const home = "203.0.113.5" func writeSetting(t *testing.T, root, content string) { t.Helper() path := filepath.Join(root, SettingFile) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestTheSettingIsAListOrALineAndAnEntryThatIsNoAddressIsLeftOutAndSaid(t *testing.T) { list, err := ParseOwn([]any{"203.0.113.5", "198.51.100.0/24", "2001:db8::/32", "203.0.113.5", "not-an-address"}) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(list.Listed, []string{"198.51.100.0/24", "2001:db8::/32", "203.0.113.5"}) { t.Errorf("listed %v", list.Listed) } if len(list.Problems) != 1 || !strings.Contains(list.Problems[0], "not-an-address") { t.Errorf("problems %v", list.Problems) } line, err := ParseOwn("203.0.113.5, 198.51.100.7") if err != nil || !reflect.DeepEqual(line.Listed, []string{"198.51.100.7", "203.0.113.5"}) { t.Errorf("a line: %v %v", line.Listed, err) } if on := list.Lies(netip.MustParseAddr("198.51.100.77")); on != "198.51.100.0/24" { t.Errorf("an address in a range lies on %q", on) } if on := list.Lies(netip.MustParseAddr("192.0.2.1")); on != "" { t.Errorf("a stranger lies on %q", on) } if _, err := ParseOwn(3.0); err == nil { t.Error("a number was taken as the setting") } } func TestTheSettingNotYetWrittenIsNoEntryAndAnUnreadableOneIsAnError(t *testing.T) { root := t.TempDir() own, err := ReadOwn(root) if err != nil || len(own.Entries) != 0 { t.Fatalf("no file: %+v %v", own, err) } writeSetting(t, root, "{\n \"own-addresses\": []\n}\n") if own, err := ReadOwn(root); err != nil || len(own.Entries) != 0 { t.Fatalf("the module's own content: %+v %v", own, err) } writeSetting(t, root, "{not json") if _, err := ReadOwn(root); err == nil { t.Fatal("a broken file read as no entry") } } // The verb of 2026-10-04's remedy, used the other way: fail2ban's banip bans what it is given, ignore list or not. func TestBanRefusesTheOperatorsOwnAddressAndWhatTheJailSparesBeforeTheDaemonIsAsked(t *testing.T) { root := t.TempDir() writeSetting(t, root, `{"own-addresses": ["203.0.113.5", "198.51.100.0/24"]}`) var calls [][]string f := Fail2ban{Root: root, Run: fake(map[string]string{"get gitea ignoreip": ignoring}, &calls)} for ip, on := range map[string]string{home: home, "198.51.100.9": "198.51.100.0/24", "192.168.1.1": "192.168.0.0/16", "10.10.0.4": "10.10.0.0/24"} { _, err := f.Ban(ctx, ip, "gitea") if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "lies on "+on) { t.Errorf("%s: %v", ip, err) } } for _, c := range calls { if len(c) > 2 && c[1] == "set" { t.Fatalf("a refused ban reached the daemon: %v", calls) } } if _, err := f.Ban(ctx, home, "gitea"); err == nil || !strings.Contains(err.Error(), "own-addresses") { t.Errorf("the refusal does not name the setting: %v", err) } writeSetting(t, root, "{broken") if _, err := f.Ban(ctx, "192.0.2.1", "gitea"); err == nil || !strings.Contains(err.Error(), "cannot be told") { t.Errorf("an unreadable setting let a ban through: %v", err) } } func TestTheWatchWritesTheListEveryJailIgnoresAndRewritesItOnlyOnAChange(t *testing.T) { root := t.TempDir() own, _ := ParseOwn([]any{home}) wrote, err := WriteList(root, own) if err != nil || !wrote { t.Fatalf("%v %v", wrote, err) } raw, _ := os.ReadFile(filepath.Join(root, ListFile)) var entries []string for _, l := range strings.Split(string(raw), "\n") { if l != "" && !strings.HasPrefix(l, "#") { entries = append(entries, l) } } if !reflect.DeepEqual(entries, []string{home}) { t.Errorf("the list fail2ban reads: %q", raw) } if wrote, _ := WriteList(root, own); wrote { t.Error("the same list was written again") } none, _ := ParseOwn(nil) if wrote, _ := WriteList(root, none); !wrote { t.Error("an emptied setting did not empty the list") } } // logAt is a line as fail2ban 1.1 logs a failure it ignored. func logAt(at time.Time, jail, ip, by string) string { return at.Format("2006-01-02 15:04:05") + ",123 fail2ban.filter [1234]: INFO [" + jail + "] Ignore " + ip + " by " + by + "\n" } func appendLog(t *testing.T, root, text string) { t.Helper() path := filepath.Join(root, LogFile) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) if err != nil { t.Fatal(err) } defer f.Close() if _, err := f.WriteString(text); err != nil { t.Fatal(err) } } func daemon(missing bool) Runner { gitea := ignoring if missing { 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) } // The 2026-10-04 loop as it now runs: forty failures from the operator's home, ignored and never banned, and // said by the health as one jail, one address and a count; a private address ignored is not the operator's. func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t *testing.T) { root := t.TempDir() writeSetting(t, root, `{"own-addresses": ["203.0.113.5"]}`) now := time.Date(2026, 10, 13, 10, 0, 0, 0, time.Local) 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, asPrinted)+ logAt(now.Add(-time.Minute), "sshd", "192.168.1.1", "ip")) s := w.Look(context.Background()) if err := WriteState(root, s); err != nil { t.Fatal(err) } if h := ReadHealth(root, now); !h.Healthy || !strings.Contains(h.Why, "every jail (2)") { t.Fatalf("nothing of the operator's in the last day, yet: %+v", h) } if !reflect.DeepEqual(s.Own, []string{home}) { t.Errorf("own %v", s.Own) } var loop strings.Builder for i := 0; i < 40; i++ { 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) s = w.Look(context.Background()) if len(s.Ignored) != 1 || s.Ignored[0].Count != 39 { t.Fatalf("a line still being written was counted, or the loop was not: %+v", s.Ignored) } appendLog(t, root, loop.String()[loop.Len()-10:]) s = w.Look(context.Background()) want := IgnoredFailures{Jail: "gitea", Address: home, Count: 40, First: now, Last: now.Add(39 * 15 * time.Second)} if len(s.Ignored) != 1 || s.Ignored[0] != want { t.Fatalf("ignored %+v", s.Ignored) } if err := WriteState(root, s); err != nil { t.Fatal(err) } h := ReadHealth(root, clock) if h.Healthy || !strings.Contains(h.Why, "the jail gitea counted 40 failure(s) from 203.0.113.5") || !strings.Contains(h.Why, "banned none") { t.Fatalf("the loop was not said: %+v", h) } // A watch started again reads the log whole and still knows the last day. again := &Watcher{F: Fail2ban{Run: daemon(false)}, Root: root, Now: func() time.Time { return clock }} if s := again.Look(context.Background()); len(s.Ignored) != 1 || s.Ignored[0].Count != 40 { t.Errorf("after a start: %+v", s.Ignored) } // A day after the last, it clears. clock = now.Add(39*15*time.Second + Remembered + time.Second) s = w.Look(context.Background()) if err := WriteState(root, s); err != nil { t.Fatal(err) } if h := ReadHealth(root, clock); !h.Healthy { t.Errorf("a day after the last it still says: %+v", h) } } func TestAJailThatLeavesTheListOutABadEntryAndAStoppedWatchAreSaid(t *testing.T) { root := t.TempDir() writeSetting(t, root, `{"own-addresses": "203.0.113.5 nonsense"}`) now := time.Date(2026, 10, 13, 10, 0, 0, 0, time.Local) appendLog(t, root, "") w := &Watcher{F: Fail2ban{Run: daemon(true)}, Root: root, Now: func() time.Time { return now }} if err := WriteState(root, w.Look(context.Background())); err != nil { t.Fatal(err) } h := ReadHealth(root, now) if h.Healthy || !strings.Contains(h.Why, "the jail(s) gitea do not ignore") || !strings.Contains(h.Why, `"nonsense"`) { t.Errorf("%+v", h) } if h := ReadHealth(root, now.Add(StaleAfter+time.Second)); h.Healthy || !strings.Contains(h.Why, "not looking") { t.Errorf("a stopped watch: %+v", h) } if h := ReadHealth(t.TempDir(), now); h.Healthy { t.Errorf("no watch at all: %+v", h) } } // The manifest: every jail's base ignore list names the list, the setting reaches the machine without a // default and only from the controller's terminal, and the watch is judged by the health tool. func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } var m struct { Tools []string `json:"tools"` Resources []map[string]any `json:"resources"` } if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } byID := map[string]map[string]any{} for _, r := range m.Resources { byID[r["id"].(string)] = r } base := templateText(t, byID["jail-local"]) var ignoreip []string for _, l := range strings.Split(base, "\n") { if strings.HasPrefix(l, "ignoreip") { ignoreip = append(ignoreip, l) } } if len(ignoreip) != 1 || !strings.HasSuffix(ignoreip[0], " "+IgnoredBy) { t.Errorf("the base ignore list: %q", ignoreip) } for id, r := range byID { if r["type"] != "file" || id == "jail-local" { continue } if strings.Contains(templateText(t, r), "ignoreip") { t.Errorf("%s sets an ignore list of its own, which leaves the operator's own addresses out", id) } } setting := byID["own-addresses"] if setting["path"] != SettingFile || setting["merge"] != "json" || setting["trusted"] != true || !reflect.DeepEqual(setting["takes-config"], []any{SettingKey}) { t.Errorf("the setting's file: %v", setting) } var content map[string]any if err := json.Unmarshal([]byte(templateText(t, setting)), &content); err != nil || !reflect.DeepEqual(content, map[string]any{SettingKey: []any{}}) { t.Errorf("the setting starts from %v (%v)", content, err) } watch := byID["watch"] health, _ := watch["health"].(map[string]any) if watch["type"] != "process" || !reflect.DeepEqual(watch["run"], []any{"./fail2ban-tools", "watch"}) || health["kind"] != "tool" || health["tool"] != "fail2ban_health" || watch["user"] != nil { t.Errorf("the watch: %v", watch) } if list := byID["own-addresses-list"]; list["path"] != ListFile || list["create-once"] != true { t.Errorf("the list is there, empty, before the daemon first reads it: %v", list) } if byID["state-dir"]["path"] != StateDir { t.Errorf("the watch's directory: %v", byID["state-dir"]) } if !contains(m.Tools, "fail2ban_health") { 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") } }