mesh/merge-gate head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head 93d58f3740a0 merged into trunk 5cd608ba8898 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
An agent polling the forge from the operator's home got that home banned for a day and then four weeks, and nothing in the house reached the control-node. Every jail spared only private ranges. The mesh-wide setting own-addresses reaches the machine as a mergeable file whose own content is the empty list, so the module composes while it is unset. A new watch process (root) writes its entries into a list every jail's base ignoreip names as file:, which fail2ban reloads without a restart; it checks every jail names that list and keeps each failure a jail ignored because of it for a day. The new fail2ban_health tool judges the watch, so the controller raises a warning naming node, jail, address and count. The ban verb refuses an address on the setting or on the jail's ignore list before the daemon is asked.
228 lines
9.0 KiB
Go
228 lines
9.0 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{Root: t.TempDir(), Run: fake(map[string]string{
|
|
"get recidive ignoreip": ignoring,
|
|
"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[1], []string{"fail2ban-client", "set", "recidive", "banip", "198.51.100.7"}) {
|
|
t.Errorf("the ban %v", calls)
|
|
}
|
|
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) != 3 {
|
|
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", "fail2ban_health"}
|
|
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)
|
|
}
|