Files
jarl/internal/store/state.go
T
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00

326 lines
12 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
}
rows, err = e.QueryContext(ctx, `SELECT id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by FROM blocklists`)
if err != nil {
return nil, err
}
for rows.Next() {
b := &model.Blocklist{}
var views, ca, ua string
if err := rows.Scan(&b.ID, &b.Name, &b.URL, &views, &b.Disabled, &b.Comment, &b.Version, &ca, &ua, &b.CreatedBy, &b.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(views), &b.ViewIDs)
b.CreatedAt, b.UpdatedAt = parseTS(ca), parseTS(ua)
st.Blocklists[b.ID] = b
}
if err := closeRows(rows); err != nil {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM block_rules`)
if err != nil {
return nil, err
}
for rows.Next() {
r := &model.BlockRule{}
var views, ca, ua string
if err := rows.Scan(&r.ID, &r.Name, &r.Action, &r.Exact, &views, &r.Comment, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
json.Unmarshal([]byte(views), &r.ViewIDs)
r.CreatedAt, r.UpdatedAt = parseTS(ca), parseTS(ua)
st.BlockRules[r.ID] = r
}
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", "blocklists", "block_rules"} {
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
}
if err := 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) }); err != nil {
return err
}
if err := diffMap(old.Blocklists, new.Blocklists, func(b *model.Blocklist) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO blocklists (id, name, url, view_ids, disabled, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.Comment, b.Version, ts(b.CreatedAt), ts(b.UpdatedAt), b.CreatedBy, b.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "blocklists", id) }); err != nil {
return err
}
return diffMap(old.BlockRules, new.BlockRules, func(r *model.BlockRule) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_rules (id, name, action, exact, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
r.ID, r.Name, r.Action, r.Exact, jsonText(nonNilIDs(r.ViewIDs)), r.Comment, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "block_rules", id) })
}
func nonNilIDs(ids []int64) []int64 {
if ids == nil {
return []int64{}
}
return ids
}
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
}