package engine import ( "fmt" "slices" "strings" "jarl/internal/dnsname" "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 Data []string `json:"data"` // their data alone, in the same order TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL 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{}, Data: []string{}} 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) vs.Data = append(vs.Data, r.Data) if vs.TTL == nil && r.TTL != nil { vs.TTL = model.Ptr(*r.TTL) } } 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) } // CompareReport is a zone in one view (the base) next to the same zone in // other views (docs/views.md#comparing-views): what the other views answer // differently, the names only some views have, and how much is the same. type CompareReport struct { Zone string `json:"zone"` Base string `json:"base"` // the view compared from Views []string `json:"views"` // the base first Linked bool `json:"linked"` // all in one link // Summary is, per other view, how it differs from the base. Summary []CompareSummary `json:"summary"` // Different are names and types more than one view has, with // different records. Different []CompareRow `json:"different"` // Only are the names and types only some views have, by the views // that have them. Only []CompareOnly `json:"only"` Same int `json:"same"` // SameRows are the ones that are the same (when asked for). SameRows []CompareRow `json:"same_rows,omitempty"` } // CompareSummary counts how a view differs from the base view. type CompareSummary struct { View string `json:"view"` Different int `json:"different"` // both have it, with different records OnlyBase int `json:"only_base"` // the base has it, this view doesn't OnlyOther int `json:"only_other"` // this view has it, the base doesn't Same int `json:"same"` } // CompareOnly are names and types that only some views have. type CompareOnly struct { Views []string `json:"views"` Rows []CompareRow `json:"rows"` } // CompareFilter narrows a comparison to a name, and optionally a type. type CompareFilter struct { Name string // relative ("www", "@") or absolute Type string All bool // include what is the same } // Compare builds the report for zones (the base first). Apex NS records are // left out: they name the servers, which views often list differently on // purpose. func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport { rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true} for _, z := range zones { rep.Views = append(rep.Views, st.ViewName(z)) rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup } for _, v := range rep.Views[1:] { rep.Summary = append(rep.Summary, CompareSummary{View: v}) } want := "" if f.Name != "" { if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil { want = o } } only := map[string]int{} for _, row := range CompareZones(st, zones) { if row.FQDN == zones[0].Name && row.Type == "NS" { continue } if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) { continue } base := row.Views[0] for i, vs := range row.Views[1:] { s := &rep.Summary[i] switch { case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records): s.Same++ case len(base.Records) > 0 && len(vs.Records) > 0: s.Different++ case len(base.Records) > 0: s.OnlyBase++ case len(vs.Records) > 0: s.OnlyOther++ } } if row.Same { rep.Same++ if f.All || want != "" { rep.SameRows = append(rep.SameRows, row) } continue } var having []string versions := map[string]bool{} for _, vs := range row.Views { if len(vs.Records) > 0 { having = append(having, vs.View) versions[strings.Join(vs.Records, "\n")] = true } } if len(versions) > 1 { rep.Different = append(rep.Different, row) continue } k := strings.Join(having, ",") i, ok := only[k] if !ok { i = len(rep.Only) only[k] = i rep.Only = append(rep.Only, CompareOnly{Views: having}) } rep.Only[i].Rows = append(rep.Only[i].Rows, row) } // The base's own names first, then by how many views have them. slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int { ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base) if ab != bb { if ab { return -1 } return 1 } return len(b.Views) - len(a.Views) }) return rep }