Ban lists: where Guard and fail2ban ban addresses

A program bans an address (every port, or a protocol's ports) for a
time; the ban list says what it is kept from: the firewall, some hosts
behind it, or everything. Bans drop ahead of every rule, close the
address's connections, group IPv6 by /64, are kept in the database
across restarts and audited. Never banned: the inside, the firewall's
own addresses, the caller, the list's never. bans grant (bans:write:LIST),
hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/,
and failed logins and refused tokens logged for Guard and fail2ban.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 16:29:37 +01:00
1 parent 6f56eefae2
commit 500bfc7bdd
44 files changed
+2541 -39

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
package server
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"housecarl/internal/api"
"housecarl/internal/model"
"housecarl/internal/store"
)
// TestBans: a program with a token for one ban list bans addresses: of
// a service's ports, every port, or numbers; renewed when banned again;
// IPv6 by /64; never the inside, the firewall's own, or the list's
// never; times cut to the list's longest; the address's connections the
// ban drops are closed; and lifted by ID or address.
func TestBans(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
for _, b := range []model.BanList{
{Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"198.51.100.0/24"}, MaxTime: 3600},
{Name: "router", Scope: model.BanScopeFirewall},
} {
if code := e.call(e.token, "POST", "/banlists", b, nil); code != 201 {
t.Fatalf("ban list %s: %d", b.Name, code)
}
}
fc := &fakeConns{list: []api.Connection{
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40000, Destination: "217.155.8.194", DestinationPort: 2222, DNATTo: "10.0.10.57", DNATPort: 22},
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40001, Destination: "10.0.10.58", DestinationPort: 22},
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40002, Destination: "217.155.8.194", DestinationPort: 22},
}}
e.srv.SetConntrack(fc)
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "guard", Grants: []string{"bans:write:web"}}, &tok); code != 201 {
t.Fatalf("token: %d", code)
}
ban := func(in api.BanInput, want int) api.BanResult {
t.Helper()
var out api.BanResult
var eb api.ErrorBody
code := e.call(tok.Secret, "POST", "/banlists/web/bans", in, &out)
if code != want {
e.call(tok.Secret, "POST", "/banlists/web/bans", in, &eb)
t.Errorf("ban %+v: %d, want %d (%+v)", in, code, want, eb)
}
return out
}
out := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`"ssh"`), Time: 600, Source: "guard/ssh", Reason: "Failed password"}, 201)
if len(out.Bans) != 1 || out.Bans[0].Protocol != "tcp" || out.Bans[0].Port != 22 || out.Bans[0].Existing || out.Bans[0].Timeout != 600 {
t.Errorf("ssh ban: %+v", out)
}
if out.Closed != 1 || len(fc.closed) != 1 || fc.closed[0].DNATTo != "10.0.10.57" {
t.Errorf("closed %d: %+v (only the one to the list's host)", out.Closed, fc.closed)
}
if _, ok := e.fw.bans[`ban_`+itoa(out.Bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; !ok {
t.Errorf("not in force: %+v", e.fw.bans)
}
if again := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`22`), Protocol: "tcp"}, 201); len(again.Bans) != 1 || !again.Bans[0].Existing {
t.Errorf("banned again: %+v", again)
}
if v6 := ban(api.BanInput{Address: "2001:db8:1:2::77", Time: 99999}, 201); len(v6.Bans) != 1 || v6.Bans[0].Address != "2001:db8:1:2::/64" ||
v6.Bans[0].Protocol != "" || v6.Bans[0].Timeout != 3600 {
t.Errorf("IPv6, every port, cut to an hour: %+v", v6)
}
if n := ban(api.BanInput{Address: "::ffff:203.0.113.10", Ports: []byte(`53`)}, 201); len(n.Bans) != 2 || n.Bans[0].Address != "203.0.113.10" {
t.Errorf("a number without a protocol is tcp and udp: %+v", n)
}
if n := ban(api.BanInput{Address: "203.0.113.11", Protocol: "tcp", Ports: []byte(`["http", "https"]`)}, 201); len(n.Bans) != 2 {
t.Errorf("two services: %+v", n)
}
if n := ban(api.BanInput{Address: "203.0.113.12", Protocol: "tcp", Ports: []byte(`"0:65535"`)}, 201); len(n.Bans) != 1 || n.Bans[0].Protocol != "" {
t.Errorf("fail2ban's every port: %+v", n)
}
ban(api.BanInput{Address: "203.0.113.13", Ports: []byte(`"bogus"`)}, 400)
ban(api.BanInput{Address: "203.0.113.13", Protocol: "tcp"}, 400)
ban(api.BanInput{Address: "not-an-address"}, 400)
for _, a := range []string{"198.51.100.4", "10.0.10.99", "217.155.8.194", "127.0.0.1", "fe80::1", "192.168.5.5"} {
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/banlists/web/bans", api.BanInput{Address: a}, &eb); code != 422 || eb.Error.Code != "never_ban" {
t.Errorf("never ban %s: %d %+v", a, code, eb)
}
}
if code := e.call(tok.Secret, "POST", "/banlists/router/bans", api.BanInput{Address: "203.0.113.9"}, nil); code != 403 {
t.Errorf("another list: %d", code)
}
// The program may read its list, and only its list.
var bl model.BanList
if code := e.call(tok.Secret, "GET", "/banlists/web", nil, &bl); code != 200 || bl.V6Group() != 64 {
t.Errorf("reading its list: %d %+v", code, bl)
}
var lists []model.BanList
if e.call(tok.Secret, "GET", "/banlists", nil, &lists); len(lists) != 1 || lists[0].Name != "web" {
t.Errorf("lists: %+v", lists)
}
if code := e.call(tok.Secret, "GET", "/banlists/router", nil, nil); code != 403 {
t.Errorf("reading another list: %d", code)
}
var bans []model.Ban
e.call(tok.Secret, "GET", "/banlists/web/bans", nil, &bans)
if len(bans) != 7 || bans[0].List != "web" || bans[0].Who != "admin" || bans[0].Source != "guard/ssh" {
t.Errorf("bans: %d %+v", len(bans), bans)
}
var removed map[string]int
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=2001:db8:1:2::5", nil, &removed); code != 200 || removed["removed"] != 1 {
t.Errorf("lift by address: %d %+v", code, removed)
}
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans/"+itoa(bans[0].ID), nil, &removed); code != 200 || removed["removed"] != 1 {
t.Errorf("lift by ID: %d %+v", code, removed)
}
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=203.0.113.200", nil, nil); code != 404 {
t.Errorf("lifting what isn't banned: %d", code)
}
if _, ok := e.fw.bans[`ban_`+itoa(bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; ok {
t.Errorf("still in force after lifting")
}
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "ban."})
if len(evs) < 10 || !strings.Contains(evs[len(evs)-1].Detail, "guard/ssh: Failed password") {
t.Errorf("audit: %d %+v", len(evs), evs[len(evs)-1])
}
// The list's name is what programs know it by.
if code := e.call(e.token, "PATCH", "/banlists/web", map[string]any{"name": "web2"}, nil); code != 409 {
t.Errorf("renaming a ban list: %d", code)
}
// Every apply puts the bans back; a deleted list's go.
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 5 {
t.Errorf("active bans: %d", len(got))
}
if code := e.call(e.token, "DELETE", "/banlists/web", nil, nil); code != 200 {
t.Errorf("deleting the list: %d", code)
}
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 0 {
t.Errorf("a deleted list's bans: %d", len(got))
}
}
// TestFailedLoginLogged: a failed login and a refused token are logged
// with the address first, for Guard's and fail2ban's patterns.
func TestFailedLoginLogged(t *testing.T) {
e := newEnv(t)
var buf bytes.Buffer
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
e.call("", "POST", "/auth/login", api.Login{Username: `x ip=8.8.8.8`, Password: "wrong"}, nil)
e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
if !strings.Contains(buf.String(), want) {
t.Errorf("no %q in\n%s", want, buf.String())
}
}
}