From 93d58f3740a0878b347952726307ae96cc55c82b Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 10:54:49 +0200 Subject: [PATCH] fail2ban: never ban the operator's own addresses, and say a failure from one (hq issue 238, ADR 0339) An agent polling the forge from the operator's home got that home banned for a day and then four weeks, and nothing in the house reached the control-node. Every jail spared only private ranges. The mesh-wide setting own-addresses reaches the machine as a mergeable file whose own content is the empty list, so the module composes while it is unset. A new watch process (root) writes its entries into a list every jail's base ignoreip names as file:, which fail2ban reloads without a restart; it checks every jail names that list and keeps each failure a jail ignored because of it for a day. The new fail2ban_health tool judges the watch, so the controller raises a warning naming node, jail, address and count. The ban verb refuses an address on the setting or on the jail's ignore list before the daemon is asked. --- health-undeclared | 2 +- modules/fail2ban/cmd/fail2ban-tools/client.go | 45 +++ .../cmd/fail2ban-tools/client_test.go | 11 +- modules/fail2ban/cmd/fail2ban-tools/main.go | 25 ++ modules/fail2ban/cmd/fail2ban-tools/own.go | 186 +++++++++++ .../fail2ban/cmd/fail2ban-tools/own_test.go | 305 ++++++++++++++++++ modules/fail2ban/cmd/fail2ban-tools/watch.go | 297 +++++++++++++++++ modules/fail2ban/module.json | 54 +++- 8 files changed, 916 insertions(+), 9 deletions(-) create mode 100644 modules/fail2ban/cmd/fail2ban-tools/own.go create mode 100644 modules/fail2ban/cmd/fail2ban-tools/own_test.go create mode 100644 modules/fail2ban/cmd/fail2ban-tools/watch.go diff --git a/health-undeclared b/health-undeclared index 11fed53a..1723e8c2 100644 --- a/health-undeclared +++ b/health-undeclared @@ -1,4 +1,4 @@ -67 +66 The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240 rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running diff --git a/modules/fail2ban/cmd/fail2ban-tools/client.go b/modules/fail2ban/cmd/fail2ban-tools/client.go index 70f3eee0..d98624d8 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/client.go +++ b/modules/fail2ban/cmd/fail2ban-tools/client.go @@ -17,6 +17,7 @@ import ( "errors" "fmt" "net" + "net/netip" "os" "os/exec" "path/filepath" @@ -147,6 +148,8 @@ type JailSettings struct { // Fail2ban is the daemon as this machine has it, through its own client. type Fail2ban struct { Run Runner + // Root is where the machine's files are read from: "" on the machine, a directory in a test. + Root string } func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) { @@ -229,6 +232,13 @@ type BanOutcome struct { } // Ban bans one address in one jail now. The daemon's own answer is how many addresses it added. +// +// **Never one of the operator's own addresses, nor one the jail spares** (novox/hq ADR 0339 §3, narrowing +// ADR 0179 §4): an address on the setting `own-addresses`, or on any entry of the jail's own ignore list — the +// private ranges ADR 0186 spares among them — is refused, naming the entry it lies on, and nothing reaches the +// daemon. fail2ban's `banip` bans whatever it is given, ignore list or not: the 2026-10-04 lockout of the +// operator's home (issue 238) needed only a jail; this verb could have done it in one call. When the setting +// cannot be read the ban is refused too: whether the address is the operator's cannot then be told. func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) { if err := address(ip); err != nil { return nil, err @@ -236,6 +246,9 @@ func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) if err := jailName(jail); err != nil { return nil, err } + if err := f.spared(ctx, ip, jail); err != nil { + return nil, err + } out, err := f.client(ctx, "set", jail, "banip", ip) if err != nil { return nil, err @@ -255,6 +268,38 @@ func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) return outcome, nil } +// spared refuses an address the operator owns or the jail ignores, saying which entry it lies on. +func (f Fail2ban) spared(ctx context.Context, ip, jail string) error { + a, err := netip.ParseAddr(ip) + if err != nil { + return fmt.Errorf("%q is not an address", ip) + } + own, err := ReadOwn(f.Root) + if err != nil { + return fmt.Errorf("refused, and nothing was banned: %v, so whether %s is one of them cannot be told", err, ip) + } + if on := own.Lies(a); on != "" { + return fmt.Errorf("refused, and nothing was banned: %s lies on %s, one of the operator's own addresses "+ + "(the setting %s), which no jail bans (novox/hq ADR 0339)", ip, on, SettingKey) + } + out, err := f.client(ctx, "get", jail, "ignoreip") + if err != nil { + return err + } + var entries []netip.Prefix + for _, word := range listed(out) { + // A file of entries (the operator's own, read above) or a name the daemon resolves is not an entry here. + if p, err := entry(word); err == nil { + entries = append(entries, p) + } + } + if on := liesOn(a, entries); on != "" { + return fmt.Errorf("refused, and nothing was banned: %s lies on %s, which the jail %s never bans — the "+ + "machine itself, the mesh's own range or a private one (novox/hq ADR 0186, ADR 0339)", ip, on, jail) + } + return nil +} + // Released is how many bans the daemon let go, of which address, from where. type Released struct { Released int `json:"released"` diff --git a/modules/fail2ban/cmd/fail2ban-tools/client_test.go b/modules/fail2ban/cmd/fail2ban-tools/client_test.go index e3cc7524..8d8bff2b 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/client_test.go +++ b/modules/fail2ban/cmd/fail2ban-tools/client_test.go @@ -108,7 +108,8 @@ func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) { func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) { var calls [][]string - f := Fail2ban{Run: fake(map[string]string{ + f := Fail2ban{Root: t.TempDir(), Run: fake(map[string]string{ + "get recidive ignoreip": ignoring, "set recidive banip 198.51.100.7": "1\n", "get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n", }, &calls)} @@ -119,8 +120,8 @@ func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing. if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" { t.Errorf("%+v", r) } - if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) { - t.Errorf("first call %v", calls[0]) + if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) { + t.Errorf("the ban %v", calls) } if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") { t.Errorf("a non-address: %v", err) @@ -128,7 +129,7 @@ func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing. if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") { t.Errorf("a non-name: %v", err) } - if len(calls) != 2 { + if len(calls) != 3 { t.Errorf("a refused ban reached the daemon: %v", calls) } } @@ -189,7 +190,7 @@ func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) { names = append(names, tool.Name) } want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban", - "node-intrusion-prevention.unban", "fail2ban_settings"} + "node-intrusion-prevention.unban", "fail2ban_settings", "fail2ban_health"} if !reflect.DeepEqual(names, want) { t.Errorf("%v", names) } diff --git a/modules/fail2ban/cmd/fail2ban-tools/main.go b/modules/fail2ban/cmd/fail2ban-tools/main.go index ab7c59e1..3948ad73 100644 --- a/modules/fail2ban/cmd/fail2ban-tools/main.go +++ b/modules/fail2ban/cmd/fail2ban-tools/main.go @@ -5,6 +5,10 @@ // by the mesh from the modules a machine runs and written as declared resources; these touch only // what the running daemon holds. // +// Started as `fail2ban-tools watch` it is the module's long-running process, as root (watch.go, novox/hq +// ADR 0339): it writes the operator's own addresses into the list every jail ignores, checks that every jail +// names that list, and keeps every failure a jail ignored from one of them, for the health tool to say. +// // stdout is the MCP channel; everything this module says, it says on stderr. package main @@ -12,7 +16,10 @@ import ( "context" "fmt" "os" + "os/signal" "strings" + "syscall" + "time" stdio "git.novox.be/novox/mesh-sdk/go" ) @@ -21,6 +28,20 @@ import ( const Seat = "node-intrusion-prevention" func main() { + if len(os.Args) > 1 { + if os.Args[1] != "watch" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: fail2ban-tools [watch]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + w := &Watcher{F: Fail2ban{Run: execRunner}, Now: time.Now} + if err := w.Watch(ctx); err != nil { + fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err) + os.Exit(1) + } + return + } if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil { fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err) os.Exit(1) @@ -60,5 +81,9 @@ func tools(f Fail2ban) []stdio.Tool { Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.", Input: map[string]any{"jail": str("the jail")}, Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }}, + {Name: "fail2ban_health", + Description: "Whether every jail on this machine ignores the operator's own addresses (the mesh-wide setting own-addresses, novox/hq ADR 0339) and none of them failed in the last day: each jail and address with the failures it ignored, how many and when the last was, and what the module's watch last saw. Not healthy while one did, so the mesh raises it as a warning; it clears a day after the last.", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return ReadHealth(f.Root, time.Now()), nil }}, } } diff --git a/modules/fail2ban/cmd/fail2ban-tools/own.go b/modules/fail2ban/cmd/fail2ban-tools/own.go new file mode 100644 index 00000000..3c80a508 --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/own.go @@ -0,0 +1,186 @@ +package main + +// The operator's own addresses (novox/hq ADR 0339, issue 238): the public addresses and ranges the operator +// names as theirs, in the mesh-wide setting `own-addresses`. Every jail ignores them beside the private ranges +// ADR 0186 spares, the ban verb refuses them, and a failure a jail counted from one is said as a warning. +// +// **The setting reaches the machine as a mergeable file, so it may be unset.** A `${setting:own-addresses}` +// would leave the module out of every machine until the operator gave a value (ADR 0262: an operator's value +// has no default), and an empty list is what the record says the mesh starts from. The mesh writes the +// setting into SettingFile, whose own content is the empty list; the watch, as root, writes the entries +// into ListFile, one per line, and every jail reads that through `ignoreip = … file:`, which fail2ban +// (1.1 and later) reloads within a second of the file changing — no restart, no ban lost. A failure ignored +// because of it is logged as `Ignore
by file:`, which is how the watch tells the +// operator's own addresses from the private ranges, which are logged `by ip`. + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/netip" + "os" + "path/filepath" + "sort" + "strings" +) + +// The files, as the machine has them. +const ( + // SettingFile is the setting as the mesh writes it: {"own-addresses": […]} or a string of entries. + SettingFile = "/etc/fail2ban/mesh-own-addresses.json" + // StateDir is the module's own directory, where the watch writes. + StateDir = "/var/lib/mesh-fail2ban" + // ListFile is the entries one per line, as fail2ban reads them. + ListFile = StateDir + "/own-addresses" + // StateFile is what the watch saw, for the health tool to read. + StateFile = StateDir + "/watch.json" + // LogFile is the daemon's log, where it says what it ignored. + LogFile = "/var/log/fail2ban.log" + // SettingKey is the setting's name (ADR 0339 §1). + SettingKey = "own-addresses" + // IgnoredBy is how every jail's ignore list names the operator's own addresses, and how the daemon's log + // names them as the reason it ignored a failure. + IgnoredBy = "file:" + 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:"-"` + Listed []string `json:"entries"` + Problems []string `json:"problems,omitempty"` +} + +// ReadOwn reads the setting from SettingFile under root. A file not written yet is no entry: the mesh has +// not written the setting there, and the record starts from none. Any other failure to read it is an error, +// so a caller that refuses on it fails closed. +func ReadOwn(root string) (Own, error) { + raw, err := os.ReadFile(filepath.Join(root, SettingFile)) + if errors.Is(err, fs.ErrNotExist) { + return Own{Listed: []string{}}, nil + } + if err != nil { + return Own{}, fmt.Errorf("the operator's own addresses cannot be read from %s: %w", SettingFile, err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return Own{}, fmt.Errorf("the operator's own addresses in %s are not JSON: %w", SettingFile, err) + } + return ParseOwn(doc[SettingKey]) +} + +// ParseOwn reads the setting's value: a list of entries, or one string of entries separated by spaces or +// commas, as `settings set` may give either. Each entry is an address or a range in either family; anything +// else is a problem, named, and left out — a typo never widens what is spared. +func ParseOwn(value any) (Own, error) { + var words []string + switch v := value.(type) { + case nil: + case string: + words = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) + case []any: + for _, item := range v { + s, ok := item.(string) + if !ok { + return Own{}, fmt.Errorf("%s holds %v, which is not text: each entry is an address or a range", SettingKey, item) + } + words = append(words, strings.TrimSpace(s)) + } + default: + return Own{}, fmt.Errorf("%s is %T: it is a list of addresses and ranges, or one line of them", SettingKey, value) + } + own := Own{Listed: []string{}} + seen := map[netip.Prefix]bool{} + for _, w := range words { + if w == "" { + continue + } + p, err := entry(w) + if err != nil { + own.Problems = append(own.Problems, err.Error()) + continue + } + if seen[p] { + continue + } + seen[p] = true + own.Entries = append(own.Entries, p) + } + sort.Slice(own.Entries, func(i, j int) bool { return own.Entries[i].String() < own.Entries[j].String() }) + for _, p := range own.Entries { + own.Listed = append(own.Listed, show(p)) + } + return own, nil +} + +// entry is one address or range; an address is the range of itself. +func entry(word string) (netip.Prefix, error) { + if strings.Contains(word, "/") { + p, err := netip.ParsePrefix(word) + if err != nil { + return netip.Prefix{}, fmt.Errorf("%q is not an address or a range", word) + } + return p.Masked(), nil + } + a, err := netip.ParseAddr(word) + if err != nil { + return netip.Prefix{}, fmt.Errorf("%q is not an address or a range", word) + } + a = a.Unmap() + return netip.PrefixFrom(a, a.BitLen()), nil +} + +// show is an entry as a person reads it: a single address without its length. +func show(p netip.Prefix) string { + if p.IsSingleIP() { + return p.Addr().String() + } + return p.String() +} + +// Lies is the entry ip lies on, or "". +func (o Own) Lies(ip netip.Addr) string { + return liesOn(ip, o.Entries) +} + +func liesOn(ip netip.Addr, entries []netip.Prefix) string { + ip = ip.Unmap() + for _, p := range entries { + if p.Contains(ip) { + return show(p) + } + } + return "" +} + +// ListText is the entries as fail2ban reads ListFile: one per line, under a line saying whose it is. +func (o Own) ListText() string { + var b strings.Builder + b.WriteString("# The operator's own addresses (novox/hq ADR 0339), written by the fail2ban module's watch from the\n") + b.WriteString("# mesh-wide setting own-addresses. Every jail ignores them. Replaced on every change; edit nothing here.\n") + for _, l := range o.Listed { + b.WriteString(l + "\n") + } + return b.String() +} + +// WriteList writes ListFile under root when its content differs, by a rename so the daemon never reads half +// of it. Says whether it wrote. +func WriteList(root string, o Own) (bool, error) { + path := filepath.Join(root, ListFile) + want := o.ListText() + if have, err := os.ReadFile(path); err == nil && string(have) == want { + return false, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + tmp := path + ".new" + if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil { + return false, err + } + if err := os.Rename(tmp, path); err != nil { + return false, err + } + return true, nil +} diff --git a/modules/fail2ban/cmd/fail2ban-tools/own_test.go b/modules/fail2ban/cmd/fail2ban-tools/own_test.go new file mode 100644 index 00000000..d1733978 --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/own_test.go @@ -0,0 +1,305 @@ +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" +) + +// 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" + +// 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, "|- file:/var/lib/mesh-fail2ban/own-addresses\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, IgnoredBy)+ + 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, IgnoredBy)) + } + 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, _ := byID["jail-local"]["content"].(string) + 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 content, _ := r["content"].(string); id != "jail-local" && strings.Contains(content, "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-settings"], []any{SettingKey}) { + t.Errorf("the setting's file: %v", setting) + } + var content map[string]any + if err := json.Unmarshal([]byte(setting["content"].(string)), &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) + } +} diff --git a/modules/fail2ban/cmd/fail2ban-tools/watch.go b/modules/fail2ban/cmd/fail2ban-tools/watch.go new file mode 100644 index 00000000..81bbf80e --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/watch.go @@ -0,0 +1,297 @@ +package main + +// The watch (novox/hq ADR 0339 §2 and §4, issue 238): the module's long-running process, as root. Every look it +// +// - writes the operator's own addresses from the setting into the list every jail ignores (own.go); +// - asks the daemon whether every jail's ignore list names that list, so no jail leaves it out; +// - reads what the daemon logged since the last look, and keeps every failure a jail ignored because the +// address is one of the operator's own, for a day. +// +// What it saw goes to StateFile, which the health tool reads as the operator's account. A failure from one +// of the operator's own addresses is never banned — that is the point — and never silent: the module's health +// says it, so the controller raises it as a warning naming the node, the jail, the address and the count, and +// it clears a day after the last one. A loop like the one of 2026-10-04 is then seen within minutes. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// The watch's timing. +var ( + // LookEvery is how often the watch looks. + LookEvery = 30 * time.Second + // Remembered is how long an ignored failure is said: a day, as the record has it. + Remembered = 24 * time.Hour + // StaleAfter is how old the watch's last look may be before the health says it stopped looking. + StaleAfter = 3 * time.Minute + // keptPerAddress bounds what is remembered of one address in one jail; the count says when it met it. + keptPerAddress = 10000 +) + +// IgnoredFailures is the failures one jail ignored from one of the operator's own addresses, in the last day. +type IgnoredFailures struct { + Jail string `json:"jail"` + Address string `json:"address"` + Count int `json:"count"` + First time.Time `json:"first"` + Last time.Time `json:"last"` + // AtLeast says the count met what is remembered of one address and is a floor. + AtLeast bool `json:"at-least,omitempty"` +} + +// State is what the watch saw at its last look. +type State struct { + Looked time.Time `json:"looked"` + // Own is the operator's own addresses as written into the list, and Problems the entries left out. + Own []string `json:"own"` + Problems []string `json:"problems,omitempty"` + // Unread is why something the watch reads could not be read at this look. + Unread []string `json:"unread,omitempty"` + // Jails is how many jails the daemon runs; Missing those whose ignore list does not name the list. + Jails int `json:"jails"` + Missing []string `json:"missing,omitempty"` + // Ignored is every jail and address of the operator's own with a failure ignored in the last day. + Ignored []IgnoredFailures `json:"ignored"` +} + +// ignoredLine is the daemon's line for a failure it ignored: `