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,…
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.
187 lines
6.6 KiB
Go
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
|
|
}
|