// Package tmpl renders reusable DNS templates for a concrete zone. // // A template stores records containing the {{domain_name}} placeholder; it is // materialised (placeholder -> zone name, without the trailing dot) just // before diff/apply against a specific domain, and parameterised (zone name // -> placeholder) when snapshotting a live zone into a reusable template. package tmpl import ( "regexp" "strings" "github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) const Placeholder = "{{domain_name}}" func zoneName(z string) string { return strings.TrimSuffix(z, ".") } // replaceZoneBoundary replaces occurrences of the zone name matched by re // with Placeholder, but only where the match sits on a DNS-label boundary: // the zone name must not be a suffix of a longer label. re must be built // from zoneBoundaryPattern for a specific zone name. A boundary is the // start/end of the string, or any character that cannot appear inside a DNS // label (i.e. not a letter, digit, or hyphen — a dot qualifies). This // prevents an external host that merely ends with the zone name as a // substring (e.g. "notreconops.ru." against zone "reconops.ru") from being // falsely parameterized. // // Replacement runs to a fixed point: a single regexp pass is non-overlapping, // so two adjacent zone occurrences separated by a single boundary character // (e.g. "reconops.ru.reconops.ru.") would only have the first one matched, // since that shared separator is consumed as the first match's right // boundary and is unavailable as the second match's left boundary. Placeholder // ends in "}", itself a valid boundary character, so re-running the pass // against the previous pass's output resolves this without double-counting. func replaceZoneBoundary(s string, re *regexp.Regexp) string { for { next := re.ReplaceAllString(s, "${1}"+Placeholder+"${2}") if next == s { return s } s = next } } // zoneBoundaryPattern builds the regexp source used to find z on a // DNS-label boundary within a record's Name or Value. func zoneBoundaryPattern(z string) string { return `(^|[^a-zA-Z0-9-])` + regexp.QuoteMeta(z) + `([^a-zA-Z0-9-]|$)` } // Materialize renders a template's records for the given zone, substituting // {{domain_name}} with the zone name (without the trailing dot) in each // record's Name and every Value. func Materialize(doc dto.TemplateDoc, zone string) []model.Record { z := zoneName(zone) out := make([]model.Record, 0, len(doc.Records)) for _, r := range doc.Records { vals := make([]string, len(r.Values)) for i, v := range r.Values { vals[i] = strings.ReplaceAll(v, Placeholder, z) } out = append(out, model.Record{ Type: model.RecordType(r.Type), Name: strings.ReplaceAll(r.Name, Placeholder, z), TTL: r.TTL, Values: vals, }) } return out } // Parameterize replaces occurrences of the zone name with {{domain_name}} in // record names and values, so a snapshot of a live zone becomes portable. // Records with no zone-name occurrence (DKIM key, external CNAME target) are // left unchanged. func Parameterize(recs []model.Record, zone string) dto.TemplateDoc { z := zoneName(zone) re := regexp.MustCompile(zoneBoundaryPattern(z)) out := dto.TemplateDoc{Records: make([]dto.RecordDTO, 0, len(recs))} for _, r := range recs { vals := make([]string, len(r.Values)) for i, v := range r.Values { vals[i] = replaceZoneBoundary(v, re) } out.Records = append(out.Records, dto.RecordDTO{ Type: string(r.Type), Name: replaceZoneBoundary(r.Name, re), TTL: r.TTL, Values: vals, }) } return out }