From d43de93e49fba1616447575fb56619fe0324ce3b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 12:01:25 +0200 Subject: [PATCH] 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. --- modules/fail2ban/client.ts | 236 ---------- modules/fail2ban/cmd/fail2ban-tools/client.go | 407 ++++++++++++++++++ .../cmd/fail2ban-tools/client_test.go | 226 ++++++++++ modules/fail2ban/cmd/fail2ban-tools/main.go | 64 +++ modules/fail2ban/go.mod | 5 + modules/fail2ban/go.sum | 2 + modules/fail2ban/module.json | 9 +- modules/fail2ban/package.json | 18 - modules/fail2ban/test/client.test.ts | 114 ----- modules/fail2ban/tools/index.ts | 62 --- modules/fail2ban/tsconfig.json | 15 - 11 files changed, 710 insertions(+), 448 deletions(-) delete mode 100644 modules/fail2ban/client.ts create mode 100644 modules/fail2ban/cmd/fail2ban-tools/client.go create mode 100644 modules/fail2ban/cmd/fail2ban-tools/client_test.go create mode 100644 modules/fail2ban/cmd/fail2ban-tools/main.go create mode 100644 modules/fail2ban/go.mod create mode 100644 modules/fail2ban/go.sum delete mode 100644 modules/fail2ban/package.json delete mode 100644 modules/fail2ban/test/client.test.ts delete mode 100644 modules/fail2ban/tools/index.ts delete mode 100644 modules/fail2ban/tsconfig.json diff --git a/modules/fail2ban/client.ts b/modules/fail2ban/client.ts deleted file mode 100644 index e87fe90..0000000 --- a/modules/fail2ban/client.ts +++ /dev/null @@ -1,236 +0,0 @@ -// 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 loading this bundle 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. - -import { execFile } from "node:child_process"; -import { accessSync, constants } from "node:fs"; -import { isIP } from "node:net"; -import { delimiter, join } from "node:path"; -import { promisify } from "node:util"; - -const execFileP = promisify(execFile); - -/** A command runner, so the verbs can be tested without a daemon. */ -export type Runner = (cmd: string, args: string[]) => Promise; - -/** 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. */ -export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] { - if (uid === 0) return [cmd, args]; - return ["sudo", ["-n", cmd, ...args]]; -} - -/** Whether a tool is on this machine: an executable of that name on the path, or where the - * system keeps its administration. */ -export function installed(tool: string, path: string = process.env.PATH ?? ""): boolean { - const dirs = [...path.split(delimiter), "/usr/sbin", "/sbin", "/usr/bin"].filter((d) => d !== ""); - return dirs.some((dir) => { - try { - accessSync(join(dir, tool), constants.X_OK); - return true; - } catch { - return false; - } - }); -} - -export const execRunner: Runner = async (cmd, args) => { - if (!installed(cmd)) throw new Error(`${cmd} is not installed on this machine`); - const [program, argv] = escalated(cmd, args); - try { - const { stdout } = await execFileP(program, argv, { maxBuffer: 16 * 1024 * 1024 }); - return stdout; - } catch (err) { - const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string }; - const said = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim(); - // 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 (e.code === "ENOENT") throw new Error(`${cmd} needs root, and sudo is not installed here for the runtime's account to escalate with`); - if (/^sudo:/m.test(said)) throw new Error(`${cmd} needs root and the runtime's account may not run it without a prompt: ${said}`); - } - if (/Failed to access socket path|Is fail2ban running|Permission denied to socket/i.test(said)) { - throw new Error("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"). - const lines = said.split("\n").map((l) => l.trim()).filter(Boolean); - throw new Error(lines.length ? lines[lines.length - 1] : (e.message ?? `${cmd} failed`)); - } -}; - -/** One jail as the daemon reports it. */ -export interface JailStatus { - jail: string; - /** What the jail is reading: files or journal matches, as fail2ban names them. */ - watching: string[]; - /** Addresses with failures counted against them right now, and all failures since the jail started. */ - failing: { now: number; total: number }; - /** Addresses held right now, and all bans since the jail started. */ - banned: { now: number; total: number; addresses: string[] }; -} - -/** One ban as the daemon holds it. */ -export interface Ban { - ip: string; - jail: string; - /** When the ban was placed, in the machine's local time as fail2ban prints it. */ - since: string; - /** When the ban ends; "never" for a permanent ban. */ - until: string; -} - -export interface JailSettings { - jail: string; - bantime: string; - findtime: string; - maxretry: number; - ignoreip: string[]; - actions: string[]; - /** The log files the jail reads, when it reads files. */ - logpath: string[]; - /** The journal match the jail reads, when it reads the journal. */ - journalmatch: string; -} - -export class Fail2banClient { - private readonly run: Runner; - - constructor(run: Runner = execRunner) { - this.run = run; - } - - /** The daemon as this machine has it, through its own client. */ - static onThisMachine(): Fail2banClient { - return new Fail2banClient(); - } - - private client(...args: string[]): Promise { - return this.run("fail2ban-client", args); - } - - /** The jails the daemon runs, by name. */ - async jails(): Promise { - const out = await this.client("status"); - const m = out.match(/Jail list:\s*(.*)/); - if (!m) return []; - return m[1].split(",").map((j) => j.trim()).filter(Boolean); - } - - /** Every jail with what it watches and holds, or one jail's detail. */ - async status(jail?: string): Promise<{ jails: JailStatus[] }> { - const names = jail ? [jail] : await this.jails(); - const jails: JailStatus[] = []; - for (const name of names) { - jails.push(parseJailStatus(name, await this.client("status", name))); - } - return { jails }; - } - - /** Every address banned now, with the jail holding it and when the ban ends. */ - async banned(jail?: string): Promise<{ banned: Ban[] }> { - const names = jail ? [jail] : await this.jails(); - const banned: Ban[] = []; - for (const name of names) { - banned.push(...parseBans(name, await this.client("get", name, "banip", "--with-time"))); - } - banned.sort((a, b) => a.until.localeCompare(b.until) || a.ip.localeCompare(b.ip)); - return { banned }; - } - - /** Ban one address in one jail now. The daemon's own answer is how many addresses it added. */ - async ban(ip: string, jail: string): Promise<{ banned: Ban | null; added: number }> { - address(ip); - name(jail); - const out = await this.client("set", jail, "banip", ip); - const added = Number.parseInt(out.trim(), 10) || 0; - const held = (await this.banned(jail)).banned.find((b) => b.ip === ip) ?? null; - return { banned: held, added }; - } - - /** Let one address go, from one jail or from every jail. The daemon's answer is how many it released. */ - async unban(ip: string, jail?: string): Promise<{ released: number; ip: string; jail: string | "every jail" }> { - address(ip); - let out: string; - if (jail) { - name(jail); - out = await this.client("set", jail, "unbanip", ip); - } else { - out = await this.client("unban", ip); - } - return { released: Number.parseInt(out.trim(), 10) || 0, ip, jail: jail ?? "every jail" }; - } - - /** One jail's effective settings — the module's own tool, beside the seat's verbs. */ - async settings(jail: string): Promise { - name(jail); - const get = (key: string) => this.client("get", jail, key); - const [bantime, findtime, maxretry, ignoreip, actions, logpath, journalmatch] = await Promise.all([ - get("bantime"), get("findtime"), get("maxretry"), get("ignoreip"), get("actions"), get("logpath"), - get("journalmatch"), - ]); - return { - jail, - bantime: bantime.trim(), - findtime: findtime.trim(), - maxretry: Number.parseInt(maxretry.trim(), 10), - ignoreip: listed(ignoreip), - actions: actions.split("\n").slice(1).map((l) => l.trim()).filter(Boolean), - logpath: /No file is currently monitored/.test(logpath) ? [] : listed(logpath), - journalmatch: journalmatch.split("\n").slice(1).map((l) => l.trim()).filter(Boolean).join(" "), - }; - } -} - -/** fail2ban's tree listings: lines like "|- 127.0.0.0/8" and "`- ::1", after a heading. */ -function listed(out: string): string[] { - return out - .split("\n") - .map((l) => l.replace(/^[\s|`-]+/, "").trim()) - .filter((l, i) => i > 0 && l.length > 0); -} - -export function parseJailStatus(jail: string, out: string): JailStatus { - const field = (label: string) => { - const m = out.match(new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ":\\t?\\s*(.*)")); - return m ? m[1].trim() : ""; - }; - const num = (label: string) => Number.parseInt(field(label), 10) || 0; - const watching = [field("File list"), field("Journal matches")].filter(Boolean); - return { - jail, - watching, - failing: { now: num("Currently failed"), total: num("Total failed") }, - banned: { - now: num("Currently banned"), - total: num("Total banned"), - addresses: field("Banned IP list").split(/\s+/).filter(Boolean), - }, - }; -} - -/** `get banip --with-time` prints one ban per line: "IP \tsince + seconds = until". */ -export function parseBans(jail: string, out: string): Ban[] { - const bans: Ban[] = []; - for (const line of out.split("\n")) { - const m = line.match(/^(\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+)/); - if (!m) continue; - bans.push({ ip: m[1], jail, since: m[2], until: Number(m[3]) < 0 ? "never" : m[4] }); - } - return bans; -} - -function address(ip: string): void { - if (!isIP(ip)) throw new Error(`${JSON.stringify(ip)} is not an address`); -} - -function name(jail: string): void { - if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(jail)) throw new Error(`${JSON.stringify(jail)} is not a jail's name`); -} diff --git a/modules/fail2ban/cmd/fail2ban-tools/client.go b/modules/fail2ban/cmd/fail2ban-tools/client.go new file mode 100644 index 0000000..70f3eee --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/client.go @@ -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 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 +} diff --git a/modules/fail2ban/cmd/fail2ban-tools/client_test.go b/modules/fail2ban/cmd/fail2ban-tools/client_test.go new file mode 100644 index 0000000..e3cc752 --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/client_test.go @@ -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) +} diff --git a/modules/fail2ban/cmd/fail2ban-tools/main.go b/modules/fail2ban/cmd/fail2ban-tools/main.go new file mode 100644 index 0000000..ab7c59e --- /dev/null +++ b/modules/fail2ban/cmd/fail2ban-tools/main.go @@ -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 `.`, 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")) }}, + } +} diff --git a/modules/fail2ban/go.mod b/modules/fail2ban/go.mod new file mode 100644 index 0000000..a9238f5 --- /dev/null +++ b/modules/fail2ban/go.mod @@ -0,0 +1,5 @@ +module fail2ban + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/fail2ban/go.sum b/modules/fail2ban/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/fail2ban/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/fail2ban/module.json b/modules/fail2ban/module.json index 36e7dd5..8e6ffb9 100644 --- a/modules/fail2ban/module.json +++ b/modules/fail2ban/module.json @@ -116,9 +116,12 @@ { "name": "tools", "kind": "bundle", - "language": "typescript", - "entrypoints": [ - "tools/index.js" + "language": "go", + "system": "arch", + "from": "cmd/fail2ban-tools", + "binary": "fail2ban-tools", + "loads": [ + "fail2ban-tools" ] } ] diff --git a/modules/fail2ban/package.json b/modules/fail2ban/package.json deleted file mode 100644 index bd2e248..0000000 --- a/modules/fail2ban/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@novox/module-fail2ban", - "version": "0.1.0", - "description": "fail2ban \u2014 intrusion prevention: the mesh composes the jails and keeps the daemon running; this module holds the node-intrusion-prevention seat and serves its verbs status, banned, ban and unban (novox/hq to-be 31, ADR 0179).", - "type": "module", - "private": true, - "dependencies": { - "@novox/mesh-sdk": "^0.1.1" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - }, - "scripts": { - "build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist", - "test": "node --test --experimental-strip-types 'test/*.test.ts'" - } -} diff --git a/modules/fail2ban/test/client.test.ts b/modules/fail2ban/test/client.test.ts deleted file mode 100644 index afb37fb..0000000 --- a/modules/fail2ban/test/client.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// 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 { test } from "node:test"; -import assert from "node:assert/strict"; -import { Fail2banClient, escalated, installed, parseBans, parseJailStatus, type Runner } from "../client.ts"; - -const STATUS = "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 WITH_TIME = - "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"; - -function fake(answers: Record, calls: string[][] = []): Runner { - return async (cmd, args) => { - calls.push([cmd, ...args]); - const key = args.join(" "); - if (key in answers) return answers[key]; - throw new Error(`unexpected ${cmd} ${key}`); - }; -} - -test("a jail's status is read into numbers, what it watches and who it holds", () => { - const s = parseJailStatus("recidive", RECIDIVE); - assert.deepEqual(s, { - jail: "recidive", - watching: ["/var/log/fail2ban.log"], - failing: { now: 36, total: 149 }, - banned: { now: 9, total: 13, addresses: ["195.178.110.30", "45.148.10.240", "92.118.39.71"] }, - }); - const j = parseJailStatus("sshd", SSHD); - assert.deepEqual(j.watching, ["_SYSTEMD_UNIT=sshd.service + _COMM=sshd"]); - assert.deepEqual(j.banned, { now: 0, total: 150, addresses: [] }); -}); - -test("status covers every jail the daemon lists, or the one named", async () => { - const calls: string[][] = []; - const f = new Fail2banClient(fake({ status: STATUS, "status recidive": RECIDIVE, "status sshd": SSHD }, calls)); - const all = await f.status(); - assert.deepEqual(all.jails.map((j) => j.jail), ["recidive", "sshd"]); - const one = await f.status("sshd"); - assert.equal(one.jails.length, 1); - assert.deepEqual(calls[calls.length - 1], ["fail2ban-client", "status", "sshd"]); -}); - -test("bans are read with when they were placed and when they end, a permanent one as never", () => { - const bans = parseBans("recidive", WITH_TIME + "203.0.113.9 \t2026-10-01 00:00:00 + -1 = never\n"); - assert.equal(bans.length, 3); - assert.deepEqual(bans[0], { ip: "195.178.110.30", jail: "recidive", since: "2026-09-26 23:18:47", until: "2026-10-03 23:18:47" }); - assert.equal(bans[2].until, "never"); - assert.deepEqual(parseBans("sshd", "\n"), []); -}); - -test("banned gathers every jail's bans, soonest to end first", async () => { - const f = new Fail2banClient(fake({ - status: STATUS, - "get recidive banip --with-time": WITH_TIME, - "get sshd banip --with-time": "198.51.100.7 \t2026-10-02 15:06:58 + 600 = 2026-10-02 15:16:58\n", - })); - const { banned } = await f.banned(); - assert.deepEqual(banned.map((b) => `${b.ip}@${b.jail}`), ["198.51.100.7@sshd", "195.178.110.30@recidive", "92.118.39.71@recidive"]); -}); - -test("ban asks the daemon by jail and answers with the ban as held; a non-address is refused before anything runs", async () => { - const calls: string[][] = []; - const f = new Fail2banClient(fake({ - "set recidive banip 198.51.100.7": "1\n", - "get recidive banip --with-time": WITH_TIME + "198.51.100.7 \t2026-10-02 17:00:00 + 604800 = 2026-10-09 17:00:00\n", - }, calls)); - const r = await f.ban("198.51.100.7", "recidive"); - assert.equal(r.added, 1); - assert.equal(r.banned?.until, "2026-10-09 17:00:00"); - assert.deepEqual(calls[0], ["fail2ban-client", "set", "recidive", "banip", "198.51.100.7"]); - await assert.rejects(() => f.ban("not-an-ip", "recidive"), /is not an address/); - await assert.rejects(() => f.ban("198.51.100.7", "a jail; rm"), /is not a jail's name/); - assert.equal(calls.length, 2); -}); - -test("unban releases from one jail or from every jail", async () => { - const calls: string[][] = []; - const f = new Fail2banClient(fake({ "set sshd unbanip 198.51.100.7": "1\n", "unban 198.51.100.7": "2\n" }, calls)); - assert.deepEqual(await f.unban("198.51.100.7", "sshd"), { released: 1, ip: "198.51.100.7", jail: "sshd" }); - assert.deepEqual(await f.unban("198.51.100.7"), { released: 2, ip: "198.51.100.7", jail: "every jail" }); - assert.deepEqual(calls[1], ["fail2ban-client", "unban", "198.51.100.7"]); -}); - -test("a jail's settings are read from the daemon's listings", async () => { - const f = new Fail2banClient(fake({ - "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", - })); - assert.deepEqual(await f.settings("sshd"), { - jail: "sshd", bantime: "86400", findtime: "86400", maxretry: 3, - ignoreip: ["127.0.0.0/8", "10.10.0.0/24", "::1"], actions: ["iptables-allports-dualchain"], - logpath: [], journalmatch: "_SYSTEMD_UNIT=sshd.service + _COMM=sshd", - }); -}); - -test("the client runs as given by root and through sudo without a prompt by anyone else", () => { - assert.deepEqual(escalated("fail2ban-client", ["status"], 0), ["fail2ban-client", ["status"]]); - assert.deepEqual(escalated("fail2ban-client", ["set", "sshd", "banip", "198.51.100.7"], 1000), - ["sudo", ["-n", "fail2ban-client", "set", "sshd", "banip", "198.51.100.7"]]); - assert.equal(installed("sh"), true); - assert.equal(installed("no-such-client-of-the-mesh"), false); -}); diff --git a/modules/fail2ban/tools/index.ts b/modules/fail2ban/tools/index.ts deleted file mode 100644 index 536101a..0000000 --- a/modules/fail2ban/tools/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -// The intrusion prevention's tools: 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 -// (novox/hq to-be 31, ADR 0179). 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. - -import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; -import { Fail2banClient } from "../client.js"; - -export function getSeatVerbs(fail2ban: Fail2banClient): ToolDefinition[] { - return [ - { - name: "status", - description: - "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.", - input: { jail: { type: "string", description: "one jail (optional)" } }, - run: async (args) => fail2ban.status(args.jail ? String(args.jail) : undefined), - }, - { - name: "banned", - description: "Every address banned on this machine right now, with the jail that holds it, when it was banned and when the ban ends.", - input: { jail: { type: "string", description: "one jail (optional)" } }, - run: async (args) => fail2ban.banned(args.jail ? String(args.jail) : undefined), - }, - { - name: "ban", - description: - "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.", - input: { - ip: { type: "string", description: "the address" }, - jail: { type: "string", description: "the jail to hold it (recidive for the long ban)" }, - }, - run: async (args) => fail2ban.ban(String(args.ip ?? ""), String(args.jail ?? "")), - }, - { - name: "unban", - description: "Let one address go, from one jail or from every jail when none is named.", - input: { - ip: { type: "string", description: "the address" }, - jail: { type: "string", description: "one jail (optional)" }, - }, - run: async (args) => fail2ban.unban(String(args.ip ?? ""), args.jail ? String(args.jail) : undefined), - }, - ]; -} - -export function getFail2banTools(fail2ban: Fail2banClient): ToolDefinition[] { - return [ - { - 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: { jail: { type: "string", description: "the jail" } }, - run: async (args) => fail2ban.settings(String(args.jail ?? "")), - }, - ]; -} - -const fail2ban = Fail2banClient.onThisMachine(); -// The seat's verbs under the seat's name: the runtime serves them on the seat's subjects where this -// module holds it (ADR 0159, 0160). The module's own under its own. -registerModuleTools("node-intrusion-prevention", () => getSeatVerbs(fail2ban)); -registerModuleTools("fail2ban", () => getFail2banTools(fail2ban)); diff --git a/modules/fail2ban/tsconfig.json b/modules/fail2ban/tsconfig.json deleted file mode 100644 index 1f1b70a..0000000 --- a/modules/fail2ban/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "client.ts", - "tools/index.ts" - ] -}