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.
227 lines
8.9 KiB
Go
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)
|
|
}
|