Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"housecarl/internal/model"
)
func scanHistory(sc interface{ Scan(...any) error }) (*model.HistoryEntry, error) {
e := &model.HistoryEntry{}
var t, changes string
var uid, revertOf, revertedBy sql.NullInt64
if err := sc.Scan(&e.ID, &t, &uid, &e.Username, &e.Via, &e.Description, &changes, &revertOf, &revertedBy); err != nil {
return nil, err
}
e.Time = parseTS(t)
e.UserID, e.RevertOf, e.RevertedBy = uid.Int64, revertOf.Int64, revertedBy.Int64
if err := json.Unmarshal([]byte(changes), &e.Changes); err != nil {
return nil, err
}
return e, nil
}
const historyCols = `id, time, user_id, username, via, description, changes, revert_of, reverted_by`
// ListHistory lists entries, newest first; before > 0 pages back.
func (s *Store) ListHistory(ctx context.Context, limit int, before int64) ([]*model.HistoryEntry, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
q := `SELECT ` + historyCols + ` FROM history`
var args []any
if before > 0 {
q += ` WHERE id < ?`
args = append(args, before)
}
q += ` ORDER BY id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*model.HistoryEntry{}
for rows.Next() {
e, err := scanHistory(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// GetHistory loads one entry.
func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry, error) {
e, err := scanHistory(s.db.QueryRowContext(ctx, `SELECT `+historyCols+` FROM history WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return e, err
}
+99
View File
@@ -0,0 +1,99 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"housecarl/internal/model"
)
const settingsKey = "settings"
// LoadState reads the whole configuration.
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
st := model.NewState()
if v, err := s.GetMeta(ctx, settingsKey); err != nil {
return nil, err
} else if v != "" {
if err := json.Unmarshal([]byte(v), &st.Settings); err != nil {
return nil, fmt.Errorf("reading settings: %w", err)
}
}
rows, err := s.db.QueryContext(ctx, `SELECT kind, id, data FROM objects ORDER BY kind, id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var kind, data string
var id int64
if err := rows.Scan(&kind, &id, &data); err != nil {
return nil, err
}
if err := st.Put(kind, id, json.RawMessage(data)); err != nil {
return nil, fmt.Errorf("reading %s %d: %w", kind, id, err)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
st.Sort()
return st, nil
}
// entry is marked reverted.
func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, c := range changes {
if err := applyChange(ctx, tx, c); err != nil {
return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err)
}
}
if e != nil {
var uid any
if e.UserID != 0 {
uid = e.UserID
}
var revertOf any
if e.RevertOf != 0 {
revertOf = e.RevertOf
}
res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`,
ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf)
if err != nil {
return err
}
if e.ID, err = res.LastInsertId(); err != nil {
return err
}
if e.RevertOf != 0 {
if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil {
return err
}
}
}
return tx.Commit()
}
func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if c.Kind == model.KindSettings {
if c.After == nil {
return fmt.Errorf("settings can't be deleted")
}
_, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After))
return err
}
if c.After == nil {
_, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE kind = ? AND id = ?`, c.Kind, c.ID)
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO objects (kind, id, data) VALUES (?, ?, ?)
ON CONFLICT(kind, id) DO UPDATE SET data = excluded.data`, c.Kind, c.ID, string(c.After))
return err
}
+297
View File
@@ -0,0 +1,297 @@
// 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);
`,
}
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")
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
"housecarl/internal/model"
)
func raw(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func TestStateAndHistory(t *testing.T) {
ctx := context.Background()
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
st, err := s.LoadState(ctx)
if err != nil {
t.Fatal(err)
}
if st.Settings.InputPolicy != model.PolicyDrop || len(st.Zones) != 0 || !st.Settings.Builtins.Established {
t.Fatalf("new database state: %+v", st)
}
ids, _ := s.AllocateIDs(ctx, 2)
z := &model.Zone{ID: ids[0], Name: "wan", Interfaces: []string{"wan"}, External: true}
r := &model.Rule{ID: ids[1], Chain: model.ChainInput, Position: 1, Action: model.ActionAccept,
Match: model.Match{Services: []string{"ssh"}, Sources: []string{"10.0.10.0/24"}}}
set := st.Settings
set.ForwardPolicy = model.PolicyAccept
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add wan",
Changes: []model.Change{
{Kind: model.KindZone, ID: z.ID, After: raw(z)},
{Kind: model.KindRule, ID: r.ID, After: raw(r)},
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
}}
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
t.Fatal(err)
}
st, _ = s.LoadState(ctx)
if len(st.Zones) != 1 || !st.Zones[0].External || st.Zones[0].Interfaces[0] != "wan" {
t.Errorf("zones %+v", st.Zones)
}
if len(st.Rules) != 1 || st.Rules[0].Services[0] != "ssh" || st.Settings.ForwardPolicy != model.PolicyAccept {
t.Errorf("rules %+v settings %+v", st.Rules, st.Settings)
}
// Undo: the inverse changes, marked as a revert.
var inv []model.Change
for _, c := range e.Changes {
inv = append(inv, c.Inverse())
}
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
if err := s.SaveChanges(ctx, inv, u); err != nil {
t.Fatal(err)
}
st, _ = s.LoadState(ctx)
if len(st.Zones) != 0 || len(st.Rules) != 0 || st.Settings.ForwardPolicy != model.PolicyDrop {
t.Errorf("after undo: %+v", st)
}
h, err := s.GetHistory(ctx, e.ID)
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
t.Errorf("history %+v %v", h, err)
}
list, _ := s.ListHistory(ctx, 10, 0)
if len(list) != 2 || list[0].ID != u.ID {
t.Errorf("history list %+v", list)
}
}
func TestUsersAndAudit(t *testing.T) {
ctx := context.Background()
s, err := Open(":memory:")
if err != nil {
t.Fatal(err)
}
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
if err := s.CreateUser(ctx, u); err != nil {
t.Fatal(err)
}
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
t.Errorf("duplicate user: %v", err)
}
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
if err := s.CreateToken(ctx, tok); err != nil {
t.Fatal(err)
}
got, err := s.GetTokenByPublicID(ctx, "p1")
if err != nil || got.Username != "admin" || got.Grants != nil {
t.Errorf("token %+v %v", got, err)
}
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "set.add", Object: "guest 10.0.30.5", Result: "success"})
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "set."})
if len(evs) != 1 || evs[0].Object != "guest 10.0.30.5" {
t.Errorf("audit %+v", evs)
}
}
+392
View File
@@ -0,0 +1,392 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"housecarl/internal/model"
)
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
u := &model.User{}
var grants, ca, ua string
var admin, disabled int
var ll sql.NullString
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
return nil, err
}
u.Admin, u.Disabled = admin != 0, disabled != 0
u.HasPassword = u.PasswordHash != ""
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
return nil, err
}
if u.Grants == nil {
u.Grants = []model.Grant{}
}
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
return u, nil
}
// CreateUser inserts a user.
func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
now := time.Now()
u.CreatedAt, u.UpdatedAt = now, now
if u.Grants == nil {
u.Grants = []model.Grant{}
}
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
u.ID, err = res.LastInsertId()
u.HasPassword = u.PasswordHash != ""
return err
}
// UpdateUser saves a user.
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
u.UpdatedAt = time.Now()
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
u.HasPassword = u.PasswordHash != ""
return nil
}
// TouchLogin records a successful login.
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, ts(time.Now()), id)
return err
}
// GetUser loads a user by ID.
func (s *Store) GetUser(ctx context.Context, id int64) (*model.User, error) {
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// GetUserByName loads a user by name (case-insensitive).
func (s *Store) GetUserByName(ctx context.Context, name string) (*model.User, error) {
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return u, err
}
// ListUsers lists all users by name.
func (s *Store) ListUsers(ctx context.Context) ([]*model.User, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username COLLATE NOCASE`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// CountAdmins counts the enabled administrators.
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE admin = 1 AND disabled = 0`).Scan(&n)
return n, err
}
// DeleteUser removes a user with their tokens and sessions.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at, t.rotated_at, t.prev_secret_hash, t.prev_expires_at`
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
t := &model.Token{}
var grants, exp, lu, rev, rot, pexp sql.NullString
var ca string
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev, &rot, &t.PrevSecretHash, &pexp); err != nil {
return nil, err
}
if grants.Valid {
if err := json.Unmarshal([]byte(grants.String), &t.Grants); err != nil {
return nil, err
}
if t.Grants == nil {
t.Grants = []model.Grant{}
}
}
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
t.RotatedAt, t.PrevExpiresAt = parseTSPtr(rot), parseTSPtr(pexp)
t.CreatedAt = parseTS(ca)
return t, nil
}
// CreateToken inserts a token.
func (s *Store) CreateToken(ctx context.Context, t *model.Token) error {
t.CreatedAt = time.Now()
var grants any
if t.Grants != nil {
grants = jsonText(t.Grants)
}
res, err := s.db.ExecContext(ctx, `INSERT INTO tokens (user_id, name, public_id, secret_hash, grants, expires_at, created_at) VALUES (?,?,?,?,?,?,?)`,
t.UserID, t.Name, t.PublicID, t.SecretHash, grants, tsPtr(t.ExpiresAt), ts(t.CreatedAt))
if isUnique(err) {
return ErrConflict
}
if err != nil {
return err
}
t.ID, err = res.LastInsertId()
return err
}
// GetTokenByPublicID loads a token by its public ID.
func (s *Store) GetTokenByPublicID(ctx context.Context, pid string) (*model.Token, error) {
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.public_id = ?`, pid))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return t, err
}
// GetToken loads a token by ID.
func (s *Store) GetToken(ctx context.Context, id int64) (*model.Token, error) {
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return t, err
}
// ListTokens lists the tokens of one user (userID > 0) or of all.
func (s *Store) ListTokens(ctx context.Context, userID int64) ([]*model.Token, error) {
q := `SELECT ` + tokenCols + ` FROM tokens t JOIN users u ON u.id = t.user_id`
var args []any
if userID > 0 {
q += ` WHERE t.user_id = ?`
args = append(args, userID)
}
q += ` ORDER BY t.id`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*model.Token
for rows.Next() {
t, err := scanToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeToken marks a token revoked.
func (s *Store) RevokeToken(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// RotateToken gives a token a new secret and expiry. With prevUntil set,
// the old secret keeps working until then; otherwise it stops at once. A
// revoked token can't be rotated (ErrNotFound).
func (s *Store) RotateToken(ctx context.Context, id int64, secretHash string, expiresAt, prevUntil *time.Time) error {
prevHash := `''`
if prevUntil != nil {
prevHash = `secret_hash`
}
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET prev_secret_hash = `+prevHash+`, prev_expires_at = ?, secret_hash = ?, expires_at = ?, rotated_at = ? WHERE id = ? AND revoked_at IS NULL`,
tsPtr(prevUntil), secretHash, tsPtr(expiresAt), ts(time.Now()), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchToken records a token's use.
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
return err
}
// CreateSession stores a session.
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
return err
}
// GetSession loads a session by the hash of its ID.
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
se := &model.Session{}
var ca, ea, ls string
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
return se, nil
}
// TouchSession updates a session's last-seen time.
func (s *Store) TouchSession(ctx context.Context, idHash string, at time.Time) error {
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?`, ts(at), idHash)
return err
}
// DeleteSession removes a session.
func (s *Store) DeleteSession(ctx context.Context, idHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id_hash = ?`, idHash)
return err
}
// DeleteUserSessions removes a user's sessions, except keep (if set).
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keep string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id_hash != ?`, userID, keep)
return err
}
// PurgeSessions removes expired sessions.
func (s *Store) PurgeSessions(ctx context.Context, now time.Time) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, ts(now))
return err
}
// AddAudit stores an audit event.
func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error {
if ev.Time.IsZero() {
ev.Time = time.Now()
}
var uid, tid any
if ev.UserID != 0 {
uid = ev.UserID
}
if ev.TokenID != 0 {
tid = ev.TokenID
}
res, err := s.db.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, ip, action, object, result, detail) VALUES (?,?,?,?,?,?,?,?,?,?)`,
ts(ev.Time), uid, ev.Username, ev.Auth, tid, ev.IP, ev.Action, ev.Object, ev.Result, ev.Detail)
if err != nil {
return err
}
ev.ID, _ = res.LastInsertId()
return nil
}
// AuditFilter selects audit events.
type AuditFilter struct {
Username string
Action string // prefix: "auth." or "set.add"
Result string
Search string // in the object or the detail
Before int64 // IDs below this (paging)
Limit int
}
// ListAudit lists audit events, newest first.
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) {
var where []string
var args []any
if f.Username != "" {
where = append(where, "username = ? COLLATE NOCASE")
args = append(args, f.Username)
}
if f.Action != "" {
where = append(where, "action LIKE ? ESCAPE '\\'")
args = append(args, likeEscape(f.Action)+"%")
}
if f.Result != "" {
where = append(where, "result = ?")
args = append(args, f.Result)
}
if f.Search != "" {
where = append(where, "(object LIKE ? ESCAPE '\\' OR detail LIKE ? ESCAPE '\\')")
p := "%" + likeEscape(f.Search) + "%"
args = append(args, p, p)
}
if f.Before > 0 {
where = append(where, "id < ?")
args = append(args, f.Before)
}
q := `SELECT id, time, user_id, username, auth, token_id, ip, action, object, result, detail FROM audit`
if len(where) > 0 {
q += " WHERE " + strings.Join(where, " AND ")
}
limit := f.Limit
if limit <= 0 || limit > 1000 {
limit = 200
}
q += " ORDER BY id DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*model.AuditEvent{}
for rows.Next() {
ev := &model.AuditEvent{}
var t string
var uid, tid sql.NullInt64
if err := rows.Scan(&ev.ID, &t, &uid, &ev.Username, &ev.Auth, &tid, &ev.IP, &ev.Action, &ev.Object, &ev.Result, &ev.Detail); err != nil {
return nil, err
}
ev.Time = parseTS(t)
ev.UserID, ev.TokenID = uid.Int64, tid.Int64
out = append(out, ev)
}
return out, rows.Err()
}
func likeEscape(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}