Files
mesh-catalog/modules/fail2ban/cmd/fail2ban-tools/own_test.go
T

332 lines
12 KiB
Go

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 <jail> 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")
}
}