Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
8.0 KiB
Go
217 lines
8.0 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// DiffOps returns operations that turn from into to. It is used to record
|
|
// how to undo an applied changeset and to turn an imported configuration
|
|
// into a changeset. Create operations keep the object IDs of to.
|
|
func DiffOps(from, to *model.State) []model.Op {
|
|
var ops []model.Op
|
|
add := func(kind string, id int64, base int, data any, summary string) {
|
|
ops = append(ops, newOp(kind, id, base, data, summary))
|
|
}
|
|
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
|
|
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
|
|
}
|
|
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
|
s := to.Settings
|
|
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
|
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
|
|
}
|
|
|
|
for _, k := range sortedKeys(to.Keys) {
|
|
n := to.Keys[k]
|
|
in := TSIGInput{Name: &n.Name, Algorithm: &n.Algorithm, Secret: &n.Secret, Comment: &n.Comment}
|
|
if o, ok := from.Keys[k]; !ok {
|
|
add(OpTSIGCreate, k, 0, in, "create TSIG key "+n.Name)
|
|
} else if !sameObject(o, n) {
|
|
add(OpTSIGUpdate, k, o.Version, in, "restore TSIG key "+n.Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.ACLs) {
|
|
n := to.ACLs[k]
|
|
in := ACLInput{Name: &n.Name, Elements: model.Ptr(append([]string{}, n.Elements...)), Comment: &n.Comment}
|
|
if o, ok := from.ACLs[k]; !ok {
|
|
add(OpACLCreate, k, 0, in, "create ACL "+n.Name)
|
|
} else if !sameObject(o, n) {
|
|
add(OpACLUpdate, k, o.Version, in, "restore ACL "+n.Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.Views) {
|
|
n := to.Views[k]
|
|
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
|
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
|
|
if n.MatchRecursiveOnly != nil {
|
|
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
|
} else {
|
|
in.MatchRecursiveOnly = model.Opt[bool]{Set: true, Null: true}
|
|
}
|
|
if o, ok := from.Views[k]; !ok {
|
|
add(OpViewCreate, k, 0, in, "create view "+n.Name)
|
|
} else if !sameObject(o, n) {
|
|
add(OpViewUpdate, k, o.Version, in, "restore view "+n.Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.Zones) {
|
|
n := to.Zones[k]
|
|
s := n.SOA
|
|
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
|
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
|
if n.Type == model.ZonePrimary {
|
|
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
|
}
|
|
if o, ok := from.Zones[k]; !ok {
|
|
add(OpZoneCreate, k, 0, in, "create zone "+zoneDesc(to, n))
|
|
} else if !sameObject(o, n) {
|
|
in.Name = nil // zones can't be renamed
|
|
if n.Type == model.ZonePrimary && o.SOA.Serial != s.Serial {
|
|
// Never move a serial backwards: let the normal bump
|
|
// happen instead.
|
|
in.SOA.Serial = nil
|
|
}
|
|
add(OpZoneUpdate, k, o.Version, in, "restore zone "+zoneDesc(to, n))
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.Options) {
|
|
n := to.Options[k]
|
|
in := OptionInput{Scope: &n.Scope, ScopeID: &n.ScopeID, Name: &n.Name, Value: &n.Value, Comment: &n.Comment, Position: &n.Position}
|
|
if o, ok := from.Options[k]; !ok {
|
|
add(OpOptionCreate, k, 0, in, "set "+optionLabel(to, n)+" "+n.Value)
|
|
} else if !sameObject(o, n) {
|
|
in.Scope, in.ScopeID = nil, nil
|
|
add(OpOptionUpdate, k, o.Version, in, "restore "+optionLabel(to, n))
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.Records) {
|
|
n := to.Records[k]
|
|
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
|
if n.TTL != nil {
|
|
in.TTL = model.Some(*n.TTL)
|
|
} else {
|
|
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
|
}
|
|
if o, ok := from.Records[k]; !ok {
|
|
add(OpRecordCreate, k, 0, in, fmt.Sprintf("add %s %s %s", n.Name, n.Type, n.Data))
|
|
} else if !sameObject(o, n) {
|
|
in.ZoneID = 0
|
|
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.Blocklists) {
|
|
n := to.Blocklists[k]
|
|
in := BlocklistInput{Name: &n.Name, URL: &n.URL, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Disabled: &n.Disabled, Comment: &n.Comment}
|
|
if o, ok := from.Blocklists[k]; !ok {
|
|
add(OpBlocklistCreate, k, 0, in, "add blocklist "+n.Name)
|
|
} else if !sameObject(o, n) {
|
|
add(OpBlocklistUpdate, k, o.Version, in, "restore blocklist "+n.Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(to.BlockRules) {
|
|
n := to.BlockRules[k]
|
|
in := BlockRuleInput{Name: &n.Name, Action: &n.Action, Exact: &n.Exact, ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
|
|
if o, ok := from.BlockRules[k]; !ok {
|
|
add(OpBlockRuleCreate, k, 0, in, "add rule: "+ruleLabel(n))
|
|
} else if !sameObject(o, n) {
|
|
add(OpBlockRuleUpdate, k, o.Version, in, "restore rule: "+ruleLabel(n))
|
|
}
|
|
}
|
|
// Deletions, most dependent objects first.
|
|
for _, k := range sortedKeys(from.BlockRules) {
|
|
if _, ok := to.BlockRules[k]; !ok {
|
|
add(OpBlockRuleDelete, k, from.BlockRules[k].Version, nil, "delete rule: "+ruleLabel(from.BlockRules[k]))
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Blocklists) {
|
|
if _, ok := to.Blocklists[k]; !ok {
|
|
add(OpBlocklistDelete, k, from.Blocklists[k].Version, nil, "delete blocklist "+from.Blocklists[k].Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Records) {
|
|
if _, ok := to.Records[k]; !ok {
|
|
o := from.Records[k]
|
|
// Records of zones that are deleted go with the zone.
|
|
if _, zoneStays := to.Zones[o.ZoneID]; zoneStays {
|
|
add(OpRecordDelete, k, o.Version, nil, fmt.Sprintf("delete %s %s %s", o.Name, o.Type, o.Data))
|
|
}
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Options) {
|
|
if _, ok := to.Options[k]; !ok {
|
|
o := from.Options[k]
|
|
stays := true
|
|
switch o.Scope {
|
|
case model.ScopeZone:
|
|
_, stays = to.Zones[o.ScopeID]
|
|
case model.ScopeView:
|
|
_, stays = to.Views[o.ScopeID]
|
|
}
|
|
if stays {
|
|
add(OpOptionDelete, k, o.Version, nil, "delete "+optionLabel(from, o))
|
|
}
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Zones) {
|
|
if _, ok := to.Zones[k]; !ok {
|
|
o := from.Zones[k]
|
|
add(OpZoneDelete, k, o.Version, nil, "delete zone "+dnsname.Display(o.Name))
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Views) {
|
|
if _, ok := to.Views[k]; !ok {
|
|
o := from.Views[k]
|
|
add(OpViewDelete, k, o.Version, DeleteInput{Cascade: true}, "delete view "+o.Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.ACLs) {
|
|
if _, ok := to.ACLs[k]; !ok {
|
|
add(OpACLDelete, k, from.ACLs[k].Version, nil, "delete ACL "+from.ACLs[k].Name)
|
|
}
|
|
}
|
|
for _, k := range sortedKeys(from.Keys) {
|
|
if _, ok := to.Keys[k]; !ok {
|
|
add(OpTSIGDelete, k, from.Keys[k].Version, nil, "delete TSIG key "+from.Keys[k].Name)
|
|
}
|
|
}
|
|
for i := range ops {
|
|
ops[i].Seq = i + 1
|
|
}
|
|
return ops
|
|
}
|
|
|
|
func stripVersion(s model.Settings) model.Settings {
|
|
s.Version = 0
|
|
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
|
|
return s
|
|
}
|
|
|
|
func sortedKeys[T any](m map[int64]*T) []int64 {
|
|
keys := make([]int64, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
|
return keys
|
|
}
|
|
|
|
// sameObject compares two objects ignoring their meta data.
|
|
func sameObject[T any](a, b *T) bool {
|
|
ca, cb := *a, *b
|
|
clearMeta(&ca)
|
|
clearMeta(&cb)
|
|
return reflect.DeepEqual(ca, cb)
|
|
}
|
|
|
|
func clearMeta(v any) {
|
|
rv := reflect.ValueOf(v).Elem()
|
|
if f := rv.FieldByName("Meta"); f.IsValid() {
|
|
f.Set(reflect.Zero(f.Type()))
|
|
}
|
|
}
|