Files
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:49 +01:00

251 lines
9.9 KiB
Go

package engine
import (
"fmt"
"reflect"
"slices"
"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,
Exposure: &s.Exposure, Acknowledged: model.Ptr(slices.Clone(s.Acknowledged)), DismissedReverse: model.Ptr(slices.Clone(s.DismissedReverse)),
EncryptedDNSHosts: model.Ptr(slices.Clone(s.EncryptedDNSHosts))}, "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),
Exposure: model.Ptr(n.Exposure), Acknowledged: model.Ptr(slices.Clone(n.Acknowledged)),
ReverseSuggestions: model.Ptr(!n.ReverseSuggestionsOff), DismissedReverse: model.Ptr(slices.Clone(n.DismissedReverse))}
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, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
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, SharedID: model.Ptr(n.SharedID)}
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))
}
}
// Steps before the lists and groups that use them.
for _, k := range sortedKeys(to.BlockSteps) {
n := to.BlockSteps[k]
in := BlockStepInput{Name: &n.Name, Position: model.Ptr(n.Position), Comment: &n.Comment}
if o, ok := from.BlockSteps[k]; !ok {
add(OpBlockStepCreate, k, 0, in, "add blocking step "+n.Name)
} else if !sameObject(o, n) {
add(OpBlockStepUpdate, k, o.Version, in, "restore blocking step "+n.Name)
}
}
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, StepID: model.Ptr(n.StepID)}
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))
}
}
for _, k := range sortedKeys(to.ClientGroups) {
n := to.ClientGroups[k]
in := ClientGroupInput{Name: &n.Name, Members: model.Ptr(append([]string{}, n.Members...)), StepID: model.Ptr(n.StepID), ViewIDs: model.Ptr(append([]int64{}, n.ViewIDs...)), Comment: &n.Comment}
if o, ok := from.ClientGroups[k]; !ok {
add(OpClientGroupCreate, k, 0, in, "add client group "+n.Name)
} else if !sameObject(o, n) {
add(OpClientGroupUpdate, k, o.Version, in, "restore client group "+n.Name)
}
}
// Deletions, most dependent objects first.
for _, k := range sortedKeys(from.ClientGroups) {
if _, ok := to.ClientGroups[k]; !ok {
add(OpClientGroupDelete, k, from.ClientGroups[k].Version, nil, "delete client group "+from.ClientGroups[k].Name)
}
}
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.BlockSteps) {
if _, ok := to.BlockSteps[k]; !ok {
add(OpBlockStepDelete, k, from.BlockSteps[k].Version, nil, "delete blocking step "+from.BlockSteps[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()))
}
}