Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
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>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,171 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// AddAudit stores an audit event.
|
||||
func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error {
|
||||
return addAudit(ctx, s.db, ev)
|
||||
}
|
||||
|
||||
// AddAudit stores an audit event inside the transaction.
|
||||
func (t *Tx) AddAudit(ev *model.AuditEvent) error { return addAudit(t.ctx, t.tx, ev) }
|
||||
|
||||
func addAudit(ctx context.Context, e execer, ev *model.AuditEvent) error {
|
||||
if ev.Time.IsZero() {
|
||||
ev.Time = time.Now()
|
||||
}
|
||||
var details any
|
||||
if len(ev.Details) > 0 {
|
||||
details = string(ev.Details)
|
||||
}
|
||||
res, err := e.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
ts(ev.Time), ev.UserID, ev.Username, ev.Auth, ev.TokenID, ev.TokenName, ev.IP, ev.UserAgent, ev.Action, ev.ObjectType, ev.ObjectID, ev.ObjectName, ev.ChangesetID, ev.Outcome, ev.Message, details)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditFilter selects audit events.
|
||||
type AuditFilter struct {
|
||||
Username string
|
||||
Action string // prefix match, e.g. "auth." or "changeset.apply"
|
||||
Outcome string
|
||||
ObjectType string
|
||||
ChangesetID int64
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Search string // substring of object name or message
|
||||
Before int64 // IDs below this (paging)
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListAudit returns 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.Outcome != "" {
|
||||
where = append(where, "outcome = ?")
|
||||
args = append(args, f.Outcome)
|
||||
}
|
||||
if f.ObjectType != "" {
|
||||
where = append(where, "object_type = ?")
|
||||
args = append(args, f.ObjectType)
|
||||
}
|
||||
if f.ChangesetID > 0 {
|
||||
where = append(where, "changeset_id = ?")
|
||||
args = append(args, f.ChangesetID)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
where = append(where, "time >= ?")
|
||||
args = append(args, ts(f.Since))
|
||||
}
|
||||
if !f.Until.IsZero() {
|
||||
where = append(where, "time < ?")
|
||||
args = append(args, ts(f.Until))
|
||||
}
|
||||
if f.Search != "" {
|
||||
where = append(where, "(object_name LIKE ? ESCAPE '\\' OR message 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, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details 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()
|
||||
var out []*model.AuditEvent
|
||||
for rows.Next() {
|
||||
ev := &model.AuditEvent{}
|
||||
var t string
|
||||
var details sql.NullString
|
||||
if err := rows.Scan(&ev.ID, &t, &ev.UserID, &ev.Username, &ev.Auth, &ev.TokenID, &ev.TokenName, &ev.IP, &ev.UserAgent, &ev.Action, &ev.ObjectType, &ev.ObjectID, &ev.ObjectName, &ev.ChangesetID, &ev.Outcome, &ev.Message, &details); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Time = parseTS(t)
|
||||
if details.Valid && details.String != "" {
|
||||
ev.Details = json.RawMessage(details.String)
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PruneAudit deletes audit events older than before.
|
||||
func (s *Store) PruneAudit(ctx context.Context, before time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM audit WHERE time < ?`, ts(before))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
func likeEscape(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
// DeployedFiles returns the files Jarl wrote last time it applied changes.
|
||||
func (s *Store) DeployedFiles(ctx context.Context) (map[string]*model.DeployedFile, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT path, sha256, size, kind, written_at, changeset_id FROM deployed_files`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]*model.DeployedFile{}
|
||||
for rows.Next() {
|
||||
f := &model.DeployedFile{}
|
||||
var wa string
|
||||
if err := rows.Scan(&f.Path, &f.SHA256, &f.Size, &f.Kind, &wa, &f.ChangesetID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.WrittenAt = parseTS(wa)
|
||||
out[f.Path] = f
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetDeployedFiles replaces the record of deployed files.
|
||||
func (t *Tx) SetDeployedFiles(files []*model.DeployedFile) error {
|
||||
if _, err := t.tx.ExecContext(t.ctx, `DELETE FROM deployed_files`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
if _, err := t.tx.ExecContext(t.ctx, `INSERT INTO deployed_files (path, sha256, size, kind, written_at, changeset_id) VALUES (?,?,?,?,?,?)`,
|
||||
f.Path, f.SHA256, f.Size, f.Kind, ts(f.WrittenAt), f.ChangesetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
const changesetCols = `id, description, status, user_id, username, via, ops, created_at, updated_at, applied_at, applied_by, result, diff`
|
||||
|
||||
func scanChangeset(sc interface{ Scan(...any) error }, withDiff bool) (*model.Changeset, error) {
|
||||
cs := &model.Changeset{}
|
||||
var ops, ca, ua, diff string
|
||||
var aa, result sql.NullString
|
||||
if err := sc.Scan(&cs.ID, &cs.Description, &cs.Status, &cs.UserID, &cs.Username, &cs.Via, &ops, &ca, &ua, &aa, &cs.AppliedBy, &result, &diff); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ops), &cs.Ops); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cs.Ops == nil {
|
||||
cs.Ops = []model.Op{}
|
||||
}
|
||||
cs.CreatedAt, cs.UpdatedAt, cs.AppliedAt = parseTS(ca), parseTS(ua), parseTSPtr(aa)
|
||||
if result.Valid && result.String != "" {
|
||||
cs.Result = json.RawMessage(result.String)
|
||||
}
|
||||
if withDiff {
|
||||
cs.Diff = diff
|
||||
}
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// CreateChangeset inserts a new changeset and sets its ID.
|
||||
func (s *Store) CreateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||
now := time.Now()
|
||||
if cs.CreatedAt.IsZero() {
|
||||
cs.CreatedAt = now
|
||||
}
|
||||
cs.UpdatedAt = now
|
||||
if cs.Status == "" {
|
||||
cs.Status = model.ChangesetOpen
|
||||
}
|
||||
if cs.Ops == nil {
|
||||
cs.Ops = []model.Op{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO changesets (description, status, user_id, username, via, ops, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
cs.Description, cs.Status, cs.UserID, cs.Username, cs.Via, jsonText(cs.Ops), ts(cs.CreatedAt), ts(cs.UpdatedAt))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChangeset loads a changeset including its diff.
|
||||
func (s *Store) GetChangeset(ctx context.Context, id int64) (*model.Changeset, error) {
|
||||
cs, err := scanChangeset(s.db.QueryRowContext(ctx, `SELECT `+changesetCols+` FROM changesets WHERE id = ?`, id), true)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return cs, err
|
||||
}
|
||||
|
||||
// ChangesetFilter selects changesets.
|
||||
type ChangesetFilter struct {
|
||||
Status string
|
||||
UserID int64
|
||||
Limit int
|
||||
Before int64 // only IDs below this (paging)
|
||||
}
|
||||
|
||||
// ListChangesets lists changesets, newest first, without diffs.
|
||||
func (s *Store) ListChangesets(ctx context.Context, f ChangesetFilter) ([]*model.Changeset, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
if f.Status != "" {
|
||||
where = append(where, "status = ?")
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
if f.UserID != 0 {
|
||||
where = append(where, "user_id = ?")
|
||||
args = append(args, f.UserID)
|
||||
}
|
||||
if f.Before > 0 {
|
||||
where = append(where, "id < ?")
|
||||
args = append(args, f.Before)
|
||||
}
|
||||
q := `SELECT ` + changesetCols + ` FROM changesets`
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
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()
|
||||
var out []*model.Changeset
|
||||
for rows.Next() {
|
||||
cs, err := scanChangeset(rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, cs)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateChangeset saves the description, status and operations of an open
|
||||
// changeset.
|
||||
func (s *Store) UpdateChangeset(ctx context.Context, cs *model.Changeset) error {
|
||||
cs.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE changesets SET description = ?, status = ?, ops = ?, updated_at = ? WHERE id = ? AND status = 'open'`,
|
||||
cs.Description, cs.Status, jsonText(cs.Ops), ts(cs.UpdatedAt), cs.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkApplied records that a changeset was applied.
|
||||
func (t *Tx) MarkApplied(cs *model.Changeset) error {
|
||||
now := time.Now()
|
||||
cs.Status = model.ChangesetApplied
|
||||
cs.AppliedAt = &now
|
||||
cs.UpdatedAt = now
|
||||
_, err := t.tx.ExecContext(t.ctx, `UPDATE changesets SET status = ?, ops = ?, applied_at = ?, applied_by = ?, result = ?, diff = ?, updated_at = ?, description = ? WHERE id = ?`,
|
||||
cs.Status, jsonText(cs.Ops), ts(now), cs.AppliedBy, nullJSON(cs.Result), cs.Diff, ts(now), cs.Description, cs.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertAppliedChangeset stores a changeset that was created and applied in
|
||||
// one step.
|
||||
func (t *Tx) InsertAppliedChangeset(cs *model.Changeset) error {
|
||||
now := time.Now()
|
||||
if cs.CreatedAt.IsZero() {
|
||||
cs.CreatedAt = now
|
||||
}
|
||||
cs.Status = model.ChangesetApplied
|
||||
cs.AppliedAt = &now
|
||||
cs.UpdatedAt = now
|
||||
res, err := t.tx.ExecContext(t.ctx, `INSERT INTO changesets (description, status, user_id, username, via, ops, created_at, updated_at, applied_at, applied_by, result, diff) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
cs.Description, cs.Status, cs.UserID, cs.Username, cs.Via, jsonText(cs.Ops), ts(cs.CreatedAt), ts(now), ts(now), cs.AppliedBy, nullJSON(cs.Result), cs.Diff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
func nullJSON(r json.RawMessage) any {
|
||||
if len(r) == 0 {
|
||||
return nil
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// DeleteChangeset removes a changeset (used for discarded one-shot
|
||||
// changesets that never became visible).
|
||||
func (s *Store) DeleteChangeset(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM changesets WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
const settingsKey = "settings"
|
||||
|
||||
// LoadState loads the complete configuration.
|
||||
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
||||
return loadState(ctx, s.db)
|
||||
}
|
||||
|
||||
func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
st := model.NewState()
|
||||
var raw string
|
||||
err := e.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, settingsKey).Scan(&raw)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
if err := json.Unmarshal([]byte(raw), &st.Settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := e.QueryContext(ctx, `SELECT id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by FROM views`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
v := &model.View{}
|
||||
var mc, md, ca, ua string
|
||||
var mro sql.NullInt64
|
||||
if err := rows.Scan(&v.ID, &v.Name, &v.Position, &mc, &md, &mro, &v.Comment, &v.Version, &ca, &ua, &v.CreatedBy, &v.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(mc), &v.MatchClients)
|
||||
json.Unmarshal([]byte(md), &v.MatchDestinations)
|
||||
if mro.Valid {
|
||||
v.MatchRecursiveOnly = model.Ptr(mro.Int64 != 0)
|
||||
}
|
||||
v.CreatedAt, v.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.Views[v.ID] = v
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, elements, comment, version, created_at, updated_at, created_by, updated_by FROM acls`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
a := &model.ACL{}
|
||||
var el, ca, ua string
|
||||
if err := rows.Scan(&a.ID, &a.Name, &el, &a.Comment, &a.Version, &ca, &ua, &a.CreatedBy, &a.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
json.Unmarshal([]byte(el), &a.Elements)
|
||||
a.CreatedAt, a.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.ACLs[a.ID] = a
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by FROM tsig_keys`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
k := &model.TSIGKey{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.Algorithm, &k.Secret, &k.Comment, &k.Version, &ca, &ua, &k.CreatedBy, &k.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, k.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.Keys[k.ID] = k
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
z := &model.Zone{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
z.CreatedAt, z.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.Zones[z.ID] = z
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
r := &model.Record{}
|
||||
var ttl sql.NullInt64
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if ttl.Valid {
|
||||
r.TTL = model.Ptr(uint32(ttl.Int64))
|
||||
}
|
||||
r.CreatedAt, r.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.Records[r.ID] = r
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by FROM options`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
o := &model.Option{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&o.ID, &o.Scope, &o.ScopeID, &o.Name, &o.Value, &o.Position, &o.Comment, &o.Version, &ca, &ua, &o.CreatedBy, &o.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt, o.UpdatedAt = parseTS(ca), parseTS(ua)
|
||||
st.Options[o.ID] = o
|
||||
}
|
||||
if err := closeRows(rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func closeRows(rows *sql.Rows) error {
|
||||
err := rows.Err()
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveState writes the differences between old and new.
|
||||
func (t *Tx) SaveState(old, new *model.State) error {
|
||||
return saveState(t.ctx, t.tx, old, new)
|
||||
}
|
||||
|
||||
// ReplaceState replaces the stored configuration with st (used by import
|
||||
// and tests).
|
||||
func (t *Tx) ReplaceState(st *model.State) error {
|
||||
for _, tbl := range []string{"views", "acls", "tsig_keys", "zones", "records", "options"} {
|
||||
if _, err := t.tx.ExecContext(t.ctx, "DELETE FROM "+tbl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return saveState(t.ctx, t.tx, model.NewState(), st)
|
||||
}
|
||||
|
||||
// LoadState loads the configuration inside the transaction.
|
||||
func (t *Tx) LoadState() (*model.State, error) { return loadState(t.ctx, t.tx) }
|
||||
|
||||
func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
if !reflect.DeepEqual(old.Settings, new.Settings) {
|
||||
if err := setMeta(ctx, e, settingsKey, jsonText(new.Settings)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := diffMap(old.Views, new.Views, func(v *model.View) error {
|
||||
var mro any
|
||||
if v.MatchRecursiveOnly != nil {
|
||||
mro = boolInt(*v.MatchRecursiveOnly)
|
||||
}
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO views (id, name, position, match_clients, match_destinations, match_recursive_only, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
v.ID, v.Name, v.Position, jsonText(nonNil(v.MatchClients)), jsonText(nonNil(v.MatchDestinations)), mro, v.Comment, v.Version, ts(v.CreatedAt), ts(v.UpdatedAt), v.CreatedBy, v.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "views", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.ACLs, new.ACLs, func(a *model.ACL) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO acls (id, name, elements, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.Name, jsonText(nonNil(a.Elements)), a.Comment, a.Version, ts(a.CreatedAt), ts(a.UpdatedAt), a.CreatedBy, a.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "acls", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Keys, new.Keys, func(k *model.TSIGKey) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO tsig_keys (id, name, algorithm, secret, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
k.ID, k.Name, k.Algorithm, k.Secret, k.Comment, k.Version, ts(k.CreatedAt), ts(k.UpdatedAt), k.CreatedBy, k.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "tsig_keys", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Records, new.Records, func(r *model.Record) error {
|
||||
var ttl any
|
||||
if r.TTL != nil {
|
||||
ttl = int64(*r.TTL)
|
||||
}
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
||||
return err
|
||||
}
|
||||
return diffMap(old.Options, new.Options, func(o *model.Option) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO options (id, scope, scope_id, name, value, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
o.ID, o.Scope, o.ScopeID, o.Name, o.Value, o.Position, o.Comment, o.Version, ts(o.CreatedAt), ts(o.UpdatedAt), o.CreatedBy, o.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "options", id) })
|
||||
}
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func del(ctx context.Context, e execer, table string, id int64) error {
|
||||
_, err := e.ExecContext(ctx, "DELETE FROM "+table+" WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// diffMap calls put for new or changed objects and remove for deleted ones.
|
||||
func diffMap[T any](old, new map[int64]*T, put func(*T) error, remove func(int64) error) error {
|
||||
for id, n := range new {
|
||||
if o, ok := old[id]; ok && reflect.DeepEqual(o, n) {
|
||||
continue
|
||||
}
|
||||
if err := put(n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for id := range old {
|
||||
if _, ok := new[id]; !ok {
|
||||
if err := remove(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Package store persists Jarl's data in SQLite.
|
||||
//
|
||||
// The configuration (views, zones, records, ...) is loaded into a
|
||||
// model.State as a whole and saved back by diffing two states, so the rest
|
||||
// of Jarl works on plain Go values. Users, tokens, sessions, changesets and
|
||||
// the audit log are accessed directly.
|
||||
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")
|
||||
|
||||
// Store is a Jarl database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DB exposes the underlying database (for backups and tests).
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Open opens (creating if needed) the database at path and applies
|
||||
// migrations. Use ":memory:" for a throwaway in-memory database.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate"
|
||||
if path == ":memory:" {
|
||||
dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate"
|
||||
} else {
|
||||
dsn += "&_pragma=journal_mode(WAL)"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == ":memory:" {
|
||||
// Every connection would get its own in-memory database.
|
||||
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
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
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);
|
||||
|
||||
CREATE TABLE views (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
match_clients TEXT NOT NULL DEFAULT '[]',
|
||||
match_destinations TEXT NOT NULL DEFAULT '[]',
|
||||
match_recursive_only INTEGER,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE acls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
elements TEXT NOT NULL DEFAULT '[]',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE tsig_keys (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE zones (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
view_id INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT NOT NULL,
|
||||
in_view TEXT NOT NULL DEFAULT '',
|
||||
file TEXT NOT NULL DEFAULT '',
|
||||
soa_mname TEXT NOT NULL DEFAULT '', soa_rname TEXT NOT NULL DEFAULT '',
|
||||
soa_serial INTEGER NOT NULL DEFAULT 0, soa_refresh INTEGER NOT NULL DEFAULT 0,
|
||||
soa_retry INTEGER NOT NULL DEFAULT 0, soa_expire INTEGER NOT NULL DEFAULT 0,
|
||||
soa_minimum INTEGER NOT NULL DEFAULT 0,
|
||||
default_ttl INTEGER NOT NULL DEFAULT 3600,
|
||||
serial_policy TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX zones_name ON zones(name);
|
||||
CREATE TABLE records (
|
||||
id INTEGER PRIMARY KEY,
|
||||
zone_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
ttl INTEGER,
|
||||
data TEXT NOT NULL,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX records_zone ON records(zone_id, name);
|
||||
CREATE TABLE options (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scope TEXT NOT NULL,
|
||||
scope_id INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
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 changesets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
via TEXT NOT NULL DEFAULT '',
|
||||
ops TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
applied_at TEXT,
|
||||
applied_by TEXT NOT NULL DEFAULT '',
|
||||
result TEXT,
|
||||
diff TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX changesets_status ON changesets(status, user_id);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT '',
|
||||
token_id INTEGER NOT NULL DEFAULT 0,
|
||||
token_name TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL DEFAULT '',
|
||||
object_id INTEGER NOT NULL DEFAULT 0,
|
||||
object_name TEXT NOT NULL DEFAULT '',
|
||||
changeset_id INTEGER NOT NULL DEFAULT 0,
|
||||
outcome TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
details TEXT
|
||||
);
|
||||
CREATE INDEX audit_time ON audit(time);
|
||||
CREATE INDEX audit_user ON audit(username, time);
|
||||
CREATE TABLE deployed_files (
|
||||
path TEXT PRIMARY KEY,
|
||||
sha256 TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
written_at TEXT NOT NULL,
|
||||
changeset_id INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
||||
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 Jarl (%d); upgrade Jarl", 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
|
||||
}
|
||||
|
||||
// Tx is a database transaction.
|
||||
type Tx struct {
|
||||
tx *sql.Tx
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// InTx runs fn in a transaction, committing if it returns nil.
|
||||
func (s *Store) InTx(ctx context.Context, fn func(tx *Tx) error) error {
|
||||
t, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(&Tx{tx: t, ctx: ctx}); err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
return t.Commit()
|
||||
}
|
||||
|
||||
type execer interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// AllocateIDs reserves n object IDs.
|
||||
func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var last int64
|
||||
err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last)
|
||||
if 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.
|
||||
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 "", ErrNotFound
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetMeta writes a meta value.
|
||||
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
|
||||
return setMeta(ctx, s.db, key, value)
|
||||
}
|
||||
|
||||
func setMeta(ctx context.Context, e execer, key, value string) error {
|
||||
_, err := e.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
|
||||
}
|
||||
|
||||
// time helpers
|
||||
|
||||
func ts(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func tsPtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return ts(*t)
|
||||
}
|
||||
|
||||
func parseTS(s string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339Nano, s)
|
||||
return t
|
||||
}
|
||||
|
||||
func parseTSPtr(s sql.NullString) *time.Time {
|
||||
if !s.Valid || s.String == "" {
|
||||
return nil
|
||||
}
|
||||
t := parseTS(s.String)
|
||||
return &t
|
||||
}
|
||||
|
||||
func jsonText(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func placeholders(n int) string {
|
||||
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestStateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
ids, err := s.AllocateIDs(ctx, 5)
|
||||
if err != nil || len(ids) != 5 || ids[0] != 1 || ids[4] != 5 {
|
||||
t.Fatalf("AllocateIDs = %v %v", ids, err)
|
||||
}
|
||||
ids2, _ := s.AllocateIDs(ctx, 2)
|
||||
if ids2[0] != 6 {
|
||||
t.Fatalf("second allocation = %v", ids2)
|
||||
}
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
st := model.NewState()
|
||||
st.Settings.Nameservers = []string{"ns1.example.com."}
|
||||
meta := model.Meta{Version: 1, CreatedAt: now, UpdatedAt: now, CreatedBy: "adam", UpdatedBy: "adam"}
|
||||
st.Views[1] = &model.View{ID: 1, Name: "internal", MatchClients: []string{"lan"}, MatchRecursiveOnly: model.Ptr(true), Meta: meta}
|
||||
st.ACLs[2] = &model.ACL{ID: 2, Name: "lan", Elements: []string{"192.168.0.0/16"}, Meta: meta}
|
||||
st.Keys[3] = &model.TSIGKey{ID: 3, Name: "k", Algorithm: "hmac-sha256", Secret: "c2VjcmV0", Meta: meta}
|
||||
st.Zones[4] = &model.Zone{ID: 4, Name: "example.com.", ViewID: 1, Type: "primary", SOA: model.SOA{MName: "ns1.example.com.", Serial: 7}, DefaultTTL: 300, Meta: meta}
|
||||
st.Records[5] = &model.Record{ID: 5, ZoneID: 4, Name: "www.example.com.", Type: "A", TTL: model.Ptr(uint32(60)), Data: "192.0.2.1", Meta: meta}
|
||||
st.Records[6] = &model.Record{ID: 6, ZoneID: 4, Name: "x.example.com.", Type: "A", Data: "192.0.2.2", Meta: meta}
|
||||
st.Options[7] = &model.Option{ID: 7, Scope: model.ScopeOptions, Name: "recursion", Value: "no", Meta: meta}
|
||||
if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(model.NewState(), st) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.LoadState(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Views[1].Name != "internal" || !*got.Views[1].MatchRecursiveOnly || got.Views[1].MatchClients[0] != "lan" {
|
||||
t.Errorf("view = %+v", got.Views[1])
|
||||
}
|
||||
if *got.Records[5].TTL != 60 || got.Records[6].TTL != nil {
|
||||
t.Errorf("ttl round trip failed")
|
||||
}
|
||||
if got.Zones[4].SOA.Serial != 7 || got.Settings.Nameservers[0] != "ns1.example.com." {
|
||||
t.Errorf("zone/settings = %+v %+v", got.Zones[4], got.Settings)
|
||||
}
|
||||
if !got.Records[5].CreatedAt.Equal(now) {
|
||||
t.Errorf("time round trip: %v vs %v", got.Records[5].CreatedAt, now)
|
||||
}
|
||||
// Diff save: delete one record, change another.
|
||||
next := got.Clone()
|
||||
delete(next.Records, 6)
|
||||
next.Records[5].Data = "192.0.2.9"
|
||||
if err := s.InTx(ctx, func(tx *Tx) error { return tx.SaveState(got, next) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := s.LoadState(ctx)
|
||||
if len(again.Records) != 1 || again.Records[5].Data != "192.0.2.9" {
|
||||
t.Errorf("after diff save: %+v", again.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersTokensSessions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
u := &model.User{Username: "Adam", PasswordHash: "x", Admin: true}
|
||||
if err := s.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateUser(ctx, &model.User{Username: "adam"}); err != ErrConflict {
|
||||
t.Errorf("duplicate username (case-insensitive) = %v", err)
|
||||
}
|
||||
got, err := s.GetUserByName(ctx, "ADAM")
|
||||
if err != nil || got.ID != u.ID || !got.Admin {
|
||||
t.Fatalf("GetUserByName = %+v %v", got, err)
|
||||
}
|
||||
tok := &model.Token{UserID: u.ID, Name: "cli", PublicID: "abc", SecretHash: "h", Grants: []model.Grant{{Resource: "records", Access: "write", Types: []string{"TXT"}}}}
|
||||
if err := s.CreateToken(ctx, tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gt, err := s.GetTokenByPublicID(ctx, "abc")
|
||||
if err != nil || gt.Username != "Adam" || len(gt.Grants) != 1 || gt.Grants[0].Types[0] != "TXT" {
|
||||
t.Fatalf("token = %+v %v", gt, err)
|
||||
}
|
||||
tok2 := &model.Token{UserID: u.ID, Name: "web", PublicID: "def", SecretHash: "h"}
|
||||
s.CreateToken(ctx, tok2)
|
||||
gt2, _ := s.GetTokenByPublicID(ctx, "def")
|
||||
if gt2.Grants != nil {
|
||||
t.Error("nil grants should stay nil (inherit)")
|
||||
}
|
||||
if err := s.RevokeToken(ctx, tok.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gt, _ = s.GetTokenByPublicID(ctx, "abc")
|
||||
if gt.RevokedAt == nil {
|
||||
t.Error("not revoked")
|
||||
}
|
||||
now := time.Now()
|
||||
se := &model.Session{IDHash: "hash", UserID: u.ID, CSRFToken: "c", CreatedAt: now, ExpiresAt: now.Add(time.Hour), LastSeenAt: now}
|
||||
if err := s.CreateSession(ctx, se); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gs, err := s.GetSession(ctx, "hash")
|
||||
if err != nil || gs.CSRFToken != "c" {
|
||||
t.Fatalf("session = %+v %v", gs, err)
|
||||
}
|
||||
if err := s.DeleteUser(ctx, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetSession(ctx, "hash"); err != ErrNotFound {
|
||||
t.Error("session not deleted with user")
|
||||
}
|
||||
if _, err := s.GetTokenByPublicID(ctx, "def"); err != ErrNotFound {
|
||||
t.Error("token not deleted with user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangesetsAndAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _ := Open(":memory:")
|
||||
defer s.Close()
|
||||
cs := &model.Changeset{Description: "test", UserID: 1, Username: "adam", Via: "cli", Ops: []model.Op{{Seq: 1, Kind: "record.create", ID: 9}}}
|
||||
if err := s.CreateChangeset(ctx, cs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetChangeset(ctx, cs.ID)
|
||||
if err != nil || got.Ops[0].Kind != "record.create" || got.Status != model.ChangesetOpen {
|
||||
t.Fatalf("changeset = %+v %v", got, err)
|
||||
}
|
||||
got.Diff = "--- a\n+++ b\n"
|
||||
got.AppliedBy = "adam"
|
||||
if err := s.InTx(ctx, func(tx *Tx) error { return tx.MarkApplied(got) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.UpdateChangeset(ctx, got); err != ErrNotFound {
|
||||
t.Errorf("updating an applied changeset = %v", err)
|
||||
}
|
||||
list, _ := s.ListChangesets(ctx, ChangesetFilter{Status: model.ChangesetApplied})
|
||||
if len(list) != 1 || list[0].Diff != "" {
|
||||
t.Errorf("list = %+v", list)
|
||||
}
|
||||
for _, a := range []string{"auth.login", "auth.login_failed", "changeset.apply"} {
|
||||
s.AddAudit(ctx, &model.AuditEvent{Action: a, Username: "adam", Outcome: "success", ObjectName: "www_example"})
|
||||
}
|
||||
evs, err := s.ListAudit(ctx, AuditFilter{Action: "auth."})
|
||||
if err != nil || len(evs) != 2 || evs[0].Action != "auth.login_failed" {
|
||||
t.Errorf("audit = %+v %v", evs, err)
|
||||
}
|
||||
evs, _ = s.ListAudit(ctx, AuditFilter{Search: "www_"})
|
||||
if len(evs) != 3 {
|
||||
t.Errorf("search found %d", len(evs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user