Files
mesh-catalog/modules/fail2ban/cmd/fail2ban-tools/own.go
T
jochen c78d0a0134
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: know the own-addresses list in the form fail2ban prints it (hq issue 238)
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.
2026-10-13 11:14:24 +02:00

201 lines
7.3 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
)
// 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 <jail> 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
}