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>
163 lines
5.5 KiB
Go
163 lines
5.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"housecarl/internal/model"
|
|
)
|
|
|
|
func raw(v any) json.RawMessage {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
func TestStateAndHistory(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
st, err := s.LoadState(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Settings.InputPolicy != model.PolicyDrop || len(st.Zones) != 0 || !st.Settings.Builtins.Established {
|
|
t.Fatalf("new database state: %+v", st)
|
|
}
|
|
ids, _ := s.AllocateIDs(ctx, 2)
|
|
z := &model.Zone{ID: ids[0], Name: "wan", Interfaces: []string{"wan"}, External: true}
|
|
r := &model.Rule{ID: ids[1], Chain: model.ChainInput, Position: 1, Action: model.ActionAccept,
|
|
Match: model.Match{Services: []string{"ssh"}, Sources: []string{"10.0.10.0/24"}}}
|
|
set := st.Settings
|
|
set.ForwardPolicy = model.PolicyAccept
|
|
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add wan",
|
|
Changes: []model.Change{
|
|
{Kind: model.KindZone, ID: z.ID, After: raw(z)},
|
|
{Kind: model.KindRule, ID: r.ID, After: raw(r)},
|
|
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
|
|
}}
|
|
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, _ = s.LoadState(ctx)
|
|
if len(st.Zones) != 1 || !st.Zones[0].External || st.Zones[0].Interfaces[0] != "wan" {
|
|
t.Errorf("zones %+v", st.Zones)
|
|
}
|
|
if len(st.Rules) != 1 || st.Rules[0].Services[0] != "ssh" || st.Settings.ForwardPolicy != model.PolicyAccept {
|
|
t.Errorf("rules %+v settings %+v", st.Rules, st.Settings)
|
|
}
|
|
// Undo: the inverse changes, marked as a revert.
|
|
var inv []model.Change
|
|
for _, c := range e.Changes {
|
|
inv = append(inv, c.Inverse())
|
|
}
|
|
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
|
|
if err := s.SaveChanges(ctx, inv, u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, _ = s.LoadState(ctx)
|
|
if len(st.Zones) != 0 || len(st.Rules) != 0 || st.Settings.ForwardPolicy != model.PolicyDrop {
|
|
t.Errorf("after undo: %+v", st)
|
|
}
|
|
h, err := s.GetHistory(ctx, e.ID)
|
|
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
|
|
t.Errorf("history %+v %v", h, err)
|
|
}
|
|
list, _ := s.ListHistory(ctx, 10, 0)
|
|
if len(list) != 2 || list[0].ID != u.ID {
|
|
t.Errorf("history list %+v", list)
|
|
}
|
|
}
|
|
|
|
func TestUsersAndAudit(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
|
|
if err := s.CreateUser(ctx, u); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("duplicate user: %v", err)
|
|
}
|
|
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
|
|
if err := s.CreateToken(ctx, tok); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := s.GetTokenByPublicID(ctx, "p1")
|
|
if err != nil || got.Username != "admin" || got.Grants != nil {
|
|
t.Errorf("token %+v %v", got, err)
|
|
}
|
|
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
|
|
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "set.add", Object: "guest 10.0.30.5", Result: "success"})
|
|
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "set."})
|
|
if len(evs) != 1 || evs[0].Object != "guest 10.0.30.5" {
|
|
t.Errorf("audit %+v", evs)
|
|
}
|
|
}
|
|
|
|
func TestBans(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer s.Close()
|
|
in := func(d time.Duration) *time.Time { t := time.Now().Add(d); return &t }
|
|
b := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(time.Hour), Who: "guard", Source: "guard/ssh"}
|
|
if existing, err := s.PutBan(ctx, b); err != nil || existing {
|
|
t.Fatalf("new ban: %v %v", existing, err)
|
|
}
|
|
id := b.ID
|
|
again := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(2 * time.Hour), Who: "guard", Reason: "again"}
|
|
if existing, err := s.PutBan(ctx, again); err != nil || !existing || again.ID != id {
|
|
t.Fatalf("renewed ban: %v %v id %d, want %d", existing, err, again.ID, id)
|
|
}
|
|
port := &model.Ban{ListID: 7, Address: "2001:db8:1:2::/64", Protocol: "tcp", Port: 22}
|
|
if _, err := s.PutBan(ctx, port); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gone := &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(-time.Second)}
|
|
if _, err := s.PutBan(ctx, gone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := &model.Ban{ListID: 8, Address: "198.51.100.2"}
|
|
if _, err := s.PutBan(ctx, other); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list, err := s.ListBans(ctx, 7)
|
|
if err != nil || len(list) != 2 {
|
|
t.Fatalf("list 7: %v %+v", err, list)
|
|
}
|
|
if list[0].Reason != "again" || list[0].Timeout < 7100 || list[0].Timeout > 7200 || list[1].Expires != nil || list[1].Timeout != 0 {
|
|
t.Errorf("bans: %+v %+v", list[0], list[1])
|
|
}
|
|
if n, _ := s.CountBans(ctx, 7); n != 2 {
|
|
t.Errorf("count %d", n)
|
|
}
|
|
// An ended ban is renewed as new.
|
|
if existing, _ := s.PutBan(ctx, &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(time.Minute)}); existing {
|
|
t.Error("an ended ban counted as existing")
|
|
}
|
|
if n, err := s.PruneBans(ctx, []int64{7}); err != nil || n != 1 {
|
|
t.Errorf("prune: %d %v (list 8's ban)", n, err)
|
|
}
|
|
if all, _ := s.ListBans(ctx, 0); len(all) != 3 {
|
|
t.Errorf("after pruning: %+v", all)
|
|
}
|
|
if err := s.DeleteBan(ctx, id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.GetBan(ctx, id); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("deleted ban: %v", err)
|
|
}
|
|
}
|