A disabled record stays in Jarl with its ID, TTL and comment but is left out of the zone; the zone file lists it as a comment. Web UI toggle, jarl record disable|enable, record.disable|enable operations and API endpoints. Database schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
9.9 KiB
Go
267 lines
9.9 KiB
Go
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, dnssec_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.DNSSECPolicy, &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, disabled, 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.Disabled, &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, dnssec_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.DNSSECPolicy, 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, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, 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
|
|
}
|