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>
331 lines
8.6 KiB
Go
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")
|
|
}
|