Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
9.4 KiB
Go
290 lines
9.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// ErrConflict is returned for unique constraint violations.
|
|
var ErrConflict = errors.New("already exists")
|
|
|
|
func isUnique(err error) bool {
|
|
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
|
}
|
|
|
|
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 username (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 returns all users ordered 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 returns the number of enabled admin users.
|
|
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`
|
|
|
|
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
|
|
t := &model.Token{}
|
|
var grants, exp, lu, rev 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); 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.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 tokens, of one user (userID > 0) or 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
|
|
}
|
|
|
|
// TouchToken records a token's use. It is best-effort and cheap.
|
|
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 all sessions of a user, 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
|
|
}
|