Files
mesh-catalog/modules/fail2ban/cmd/fail2ban-tools/own.go
T
jochen 93d58f3740
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,…
fail2ban: never ban the operator's own addresses, and say a failure from one (hq issue 238, ADR 0339)
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.
2026-10-13 10:54:49 +02:00

187 lines
6.6 KiB
Go

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
}