144 lines
3.4 KiB
Go
144 lines
3.4 KiB
Go
package model
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
type RecordType string
|
|
|
|
const (
|
|
A RecordType = "A"
|
|
AAAA RecordType = "AAAA"
|
|
CNAME RecordType = "CNAME"
|
|
MX RecordType = "MX"
|
|
TXT RecordType = "TXT"
|
|
SRV RecordType = "SRV"
|
|
NS RecordType = "NS"
|
|
SOA RecordType = "SOA"
|
|
)
|
|
|
|
// Managed reports whether the type participates in diff+apply.
|
|
// NS and SOA are read-only.
|
|
func (t RecordType) Managed() bool {
|
|
switch t {
|
|
case A, AAAA, CNAME, MX, TXT, SRV:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Record is the provider-neutral representation of a DNS RRset.
|
|
// For MX the value is "<priority> <target>"; for SRV it is
|
|
// "<priority> <weight> <port> <target>". Values is an unordered set.
|
|
type Record struct {
|
|
Type RecordType
|
|
Name string
|
|
TTL int
|
|
Values []string
|
|
}
|
|
|
|
// Key uniquely identifies an RRset within a zone.
|
|
func (r Record) Key() string {
|
|
return string(r.Type) + " " + normalizeName(r.Name)
|
|
}
|
|
|
|
func normalizeName(name string) string {
|
|
n := strings.ToLower(strings.TrimSpace(name))
|
|
if n != "" && !strings.HasSuffix(n, ".") {
|
|
n += "."
|
|
}
|
|
return n
|
|
}
|
|
|
|
// normalizeValue canonicalizes a single RR value for comparison.
|
|
func normalizeValue(t RecordType, content string) string {
|
|
if t == TXT {
|
|
// byte-exact — case and whitespace are significant (DKIM/SPF/DMARC);
|
|
// only the presentation quoting/chunking is stripped.
|
|
return TXTPayload(content)
|
|
}
|
|
c := strings.Join(strings.Fields(content), " ") // collapse whitespace
|
|
switch t {
|
|
case MX:
|
|
parts := strings.SplitN(c, " ", 2)
|
|
if len(parts) == 2 {
|
|
return parts[0] + " " + normalizeName(parts[1])
|
|
}
|
|
return c
|
|
case SRV:
|
|
f := strings.Fields(c)
|
|
if len(f) == 4 {
|
|
return f[0] + " " + f[1] + " " + f[2] + " " + normalizeName(f[3])
|
|
}
|
|
return c
|
|
case CNAME, NS:
|
|
return normalizeName(c)
|
|
default: // A, AAAA, SOA
|
|
return strings.ToLower(c)
|
|
}
|
|
}
|
|
|
|
// NormalizedValues returns sorted, normalized values.
|
|
func (r Record) NormalizedValues() []string {
|
|
out := make([]string, len(r.Values))
|
|
for i, v := range r.Values {
|
|
out[i] = normalizeValue(r.Type, v)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// Equal reports whether two records have the same TTL and value set.
|
|
func (r Record) Equal(o Record) bool {
|
|
if r.TTL != o.TTL {
|
|
return false
|
|
}
|
|
return slices.Equal(r.NormalizedValues(), o.NormalizedValues())
|
|
}
|
|
|
|
// TXTPayload returns the logical content of a TXT value. A value written as
|
|
// one or more quoted strings ("a" "b") is the concatenation of their
|
|
// contents; an unquoted value is returned as is. This makes a template value
|
|
// without quotes comparable with the quoted form providers store.
|
|
func TXTPayload(v string) string {
|
|
t := strings.TrimSpace(v)
|
|
if !strings.HasPrefix(t, `"`) {
|
|
return v
|
|
}
|
|
var b strings.Builder
|
|
inQ := false
|
|
for i := 0; i < len(t); i++ {
|
|
c := t[i]
|
|
switch {
|
|
case inQ && c == '\\' && i+1 < len(t):
|
|
i++
|
|
b.WriteByte(t[i])
|
|
case c == '"':
|
|
inQ = !inQ
|
|
case inQ:
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// QuoteTXT renders a TXT value in the quoted form providers require: payload
|
|
// split into <=255-byte strings, each quoted. Already-quoted values pass
|
|
// through unchanged.
|
|
func QuoteTXT(v string) string {
|
|
if strings.HasPrefix(strings.TrimSpace(v), `"`) {
|
|
return v
|
|
}
|
|
const max = 255
|
|
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
|
|
var parts []string
|
|
for len(v) > max {
|
|
parts = append(parts, `"`+esc.Replace(v[:max])+`"`)
|
|
v = v[max:]
|
|
}
|
|
parts = append(parts, `"`+esc.Replace(v)+`"`)
|
|
return strings.Join(parts, " ")
|
|
}
|