mesh/merge-gate head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1791883115921573192 (novox/mesh-catalog at 45d01e77 into main,…
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.
306 lines
11 KiB
Go
306 lines
11 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"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
}
|