mesh/merge-gate head c78d0a01342c merged into trunk f35b06750452 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head c78d0a01342c merged into trunk f35b06750452 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
fail2ban reads the ignoreip entry file:/var/lib/... and prints it back as file://var/lib/..., in get <jail> ignoreip and in its "Ignore ... by ..." log lines alike. The watch compared the text, so on ace it said every jail lacked the list while each held it, failing fail2ban's health and its walk; and it would never have counted a failure from the operator's own address. Compare the path instead, with a test on fail2ban's real form.
329 lines
12 KiB
Go
329 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, _ := 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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|