fail2ban: its tools in Go
Go is the default for module code. One binary, fail2ban-tools, serving the node-intrusion-prevention seat's four verbs and fail2ban_settings over the SDK, with the same parsing and the same tests; read back against the control node's live daemon.
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
// The intrusion prevention's verbs over a fake daemon, with the shapes fail2ban-client 1.1.0 printed
|
||||
// on the control node on 2026-10-02 (novox/hq ADR 0179).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const statusAll = "Status\n|- Number of jail:\t2\n`- Jail list:\trecidive, sshd\n"
|
||||
const recidive = "Status for the jail: recidive\n|- Filter\n| |- Currently failed:\t36\n| |- Total failed:\t149\n" +
|
||||
"| `- File list:\t/var/log/fail2ban.log\n`- Actions\n |- Currently banned:\t9\n |- Total banned:\t13\n" +
|
||||
" `- Banned IP list:\t195.178.110.30 45.148.10.240 92.118.39.71\n"
|
||||
const sshd = "Status for the jail: sshd\n|- Filter\n| |- Currently failed:\t5\n| |- Total failed:\t11776\n" +
|
||||
"| `- Journal matches:\t_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n`- Actions\n |- Currently banned:\t0\n" +
|
||||
" |- Total banned:\t150\n `- Banned IP list:\t\n"
|
||||
const withTime = "195.178.110.30 \t2026-09-26 23:18:47 + 604800 = 2026-10-03 23:18:47\n" +
|
||||
"92.118.39.71 \t2026-09-28 10:33:49 + 604800 = 2026-10-05 10:33:49\n"
|
||||
|
||||
func fake(answers map[string]string, calls *[][]string) Runner {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if calls != nil {
|
||||
*calls = append(*calls, append([]string{name}, args...))
|
||||
}
|
||||
if out, ok := answers[strings.Join(args, " ")]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected %s %s", name, strings.Join(args, " "))
|
||||
}
|
||||
}
|
||||
|
||||
var ctx = context.Background()
|
||||
|
||||
func TestAJailsStatusIsReadIntoNumbersWhatItWatchesAndWhoItHolds(t *testing.T) {
|
||||
got := parseJailStatus("recidive", recidive)
|
||||
want := JailStatus{Jail: "recidive", Watching: []string{"/var/log/fail2ban.log"}, Failing: Counted{36, 149},
|
||||
Banned: Held{9, 13, []string{"195.178.110.30", "45.148.10.240", "92.118.39.71"}}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
j := parseJailStatus("sshd", sshd)
|
||||
if !reflect.DeepEqual(j.Watching, []string{"_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}) {
|
||||
t.Errorf("watching %v", j.Watching)
|
||||
}
|
||||
if !reflect.DeepEqual(j.Banned, Held{0, 150, []string{}}) {
|
||||
t.Errorf("banned %+v", j.Banned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusCoversEveryJailTheDaemonListsOrTheOneNamed(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"status": statusAll, "status recidive": recidive, "status sshd": sshd}, &calls)}
|
||||
all, err := f.Status(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all["jails"]) != 2 || all["jails"][0].Jail != "recidive" || all["jails"][1].Jail != "sshd" {
|
||||
t.Errorf("%+v", all)
|
||||
}
|
||||
one, err := f.Status(ctx, "sshd")
|
||||
if err != nil || len(one["jails"]) != 1 {
|
||||
t.Fatalf("%+v %v", one, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[len(calls)-1], []string{"fail2ban-client", "status", "sshd"}) {
|
||||
t.Errorf("last call %v", calls[len(calls)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansAreReadWithWhenTheyEndAPermanentOneAsNever(t *testing.T) {
|
||||
bans := parseBans("recidive", withTime+"203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n")
|
||||
if len(bans) != 3 {
|
||||
t.Fatalf("%+v", bans)
|
||||
}
|
||||
if bans[0] != (Ban{IP: "195.178.110.30", Jail: "recidive", Since: "2026-09-26 23:18:47", Until: "2026-10-03 23:18:47"}) {
|
||||
t.Errorf("%+v", bans[0])
|
||||
}
|
||||
if bans[2].Until != "never" {
|
||||
t.Errorf("a permanent ban ends %q", bans[2].Until)
|
||||
}
|
||||
if got := parseBans("sshd", "\n"); len(got) != 0 {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannedGathersEveryJailsBansSoonestToEndFirst(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"status": statusAll,
|
||||
"get recidive banip --with-time": withTime,
|
||||
"get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n",
|
||||
}, nil)}
|
||||
got, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var order []string
|
||||
for _, b := range got["banned"] {
|
||||
order = append(order, b.IP+"@"+b.Jail)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"}) {
|
||||
t.Errorf("%v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanAsksByJailAndAnswersTheBanAsHeldRefusingANonAddressFirst(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"set recidive banip 198.51.100.7": "1\n",
|
||||
"get recidive banip --with-time": withTime + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n",
|
||||
}, &calls)}
|
||||
r, err := f.Ban(ctx, "198.51.100.7", "recidive")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Added != 1 || r.Banned == nil || r.Banned.Until != "2026-10-09 17:00:00" {
|
||||
t.Errorf("%+v", r)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[0], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("first call %v", calls[0])
|
||||
}
|
||||
if _, err := f.Ban(ctx, "not-an-ip", "recidive"); err == nil || !strings.Contains(err.Error(), "is not an address") {
|
||||
t.Errorf("a non-address: %v", err)
|
||||
}
|
||||
if _, err := f.Ban(ctx, "198.51.100.7", "a jail; rm"); err == nil || !strings.Contains(err.Error(), "is not a jail's name") {
|
||||
t.Errorf("a non-name: %v", err)
|
||||
}
|
||||
if len(calls) != 2 {
|
||||
t.Errorf("a refused ban reached the daemon: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnbanReleasesFromOneJailOrFromEveryJail(t *testing.T) {
|
||||
var calls [][]string
|
||||
f := Fail2ban{Run: fake(map[string]string{"set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n"}, &calls)}
|
||||
one, err := f.Unban(ctx, "198.51.100.7", "sshd")
|
||||
if err != nil || *one != (Released{1, "198.51.100.7", "sshd"}) {
|
||||
t.Errorf("%+v %v", one, err)
|
||||
}
|
||||
every, err := f.Unban(ctx, "198.51.100.7", "")
|
||||
if err != nil || *every != (Released{2, "198.51.100.7", "every jail"}) {
|
||||
t.Errorf("%+v %v", every, err)
|
||||
}
|
||||
if !reflect.DeepEqual(calls[1], []string{"fail2ban-client", "unban", "198.51.100.7"}) {
|
||||
t.Errorf("%v", calls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJailsSettingsAreReadFromTheDaemonsListings(t *testing.T) {
|
||||
f := Fail2ban{Run: fake(map[string]string{
|
||||
"get sshd bantime": "86400\n", "get sshd findtime": "86400\n", "get sshd maxretry": "3\n",
|
||||
"get sshd ignoreip": "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n`- ::1\n",
|
||||
"get sshd actions": "The jail sshd has the following actions:\niptables-allports-dualchain\n",
|
||||
"get sshd logpath": "No file is currently monitored\n",
|
||||
"get sshd journalmatch": "Current match filter:\n_SYSTEMD_UNIT=sshd.service + _COMM=sshd\n",
|
||||
}, nil)}
|
||||
got, err := f.Settings(ctx, "sshd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := &JailSettings{Jail: "sshd", Bantime: "86400", Findtime: "86400", Maxretry: 3,
|
||||
Ignoreip: []string{"127.0.0.0/8", "10.10.0.0/24", "::1"}, Actions: []string{"iptables-allports-dualchain"},
|
||||
Logpath: []string{}, Journal: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClientRunsAsGivenByRootAndThroughSudoByAnyoneElse(t *testing.T) {
|
||||
if p, a := escalated(0, "fail2ban-client", []string{"status"}); p != "fail2ban-client" || !reflect.DeepEqual(a, []string{"status"}) {
|
||||
t.Errorf("as root: %s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(1000, "fail2ban-client", []string{"set", "sshd", "banip", "198.51.100.7"}); p != "sudo" ||
|
||||
!reflect.DeepEqual(a, []string{"-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"}) {
|
||||
t.Errorf("as an account: %s %v", p, a)
|
||||
}
|
||||
if !installed("sh", "/bin:/usr/bin") || installed("no-such-client-of-the-mesh", "/bin:/usr/bin") {
|
||||
t.Error("installed is wrong about sh or about a tool nobody has")
|
||||
}
|
||||
}
|
||||
|
||||
// The tools carry the seat's four verbs under the seat's name, and the module's own under its own.
|
||||
func TestTheSeatsVerbsAndTheModulesOwnToolAreServed(t *testing.T) {
|
||||
var names []string
|
||||
for _, tool := range tools(Fail2ban{Run: fake(nil, nil)}) {
|
||||
names = append(names, tool.Name)
|
||||
}
|
||||
want := []string{"node-intrusion-prevention.status", "node-intrusion-prevention.banned", "node-intrusion-prevention.ban",
|
||||
"node-intrusion-prevention.unban", "fail2ban_settings"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Errorf("%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon on this machine, read only — status, bans and one jail's settings — when asked for with
|
||||
// FAIL2BAN_LIVE=1: the shapes above are what fail2ban-client printed once, and this is what it prints
|
||||
// now.
|
||||
func TestTheLiveDaemonReadsBack(t *testing.T) {
|
||||
if os.Getenv("FAIL2BAN_LIVE") != "1" {
|
||||
t.Skip("set FAIL2BAN_LIVE=1 to read the daemon on this machine")
|
||||
}
|
||||
f := Fail2ban{Run: execRunner}
|
||||
status, err := f.Status(ctx, "")
|
||||
if err != nil || len(status["jails"]) == 0 {
|
||||
t.Fatalf("status: %+v %v", status, err)
|
||||
}
|
||||
for _, j := range status["jails"] {
|
||||
t.Logf("%s: watching %v, failing %d, banned %d now of %d", j.Jail, j.Watching, j.Failing.Now, j.Banned.Now, j.Banned.Total)
|
||||
if len(j.Watching) == 0 {
|
||||
t.Errorf("%s watches nothing as read", j.Jail)
|
||||
}
|
||||
}
|
||||
banned, err := f.Banned(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("banned: %v", err)
|
||||
}
|
||||
t.Logf("%d bans held", len(banned["banned"]))
|
||||
settings, err := f.Settings(ctx, "sshd")
|
||||
if err != nil || settings.Maxretry == 0 || len(settings.Ignoreip) == 0 {
|
||||
t.Fatalf("settings: %+v %v", settings, err)
|
||||
}
|
||||
t.Logf("sshd: bantime %s, maxretry %d, ignores %v", settings.Bantime, settings.Maxretry, settings.Ignoreip)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// fail2ban-tools (novox/hq to-be 31, ADR 0179): the intrusion prevention's tools. One binary, launched
|
||||
// by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR
|
||||
// 0198): the node-intrusion-prevention seat's four verbs — who is banned, the jails' state, ban one,
|
||||
// let one go — and the module's own reading of a jail's settings. The jails themselves are composed
|
||||
// by the mesh from the modules a machine runs and written as declared resources; these touch only
|
||||
// what the running daemon holds.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-intrusion-prevention"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(Fail2ban{Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[fail2ban] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject. The module's own tools keep their bare names.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(f Fail2ban) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
oneJail := map[string]any{"jail": str("one jail (optional)")}
|
||||
return []stdio.Tool{
|
||||
verb("status", "Every jail on this machine with what it watches, how many addresses it is counting failures against and holding now, and the totals since it started; one jail's detail when named.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Status(ctx, arg(a, "jail")) }),
|
||||
verb("banned", "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.",
|
||||
oneJail, func(a map[string]any) (any, error) { return f.Banned(ctx, arg(a, "jail")) }),
|
||||
verb("ban", "Ban one address in one jail now, for the jail's ban time — an operator's act on the live ban list, which the mesh never writes itself.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("the jail to hold it (recidive for the long ban)")},
|
||||
func(a map[string]any) (any, error) { return f.Ban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
verb("unban", "Let one address go, from one jail or from every jail when none is named.",
|
||||
map[string]any{"ip": str("the address"), "jail": str("one jail (optional)")},
|
||||
func(a map[string]any) (any, error) { return f.Unban(ctx, arg(a, "ip"), arg(a, "jail")) }),
|
||||
{Name: "fail2ban_settings",
|
||||
Description: "One jail's effective settings on this machine: ban time, window, tries, the addresses it never bans, its actions and what it reads.",
|
||||
Input: map[string]any{"jail": str("the jail")},
|
||||
Run: func(a map[string]any) (any, error) { return f.Settings(ctx, arg(a, "jail")) }},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user