package main // The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed // on the control node on 2026-10-02 (novox/hq ADR 0179). import ( "context" "fmt" "os" "reflect" "strings" "testing" ) const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n" const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" + "| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" + " `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n" const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" + "| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" + " |- Total banned:\t150\n `- Banned IP list:\t\n" const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" + "92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n" func fake(answers map[string]string, calls *[][]string) Runner { return func(_ context.Context, name string, args ...string) (string, error) { if calls != nil { *calls = append(*calls, append([]string{name}, args...)) } if out, ok := answers[strings.Join(args, " ")]; ok { return out, nil } return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " ")) } } var ctx = context.Background() func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) { got := parseJailStatus("recidive", recidive) want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149}, Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}} if !reflect.DeepEqual(got, want) { t.Fatalf("%+v", got) } j := parseJailStatus("sshd", sshd) if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) { t.Errorf("watching %v", j.Watching) } if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) { t.Errorf("banned %+v", j.Banned) } } func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) { var calls [][]string f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)} all, err := f.Status(ctx, "") if err != nil { t.Fatal(err) } if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" { t.Errorf("%+v", all) } one, err := f.Status(ctx, "sshd") if err != nil || len(one["jails"]) != 1 { t.Fatalf("%+v %v", one, err) } if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) { t.Errorf("last call %v", calls[len(calls)-1]) } } func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) { bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n") if len(bans) != 3 { t.Fatalf("%+v", bans) } if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) { t.Errorf("%+v", bans[0]) } if bans[2].Until != "never" { t.Errorf("a permanent ban ends %q", bans[2].Until) } if got := parseBans("sshd", "\n"); len(got) != 0 { t.Errorf("%+v", got) } } func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) { f := Fail2ban{Run: fake(map[string]string{ "status": statusAll, "get recidive banip --with-time": withTime, "get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n", }, nil)} got, err := f.Banned(ctx, "") if err != nil { t.Fatal(err) } var order []string for _, b := range got["banned"] { order = append(order, b.IP+"@"+b.Jail) } if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) { t.Errorf("%v", order) } } func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) { var calls [][]string f := Fail2ban{Run: fake(map[string]string{ "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)} r, err := f.Ban(ctx, "198.51.100.7", "recidive") if err != nil { t.Fatal(err) } 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 _, 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) } 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 { t.Errorf("a refused ban reached the daemon: %v", calls) } } func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) { var calls [][]string f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)} one, err := f.Unban(ctx, "198.51.100.7", "sshd") if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) { t.Errorf("%+v %v", one, err) } every, err := f.Unban(ctx, "198.51.100.7", "") if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) { t.Errorf("%+v %v", every, err) } if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) { t.Errorf("%v", calls[1]) } } func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) { f := Fail2ban{Run: fake(map[string]string{ "get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n", "get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n", "get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n", "get sshd logpath": "No file is currently monitored\n", "get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n", }, nil)} got, err := f.Settings(ctx, "sshd") if err != nil { t.Fatal(err) } want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3, Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"}, Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"} if !reflect.DeepEqual(got, want) { t.Fatalf("%+v", got) } } func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) { if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) { t.Errorf("as root: %s %v", p, a) } if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) { t.Errorf("as an account: %s %v", p, a) } if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") { t.Error("installed is wrong about sh or about a tool nobody has") } } // The tools carry the seat's four verbs under the seat's name, and the module's own under its own. func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) { var names []string for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) { 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"} if !reflect.DeepEqual(names, want) { t.Errorf("%v", names) } } // The daemon on this machine, read only — status, bans and one jail's settings — when asked for with // FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints // now. func TestTheLiveDaemonReadsBack(t *testing.T) { if os.Getenv("FAIL2BAN_LIVE") != "1" { t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine") } f := Fail2ban{Run: execRunner} status, err := f.Status(ctx, "") if err != nil || len(status["jails"]) == 0 { t.Fatalf("status: %+v %v", status, err) } for _, j := range status["jails"] { t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total) if len(j.Watching) == 0 { t.Errorf("%s watches nothing as read", j.Jail) } } banned, err := f.Banned(ctx, "") if err != nil { t.Fatalf("banned: %v", err) } t.Logf("%d bans held", len(banned["banned"])) settings, err := f.Settings(ctx, "sshd") if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 { t.Fatalf("settings: %+v %v", settings, err) } t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip) }