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 failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
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.
453 lines
14 KiB
Go
453 lines
14 KiB
Go
// fail2ban's own code, in the module (novox/hq ADR 0039). The jails are composed by the mesh from the
|
|
// modules a machine runs (to-be 31) and written as declared resources; the daemon is kept running by
|
|
// one. This code exists only to read and steer the *live* state the daemon owns: who is banned now
|
|
// and until when, and the ban or release an operator asks for — the node-intrusion-prevention seat's
|
|
// four verbs (ADR 0179). The daemon's state is fail2ban's, not the mesh's: the mesh composes the
|
|
// jails and never writes the ban list.
|
|
//
|
|
// Spoken through fail2ban-client over the daemon's socket. Client and daemon come from the one
|
|
// package this module declares on the machine, and the socket is root's: root is the module's
|
|
// concern (ADR 0175 §4), and the runtime launching this binary runs as the operator's account (to-be
|
|
// 38 WP4), so the client is run through sudo without a prompt where the account is not root.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Runner runs one command and answers what it printed, so the verbs can be tested without a daemon.
|
|
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
|
|
|
// escalated is the command as it is run: as given when this process is root, else through sudo
|
|
// without a prompt. The daemon's socket answers only to root.
|
|
func escalated(uid int, name string, args []string) (string, []string) {
|
|
if uid == 0 {
|
|
return name, args
|
|
}
|
|
return "sudo", append([]string{"-n", name}, args...)
|
|
}
|
|
|
|
// installed is whether a tool is on this machine: an executable of that name on the path, or where
|
|
// the system keeps its administration.
|
|
func installed(tool, path string) bool {
|
|
dirs := append(filepath.SplitList(path), "/usr/sbin", "/sbin", "/usr/bin")
|
|
for _, dir := range dirs {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(filepath.Join(dir, tool)); err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var socketTrouble = regexp.MustCompile(`(?i)Failed to access socket path|Is fail2ban running|Permission denied to socket`)
|
|
|
|
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
|
if !installed(name, os.Getenv("PATH")) {
|
|
return "", fmt.Errorf("%s is not installed on this machine", name)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
program, argv := escalated(os.Getuid(), name, args)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, program, argv...)
|
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return stdout.String(), nil
|
|
}
|
|
said := strings.TrimSpace(stdout.String() + stderr.String())
|
|
// What failed is named by how it failed: sudo missing is a spawn error, sudo refusing speaks on
|
|
// its own stderr line, and the rest is the client's own answer.
|
|
if program == "sudo" {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
|
}
|
|
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
|
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
|
}
|
|
}
|
|
if socketTrouble.MatchString(said) {
|
|
return "", errors.New("fail2ban is not running on this machine, or its socket does not answer the runtime's account")
|
|
}
|
|
// fail2ban-client's own last line is the one a person reads ("Sorry but the jail 'x' does not exist").
|
|
var lines []string
|
|
for _, l := range strings.Split(said, "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
lines = append(lines, l)
|
|
}
|
|
}
|
|
if len(lines) > 0 {
|
|
return "", errors.New(lines[len(lines)-1])
|
|
}
|
|
return "", fmt.Errorf("%s failed: %v", name, err)
|
|
}
|
|
|
|
// Counted is a jail's count now and since it started.
|
|
type Counted struct {
|
|
Now int `json:"now"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// Held is what a jail holds: the count now and since it started, and the addresses.
|
|
type Held struct {
|
|
Now int `json:"now"`
|
|
Total int `json:"total"`
|
|
Addresses []string `json:"addresses"`
|
|
}
|
|
|
|
// JailStatus is one jail as the daemon reports it.
|
|
type JailStatus struct {
|
|
Jail string `json:"jail"`
|
|
// Watching is what the jail is reading: files or journal matches, as fail2ban names them.
|
|
Watching []string `json:"watching"`
|
|
// Failing is the addresses with failures counted against them now, and all failures since the
|
|
// jail started.
|
|
Failing Counted `json:"failing"`
|
|
// Banned is the addresses held right now, and all bans since the jail started.
|
|
Banned Held `json:"banned"`
|
|
}
|
|
|
|
// Ban is one ban as the daemon holds it.
|
|
type Ban struct {
|
|
IP string `json:"ip"`
|
|
Jail string `json:"jail"`
|
|
// Since is when the ban was placed, in the machine's local time as fail2ban prints it.
|
|
Since string `json:"since"`
|
|
// Until is when the ban ends; "never" for a permanent ban.
|
|
Until string `json:"until"`
|
|
}
|
|
|
|
// JailSettings is one jail's effective settings.
|
|
type JailSettings struct {
|
|
Jail string `json:"jail"`
|
|
Bantime string `json:"bantime"`
|
|
Findtime string `json:"findtime"`
|
|
Maxretry int `json:"maxretry"`
|
|
Ignoreip []string `json:"ignoreip"`
|
|
Actions []string `json:"actions"`
|
|
Logpath []string `json:"logpath"`
|
|
Journal string `json:"journalmatch"`
|
|
}
|
|
|
|
// Fail2ban is the daemon as this machine has it, through its own client.
|
|
type Fail2ban struct {
|
|
Run Runner
|
|
// Root is where the machine's files are read from: "" on the machine, a directory in a test.
|
|
Root string
|
|
}
|
|
|
|
func (f Fail2ban) client(ctx context.Context, args ...string) (string, error) {
|
|
return f.Run(ctx, "fail2ban-client", args...)
|
|
}
|
|
|
|
var jailList = regexp.MustCompile(`Jail list:[ \t]*(.*)`)
|
|
|
|
// Jails is the jails the daemon runs, by name.
|
|
func (f Fail2ban) Jails(ctx context.Context) ([]string, error) {
|
|
out, err := f.client(ctx, "status")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := jailList.FindStringSubmatch(out)
|
|
if m == nil {
|
|
return []string{}, nil
|
|
}
|
|
var jails []string
|
|
for _, j := range strings.Split(m[1], ",") {
|
|
if j = strings.TrimSpace(j); j != "" {
|
|
jails = append(jails, j)
|
|
}
|
|
}
|
|
return jails, nil
|
|
}
|
|
|
|
func (f Fail2ban) named(ctx context.Context, jail string) ([]string, error) {
|
|
if jail != "" {
|
|
return []string{jail}, nil
|
|
}
|
|
return f.Jails(ctx)
|
|
}
|
|
|
|
// Status is every jail with what it watches and holds, or one jail's detail.
|
|
func (f Fail2ban) Status(ctx context.Context, jail string) (map[string][]JailStatus, error) {
|
|
names, err := f.named(ctx, jail)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jails := []JailStatus{}
|
|
for _, name := range names {
|
|
out, err := f.client(ctx, "status", name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
jails = append(jails, parseJailStatus(name, out))
|
|
}
|
|
return map[string][]JailStatus{"jails": jails}, nil
|
|
}
|
|
|
|
// Banned is every address banned now, with the jail holding it and when the ban ends, soonest to
|
|
// end first.
|
|
func (f Fail2ban) Banned(ctx context.Context, jail string) (map[string][]Ban, error) {
|
|
names, err := f.named(ctx, jail)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
banned := []Ban{}
|
|
for _, name := range names {
|
|
out, err := f.client(ctx, "get", name, "banip", "--with-time")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
banned = append(banned, parseBans(name, out)...)
|
|
}
|
|
sort.SliceStable(banned, func(a, b int) bool {
|
|
if banned[a].Until != banned[b].Until {
|
|
return banned[a].Until < banned[b].Until
|
|
}
|
|
return banned[a].IP < banned[b].IP
|
|
})
|
|
return map[string][]Ban{"banned": banned}, nil
|
|
}
|
|
|
|
// BanOutcome is a ban as held, and how many addresses the daemon said it added.
|
|
type BanOutcome struct {
|
|
Banned *Ban `json:"banned"`
|
|
Added int `json:"added"`
|
|
}
|
|
|
|
// Ban bans one address in one jail now. The daemon's own answer is how many addresses it added.
|
|
//
|
|
// **Never one of the operator's own addresses, nor one the jail spares** (novox/hq ADR 0339 §3, narrowing
|
|
// ADR 0179 §4): an address on the setting `own-addresses`, or on any entry of the jail's own ignore list — the
|
|
// private ranges ADR 0186 spares among them — is refused, naming the entry it lies on, and nothing reaches the
|
|
// daemon. fail2ban's `banip` bans whatever it is given, ignore list or not: the 2026-10-04 lockout of the
|
|
// operator's home (issue 238) needed only a jail; this verb could have done it in one call. When the setting
|
|
// cannot be read the ban is refused too: whether the address is the operator's cannot then be told.
|
|
func (f Fail2ban) Ban(ctx context.Context, ip, jail string) (*BanOutcome, error) {
|
|
if err := address(ip); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := jailName(jail); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.spared(ctx, ip, jail); err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := f.client(ctx, "set", jail, "banip", ip)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
added, _ := strconv.Atoi(strings.TrimSpace(out))
|
|
held, err := f.Banned(ctx, jail)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
outcome := &BanOutcome{Added: added}
|
|
for _, b := range held["banned"] {
|
|
if b.IP == ip {
|
|
b := b
|
|
outcome.Banned = &b
|
|
}
|
|
}
|
|
return outcome, nil
|
|
}
|
|
|
|
// spared refuses an address the operator owns or the jail ignores, saying which entry it lies on.
|
|
func (f Fail2ban) spared(ctx context.Context, ip, jail string) error {
|
|
a, err := netip.ParseAddr(ip)
|
|
if err != nil {
|
|
return fmt.Errorf("%q is not an address", ip)
|
|
}
|
|
own, err := ReadOwn(f.Root)
|
|
if err != nil {
|
|
return fmt.Errorf("refused, and nothing was banned: %v, so whether %s is one of them cannot be told", err, ip)
|
|
}
|
|
if on := own.Lies(a); on != "" {
|
|
return fmt.Errorf("refused, and nothing was banned: %s lies on %s, one of the operator's own addresses "+
|
|
"(the setting %s), which no jail bans (novox/hq ADR 0339)", ip, on, SettingKey)
|
|
}
|
|
out, err := f.client(ctx, "get", jail, "ignoreip")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var entries []netip.Prefix
|
|
for _, word := range listed(out) {
|
|
// A file of entries (the operator's own, read above) or a name the daemon resolves is not an entry here.
|
|
if p, err := entry(word); err == nil {
|
|
entries = append(entries, p)
|
|
}
|
|
}
|
|
if on := liesOn(a, entries); on != "" {
|
|
return fmt.Errorf("refused, and nothing was banned: %s lies on %s, which the jail %s never bans — the "+
|
|
"machine itself, the mesh's own range or a private one (novox/hq ADR 0186, ADR 0339)", ip, on, jail)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Released is how many bans the daemon let go, of which address, from where.
|
|
type Released struct {
|
|
Released int `json:"released"`
|
|
IP string `json:"ip"`
|
|
Jail string `json:"jail"`
|
|
}
|
|
|
|
// Unban lets one address go, from one jail or from every jail. The daemon's answer is how many it
|
|
// released.
|
|
func (f Fail2ban) Unban(ctx context.Context, ip, jail string) (*Released, error) {
|
|
if err := address(ip); err != nil {
|
|
return nil, err
|
|
}
|
|
var out string
|
|
var err error
|
|
if jail != "" {
|
|
if err := jailName(jail); err != nil {
|
|
return nil, err
|
|
}
|
|
out, err = f.client(ctx, "set", jail, "unbanip", ip)
|
|
} else {
|
|
out, err = f.client(ctx, "unban", ip)
|
|
jail = "every jail"
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
released, _ := strconv.Atoi(strings.TrimSpace(out))
|
|
return &Released{Released: released, IP: ip, Jail: jail}, nil
|
|
}
|
|
|
|
// Settings is one jail's effective settings — the module's own tool, beside the seat's verbs.
|
|
func (f Fail2ban) Settings(ctx context.Context, jail string) (*JailSettings, error) {
|
|
if err := jailName(jail); err != nil {
|
|
return nil, err
|
|
}
|
|
got := map[string]string{}
|
|
for _, key := range []string{"bantime", "findtime", "maxretry", "ignoreip", "actions", "logpath", "journalmatch"} {
|
|
out, err := f.client(ctx, "get", jail, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
got[key] = out
|
|
}
|
|
maxretry, _ := strconv.Atoi(strings.TrimSpace(got["maxretry"]))
|
|
s := &JailSettings{
|
|
Jail: jail,
|
|
Bantime: strings.TrimSpace(got["bantime"]),
|
|
Findtime: strings.TrimSpace(got["findtime"]),
|
|
Maxretry: maxretry,
|
|
Ignoreip: listed(got["ignoreip"]),
|
|
Actions: afterHeading(got["actions"]),
|
|
Logpath: []string{},
|
|
Journal: strings.Join(afterHeading(got["journalmatch"]), " "),
|
|
}
|
|
if !strings.Contains(got["logpath"], "No file is currently monitored") {
|
|
s.Logpath = listed(got["logpath"])
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
var treeMarks = regexp.MustCompile("^[\\s|`-]+")
|
|
|
|
// listed reads fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading.
|
|
func listed(out string) []string {
|
|
items := []string{}
|
|
for i, l := range strings.Split(out, "\n") {
|
|
l = strings.TrimSpace(treeMarks.ReplaceAllString(l, ""))
|
|
if i > 0 && l != "" {
|
|
items = append(items, l)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
// afterHeading is every non-empty line after the first, trimmed.
|
|
func afterHeading(out string) []string {
|
|
items := []string{}
|
|
for i, l := range strings.Split(out, "\n") {
|
|
if l = strings.TrimSpace(l); i > 0 && l != "" {
|
|
items = append(items, l)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func parseJailStatus(jail, out string) JailStatus {
|
|
field := func(label string) string {
|
|
m := regexp.MustCompile(regexp.QuoteMeta(label) + `:\t?[ \t]*(.*)`).FindStringSubmatch(out)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
num := func(label string) int {
|
|
n, _ := strconv.Atoi(field(label))
|
|
return n
|
|
}
|
|
watching := []string{}
|
|
for _, w := range []string{field("File list"), field("Journal matches")} {
|
|
if w != "" {
|
|
watching = append(watching, w)
|
|
}
|
|
}
|
|
addresses := strings.Fields(field("Banned IP list"))
|
|
if addresses == nil {
|
|
addresses = []string{}
|
|
}
|
|
return JailStatus{
|
|
Jail: jail,
|
|
Watching: watching,
|
|
Failing: Counted{Now: num("Currently failed"), Total: num("Total failed")},
|
|
Banned: Held{Now: num("Currently banned"), Total: num("Total banned"), Addresses: addresses},
|
|
}
|
|
}
|
|
|
|
var banLine = regexp.MustCompile(`^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+ (-?\d+) = (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}|\S+)`)
|
|
|
|
// parseBans reads `get <jail> banip --with-time`, one ban per line: "IP \tsince + seconds = until".
|
|
func parseBans(jail, out string) []Ban {
|
|
bans := []Ban{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
m := banLine.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
until := m[4]
|
|
if seconds, _ := strconv.Atoi(m[3]); seconds < 0 {
|
|
until = "never"
|
|
}
|
|
bans = append(bans, Ban{IP: m[1], Jail: jail, Since: m[2], Until: until})
|
|
}
|
|
return bans
|
|
}
|
|
|
|
func address(ip string) error {
|
|
if net.ParseIP(ip) == nil {
|
|
return fmt.Errorf("%q is not an address", ip)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var jailNamed = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
|
|
func jailName(jail string) error {
|
|
if !jailNamed.MatchString(jail) {
|
|
return fmt.Errorf("%q is not a jail's name", jail)
|
|
}
|
|
return nil
|
|
}
|