Encrypted DNS health, comparing a zone's views, --local everywhere
jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67ae0091b3
commit
13ffd8cad4
31 files changed
+1153
-18
No files matched your search
@@ -0,0 +1,104 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// ViewSet is what one view has for a name and type.
|
||||
type ViewSet struct {
|
||||
View string `json:"view"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||
IDs []int64 `json:"ids"` // the records, in the same order
|
||||
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||
}
|
||||
|
||||
// CompareRow is one name and type across views.
|
||||
type CompareRow struct {
|
||||
Name string `json:"name"` // relative to the zone
|
||||
FQDN string `json:"fqdn"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []ViewSet `json:"views"` // in the order of the zones given
|
||||
}
|
||||
|
||||
// CompareZones lines up the records of zones of the same name in
|
||||
// different views (docs/views.md#linked-zones), one row per name and type,
|
||||
// sorted like a zone file.
|
||||
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
||||
type key struct{ name, typ string }
|
||||
sets := make([]map[key][]*model.Record, len(zones))
|
||||
var keys []key
|
||||
seen := map[key]bool{}
|
||||
var all []*model.Record
|
||||
for i, z := range zones {
|
||||
sets[i] = map[key][]*model.Record{}
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
sets[i][k] = append(sets[i][k], r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
all = append(all, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(zones) > 0 {
|
||||
// Zone file order: the apex first, then by name and type.
|
||||
model.SortRecords(all, zones[0])
|
||||
keys = keys[:0]
|
||||
for _, r := range all {
|
||||
keys = append(keys, key{r.Name, r.Type})
|
||||
}
|
||||
}
|
||||
var out []CompareRow
|
||||
for _, k := range keys {
|
||||
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
|
||||
if len(zones) > 0 {
|
||||
row.Name = relName(k.name, zones[0].Name)
|
||||
}
|
||||
var first string
|
||||
for i, z := range zones {
|
||||
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
|
||||
rs := slices.Clone(sets[i][k])
|
||||
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
for _, r := range rs {
|
||||
d := r.Data
|
||||
if r.TTL != nil {
|
||||
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
|
||||
}
|
||||
if r.Disabled {
|
||||
d += " (disabled)"
|
||||
}
|
||||
vs.Records = append(vs.Records, d)
|
||||
vs.IDs = append(vs.IDs, r.ID)
|
||||
}
|
||||
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||
vs.Linked = "shared"
|
||||
if rs[0].SharedID == 0 {
|
||||
vs.Linked = "local"
|
||||
}
|
||||
}
|
||||
sig := strings.Join(vs.Records, "\n")
|
||||
if i == 0 {
|
||||
first = sig
|
||||
} else if sig != first {
|
||||
row.Same = false
|
||||
}
|
||||
row.Views = append(row.Views, vs)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relName(name, zone string) string {
|
||||
if name == zone {
|
||||
return "@"
|
||||
}
|
||||
return strings.TrimSuffix(name, "."+zone)
|
||||
}
|
||||
+18
-11
@@ -801,9 +801,10 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
in.SharedID = &ids[0]
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
// local on a zone that isn't linked: its records are local anyway.
|
||||
in.Local = nil
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||
if ptr {
|
||||
@@ -834,10 +835,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
var pre []model.Op
|
||||
if in.Local != nil {
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
if z.LinkGroup == 0 && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
if want := *in.Local; want != (r.SharedID == 0) {
|
||||
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||
// The whole set of this name and type changes sides.
|
||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||
id := int64(0)
|
||||
@@ -848,10 +849,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||
}
|
||||
if !want {
|
||||
// Sharing a version makes it every view's.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
}
|
||||
if z.LinkGroup != 0 && !*in.Local {
|
||||
// Sharing a version makes it every view's, also when it is
|
||||
// shared already and another view overrides it.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
in.Local = nil
|
||||
if string(mustJSON(in)) == "{}" {
|
||||
@@ -1153,8 +1155,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
// Explicitly shared: this version becomes every view's.
|
||||
pre = x.dropOverrides(z, owner, typ)
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
in.Local = nil
|
||||
if len(canon) > 0 {
|
||||
@@ -1356,3 +1358,8 @@ func firstNonEmptyStr(a, b string) string {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// notLinked refuses sharing a record of a zone that isn't linked.
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
@@ -285,3 +285,59 @@ func TestLinkExisting(t *testing.T) {
|
||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||
}
|
||||
}
|
||||
|
||||
// --local on a zone that isn't linked is fine (its records are local
|
||||
// anyway); --shared there is refused.
|
||||
func TestLocalOnUnlinkedZone(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
local, shared := true, false
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
|
||||
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
|
||||
t.Errorf("records: %q", v)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
|
||||
t.Errorf("--shared on an unlinked zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := st.ZonesNamed("example.com.")
|
||||
st.SortByView(zones)
|
||||
diff := map[string][]string{}
|
||||
same := 0
|
||||
for _, row := range CompareZones(st, zones) {
|
||||
if row.Same {
|
||||
same++
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for _, vs := range row.Views {
|
||||
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
|
||||
}
|
||||
diff[row.Name+" "+row.Type] = cells
|
||||
}
|
||||
want := map[string][]string{
|
||||
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
|
||||
"nas A": {"internal=10.0.0.20/local", "external=/"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !slices.Equal(diff[k], v) {
|
||||
t.Errorf("%s: %q, want %q", k, diff[k], v)
|
||||
}
|
||||
}
|
||||
if len(diff) != 2 || same < 3 {
|
||||
t.Errorf("differ %v, same %d", diff, same)
|
||||
}
|
||||
// "Use this version" on the shared one replaces external's override.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
|
||||
t.Errorf("sharing the shared version: external %q", v)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user