Client groups put devices (addresses, networks, or ACLs of them) at a blocking step: off (only your rules), normal (the default), or steps added above it, each including the lists of the steps below. Lists start at a step. In each view, a gate policy zone in front of a step's lists lets the devices below that step through (rpz-client-ip triggers with rpz-passthru), so every list is still loaded once. The IPv6 part of a gate leaves the IPv4-mapped range out: named matches IPv4 clients as ::ffff:a.b.c.d. Web UI: a Devices panel on the Blocking page and a step on each list. CLI: jarl block step, jarl block group, jarl blocklist --step. API: /blocking/steps, /blocking/groups. Database schema 8. Also: copying the state kept empty lists as empty (not nil), so reverting a changeset no longer adds restore operations for unchanged blocklists, rules and views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
378 lines
15 KiB
Go
378 lines
15 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, disabled, 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.Disabled, &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, step_id, 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.StepID, &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
|
|
}
|
|
|
|
rows, err = e.QueryContext(ctx, `SELECT id, name, position, comment, version, created_at, updated_at, created_by, updated_by FROM block_steps`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
s := &model.BlockStep{}
|
|
var ca, ua string
|
|
if err := rows.Scan(&s.ID, &s.Name, &s.Position, &s.Comment, &s.Version, &ca, &ua, &s.CreatedBy, &s.UpdatedBy); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
s.CreatedAt, s.UpdatedAt = parseTS(ca), parseTS(ua)
|
|
st.BlockSteps[s.ID] = s
|
|
}
|
|
if err := closeRows(rows); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err = e.QueryContext(ctx, `SELECT id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by FROM client_groups`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for rows.Next() {
|
|
g := &model.ClientGroup{}
|
|
var members, views, ca, ua string
|
|
if err := rows.Scan(&g.ID, &g.Name, &members, &g.StepID, &views, &g.Comment, &g.Version, &ca, &ua, &g.CreatedBy, &g.UpdatedBy); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
json.Unmarshal([]byte(members), &g.Members)
|
|
json.Unmarshal([]byte(views), &g.ViewIDs)
|
|
g.CreatedAt, g.UpdatedAt = parseTS(ca), parseTS(ua)
|
|
st.ClientGroups[g.ID] = g
|
|
}
|
|
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", "block_steps", "client_groups"} {
|
|
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, disabled, 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.Disabled, 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, step_id, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
b.ID, b.Name, b.URL, jsonText(nonNilIDs(b.ViewIDs)), b.Disabled, b.StepID, 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
|
|
}
|
|
if err := 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) }); err != nil {
|
|
return err
|
|
}
|
|
if err := diffMap(old.BlockSteps, new.BlockSteps, func(s *model.BlockStep) error {
|
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO block_steps (id, name, position, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?)`,
|
|
s.ID, s.Name, s.Position, s.Comment, s.Version, ts(s.CreatedAt), ts(s.UpdatedAt), s.CreatedBy, s.UpdatedBy)
|
|
return err
|
|
}, func(id int64) error { return del(ctx, e, "block_steps", id) }); err != nil {
|
|
return err
|
|
}
|
|
return diffMap(old.ClientGroups, new.ClientGroups, func(g *model.ClientGroup) error {
|
|
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO client_groups (id, name, members, step_id, view_ids, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
|
g.ID, g.Name, jsonText(nonNil(g.Members)), g.StepID, jsonText(nonNilIDs(g.ViewIDs)), g.Comment, g.Version, ts(g.CreatedAt), ts(g.UpdatedAt), g.CreatedBy, g.UpdatedBy)
|
|
return err
|
|
}, func(id int64) error { return del(ctx, e, "client_groups", 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
|
|
}
|