Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
8.2 KiB
Go
314 lines
8.2 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);
|
|
`,
|
|
}
|
|
|
|
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")
|
|
}
|