Files
abrown38andClaude Opus 5.5 500bfc7bdd 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>
2026-10-22 16:29:37 +01:00

136 lines
4.4 KiB
Go

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
}