Merge pull request 'fail2ban: its tools in Go' (#53) from feat/fail2ban-in-go into main
This commit was merged in pull request #53.
This commit is contained in:
@@ -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<string>;
|
||||
|
||||
/** 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<string> {
|
||||
return this.run("fail2ban-client", args);
|
||||
}
|
||||
|
||||
/** The jails the daemon runs, by name. */
|
||||
async jails(): Promise<string[]> {
|
||||
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<JailSettings> {
|
||||
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 <jail> 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`);
|
||||
}
|
||||
@@ -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")) }},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module fail2ban
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>, 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);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user