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:
1 parent
6f56eefae2
commit
500bfc7bdd
44 files changed
+2541
-39
No files matched your search
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const banCols = `id, list_id, address, protocol, port, expires_at, created_at, who, source, reason`
|
||||
|
||||
func scanBan(sc interface{ Scan(...any) error }, now time.Time) (*model.Ban, error) {
|
||||
b := &model.Ban{}
|
||||
var expires sql.NullString
|
||||
var created string
|
||||
if err := sc.Scan(&b.ID, &b.ListID, &b.Address, &b.Protocol, &b.Port, &expires, &created, &b.Who, &b.Source, &b.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Created = parseTS(created)
|
||||
b.Expires = parseTSPtr(expires)
|
||||
if b.Expires != nil {
|
||||
b.Timeout = max(int(b.Expires.Sub(now).Round(time.Second)/time.Second), 1)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// PutBan saves a ban, or renews the same ban (list, address, protocol
|
||||
// and port) with its new end, who and why (an empty source or reason
|
||||
// keeps the old). existing tells which; b gets
|
||||
// its ID either way.
|
||||
func (s *Store) PutBan(ctx context.Context, b *model.Ban) (existing bool, err error) {
|
||||
now := time.Now()
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id FROM bans WHERE list_id = ? AND address = ? AND protocol = ? AND port = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`, b.ListID, b.Address, b.Protocol, b.Port, ts(now)).Scan(&b.ID)
|
||||
existing = err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
b.Created = now
|
||||
err = s.db.QueryRowContext(ctx, `INSERT INTO bans (list_id, address, protocol, port, expires_at, created_at, who, source, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (list_id, address, protocol, port) DO UPDATE SET expires_at = excluded.expires_at,
|
||||
who = excluded.who, source = COALESCE(NULLIF(excluded.source, ''), bans.source),
|
||||
reason = COALESCE(NULLIF(excluded.reason, ''), bans.reason),
|
||||
created_at = CASE WHEN bans.expires_at IS NULL OR bans.expires_at > excluded.created_at THEN bans.created_at ELSE excluded.created_at END
|
||||
RETURNING id, created_at`,
|
||||
b.ListID, b.Address, b.Protocol, b.Port, tsPtr(b.Expires), ts(now), b.Who, b.Source, b.Reason).Scan(&b.ID, new(string))
|
||||
return existing, err
|
||||
}
|
||||
|
||||
// ListBans lists the active bans of a list (0: of all lists), oldest
|
||||
// first.
|
||||
func (s *Store) ListBans(ctx context.Context, listID int64) ([]*model.Ban, error) {
|
||||
now := time.Now()
|
||||
q := `SELECT ` + banCols + ` FROM bans WHERE (expires_at IS NULL OR expires_at > ?)`
|
||||
args := []any{ts(now)}
|
||||
if listID != 0 {
|
||||
q += ` AND list_id = ?`
|
||||
args = append(args, listID)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q+` ORDER BY id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.Ban{}
|
||||
for rows.Next() {
|
||||
b, err := scanBan(rows, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBan loads an active ban.
|
||||
func (s *Store) GetBan(ctx context.Context, id int64) (*model.Ban, error) {
|
||||
now := time.Now()
|
||||
b, err := scanBan(s.db.QueryRowContext(ctx, `SELECT `+banCols+` FROM bans WHERE id = ? AND (expires_at IS NULL OR expires_at > ?)`, id, ts(now)), now)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
// DeleteBan removes a ban.
|
||||
func (s *Store) DeleteBan(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountBans counts a list's active bans.
|
||||
func (s *Store) CountBans(ctx context.Context, listID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM bans WHERE list_id = ? AND (expires_at IS NULL OR expires_at > ?)`, listID, ts(time.Now())).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// PruneBans deletes the bans that ended, and those of lists that no
|
||||
// longer exist (lists names the ones that do).
|
||||
func (s *Store) PruneBans(ctx context.Context, lists []int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE expires_at <= ?`, ts(time.Now()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT list_id FROM bans`)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
var gone []int64
|
||||
keep := map[int64]bool{}
|
||||
for _, id := range lists {
|
||||
keep[id] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if rows.Scan(&id) == nil && !keep[id] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range gone {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE list_id = ?`, id)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
m, _ := res.RowsAffected()
|
||||
n += m
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -179,6 +179,23 @@ CREATE TABLE changesets (
|
||||
history_id INTEGER
|
||||
);
|
||||
CREATE INDEX changesets_status ON changesets (status);
|
||||
`,
|
||||
// 3: ban lists' bans (the lists are objects)
|
||||
`
|
||||
CREATE TABLE bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
who TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (list_id, address, protocol, port)
|
||||
);
|
||||
CREATE INDEX bans_expires ON bans (expires_at);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -103,3 +103,60 @@ func TestUsersAndAudit(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user