201 lines
7.3 KiB
Go
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 node-a 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
|
|
}
|