Files
mesh-catalog/modules/fail2ban/cmd/fail2ban-tools/client_test.go
T
jschoubben d43de93e49 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.
2026-10-05 12:01:25 +02:00

227 lines
8.9 KiB
Go

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)
}