Merge pull request 'fail2ban: never ban the operator's own addresses, and say a failure from one (hq issue 238, ADR 0339)' (#266) from fix/238-the-operators-own-addresses into main
This commit was merged in pull request #266.
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
67
|
||||
66
|
||||
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
|
||||
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
|
||||
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -147,6 +148,8 @@ type JailSettings struct {
|
||||
// Fail2ban is the daemon as this machine has it, through its own client.
|
||||
type Fail2ban struct {
|
||||
Run Runner
|
||||
// Root is where the machine's files are read from: "" on the machine, a directory in a test.
|
||||
Root string
|
||||
}
|
||||
|
||||
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
|
||||
@@ -229,6 +232,13 @@ type BanOutcome struct {
|
||||
}
|
||||
|
||||
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
|
||||
//
|
||||
// **Never one of the operator's own addresses, nor one the jail spares** (novox/hq ADR 0339 §3, narrowing
|
||||
// ADR 0179 §4): an address on the setting `own-addresses`, or on any entry of the jail's own ignore list — the
|
||||
// private ranges ADR 0186 spares among them — is refused, naming the entry it lies on, and nothing reaches the
|
||||
// daemon. fail2ban's `banip` bans whatever it is given, ignore list or not: the 2026-10-04 lockout of the
|
||||
// operator's home (issue 238) needed only a jail; this verb could have done it in one call. When the setting
|
||||
// cannot be read the ban is refused too: whether the address is the operator's cannot then be told.
|
||||
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
|
||||
if err := address(ip); err != nil {
|
||||
return nil, err
|
||||
@@ -236,6 +246,9 @@ func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error)
|
||||
if err := jailName(jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := f.spared(ctx, ip, jail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := f.client(ctx, "set", jail, "banip", ip)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -255,6 +268,38 @@ func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error)
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// spared refuses an address the operator owns or the jail ignores, saying which entry it lies on.
|
||||
func (f Fail2ban) spared(ctx context.Context, ip, jail string) error {
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%q is not an address", ip)
|
||||
}
|
||||
own, err := ReadOwn(f.Root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refused, and nothing was banned: %v, so whether %s is one of them cannot be told", err, ip)
|
||||
}
|
||||
if on := own.Lies(a); on != "" {
|
||||
return fmt.Errorf("refused, and nothing was banned: %s lies on %s, one of the operator's own addresses "+
|
||||
"(the setting %s), which no jail bans (novox/hq ADR 0339)", ip, on, SettingKey)
|
||||
}
|
||||
out, err := f.client(ctx, "get", jail, "ignoreip")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var entries []netip.Prefix
|
||||
for _, word := range listed(out) {
|
||||
// A file of entries (the operator's own, read above) or a name the daemon resolves is not an entry here.
|
||||
if p, err := entry(word); err == nil {
|
||||
entries = append(entries, p)
|
||||
}
|
||||
}
|
||||
if on := liesOn(a, entries); on != "" {
|
||||
return fmt.Errorf("refused, and nothing was banned: %s lies on %s, which the jail %s never bans — the "+
|
||||
"machine itself, the mesh's own range or a private one (novox/hq ADR 0186, ADR 0339)", ip, on, jail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Released is how many bans the daemon let go, of which address, from where.
|
||||
type Released struct {
|
||||
Released int `json:"released"`
|
||||
|
||||
@@ -108,7 +108,8 @@ func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
|
||||
|
||||
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
f := Fail2ban{Root: t.TempDir(), Run: fake(map[string]string{
|
||||
"get recidive ignoreip": ignoring,
|
||||
"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)}
|
||||
@@ -119,8 +120,8 @@ func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.
|
||||
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 !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("the ban %v", calls)
|
||||
}
|
||||
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)
|
||||
@@ -128,7 +129,7 @@ func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.
|
||||
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 {
|
||||
if len(calls) != 3 {
|
||||
t.Errorf("a refused ban reached the daemon: %v", calls)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +190,7 @@ func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
|
||||
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"}
|
||||
"node-intrusion-prevention.unban", "fail2ban_settings", "fail2ban_health"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("%v", names)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// by the mesh from the modules a machine runs and written as declared resources; these touch only
|
||||
// what the running daemon holds.
|
||||
//
|
||||
// Started as `fail2ban-tools watch` it is the module's long-running process, as root (watch.go, novox/hq
|
||||
// ADR 0339): it writes the operator's own addresses into the list every jail ignores, checks that every jail
|
||||
// names that list, and keeps every failure a jail ignored from one of them, for the health tool to say.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
@@ -12,7 +16,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -21,6 +28,20 @@ import (
|
||||
const Seat = "node-intrusion-prevention"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
if os.Args[1] != "watch" || len(os.Args) != 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: fail2ban-tools [watch]")
|
||||
os.Exit(2)
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
w := &Watcher{F: Fail2ban{Run: execRunner}, Now: time.Now}
|
||||
if err := w.Watch(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -60,5 +81,9 @@ func tools(f Fail2ban) []stdio.Tool {
|
||||
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
Input: map[string]any{"jail": str("the jail")},
|
||||
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
|
||||
{Name: "fail2ban_health",
|
||||
Description: "Whether every jail on this machine ignores the operator's own addresses (the mesh-wide setting own-addresses, novox/hq ADR 0339) and none of them failed in the last day: each jail and address with the failures it ignored, how many and when the last was, and what the module's watch last saw. Not healthy while one did, so the mesh raises it as a warning; it clears a day after the last.",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return ReadHealth(f.Root, time.Now()), nil }},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
// The operator's own addresses (novox/hq ADR 0339, issue 238): the public addresses and ranges the operator
|
||||
// names as theirs, in the mesh-wide setting `own-addresses`. Every jail ignores them beside the private ranges
|
||||
// ADR 0186 spares, the ban verb refuses them, and a failure a jail counted from one is said as a warning.
|
||||
//
|
||||
// **The setting reaches the machine as a mergeable file, so it may be unset.** A `${setting:own-addresses}`
|
||||
// would leave the module out of every machine until the operator gave a value (ADR 0262: an operator's value
|
||||
// has no default), and an empty list is what the record says the mesh starts from. The mesh writes the
|
||||
// setting into SettingFile, whose own content is the empty list; the watch, as root, writes the entries
|
||||
// into ListFile, one per line, and every jail reads that through `ignoreip = … file:<ListFile>`, which fail2ban
|
||||
// (1.1 and later) reloads within a second of the file changing — no restart, no ban lost. A failure ignored
|
||||
// because of it is logged as `Ignore <address> by file:<ListFile>`, which is how the watch tells the
|
||||
// operator's own addresses from the private ranges, which are logged `by ip`.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The files, as the machine has them.
|
||||
const (
|
||||
// SettingFile is the setting as the mesh writes it: {"own-addresses": […]} or a string of entries.
|
||||
SettingFile = "/etc/fail2ban/mesh-own-addresses.json"
|
||||
// StateDir is the module's own directory, where the watch writes.
|
||||
StateDir = "/var/lib/mesh-fail2ban"
|
||||
// ListFile is the entries one per line, as fail2ban reads them.
|
||||
ListFile = StateDir + "/own-addresses"
|
||||
// StateFile is what the watch saw, for the health tool to read.
|
||||
StateFile = StateDir + "/watch.json"
|
||||
// LogFile is the daemon's log, where it says what it ignored.
|
||||
LogFile = "/var/log/fail2ban.log"
|
||||
// SettingKey is the setting's name (ADR 0339 §1).
|
||||
SettingKey = "own-addresses"
|
||||
// IgnoredBy is how every jail's ignore list names the operator's own addresses, and how the daemon's log
|
||||
// names them as the reason it ignored a failure.
|
||||
IgnoredBy = "file:" + ListFile
|
||||
)
|
||||
|
||||
// Own is the setting read: its entries, and each one that is not an address or a range, with why.
|
||||
type Own struct {
|
||||
Entries []netip.Prefix `json:"-"`
|
||||
Listed []string `json:"entries"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
// ReadOwn reads the setting from SettingFile under root. A file not written yet is no entry: the mesh has
|
||||
// not written the setting there, and the record starts from none. Any other failure to read it is an error,
|
||||
// so a caller that refuses on it fails closed.
|
||||
func ReadOwn(root string) (Own, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(root, SettingFile))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return Own{Listed: []string{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Own{}, fmt.Errorf("the operator's own addresses cannot be read from %s: %w", SettingFile, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return Own{}, fmt.Errorf("the operator's own addresses in %s are not JSON: %w", SettingFile, err)
|
||||
}
|
||||
return ParseOwn(doc[SettingKey])
|
||||
}
|
||||
|
||||
// ParseOwn reads the setting's value: a list of entries, or one string of entries separated by spaces or
|
||||
// commas, as `settings set` may give either. Each entry is an address or a range in either family; anything
|
||||
// else is a problem, named, and left out — a typo never widens what is spared.
|
||||
func ParseOwn(value any) (Own, error) {
|
||||
var words []string
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
case string:
|
||||
words = strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' })
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return Own{}, fmt.Errorf("%s holds %v, which is not text: each entry is an address or a range", SettingKey, item)
|
||||
}
|
||||
words = append(words, strings.TrimSpace(s))
|
||||
}
|
||||
default:
|
||||
return Own{}, fmt.Errorf("%s is %T: it is a list of addresses and ranges, or one line of them", SettingKey, value)
|
||||
}
|
||||
own := Own{Listed: []string{}}
|
||||
seen := map[netip.Prefix]bool{}
|
||||
for _, w := range words {
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
p, err := entry(w)
|
||||
if err != nil {
|
||||
own.Problems = append(own.Problems, err.Error())
|
||||
continue
|
||||
}
|
||||
if seen[p] {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
own.Entries = append(own.Entries, p)
|
||||
}
|
||||
sort.Slice(own.Entries, func(i, j int) bool { return own.Entries[i].String() < own.Entries[j].String() })
|
||||
for _, p := range own.Entries {
|
||||
own.Listed = append(own.Listed, show(p))
|
||||
}
|
||||
return own, nil
|
||||
}
|
||||
|
||||
// entry is one address or range; an address is the range of itself.
|
||||
func entry(word string) (netip.Prefix, error) {
|
||||
if strings.Contains(word, "/") {
|
||||
p, err := netip.ParsePrefix(word)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("%q is not an address or a range", word)
|
||||
}
|
||||
return p.Masked(), nil
|
||||
}
|
||||
a, err := netip.ParseAddr(word)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("%q is not an address or a range", word)
|
||||
}
|
||||
a = a.Unmap()
|
||||
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||
}
|
||||
|
||||
// show is an entry as a person reads it: a single address without its length.
|
||||
func show(p netip.Prefix) string {
|
||||
if p.IsSingleIP() {
|
||||
return p.Addr().String()
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
|
||||
// Lies is the entry ip lies on, or "".
|
||||
func (o Own) Lies(ip netip.Addr) string {
|
||||
return liesOn(ip, o.Entries)
|
||||
}
|
||||
|
||||
func liesOn(ip netip.Addr, entries []netip.Prefix) string {
|
||||
ip = ip.Unmap()
|
||||
for _, p := range entries {
|
||||
if p.Contains(ip) {
|
||||
return show(p)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ListText is the entries as fail2ban reads ListFile: one per line, under a line saying whose it is.
|
||||
func (o Own) ListText() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# The operator's own addresses (novox/hq ADR 0339), written by the fail2ban module's watch from the\n")
|
||||
b.WriteString("# mesh-wide setting own-addresses. Every jail ignores them. Replaced on every change; edit nothing here.\n")
|
||||
for _, l := range o.Listed {
|
||||
b.WriteString(l + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WriteList writes ListFile under root when its content differs, by a rename so the daemon never reads half
|
||||
// of it. Says whether it wrote.
|
||||
func WriteList(root string, o Own) (bool, error) {
|
||||
path := filepath.Join(root, ListFile)
|
||||
want := o.ListText()
|
||||
if have, err := os.ReadFile(path); err == nil && string(have) == want {
|
||||
return false, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
// The watch (novox/hq ADR 0339 §2 and §4, issue 238): the module's long-running process, as root. Every look it
|
||||
//
|
||||
// - writes the operator's own addresses from the setting into the list every jail ignores (own.go);
|
||||
// - asks the daemon whether every jail's ignore list names that list, so no jail leaves it out;
|
||||
// - reads what the daemon logged since the last look, and keeps every failure a jail ignored because the
|
||||
// address is one of the operator's own, for a day.
|
||||
//
|
||||
// What it saw goes to StateFile, which the health tool reads as the operator's account. A failure from one
|
||||
// of the operator's own addresses is never banned — that is the point — and never silent: the module's health
|
||||
// says it, so the controller raises it as a warning naming the node, the jail, the address and the count, and
|
||||
// it clears a day after the last one. A loop like the one of 2026-10-04 is then seen within minutes.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The watch's timing.
|
||||
var (
|
||||
// LookEvery is how often the watch looks.
|
||||
LookEvery = 30 * time.Second
|
||||
// Remembered is how long an ignored failure is said: a day, as the record has it.
|
||||
Remembered = 24 * time.Hour
|
||||
// StaleAfter is how old the watch's last look may be before the health says it stopped looking.
|
||||
StaleAfter = 3 * time.Minute
|
||||
// keptPerAddress bounds what is remembered of one address in one jail; the count says when it met it.
|
||||
keptPerAddress = 10000
|
||||
)
|
||||
|
||||
// IgnoredFailures is the failures one jail ignored from one of the operator's own addresses, in the last day.
|
||||
type IgnoredFailures struct {
|
||||
Jail string `json:"jail"`
|
||||
Address string `json:"address"`
|
||||
Count int `json:"count"`
|
||||
First time.Time `json:"first"`
|
||||
Last time.Time `json:"last"`
|
||||
// AtLeast says the count met what is remembered of one address and is a floor.
|
||||
AtLeast bool `json:"at-least,omitempty"`
|
||||
}
|
||||
|
||||
// State is what the watch saw at its last look.
|
||||
type State struct {
|
||||
Looked time.Time `json:"looked"`
|
||||
// Own is the operator's own addresses as written into the list, and Problems the entries left out.
|
||||
Own []string `json:"own"`
|
||||
Problems []string `json:"problems,omitempty"`
|
||||
// Unread is why something the watch reads could not be read at this look.
|
||||
Unread []string `json:"unread,omitempty"`
|
||||
// Jails is how many jails the daemon runs; Missing those whose ignore list does not name the list.
|
||||
Jails int `json:"jails"`
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
// Ignored is every jail and address of the operator's own with a failure ignored in the last day.
|
||||
Ignored []IgnoredFailures `json:"ignored"`
|
||||
}
|
||||
|
||||
// ignoredLine is the daemon's line for a failure it ignored: `<time>,<ms> fail2ban.filter [<pid>]: INFO
|
||||
// [<jail>] Ignore <address> by <source>` (fail2ban 1.1, server/filter.py logIgnoreIp).
|
||||
var ignoredLine = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+ .*?\]:\s+\S+\s+\[([^\]]+)\] Ignore (\S+) by (\S+)\s*$`)
|
||||
|
||||
// Watcher is the watch's memory between looks: where it is in the log and what it saw there.
|
||||
type Watcher struct {
|
||||
F Fail2ban
|
||||
Root string
|
||||
Now func() time.Time
|
||||
|
||||
logFile os.FileInfo
|
||||
offset int64
|
||||
partial string
|
||||
seen map[[2]string][]time.Time
|
||||
}
|
||||
|
||||
// Look is one look: the list written, the jails asked, the log read, the state answered.
|
||||
func (w *Watcher) Look(ctx context.Context) State {
|
||||
now := w.Now()
|
||||
s := State{Looked: now, Own: []string{}, Ignored: []IgnoredFailures{}}
|
||||
own, err := ReadOwn(w.Root)
|
||||
if err != nil {
|
||||
s.Unread = append(s.Unread, err.Error())
|
||||
} else {
|
||||
s.Own, s.Problems = own.Listed, own.Problems
|
||||
if _, err := WriteList(w.Root, own); err != nil {
|
||||
s.Unread = append(s.Unread, fmt.Sprintf("the list every jail ignores (%s) cannot be written: %v", ListFile, err))
|
||||
}
|
||||
}
|
||||
if jails, err := w.F.Jails(ctx); err != nil {
|
||||
s.Unread = append(s.Unread, fmt.Sprintf("the daemon cannot be asked for its jails: %v", err))
|
||||
} else {
|
||||
s.Jails = len(jails)
|
||||
for _, j := range jails {
|
||||
out, err := w.F.client(ctx, "get", j, "ignoreip")
|
||||
if err != nil {
|
||||
s.Unread = append(s.Unread, fmt.Sprintf("the jail %s's ignore list cannot be read: %v", j, err))
|
||||
continue
|
||||
}
|
||||
if !contains(listed(out), IgnoredBy) {
|
||||
s.Missing = append(s.Missing, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := w.readLog(now); err != nil {
|
||||
s.Unread = append(s.Unread, err.Error())
|
||||
}
|
||||
s.Ignored = w.ignored(now)
|
||||
return s
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, l := range list {
|
||||
if l == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readLog reads what the daemon logged since the last look. A log rotated away is read from its start; the
|
||||
// first look reads the whole of it, so a watch started again still knows the last day.
|
||||
func (w *Watcher) readLog(now time.Time) error {
|
||||
path := filepath.Join(w.Root, LogFile)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the daemon's log %s cannot be read: %w", LogFile, err)
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the daemon's log %s cannot be read: %w", LogFile, err)
|
||||
}
|
||||
if w.logFile == nil || !os.SameFile(w.logFile, info) || info.Size() < w.offset {
|
||||
w.offset, w.partial = 0, ""
|
||||
}
|
||||
w.logFile = info
|
||||
if _, err := f.Seek(w.offset, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("the daemon's log %s cannot be read: %w", LogFile, err)
|
||||
}
|
||||
r := bufio.NewReader(f)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
w.offset += int64(len(line))
|
||||
if err != nil {
|
||||
// A line the daemon is still writing is kept for the next look.
|
||||
w.partial += line
|
||||
break
|
||||
}
|
||||
w.take(w.partial+strings.TrimRight(line, "\n"), now)
|
||||
w.partial = ""
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// take keeps one line of the log when it is a failure ignored because of the operator's own addresses.
|
||||
func (w *Watcher) take(line string, now time.Time) {
|
||||
m := ignoredLine.FindStringSubmatch(line)
|
||||
if m == nil || m[4] != IgnoredBy {
|
||||
return
|
||||
}
|
||||
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
|
||||
if err != nil || now.Sub(at) > Remembered {
|
||||
return
|
||||
}
|
||||
if w.seen == nil {
|
||||
w.seen = map[[2]string][]time.Time{}
|
||||
}
|
||||
key := [2]string{m[2], m[3]}
|
||||
times := append(w.seen[key], at)
|
||||
if len(times) > keptPerAddress {
|
||||
times = times[len(times)-keptPerAddress:]
|
||||
}
|
||||
w.seen[key] = times
|
||||
}
|
||||
|
||||
// ignored is what was seen in the last day, by jail and address; anything older is forgotten.
|
||||
func (w *Watcher) ignored(now time.Time) []IgnoredFailures {
|
||||
out := []IgnoredFailures{}
|
||||
for key, times := range w.seen {
|
||||
kept := times[:0]
|
||||
for _, at := range times {
|
||||
if now.Sub(at) <= Remembered {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
delete(w.seen, key)
|
||||
continue
|
||||
}
|
||||
w.seen[key] = kept
|
||||
sort.Slice(kept, func(i, j int) bool { return kept[i].Before(kept[j]) })
|
||||
out = append(out, IgnoredFailures{Jail: key[0], Address: key[1], Count: len(kept), First: kept[0],
|
||||
Last: kept[len(kept)-1], AtLeast: len(kept) >= keptPerAddress})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Jail != out[j].Jail {
|
||||
return out[i].Jail < out[j].Jail
|
||||
}
|
||||
return out[i].Address < out[j].Address
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteState writes the state for the health tool, readable by the operator's account, by a rename.
|
||||
func WriteState(root string, s State) error {
|
||||
raw, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(root, StateFile)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".new"
|
||||
if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Watch looks until it is stopped.
|
||||
func (w *Watcher) Watch(ctx context.Context) error {
|
||||
for {
|
||||
if err := WriteState(w.Root, w.Look(ctx)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] what the watch saw cannot be written to %s: %v\n", StateFile, err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-time.After(LookEvery):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Health is the module's health as the node-engine reads it (novox/hq ADR 0240): healthy, and why.
|
||||
type Health struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Why string `json:"why"`
|
||||
State *State `json:"seen,omitempty"`
|
||||
}
|
||||
|
||||
// ReadHealth judges what the watch last saw. Not healthy when the watch is not looking, when something it
|
||||
// reads cannot be read, when the setting holds an entry that is not an address, when a jail's ignore list
|
||||
// leaves the operator's own addresses out, and when a jail ignored a failure from one in the last day — each
|
||||
// said, so the controller's warning names the jail, the address and the count (ADR 0339 §4).
|
||||
func ReadHealth(root string, now time.Time) Health {
|
||||
raw, err := os.ReadFile(filepath.Join(root, StateFile))
|
||||
if err != nil {
|
||||
return Health{Why: fmt.Sprintf("the watch has written nothing this tool can read (%v): it is not looking", err)}
|
||||
}
|
||||
var s State
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return Health{Why: fmt.Sprintf("what the watch wrote is not its state: %v", err)}
|
||||
}
|
||||
if age := now.Sub(s.Looked); age > StaleAfter {
|
||||
return Health{State: &s, Why: fmt.Sprintf("the watch last looked at %s, %s ago: it is not looking",
|
||||
s.Looked.Local().Format("2006-01-02 15:04:05"), age.Round(time.Second))}
|
||||
}
|
||||
var wrong []string
|
||||
wrong = append(wrong, s.Unread...)
|
||||
if len(s.Problems) > 0 {
|
||||
wrong = append(wrong, fmt.Sprintf("the setting %s holds what is not an address or a range, left out: %s",
|
||||
SettingKey, strings.Join(s.Problems, ", ")))
|
||||
}
|
||||
if len(s.Missing) > 0 {
|
||||
wrong = append(wrong, fmt.Sprintf("the jail(s) %s do not ignore the operator's own addresses: their ignore "+
|
||||
"list lacks %s (novox/hq ADR 0339 §2)", strings.Join(s.Missing, ", "), IgnoredBy))
|
||||
}
|
||||
for _, i := range s.Ignored {
|
||||
count := fmt.Sprint(i.Count)
|
||||
if i.AtLeast {
|
||||
count = "at least " + count
|
||||
}
|
||||
wrong = append(wrong, fmt.Sprintf("the jail %s counted %s failure(s) from %s, one of the operator's own "+
|
||||
"addresses, in the last day, the last at %s, and banned none (novox/hq ADR 0339); it clears a day after "+
|
||||
"the last", i.Jail, count, i.Address, i.Last.Local().Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if len(wrong) > 0 {
|
||||
return Health{State: &s, Why: strings.Join(wrong, "; ")}
|
||||
}
|
||||
return Health{Healthy: true, State: &s, Why: fmt.Sprintf("every jail (%d) ignores the operator's own addresses "+
|
||||
"(%d entr%s), and none failed in the last day", s.Jails, len(s.Own), plural(len(s.Own)))}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return "y"
|
||||
}
|
||||
return "ies"
|
||||
}
|
||||
@@ -17,7 +17,8 @@
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"fail2ban_settings"
|
||||
"fail2ban_settings",
|
||||
"fail2ban_health"
|
||||
],
|
||||
"jailing": {
|
||||
"into": "/etc/fail2ban/jail.d/mesh.conf",
|
||||
@@ -29,6 +30,20 @@
|
||||
"type": "package",
|
||||
"package": "fail2ban"
|
||||
},
|
||||
{
|
||||
"id": "state-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-fail2ban",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "own-addresses-list",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh-fail2ban/own-addresses",
|
||||
"mode": "0644",
|
||||
"create-once": true,
|
||||
"content": ""
|
||||
},
|
||||
{
|
||||
"id": "jail-d",
|
||||
"type": "directory",
|
||||
@@ -58,7 +73,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\n# **A ban list never holds a neighbour.** The mesh's own range is named rather than written\n# (novox/hq ADR 0112), and every private range beside it: a source on one is somebody's own\n# network, not the internet. On a machine behind a router that reflects local traffic, every\n# client in the house arrives as the gateway's address — so one mistyped local request banned\n# 192.168.1.1 on the home server and would have cut the whole house off from it (ADR 0186).\n#\n# **Nor the operator's own addresses** (novox/hq ADR 0339, issue 238): the public addresses the operator names\n# as theirs, in the mesh-wide setting own-addresses. An agent polling the forge from the operator's home banned\n# that home for four weeks on 2026-10-04, and nothing in the house reached the control-node until it was found\n# among 400 bans. The file is written by this module's watch from the setting, one entry per line; fail2ban\n# reads it again within a second of a change, so a new value needs no restart. Empty until the operator\n# fills the setting. A jail that sets its own ignoreip leaves all of this out; the watch says so.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range} 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16 fc00::/7 fe80::/10 file:/var/lib/mesh-fail2ban/own-addresses\n\n# Three failures in a day ban for a day (novox/hq ADR 0179). The attackers this mesh sees pace\n# themselves at one try every ten minutes, under any ten-minute window; a day's window counts\n# them, and a day's ban costs a person who mistyped three times once, from one address, while\n# the mesh's own range is never banned at all.\nbantime = 1d\nfindtime = 1d\nmaxretry = 3\n\n# Ban through iptables, not through a firewall front-end the machine may not have. ufw is\n# installed on two of this mesh's machines and absent on the other two, and fail2ban finds out\n# only at ban time: the service reports healthy, the jail counts the attempt, the ban command\n# exits 127, and nothing is blocked. Proven on 2026-09-28 -- 'ufw: command not found' on a\n# machine the mesh reported as protected.\n#\n# The action below is this module's own, already used by the recidive jail on every machine\n# here, and it bans in DOCKER-USER as well as INPUT, so a container's published port is\n# covered too.\nbanaction = iptables-allports-dualchain\nbanaction_allports = iptables-allports-dualchain\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
@@ -67,6 +82,36 @@
|
||||
"mode": "0644",
|
||||
"content": "[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\nmaxretry = 3\nfindtime = 1d\nbantime = 1d\n"
|
||||
},
|
||||
{
|
||||
"id": "own-addresses",
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/mesh-own-addresses.json",
|
||||
"mode": "0644",
|
||||
"merge": "json",
|
||||
"takes-settings": [
|
||||
"own-addresses"
|
||||
],
|
||||
"trusted": true,
|
||||
"content": "{\n \"own-addresses\": []\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "watch",
|
||||
"type": "process",
|
||||
"name": "fail2ban-watch",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./fail2ban-tools",
|
||||
"watch"
|
||||
],
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "fail2ban_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "90s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "log",
|
||||
"type": "file",
|
||||
@@ -108,7 +153,10 @@
|
||||
"jail-recidive",
|
||||
"action-dualchain",
|
||||
"composed-jails"
|
||||
]
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user