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:
2026-10-05 12:01:25 +02:00
parent 4224ac7252
commit d43de93e49
11 changed files with 710 additions and 448 deletions
@@ -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")) }},
}
}