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>
136 lines
4.4 KiB
Go
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
|
|
}
|