332 lines
12 KiB
Go
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")
|
|
}
|
|
}
|