Files
housecarl/internal/store/store.go
T
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

331 lines
8.6 KiB
Go

// Package store keeps Housecarl's data in SQLite: the configuration, the
// history of changes, users, tokens, sessions and the audit log.
//
// The configuration is loaded into a model.State as a whole and saved as
// changes (model.Change), one JSON row per object.
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
// ErrNotFound is returned when a row does not exist.
var ErrNotFound = errors.New("not found")
// ErrConflict is returned for unique constraint violations.
var ErrConflict = errors.New("already exists")
// Store is a Housecarl database.
type Store struct {
db *sql.DB
}
// DB exposes the database (backups, tests).
func (s *Store) DB() *sql.DB { return s.db }
// Path is the database file ("" in memory).
func (s *Store) Path() string {
var file string
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
return ""
}
return file
}
// Open opens (creating if needed) the database at path and applies
// migrations. ":memory:" makes a throwaway database.
func Open(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate&_pragma=journal_mode(WAL)"
if path == ":memory:" {
dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
if path == ":memory:" {
db.SetMaxOpenConns(1)
}
s := &Store{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("migrating database: %w", err)
}
return s, nil
}
// OpenExisting opens a database without creating or migrating it, for
// commands that run next to a running housecarld ('housecarld backup').
func OpenExisting(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?mode=rw&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_txlock=immediate")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// SchemaVersion is the schema version this Housecarl uses.
func SchemaVersion() int { return len(migrations) }
// Append new migrations; never edit one that has shipped.
var migrations = []string{
// 1: initial schema
`
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE seq (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
INSERT INTO seq (name, value) VALUES ('object', 0);
-- The configuration: one row per object (zone, set, service, rule,
-- forward, snat, redirect), as JSON. The settings are in meta.
CREATE TABLE objects (
kind TEXT NOT NULL,
id INTEGER NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (kind, id)
);
CREATE TABLE history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
user_id INTEGER,
username TEXT NOT NULL,
via TEXT NOT NULL,
description TEXT NOT NULL,
changes TEXT NOT NULL,
revert_of INTEGER,
reverted_by INTEGER
);
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
admin INTEGER NOT NULL DEFAULT 0,
grants TEXT NOT NULL DEFAULT '[]',
disabled INTEGER NOT NULL DEFAULT 0,
comment TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
public_id TEXT NOT NULL UNIQUE,
secret_hash TEXT NOT NULL,
grants TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
last_used_at TEXT,
last_used_ip TEXT NOT NULL DEFAULT '',
revoked_at TEXT,
rotated_at TEXT,
prev_secret_hash TEXT NOT NULL DEFAULT '',
prev_expires_at TEXT
);
CREATE TABLE sessions (
id_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT ''
);
CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
user_id INTEGER,
username TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
token_id INTEGER,
ip TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
object TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX audit_time ON audit (time);
`,
// 2: changesets, staged changes to review and apply together
`
CREATE TABLE changesets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT NOT NULL,
via TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
ops TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
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);
`,
}
func (s *Store) migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
return err
}
var v int
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
if _, err := s.db.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (0)`); err != nil {
return err
}
} else if err != nil {
return err
}
if v > len(migrations) {
return fmt.Errorf("database schema version %d is newer than this Housecarl (%d); upgrade Housecarl", v, len(migrations))
}
for i := v; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, `UPDATE schema_version SET version = ?`, i+1); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// Version returns the database's schema version.
func (s *Store) Version(ctx context.Context) (int, error) {
var v int
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
return v, err
}
// AllocateIDs reserves n object IDs (all kinds share one sequence, so an
// ID names one object).
func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) {
if n <= 0 {
return nil, nil
}
var last int64
if err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last); err != nil {
return nil, err
}
ids := make([]int64, n)
for i := range ids {
ids[i] = last - int64(n) + 1 + int64(i)
}
return ids, nil
}
// GetMeta reads a meta value ("" when unset).
func (s *Store) GetMeta(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// SetMeta writes a meta value.
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
// Backup writes a consistent copy of the database to path.
func (s *Store) Backup(ctx context.Context, path string) error {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path)
return err
}
// ---- helpers
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
func ts(t time.Time) string { return t.UTC().Format(tsLayout) }
func tsPtr(t *time.Time) any {
if t == nil {
return nil
}
return ts(*t)
}
func parseTS(s string) time.Time {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return time.Time{}
}
return t
}
func parseTSPtr(s sql.NullString) *time.Time {
if !s.Valid || s.String == "" {
return nil
}
t := parseTS(s.String)
return &t
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func jsonText(v any) string {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(b)
}
func isUnique(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}