package localzone import ( "fmt" "path/filepath" "sort" "strconv" "strings" "github.com/miekg/dns" ) // Record is one authoritative record as the API and the console see it. // // The compiled store keeps plain dns.RR slices, because that is what the // query path reads; this presentation form is built on demand for the admin // API only, so a zone browser can show what the operator actually wrote. type Record struct { // Name is the fully-qualified owner, trailing dot included. A wildcard // owner is reported as written ("*.example.com."). Name string `json:"name"` // Type is the RR type, uppercase ("A", "MX", "NS"). Type string `json:"type"` // TTL is the record's own TTL in seconds. TTL uint32 `json:"ttl"` // Value is the presentation-format RDATA with every domain name // absolute, so a record read out of the store can be pasted into a new // zone file unchanged. Value string `json:"value"` // Wildcard is true when the owner was written "*.name". Wildcard bool `json:"wildcard"` // Delegation is true for an NS record below the apex: the cut that hands // the names under it to another server. Delegation bool `json:"delegation"` // Managed is true when the record came from the console-managed // directory, the only data the API may rewrite. Managed bool `json:"managed"` // Source is the file the record was parsed from. Source string `json:"source"` } // Key identifies the RRset a record belongs to: owner name plus type. A // delegation is the NS RRset of one name, so it is the same key. func (r Record) Key() string { return r.Name + "|" + r.Type } // Zone is a compiled zone as the admin API reports it. type Zone struct { // Origin is the apex, lowercased and fully qualified. Origin string `json:"origin"` // Serial is the SOA serial, 0 when the zone has no SOA. Serial uint32 `json:"serial"` // Soa is the zone's start of authority in presentation form, with every // name absolute, or "" when the zone has no SOA. It is not a record the // API writes: the writer owns it, and a new managed file is seeded from // this so a zone keeps the server list it already had. Soa string `json:"soa"` // DefaultTTL is the zone's smallest TTL, which is what a record written // without one inherits. DefaultTTL uint32 `json:"default_ttl"` // Records are sorted by owner, then type, then value. Records []Record `json:"records"` // Delegations is the subset of Records that is a cut below the apex, // with the NS targets collected per name. Delegations []Delegation `json:"delegations"` // RecordCount is len(Records). RecordCount int `json:"record_count"` // Managed is true when every source file of the zone is inside the // console-managed directory, i.e. the whole zone is API-writable. Managed bool `json:"managed"` // Files are the sources this zone was compiled from. Files []string `json:"files"` } // Delegation is a cut in the zone: a name below the apex whose NS records // hand it, and everything under it, to other servers. type Delegation struct { // Name is the delegated name, fully qualified. Name string `json:"name"` // Targets are the NS targets, lowercased and fully qualified. Targets []string `json:"targets"` // Managed is true when the delegation came from the managed directory. Managed bool `json:"managed"` } // Snapshot renders one zone for the API, or nil when the store holds no such // zone. Records from every source file are included and each one carries the // file it came from, because the console has to be able to tell an // operator's read-only master file apart from data it may rewrite. func (s *Store) Snapshot(origin, managedDir string) *Zone { if s == nil { return nil } z, ok := s.zones[strings.ToLower(dns.Fqdn(origin))] if !ok { return nil } view := &Zone{ Origin: z.origin, DefaultTTL: z.minTTL, Files: append([]string(nil), z.sources...), Managed: true, } if z.soa != nil { view.Serial = z.soa.Serial view.Soa = fmt.Sprintf("%s %s %d %d %d %d %d", z.soa.Ns, z.soa.Mbox, z.soa.Serial, z.soa.Refresh, z.soa.Retry, z.soa.Expire, z.soa.Minttl) } for k, rrs := range z.exact { name, rrtype, _ := strings.Cut(k, "|") for _, rr := range rrs { view.Records = append(view.Records, z.record(rr, name, rrtype, k, managedDir)) } } for name, byType := range z.wild { for rrtype, rrs := range byType { rrtypeStr := dns.TypeToString[rrtype] k := wildKey(name, rrtypeStr) for _, rr := range rrs { view.Records = append(view.Records, z.record(rr, "*."+name, rrtypeStr, k, managedDir)) } } } for _, rec := range view.Records { if !rec.Managed { view.Managed = false break } } sortRecords(view.Records) view.RecordCount = len(view.Records) view.Delegations = delegations(view.Records) return view } // SnapshotAll renders every compiled zone, sorted by apex. func (s *Store) SnapshotAll(managedDir string) []*Zone { if s == nil { return nil } out := make([]*Zone, 0, len(s.zones)) for _, origin := range s.Zones() { if z := s.Snapshot(origin, managedDir); z != nil { out = append(out, z) } } return out } // record builds the presentation form of one compiled record. key is the // index it is stored under, which is where its source file is recorded. func (z *zone) record(rr dns.RR, name, rrtype, key, managedDir string) Record { rec := Record{ Name: name, Type: rrtype, TTL: rr.Header().Ttl, Value: rdata(rr), Wildcard: strings.HasPrefix(name, "*."), Source: z.source(key), } // The apex NS set is the zone's own server list, not a cut; only an NS // record below the apex hands names to another server. rec.Delegation = rec.Type == "NS" && !rec.Wildcard && name != z.origin rec.Managed = inDir(rec.Source, managedDir) return rec } // delegations collects the cuts out of a rendered record list. func delegations(recs []Record) []Delegation { byName := make(map[string]*Delegation) var order []string for _, rec := range recs { if !rec.Delegation { continue } d, ok := byName[rec.Name] if !ok { d = &Delegation{Name: rec.Name, Managed: rec.Managed} byName[rec.Name] = d order = append(order, rec.Name) } d.Targets = append(d.Targets, rec.Value) } sort.Strings(order) out := make([]Delegation, 0, len(order)) for _, name := range order { d := byName[name] sort.Strings(d.Targets) d.Targets = dedupe(d.Targets) out = append(out, *d) } return out } // rdata renders the RDATA of a record with domain names absolute. The generic // fallback is the RR's own String(), which is right for every type not listed // here. func rdata(rr dns.RR) string { switch v := rr.(type) { case *dns.A: return v.A.String() case *dns.AAAA: return v.AAAA.String() case *dns.CNAME: return v.Target case *dns.NS: return v.Ns case *dns.PTR: return v.Ptr case *dns.MX: return utoa(uint64(v.Preference)) + " " + v.Mx case *dns.SRV: return utoa(uint64(v.Priority)) + " " + utoa(uint64(v.Weight)) + " " + utoa(uint64(v.Port)) + " " + v.Target case *dns.TXT: return quoteStrings(v.Txt) case *dns.CAA: return utoa(uint64(v.Flag)) + " " + v.Tag + " " + quote(v.Value) default: // RR.String() is the whole line, owner and TTL included. The RDATA // is everything after the type, which is the fourth tab-separated // field. fields := strings.Split(rr.String(), "\t") if len(fields) < 5 { return strings.TrimSpace(rr.String()) } return strings.Join(fields[4:], " ") } } func utoa(v uint64) string { return strconv.FormatUint(v, 10) } func quoteStrings(in []string) string { out := make([]string, 0, len(in)) for _, s := range in { out = append(out, quote(s)) } return strings.Join(out, " ") } func quote(s string) string { var sb strings.Builder sb.Grow(len(s) + 2) sb.WriteByte('"') for i := 0; i < len(s); i++ { if c := s[i]; c == '"' || c == '\\' { sb.WriteByte('\\') } sb.WriteByte(s[i]) } sb.WriteByte('"') return sb.String() } // sortRecords orders a rendered zone by owner, then type, then value, so the // console's table is stable and the writer's output is deterministic. func sortRecords(recs []Record) { sort.Slice(recs, func(i, j int) bool { if recs[i].Name != recs[j].Name { return recs[i].Name < recs[j].Name } if recs[i].Type != recs[j].Type { return recs[i].Type < recs[j].Type } return recs[i].Value < recs[j].Value }) } func dedupe(in []string) []string { if len(in) == 0 { return in } out := in[:1] for _, s := range in[1:] { if s != out[len(out)-1] { out = append(out, s) } } return out } // inDir reports whether a source file lives under a directory, which is how a // record is recognised as console-managed. func inDir(file, dir string) bool { if file == "" || dir == "" { return false } abs, err := filepath.Abs(file) if err != nil { abs = file } base, err := filepath.Abs(dir) if err != nil { base = dir } rel, err := filepath.Rel(base, abs) if err != nil { return false } return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) }