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:`, 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
by file:`, 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 ) // isOurList says whether an entry of a jail's ignore list, or the source the daemon's log names, is the list of // the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads // `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1), // in `get ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own // reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list // and the watch said none did). func isOurList(word string) bool { path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:") if !ok { return false } return "/"+strings.TrimLeft(path, "/") == 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 }