diff --git a/models/dns.go b/models/dns.go index 7f00858d8..c50d61f6a 100644 --- a/models/dns.go +++ b/models/dns.go @@ -1,20 +1,7 @@ package models import ( - "bytes" - "encoding/gob" "encoding/json" - "fmt" - "log" - "net" - "reflect" - "strconv" - "strings" - - "github.com/StackExchange/dnscontrol/pkg/transform" - "github.com/miekg/dns" - "github.com/pkg/errors" - "golang.org/x/net/idna" ) // DefaultTTL is applied to any DNS record without an explicit TTL. @@ -53,287 +40,6 @@ type DNSProviderConfig struct { Metadata json.RawMessage `json:"meta,omitempty"` } -// RecordConfig stores a DNS record. -// Providers are responsible for validating or normalizing the data -// that goes into a RecordConfig. -// If you update Name, you have to update NameFQDN and vice-versa. -// -// Name: -// This is the shortname i.e. the NameFQDN without the origin suffix. -// It should never have a trailing "." -// It should never be null. It should store It "@", not the apex domain, not null, etc. -// It shouldn't end with the domain origin. If the origin is "foo.com." then -// if Name == "foo.com" then that literally means "foo.com.foo.com." is -// the intended FQDN. -// NameFQDN: -// This is the FQDN version of Name. -// It should never have a trailiing ".". -// Valid types: -// Official: -// A -// AAAA -// ANAME -// CAA -// CNAME -// MX -// NS -// PTR -// SRV -// TLSA -// TXT -// Pseudo-Types: -// ALIAs -// CF_REDIRECT -// CF_TEMP_REDIRECT -// FRAME -// IMPORT_TRANSFORM -// NAMESERVER -// NO_PURGE -// PAGE_RULE -// PURGE -// URL -// URL301 -type RecordConfig struct { - Type string `json:"type"` - Name string `json:"name"` // The short name. See below. - Target string `json:"target"` // If a name, must end with "." - TTL uint32 `json:"ttl,omitempty"` - Metadata map[string]string `json:"meta,omitempty"` - NameFQDN string `json:"-"` // Must end with ".$origin". See below. - MxPreference uint16 `json:"mxpreference,omitempty"` - SrvPriority uint16 `json:"srvpriority,omitempty"` - SrvWeight uint16 `json:"srvweight,omitempty"` - SrvPort uint16 `json:"srvport,omitempty"` - CaaTag string `json:"caatag,omitempty"` - CaaFlag uint8 `json:"caaflag,omitempty"` - TlsaUsage uint8 `json:"tlsausage,omitempty"` - TlsaSelector uint8 `json:"tlsaselector,omitempty"` - TlsaMatchingType uint8 `json:"tlsamatchingtype,omitempty"` - TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one. - R53Alias map[string]string `json:"r53_alias,omitempty"` - - CombinedTarget bool `json:"-"` - - Original interface{} `json:"-"` // Store pointer to provider-specific record object. Used in diffing. -} - -func (rc *RecordConfig) String() (content string) { - if rc.CombinedTarget { - return rc.Target - } - - content = fmt.Sprintf("%s %s %s %d", rc.Type, rc.NameFQDN, rc.Target, rc.TTL) - switch rc.Type { // #rtype_variations - case "A", "AAAA", "CNAME", "NS", "PTR", "TXT": - // Nothing special. - case "MX": - content += fmt.Sprintf(" pref=%d", rc.MxPreference) - case "SOA": - content = fmt.Sprintf("%s %s %s %d", rc.Type, rc.Name, rc.Target, rc.TTL) - case "SRV": - content += fmt.Sprintf(" srvpriority=%d srvweight=%d srvport=%d", rc.SrvPriority, rc.SrvWeight, rc.SrvPort) - case "TLSA": - content += fmt.Sprintf(" tlsausage=%d tlsaselector=%d tlsamatchingtype=%d", rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType) - case "CAA": - content += fmt.Sprintf(" caatag=%s caaflag=%d", rc.CaaTag, rc.CaaFlag) - case "R53_ALIAS": - content += fmt.Sprintf(" type=%s zone_id=%s", rc.R53Alias["type"], rc.R53Alias["zone_id"]) - default: - msg := fmt.Sprintf("rc.String rtype %v unimplemented", rc.Type) - panic(msg) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. - } - for k, v := range rc.Metadata { - content += fmt.Sprintf(" %s=%s", k, v) - } - return content -} - -// Content combines Target and other fields into one string. -func (rc *RecordConfig) Content() string { - if rc.CombinedTarget { - return rc.Target - } - - // If this is a pseudo record, just return the target. - if _, ok := dns.StringToType[rc.Type]; !ok { - return rc.Target - } - - // We cheat by converting to a dns.RR and use the String() function. - // Sadly that function always includes a header, which we must strip out. - // TODO(tlim): Request the dns project add a function that returns - // the string without the header. - rr := rc.ToRR() - header := rr.Header().String() - full := rr.String() - if !strings.HasPrefix(full, header) { - panic("dns.Hdr.String() not acting as we expect") - } - return full[len(header):] -} - -// MergeToTarget combines "extra" fields into .Target, and zeros the merged fields. -func (rc *RecordConfig) MergeToTarget() { - if rc.CombinedTarget { - pm := strings.Join([]string{"MergeToTarget: Already collapsed: ", rc.Name, rc.Target}, " ") - panic(pm) - } - - // Merge "extra" fields into the Target. - rc.Target = rc.Content() - - // Zap any fields that may have been merged. - rc.MxPreference = 0 - rc.SrvPriority = 0 - rc.SrvWeight = 0 - rc.SrvPort = 0 - rc.CaaFlag = 0 - rc.CaaTag = "" - rc.TlsaUsage = 0 - rc.TlsaMatchingType = 0 - rc.TlsaSelector = 0 - - rc.CombinedTarget = true -} - -// ToRR converts a RecordConfig to a dns.RR. -func (rc *RecordConfig) ToRR() dns.RR { - - // Don't call this on fake types. - rdtype, ok := dns.StringToType[rc.Type] - if !ok { - log.Fatalf("No such DNS type as (%#v)\n", rc.Type) - } - - // Magicallly create an RR of the correct type. - rr := dns.TypeToRR[rdtype]() - - // Fill in the header. - rr.Header().Name = rc.NameFQDN + "." - rr.Header().Rrtype = rdtype - rr.Header().Class = dns.ClassINET - rr.Header().Ttl = rc.TTL - if rc.TTL == 0 { - rr.Header().Ttl = DefaultTTL - } - - // Fill in the data. - switch rdtype { // #rtype_variations - case dns.TypeA: - rr.(*dns.A).A = net.ParseIP(rc.Target) - case dns.TypeAAAA: - rr.(*dns.AAAA).AAAA = net.ParseIP(rc.Target) - case dns.TypeCNAME: - rr.(*dns.CNAME).Target = rc.Target - case dns.TypePTR: - rr.(*dns.PTR).Ptr = rc.Target - case dns.TypeMX: - rr.(*dns.MX).Preference = rc.MxPreference - rr.(*dns.MX).Mx = rc.Target - case dns.TypeNS: - rr.(*dns.NS).Ns = rc.Target - case dns.TypeSOA: - t := strings.Replace(rc.Target, `\ `, ` `, -1) - parts := strings.Fields(t) - rr.(*dns.SOA).Ns = parts[0] - rr.(*dns.SOA).Mbox = parts[1] - rr.(*dns.SOA).Serial = atou32(parts[2]) - rr.(*dns.SOA).Refresh = atou32(parts[3]) - rr.(*dns.SOA).Retry = atou32(parts[4]) - rr.(*dns.SOA).Expire = atou32(parts[5]) - rr.(*dns.SOA).Minttl = atou32(parts[6]) - case dns.TypeSRV: - rr.(*dns.SRV).Priority = rc.SrvPriority - rr.(*dns.SRV).Weight = rc.SrvWeight - rr.(*dns.SRV).Port = rc.SrvPort - rr.(*dns.SRV).Target = rc.Target - case dns.TypeCAA: - rr.(*dns.CAA).Flag = rc.CaaFlag - rr.(*dns.CAA).Tag = rc.CaaTag - rr.(*dns.CAA).Value = rc.Target - case dns.TypeTLSA: - rr.(*dns.TLSA).Usage = rc.TlsaUsage - rr.(*dns.TLSA).MatchingType = rc.TlsaMatchingType - rr.(*dns.TLSA).Selector = rc.TlsaSelector - rr.(*dns.TLSA).Certificate = rc.Target - case dns.TypeTXT: - rr.(*dns.TXT).Txt = rc.TxtStrings - default: - panic(fmt.Sprintf("ToRR: Unimplemented rtype %v", rc.Type)) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. - } - - return rr -} - -func atou32(s string) uint32 { - i64, err := strconv.ParseInt(s, 10, 32) - if err != nil { - panic(fmt.Sprintf("atou32 failed (%v) (err=%v", s, err)) - } - return uint32(i64) -} - -// Records is a list of *RecordConfig. -type Records []*RecordConfig - -// Grouped returns a map of keys to records. -func (r Records) Grouped() map[RecordKey]Records { - groups := map[RecordKey]Records{} - for _, rec := range r { - groups[rec.Key()] = append(groups[rec.Key()], rec) - } - return groups -} - -// PostProcessRecords does any post-processing of the downloaded DNS records. -func PostProcessRecords(recs []*RecordConfig) { - Downcase(recs) - fixTxt(recs) -} - -// Downcase converts all labels and targets to lowercase in a list of RecordConfig. -func Downcase(recs []*RecordConfig) { - for _, r := range recs { - r.Name = strings.ToLower(r.Name) - r.NameFQDN = strings.ToLower(r.NameFQDN) - switch r.Type { - case "ANAME", "CNAME", "MX", "NS", "PTR": - r.Target = strings.ToLower(r.Target) - case "A", "AAAA", "ALIAS", "CAA", "IMPORT_TRANSFORM", "SRV", "TLSA", "TXT", "SOA", "CF_REDIRECT", "CF_TEMP_REDIRECT": - // Do nothing. - default: - // TODO: we'd like to panic here, but custom record types complicate things. - } - } - return -} - -// fixTxt fixes TXT records generated by providers that do not understand CanUseTXTMulti. -func fixTxt(recs []*RecordConfig) { - for _, r := range recs { - if r.Type == "TXT" { - if len(r.TxtStrings) == 0 { - r.TxtStrings = []string{r.Target} - } - } - } -} - -// RecordKey represents a resource record in a format used by some systems. -type RecordKey struct { - Name string - Type string -} - -// Key converts a RecordConfig into a RecordKey. -func (rc *RecordConfig) Key() RecordKey { - return RecordKey{rc.Name, rc.Type} -} - // Nameserver describes a nameserver. type Nameserver struct { Name string `json:"name"` // Normalized to a FQDN with NO trailing "." @@ -349,252 +55,6 @@ func StringsToNameservers(nss []string) []*Nameserver { return nservers } -// DomainConfig describes a DNS domain (tecnically a DNS zone). -type DomainConfig struct { - Name string `json:"name"` // NO trailing "." - RegistrarName string `json:"registrar"` - DNSProviderNames map[string]int `json:"dnsProviders"` - - Metadata map[string]string `json:"meta,omitempty"` - Records Records `json:"records"` - Nameservers []*Nameserver `json:"nameservers,omitempty"` - KeepUnknown bool `json:"keepunknown,omitempty"` - IgnoredLabels []string `json:"ignored_labels,omitempty"` - - // These fields contain instantiated provider instances once everything is linked up. - // This linking is in two phases: - // 1. Metadata (name/type) is availible just from the dnsconfig. Validation can use that. - // 2. Final driver instances are loaded after we load credentials. Any actual provider interaction requires that. - RegistrarInstance *RegistrarInstance `json:"-"` - DNSProviderInstances []*DNSProviderInstance `json:"-"` -} - -// Copy returns a deep copy of the DomainConfig. -func (dc *DomainConfig) Copy() (*DomainConfig, error) { - newDc := &DomainConfig{} - // provider instances are interfaces that gob hates if you don't register them. - // and the specific types are not gob encodable since nothing is exported. - // should find a better solution for this now. - // - // current strategy: remove everything, gob copy it. Then set both to stored copy. - reg := dc.RegistrarInstance - dnsps := dc.DNSProviderInstances - dc.RegistrarInstance = nil - dc.DNSProviderInstances = nil - err := copyObj(dc, newDc) - dc.RegistrarInstance = reg - newDc.RegistrarInstance = reg - dc.DNSProviderInstances = dnsps - newDc.DNSProviderInstances = dnsps - return newDc, err -} - -// Copy returns a deep copy of a RecordConfig. -func (rc *RecordConfig) Copy() (*RecordConfig, error) { - newR := &RecordConfig{} - err := copyObj(rc, newR) - return newR, err -} - -// Punycode will convert all records to punycode format. -// It will encode: -// - Name -// - NameFQDN -// - Target (CNAME and MX only) -func (dc *DomainConfig) Punycode() error { - var err error - for _, rec := range dc.Records { - rec.Name, err = idna.ToASCII(rec.Name) - if err != nil { - return err - } - rec.NameFQDN, err = idna.ToASCII(rec.NameFQDN) - if err != nil { - return err - } - switch rec.Type { // #rtype_variations - case "ALIAS", "MX", "NS", "CNAME", "PTR", "SRV", "URL", "URL301", "FRAME", "R53_ALIAS": - rec.Target, err = idna.ToASCII(rec.Target) - if err != nil { - return err - } - case "A", "AAAA", "CAA", "TXT", "TLSA": - // Nothing to do. - default: - msg := fmt.Sprintf("Punycode rtype %v unimplemented", rec.Type) - panic(msg) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. - } - } - return nil -} - -// CombineMXs will merge the priority into the target field for all mx records. -// Useful for providers that desire them as one field. -func (dc *DomainConfig) CombineMXs() { - for _, rec := range dc.Records { - if rec.Type == "MX" { - if rec.CombinedTarget { - pm := strings.Join([]string{"CombineMXs: Already collapsed: ", rec.Name, rec.Target}, " ") - panic(pm) - } - rec.Target = fmt.Sprintf("%d %s", rec.MxPreference, rec.Target) - rec.MxPreference = 0 - rec.CombinedTarget = true - } - } -} - -// SplitCombinedMxValue splits a combined MX preference and target into -// separate entities, i.e. splitting "10 aspmx2.googlemail.com." -// into "10" and "aspmx2.googlemail.com.". -func SplitCombinedMxValue(s string) (preference uint16, target string, err error) { - parts := strings.Fields(s) - - if len(parts) != 2 { - return 0, "", errors.Errorf("MX value %#v contains too many fields", s) - } - - n64, err := strconv.ParseUint(parts[0], 10, 16) - if err != nil { - return 0, "", errors.Errorf("MX preference %#v does not fit into a uint16", parts[0]) - } - return uint16(n64), parts[1], nil -} - -// CombineSRVs will merge the priority, weight, and port into the target field for all srv records. -// Useful for providers that desire them as one field. -func (dc *DomainConfig) CombineSRVs() { - for _, rec := range dc.Records { - if rec.Type == "SRV" { - if rec.CombinedTarget { - pm := strings.Join([]string{"CombineSRVs: Already collapsed: ", rec.Name, rec.Target}, " ") - panic(pm) - } - rec.Target = fmt.Sprintf("%d %d %d %s", rec.SrvPriority, rec.SrvWeight, rec.SrvPort, rec.Target) - rec.CombinedTarget = true - } - } -} - -// SplitCombinedSrvValue splits a combined SRV priority, weight, port and target into -// separate entities, some DNS providers want "5" "10" 15" and "foo.com.", -// while other providers want "5 10 15 foo.com.". -func SplitCombinedSrvValue(s string) (priority, weight, port uint16, target string, err error) { - parts := strings.Fields(s) - - if len(parts) != 4 { - return 0, 0, 0, "", errors.Errorf("SRV value %#v contains too many fields", s) - } - - priorityconv, err := strconv.ParseInt(parts[0], 10, 16) - if err != nil { - return 0, 0, 0, "", errors.Errorf("Priority %#v does not fit into a uint16", parts[0]) - } - weightconv, err := strconv.ParseInt(parts[1], 10, 16) - if err != nil { - return 0, 0, 0, "", errors.Errorf("Weight %#v does not fit into a uint16", parts[0]) - } - portconv, err := strconv.ParseInt(parts[2], 10, 16) - if err != nil { - return 0, 0, 0, "", errors.Errorf("Port %#v does not fit into a uint16", parts[0]) - } - return uint16(priorityconv), uint16(weightconv), uint16(portconv), parts[3], nil -} - -// CombineCAAs will merge the tags and flags into the target field for all CAA records. -// Useful for providers that desire them as one field. -func (dc *DomainConfig) CombineCAAs() { - for _, rec := range dc.Records { - if rec.Type == "CAA" { - if rec.CombinedTarget { - pm := strings.Join([]string{"CombineCAAs: Already collapsed: ", rec.Name, rec.Target}, " ") - panic(pm) - } - rec.Target = rec.Content() - rec.CombinedTarget = true - } - } -} - -// SplitCombinedCaaValue parses a string listing the parts of a CAA record into its components. -func SplitCombinedCaaValue(s string) (tag string, flag uint8, value string, err error) { - - splitData := strings.SplitN(s, " ", 3) - if len(splitData) != 3 { - err = errors.Errorf("Unexpected data for CAA record returned by Vultr") - return - } - - lflag, err := strconv.ParseUint(splitData[0], 10, 8) - if err != nil { - return - } - flag = uint8(lflag) - - tag = splitData[1] - - value = splitData[2] - if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { - value = value[1 : len(value)-1] - } - if strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`) { - value = value[1 : len(value)-1] - } - return -} - -func copyObj(input interface{}, output interface{}) error { - buf := &bytes.Buffer{} - enc := gob.NewEncoder(buf) - dec := gob.NewDecoder(buf) - if err := enc.Encode(input); err != nil { - return err - } - return dec.Decode(output) -} - -// HasRecordTypeName returns True if there is a record with this rtype and name. -func (dc *DomainConfig) HasRecordTypeName(rtype, name string) bool { - for _, r := range dc.Records { - if r.Type == rtype && r.Name == name { - return true - } - } - return false -} - -// Filter removes all records that don't match the filter f. -func (dc *DomainConfig) Filter(f func(r *RecordConfig) bool) { - recs := []*RecordConfig{} - for _, r := range dc.Records { - if f(r) { - recs = append(recs, r) - } - } - dc.Records = recs -} - -// InterfaceToIP returns an IP address when given a 32-bit value or a string. That is, -// dnsconfig.js output may represent IP addresses as either a string ("1.2.3.4") -// or as an numeric value (the integer representation of the 32-bit value). This function -// converts either to a net.IP. -func InterfaceToIP(i interface{}) (net.IP, error) { - switch v := i.(type) { - case float64: - u := uint32(v) - return transform.UintToIP(u), nil - case string: - if ip := net.ParseIP(v); ip != nil { - return ip, nil - } - return nil, errors.Errorf("%s is not a valid ip address", v) - default: - return nil, errors.Errorf("cannot convert type %s to ip", reflect.TypeOf(i)) - } -} - // Correction is anything that can be run. Implementation is up to the specific provider. type Correction struct { F func() error `json:"-"` diff --git a/models/domain.go b/models/domain.go new file mode 100644 index 000000000..455cbf9d6 --- /dev/null +++ b/models/domain.go @@ -0,0 +1,102 @@ +package models + +import ( + "fmt" + + "golang.org/x/net/idna" +) + +// DomainConfig describes a DNS domain (tecnically a DNS zone). +type DomainConfig struct { + Name string `json:"name"` // NO trailing "." + RegistrarName string `json:"registrar"` + DNSProviderNames map[string]int `json:"dnsProviders"` + + Metadata map[string]string `json:"meta,omitempty"` + Records Records `json:"records"` + Nameservers []*Nameserver `json:"nameservers,omitempty"` + KeepUnknown bool `json:"keepunknown,omitempty"` + IgnoredLabels []string `json:"ignored_labels,omitempty"` + + // These fields contain instantiated provider instances once everything is linked up. + // This linking is in two phases: + // 1. Metadata (name/type) is availible just from the dnsconfig. Validation can use that. + // 2. Final driver instances are loaded after we load credentials. Any actual provider interaction requires that. + RegistrarInstance *RegistrarInstance `json:"-"` + DNSProviderInstances []*DNSProviderInstance `json:"-"` +} + +// Copy returns a deep copy of the DomainConfig. +func (dc *DomainConfig) Copy() (*DomainConfig, error) { + newDc := &DomainConfig{} + // provider instances are interfaces that gob hates if you don't register them. + // and the specific types are not gob encodable since nothing is exported. + // should find a better solution for this now. + // + // current strategy: remove everything, gob copy it. Then set both to stored copy. + reg := dc.RegistrarInstance + dnsps := dc.DNSProviderInstances + dc.RegistrarInstance = nil + dc.DNSProviderInstances = nil + err := copyObj(dc, newDc) + dc.RegistrarInstance = reg + newDc.RegistrarInstance = reg + dc.DNSProviderInstances = dnsps + newDc.DNSProviderInstances = dnsps + return newDc, err +} + +// HasRecordTypeName returns True if there is a record with this rtype and name. +func (dc *DomainConfig) HasRecordTypeName(rtype, name string) bool { + for _, r := range dc.Records { + if r.Type == rtype && r.Name == name { + return true + } + } + return false +} + +// Filter removes all records that don't match the filter f. +func (dc *DomainConfig) Filter(f func(r *RecordConfig) bool) { + recs := []*RecordConfig{} + for _, r := range dc.Records { + if f(r) { + recs = append(recs, r) + } + } + dc.Records = recs +} + +// Punycode will convert all records to punycode format. +// It will encode: +// - Name +// - NameFQDN +// - Target (CNAME and MX only) +func (dc *DomainConfig) Punycode() error { + var err error + for _, rec := range dc.Records { + rec.Name, err = idna.ToASCII(rec.Name) + if err != nil { + return err + } + rec.NameFQDN, err = idna.ToASCII(rec.NameFQDN) + if err != nil { + return err + } + switch rec.Type { // #rtype_variations + case "ALIAS", "MX", "NS", "CNAME", "PTR", "SRV", "URL", "URL301", "FRAME", "R53_ALIAS": + rec.Target, err = idna.ToASCII(rec.Target) + if err != nil { + return err + } + case "A", "AAAA", "CAA", "TXT", "TLSA": + // Nothing to do. + default: + msg := fmt.Sprintf("Punycode rtype %v unimplemented", rec.Type) + panic(msg) + // We panic so that we quickly find any switch statements + // that have not been updated for a new RR type. + } + } + return nil +} diff --git a/models/provider.go b/models/provider.go index b71110802..3f063b683 100644 --- a/models/provider.go +++ b/models/provider.go @@ -1,25 +1,30 @@ package models +// DNSProvider is an interface for DNS Provider plug-ins. type DNSProvider interface { GetNameservers(domain string) ([]*Nameserver, error) GetDomainCorrections(dc *DomainConfig) ([]*Correction, error) } +// Registrar is an interface for Registrar plug-ins. type Registrar interface { GetRegistrarCorrections(dc *DomainConfig) ([]*Correction, error) } +// ProviderBase describes providers. type ProviderBase struct { Name string IsDefault bool ProviderType string } +// RegistrarInstance is a single registrar. type RegistrarInstance struct { ProviderBase Driver Registrar } +// DNSProviderInstance is a single DNS provider. type DNSProviderInstance struct { ProviderBase Driver DNSProvider diff --git a/models/txt.go b/models/quotes.go similarity index 56% rename from models/txt.go rename to models/quotes.go index db924c07e..2fcce74a3 100644 --- a/models/txt.go +++ b/models/quotes.go @@ -2,26 +2,6 @@ package models import "strings" -// SetTxt sets the value of a TXT record to s. -func (rc *RecordConfig) SetTxt(s string) { - rc.Target = s - rc.TxtStrings = []string{s} -} - -// SetTxts sets the value of a TXT record to the list of strings s. -func (rc *RecordConfig) SetTxts(s []string) { - rc.Target = s[0] - rc.TxtStrings = s -} - -// SetTxtParse sets the value of TXT record if the list of strings is combined into one string. -// `foo` -> []string{"foo"} -// `"foo"` -> []string{"foo"} -// `"foo" "bar"` -> []string{"foo" "bar"} -func (rc *RecordConfig) SetTxtParse(s string) { - rc.SetTxts(ParseQuotedTxt(s)) -} - // IsQuoted returns true if the string starts and ends with a double quote. func IsQuoted(s string) bool { if s == "" { @@ -47,7 +27,7 @@ func StripQuotes(s string) string { // ParseQuotedTxt returns the individual strings of a combined quoted string. // `foo` -> []string{"foo"} // `"foo"` -> []string{"foo"} -// `"foo" "bar"` -> []string{"foo" "bar"} +// `"foo" "bar"` -> []string{"foo", "bar"} // NOTE: it is assumed there is exactly one space between the quotes. func ParseQuotedTxt(s string) []string { if !IsQuoted(s) { diff --git a/models/txt_test.go b/models/quotes_test.go similarity index 70% rename from models/txt_test.go rename to models/quotes_test.go index 680bc1f0e..0dd31ce61 100644 --- a/models/txt_test.go +++ b/models/quotes_test.go @@ -56,16 +56,24 @@ func TestSetTxtParse(t *testing.T) { e1 string e2 []string }{ - {``, ``, []string{``}}, {`foo`, `foo`, []string{`foo`}}, {`"foo"`, `foo`, []string{`foo`}}, + {`"foo bar"`, `foo bar`, []string{`foo bar`}}, + {`foo bar`, `foo bar`, []string{`foo bar`}}, {`"aaa" "bbb"`, `aaa`, []string{`aaa`, `bbb`}}, } for i, test := range tests { - x := &RecordConfig{Type: "TXT"} - x.SetTxtParse(test.d1) - if x.Target != test.e1 { - t.Errorf("%v: expected Target=(%v) got (%v)", i, test.e1, x.Target) + ls := ParseQuotedTxt(test.d1) + if ls[0] != test.e1 { + t.Errorf("%v: expected Target=(%v) got (%v)", i, test.e1, ls[0]) + } + if len(ls) != len(test.e2) { + t.Errorf("%v: expected TxtStrings=(%v) got (%v)", i, test.e2, ls) + } + for i := range ls { + if len(ls[i]) != len(test.e2[i]) { + t.Errorf("%v: expected TxtStrings=(%v) got (%v)", i, test.e2, ls) + } } } } diff --git a/models/record.go b/models/record.go new file mode 100644 index 000000000..568b73f2b --- /dev/null +++ b/models/record.go @@ -0,0 +1,277 @@ +package models + +import ( + "fmt" + "log" + "strings" + + "github.com/miekg/dns" + "github.com/miekg/dns/dnsutil" + "github.com/pkg/errors" +) + +// RecordConfig stores a DNS record. +// Valid types: +// Official: +// A +// AAAA +// ANAME // Technically not an official rtype yet. +// CAA +// CNAME +// MX +// NS +// PTR +// SRV +// TLSA +// TXT +// Pseudo-Types: +// ALIAs +// CF_REDIRECT +// CF_TEMP_REDIRECT +// FRAME +// IMPORT_TRANSFORM +// NAMESERVER +// NO_PURGE +// PAGE_RULE +// PURGE +// URL +// URL301 +// +// Notes about the fields: +// +// Name: +// This is the shortname i.e. the NameFQDN without the origin suffix. +// It should never have a trailing "." +// It should never be null. The apex (naked domain) is stored as "@". +// If the origin is "foo.com." and Name is "foo.com", this literally means +// the intended FQDN is "foo.com.foo.com." (which may look odd) +// NameFQDN: +// This is the FQDN version of Name. +// It should never have a trailiing ".". +// NOTE: Eventually we will unexport Name/NameFQDN. Please start using +// the setters (SetLabel/SetLabelFromFQDN) and getters (GetLabel/GetLabelFQDN). +// as they will always work. +// Target: +// This is the host or IP address of the record, with +// the other related paramters (weight, priority, etc.) stored in individual +// fields. +// NOTE: Eventually we will unexport Target. Please start using the +// setters (SetTarget*) and getters (GetTarget*) as they will always work. +// +// Idioms: +// rec.Label() == "@" // Is this record at the apex? +// +type RecordConfig struct { + Type string `json:"type"` // All caps rtype name. + Name string `json:"name"` // The short name. See above. + NameFQDN string `json:"-"` // Must end with ".$origin". See above. + Target string `json:"target"` // If a name, must end with "." + TTL uint32 `json:"ttl,omitempty"` + Metadata map[string]string `json:"meta,omitempty"` + MxPreference uint16 `json:"mxpreference,omitempty"` + SrvPriority uint16 `json:"srvpriority,omitempty"` + SrvWeight uint16 `json:"srvweight,omitempty"` + SrvPort uint16 `json:"srvport,omitempty"` + CaaTag string `json:"caatag,omitempty"` + CaaFlag uint8 `json:"caaflag,omitempty"` + TlsaUsage uint8 `json:"tlsausage,omitempty"` + TlsaSelector uint8 `json:"tlsaselector,omitempty"` + TlsaMatchingType uint8 `json:"tlsamatchingtype,omitempty"` + TxtStrings []string `json:"txtstrings,omitempty"` // TxtStrings stores all strings (including the first). Target stores only the first one. + R53Alias map[string]string `json:"r53_alias,omitempty"` + + Original interface{} `json:"-"` // Store pointer to provider-specific record object. Used in diffing. +} + +// Copy returns a deep copy of a RecordConfig. +func (rc *RecordConfig) Copy() (*RecordConfig, error) { + newR := &RecordConfig{} + err := copyObj(rc, newR) + return newR, err +} + +// SetLabel sets the .Name/.NameFQDN fields given a short name and origin. +// origin must not have a trailing dot: The entire code base +// maintains dc.Name without the trailig dot. Finding a dot here means +// something is very wrong. +// short must not have a training dot: That would mean you have +// a FQDN, and shouldn't be using SetLabel(). Maybe SetLabelFromFQDN()? +func (rc *RecordConfig) SetLabel(short, origin string) { + + // Assertions that make sure the function is being used correctly: + if strings.HasSuffix(origin, ".") { + panic(errors.Errorf("origin (%s) is not supposed to end with a dot", origin)) + } + if strings.HasSuffix(short, ".") { + panic(errors.Errorf("short (%s) is not supposed to end with a dot", origin)) + } + + // TODO(tlim): We should add more validation here or in a separate validation + // module. We might want to check things like (\w+\.)+ + + short = strings.ToLower(short) + origin = strings.ToLower(origin) + if short == "" || short == "@" { + rc.Name = "@" + rc.NameFQDN = origin + } else { + rc.Name = short + rc.NameFQDN = dnsutil.AddOrigin(short, origin) + } +} + +// SetLabelFromFQDN sets the .Name/.NameFQDN fields given a FQDN and origin. +// fqdn may have a trailing "." but it is not required. +// origin may not have a trailing dot. +func (rc *RecordConfig) SetLabelFromFQDN(fqdn, origin string) { + + // Assertions that make sure the function is being used correctly: + if strings.HasSuffix(origin, ".") { + panic(errors.Errorf("origin (%s) is not supposed to end with a dot", origin)) + } + if strings.HasSuffix(fqdn, "..") { + panic(errors.Errorf("fqdn (%s) is not supposed to end with double dots", origin)) + } + + if strings.HasSuffix(fqdn, ".") { + // Trim off a trailing dot. + fqdn = fqdn[:len(fqdn)-1] + } + + fqdn = strings.ToLower(fqdn) + origin = strings.ToLower(origin) + rc.Name = dnsutil.TrimDomainName(fqdn, origin) + rc.NameFQDN = fqdn +} + +// GetLabel returns the shortname of the label associated with this RecordConfig. +// It will never end with "." +// It does not need further shortening (i.e. if it returns "foo.com" and the +// domain is "foo.com" then the FQDN is actually "foo.com.foo.com"). +// It will never be "" (the apex is returned as "@"). +func (rc *RecordConfig) GetLabel() string { + return rc.Name +} + +// GetLabelFQDN returns the FQDN of the label associated with this RecordConfig. +// It will not end with ".". +func (rc *RecordConfig) GetLabelFQDN() string { + return rc.NameFQDN +} + +// ToRR converts a RecordConfig to a dns.RR. +func (rc *RecordConfig) ToRR() dns.RR { + + // Don't call this on fake types. + rdtype, ok := dns.StringToType[rc.Type] + if !ok { + log.Fatalf("No such DNS type as (%#v)\n", rc.Type) + } + + // Magicallly create an RR of the correct type. + rr := dns.TypeToRR[rdtype]() + + // Fill in the header. + rr.Header().Name = rc.NameFQDN + "." + rr.Header().Rrtype = rdtype + rr.Header().Class = dns.ClassINET + rr.Header().Ttl = rc.TTL + if rc.TTL == 0 { + rr.Header().Ttl = DefaultTTL + } + + // Fill in the data. + switch rdtype { // #rtype_variations + case dns.TypeA: + rr.(*dns.A).A = rc.GetTargetIP() + case dns.TypeAAAA: + rr.(*dns.AAAA).AAAA = rc.GetTargetIP() + case dns.TypeCNAME: + rr.(*dns.CNAME).Target = rc.GetTargetField() + case dns.TypePTR: + rr.(*dns.PTR).Ptr = rc.GetTargetField() + case dns.TypeMX: + rr.(*dns.MX).Preference = rc.MxPreference + rr.(*dns.MX).Mx = rc.GetTargetField() + case dns.TypeNS: + rr.(*dns.NS).Ns = rc.GetTargetField() + case dns.TypeSOA: + t := strings.Replace(rc.GetTargetField(), `\ `, ` `, -1) + parts := strings.Fields(t) + rr.(*dns.SOA).Ns = parts[0] + rr.(*dns.SOA).Mbox = parts[1] + rr.(*dns.SOA).Serial = atou32(parts[2]) + rr.(*dns.SOA).Refresh = atou32(parts[3]) + rr.(*dns.SOA).Retry = atou32(parts[4]) + rr.(*dns.SOA).Expire = atou32(parts[5]) + rr.(*dns.SOA).Minttl = atou32(parts[6]) + case dns.TypeSRV: + rr.(*dns.SRV).Priority = rc.SrvPriority + rr.(*dns.SRV).Weight = rc.SrvWeight + rr.(*dns.SRV).Port = rc.SrvPort + rr.(*dns.SRV).Target = rc.GetTargetField() + case dns.TypeCAA: + rr.(*dns.CAA).Flag = rc.CaaFlag + rr.(*dns.CAA).Tag = rc.CaaTag + rr.(*dns.CAA).Value = rc.GetTargetField() + case dns.TypeTLSA: + rr.(*dns.TLSA).Usage = rc.TlsaUsage + rr.(*dns.TLSA).MatchingType = rc.TlsaMatchingType + rr.(*dns.TLSA).Selector = rc.TlsaSelector + rr.(*dns.TLSA).Certificate = rc.GetTargetField() + case dns.TypeTXT: + rr.(*dns.TXT).Txt = rc.TxtStrings + default: + panic(fmt.Sprintf("ToRR: Unimplemented rtype %v", rc.Type)) + // We panic so that we quickly find any switch statements + // that have not been updated for a new RR type. + } + + return rr +} + +// RecordKey represents a resource record in a format used by some systems. +type RecordKey struct { + Name string + Type string +} + +// Key converts a RecordConfig into a RecordKey. +func (rc *RecordConfig) Key() RecordKey { + return RecordKey{rc.Name, rc.Type} +} + +// Records is a list of *RecordConfig. +type Records []*RecordConfig + +// Grouped returns a map of keys to records. +func (r Records) Grouped() map[RecordKey]Records { + groups := map[RecordKey]Records{} + for _, rec := range r { + groups[rec.Key()] = append(groups[rec.Key()], rec) + } + return groups +} + +// PostProcessRecords does any post-processing of the downloaded DNS records. +func PostProcessRecords(recs []*RecordConfig) { + Downcase(recs) + //fixTxt(recs) +} + +// Downcase converts all labels and targets to lowercase in a list of RecordConfig. +func Downcase(recs []*RecordConfig) { + for _, r := range recs { + r.Name = strings.ToLower(r.Name) + r.NameFQDN = strings.ToLower(r.NameFQDN) + switch r.Type { + case "ANAME", "CNAME", "MX", "NS", "PTR": + r.Target = strings.ToLower(r.Target) + case "A", "AAAA", "ALIAS", "CAA", "IMPORT_TRANSFORM", "SRV", "TLSA", "TXT", "SOA", "CF_REDIRECT", "CF_TEMP_REDIRECT": + // Do nothing. + default: + // TODO: we'd like to panic here, but custom record types complicate things. + } + } + return +} diff --git a/models/t_caa.go b/models/t_caa.go new file mode 100644 index 000000000..cfd8d89ec --- /dev/null +++ b/models/t_caa.go @@ -0,0 +1,46 @@ +package models + +import ( + "strconv" + "strings" + + "github.com/pkg/errors" +) + +// SetTargetCAA sets the CAA fields. +func (rc *RecordConfig) SetTargetCAA(flag uint8, tag string, target string) error { + rc.CaaTag = tag + rc.CaaFlag = flag + rc.Target = target + if rc.Type == "" { + rc.Type = "CAA" + } + if rc.Type != "CAA" { + panic("assertion failed: SetTargetCAA called when .Type is not CAA") + } + + if tag != "issue" && tag != "issuewild" && tag != "iodef" { + return errors.Errorf("CAA tag (%v) is not one of issue/issuewild/iodef", tag) + } + + return nil +} + +// SetTargetCAAStrings is like SetTargetCAA but accepts strings. +func (rc *RecordConfig) SetTargetCAAStrings(flag, tag, target string) error { + i64flag, err := strconv.ParseUint(flag, 10, 8) + if err != nil { + return errors.Wrap(err, "CAA flag does not fit in 8 bits") + } + return rc.SetTargetCAA(uint8(i64flag), tag, target) +} + +// SetTargetCAAString is like SetTargetCAA but accepts one big string. +// Ex: `0 issue "letsencrypt.org"` +func (rc *RecordConfig) SetTargetCAAString(s string) error { + part := strings.Fields(s) + if len(part) != 3 { + return errors.Errorf("CAA value does not contain 3 fields: (%#v)", s) + } + return rc.SetTargetCAAStrings(part[0], part[1], StripQuotes(part[2])) +} diff --git a/models/t_mx.go b/models/t_mx.go new file mode 100644 index 000000000..10812af86 --- /dev/null +++ b/models/t_mx.go @@ -0,0 +1,40 @@ +package models + +import ( + "strconv" + "strings" + + "github.com/pkg/errors" +) + +// SetTargetMX sets the MX fields. +func (rc *RecordConfig) SetTargetMX(pref uint16, target string) error { + rc.MxPreference = pref + rc.Target = target + if rc.Type == "" { + rc.Type = "MX" + } + if rc.Type != "MX" { + panic("assertion failed: SetTargetMX called when .Type is not MX") + } + + return nil +} + +// SetTargetMXStrings is like SetTargetMX but accepts strings. +func (rc *RecordConfig) SetTargetMXStrings(pref, target string) error { + u64pref, err := strconv.ParseUint(pref, 10, 16) + if err != nil { + return errors.Wrap(err, "can't parse MX data") + } + return rc.SetTargetMX(uint16(u64pref), target) +} + +// SetTargetMXString is like SetTargetMX but accepts one big string. +func (rc *RecordConfig) SetTargetMXString(s string) error { + part := strings.Fields(s) + if len(part) != 2 { + return errors.Errorf("MX value does not contain 2 fields: (%#v)", s) + } + return rc.SetTargetMXStrings(part[0], part[1]) +} diff --git a/models/t_parse.go b/models/t_parse.go new file mode 100644 index 000000000..c33edb0a1 --- /dev/null +++ b/models/t_parse.go @@ -0,0 +1,53 @@ +package models + +import ( + "net" + + "github.com/pkg/errors" +) + +// PopulateFromString populates a RecordConfig given a type and string. +// Many providers give all the parameters of a resource record in one big +// string (all the parameters of an MX, SRV, CAA, etc). Rather than have +// each provider rewrite this code many times, here's a helper function to use. +// +// At this time, the idiom is to panic rather than continue with potentially +// misunderstood data. We do this panic() at the provider level. +// Therefore the typical calling sequence is: +// if err := rc.PopulateFromString(rtype, value, origin); err != nil { +// panic(errors.Wrap(err, "unparsable record received from provider")) +// } +func (r *RecordConfig) PopulateFromString(rtype, contents, origin string) error { + if r.Type != "" && r.Type != rtype { + panic(errors.Errorf("assertion failed: rtype already set (%s) (%s)", rtype, r.Type)) + } + switch r.Type = rtype; rtype { // #rtype_variations + case "A": + ip := net.ParseIP(contents) + if ip == nil || ip.To4() == nil { + return errors.Errorf("A record with invalid IP: %s", contents) + } + return r.SetTargetIP(ip) // Reformat to canonical form. + case "AAAA": + ip := net.ParseIP(contents) + if ip == nil || ip.To16() == nil { + return errors.Errorf("AAAA record with invalid IP: %s", contents) + } + return r.SetTargetIP(ip) // Reformat to canonical form. + case "ANAME", "CNAME", "NS", "PTR": + return r.SetTarget(contents) + case "CAA": + return r.SetTargetCAAString(contents) + case "MX": + return r.SetTargetMXString(contents) + case "SRV": + return r.SetTargetSRVString(contents) + case "TLSA": + return r.SetTargetTLSAString(contents) + case "TXT": + return r.SetTargetTXTString(contents) + default: + return errors.Errorf("Unknown rtype (%s) when parsing (%s) domain=(%s)", + rtype, contents, origin) + } +} diff --git a/models/t_srv.go b/models/t_srv.go new file mode 100644 index 000000000..92aa8f653 --- /dev/null +++ b/models/t_srv.go @@ -0,0 +1,64 @@ +package models + +import ( + "strconv" + "strings" + + "github.com/pkg/errors" +) + +// SetTargetSRV sets the SRV fields. +func (rc *RecordConfig) SetTargetSRV(priority, weight, port uint16, target string) error { + rc.SrvPriority = priority + rc.SrvWeight = weight + rc.SrvPort = port + rc.Target = target + if rc.Type == "" { + rc.Type = "SRV" + } + if rc.Type != "SRV" { + panic("assertion failed: SetTargetSRV called when .Type is not SRV") + } + return nil +} + +// setTargetIntAndStrings is like SetTargetSRV but accepts priority as an int, the other parameters as strings. +func (rc *RecordConfig) setTargetIntAndStrings(priority uint16, weight, port, target string) (err error) { + var i64weight, i64port uint64 + if i64weight, err = strconv.ParseUint(weight, 10, 16); err == nil { + if i64port, err = strconv.ParseUint(port, 10, 16); err == nil { + return rc.SetTargetSRV(priority, uint16(i64weight), uint16(i64port), target) + } + } + return errors.Wrap(err, "SRV value too big for uint16") +} + +// SetTargetSRVStrings is like SetTargetSRV but accepts all parameters as strings. +func (rc *RecordConfig) SetTargetSRVStrings(priority, weight, port, target string) (err error) { + var i64priority uint64 + if i64priority, err = strconv.ParseUint(priority, 10, 16); err == nil { + return rc.setTargetIntAndStrings(uint16(i64priority), weight, port, target) + } + return errors.Wrap(err, "SRV value too big for uint16") +} + +// SetTargetSRVPriorityString is like SetTargetSRV but accepts priority as an +// uint16 and the rest of the values joined in a string that needs to be parsed. +// This is a helper function that comes in handy when a provider re-uses the MX preference +// field as the SRV priority. +func (rc *RecordConfig) SetTargetSRVPriorityString(priority uint16, s string) error { + part := strings.Fields(s) + if len(part) != 3 { + return errors.Errorf("SRV value does not contain 3 fields: (%#v)", s) + } + return rc.setTargetIntAndStrings(priority, part[0], part[1], part[2]) +} + +// SetTargetSRVString is like SetTargetSRV but accepts one big string to be parsed. +func (rc *RecordConfig) SetTargetSRVString(s string) error { + part := strings.Fields(s) + if len(part) != 4 { + return errors.Errorf("SRC value does not contain 4 fields: (%#v)", s) + } + return rc.SetTargetSRVStrings(part[0], part[1], part[2], part[3]) +} diff --git a/models/t_tlsa.go b/models/t_tlsa.go new file mode 100644 index 000000000..246ef1b79 --- /dev/null +++ b/models/t_tlsa.go @@ -0,0 +1,45 @@ +package models + +import ( + "strconv" + "strings" + + "github.com/pkg/errors" +) + +// SetTargetTLSA sets the TLSA fields. +func (rc *RecordConfig) SetTargetTLSA(usage, selector, matchingtype uint8, target string) error { + rc.TlsaUsage = usage + rc.TlsaSelector = selector + rc.TlsaMatchingType = matchingtype + rc.Target = target + if rc.Type == "" { + rc.Type = "TLSA" + } + if rc.Type != "TLSA" { + panic("assertion failed: SetTargetTLSA called when .Type is not TLSA") + } + return nil +} + +// SetTargetTLSAStrings is like SetTargetTLSA but accepts strings. +func (rc *RecordConfig) SetTargetTLSAStrings(usage, selector, matchingtype, target string) (err error) { + var i64usage, i64selector, i64matchingtype uint64 + if i64usage, err = strconv.ParseUint(usage, 10, 8); err == nil { + if i64selector, err = strconv.ParseUint(selector, 10, 8); err == nil { + if i64matchingtype, err = strconv.ParseUint(matchingtype, 10, 8); err == nil { + return rc.SetTargetTLSA(uint8(i64usage), uint8(i64selector), uint8(i64matchingtype), target) + } + } + } + return errors.Wrap(err, "TLSA has value that won't fit in field") +} + +// SetTargetTLSAString is like SetTargetTLSA but accepts one big string. +func (rc *RecordConfig) SetTargetTLSAString(s string) error { + part := strings.Fields(s) + if len(part) != 4 { + return errors.Errorf("TLSA value does not contain 4 fields: (%#v)", s) + } + return rc.SetTargetTLSAStrings(part[0], part[1], part[2], part[3]) +} diff --git a/models/t_txt.go b/models/t_txt.go new file mode 100644 index 000000000..06153a04b --- /dev/null +++ b/models/t_txt.go @@ -0,0 +1,35 @@ +package models + +// SetTargetTXT sets the TXT fields when there is 1 string. +func (rc *RecordConfig) SetTargetTXT(s string) error { + rc.Target = s + rc.TxtStrings = []string{s} + if rc.Type == "" { + rc.Type = "TXT" + } + if rc.Type != "TXT" { + panic("assertion failed: SetTargetTXT called when .Type is not TXT") + } + return nil +} + +// SetTargetTXTs sets the TXT fields when there are many strings. +func (rc *RecordConfig) SetTargetTXTs(s []string) error { + rc.Target = s[0] + rc.TxtStrings = s + if rc.Type == "" { + rc.Type = "TXT" + } + if rc.Type != "TXT" { + panic("assertion failed: SetTargetTXT called when .Type is not TXT") + } + return nil +} + +// SetTargetTXTString is like SetTargetTXT but accepts one big string. +// Ex: foo << 1 string +// foo bar << 1 string +// "foo" "bar" << 2 strings +func (rc *RecordConfig) SetTargetTXTString(s string) error { + return rc.SetTargetTXTs(ParseQuotedTxt(s)) +} diff --git a/models/target.go b/models/target.go new file mode 100644 index 000000000..a0bf9de67 --- /dev/null +++ b/models/target.go @@ -0,0 +1,115 @@ +package models + +import ( + "net" + "strings" + + "github.com/miekg/dns" + "github.com/pkg/errors" +) + +/* .Target is kind of a mess. +For simple rtypes it is the record's value. (i.e. for an A record + it is the IP address). +For complex rtypes (like an MX record has a preference and a value) + it might be a space-delimited string with all the parameters, or it + might just be the hostname. + +This was a bad design decision that I regret. Eventually we will eliminate this +field and replace it with setters/getters. The setters/getters are below +so that it is easy to do things the right way in preparation. +*/ + +// GetTargetField returns the target. There may be other fields (for example +// an MX record also has a .MxPreference field. +func (rc *RecordConfig) GetTargetField() string { + return rc.Target +} + +// // GetTargetSingle returns the target for types that have a single value target +// // and panics for all others. +// func (rc *RecordConfig) GetTargetSingle() string { +// if rc.Type == "MX" || rc.Type == "SRV" || rc.Type == "CAA" || rc.Type == "TLSA" || rc.Type == "TXT" { +// panic("TargetSingle called on a type with a multi-parameter rtype.") +// } +// return rc.Target +// } + +// GetTargetIP returns the net.IP stored in Target. +func (rc *RecordConfig) GetTargetIP() net.IP { + if rc.Type != "A" && rc.Type != "AAAA" { + panic(errors.Errorf("GetTargetIP called on an inappropriate rtype (%s)", rc.Type)) + } + return net.ParseIP(rc.Target) +} + +// GetTargetCombined returns a string with the various fields combined. +// For example, an MX record might output `10 mx10.example.tld`. +func (rc *RecordConfig) GetTargetCombined() string { + // If this is a pseudo record, just return the target. + if _, ok := dns.StringToType[rc.Type]; !ok { + return rc.Target + } + + // We cheat by converting to a dns.RR and use the String() function. + // This combines all the data for us, and even does proper quoting. + // Sadly String() always includes a header, which we must strip out. + // TODO(tlim): Request the dns project add a function that returns + // the string without the header. + rr := rc.ToRR() + header := rr.Header().String() + full := rr.String() + if !strings.HasPrefix(full, header) { + panic("assertion failed. dns.Hdr.String() behavior has changed in an incompatible way") + } + return full[len(header):] +} + +// // GetTargetDebug returns a string with the various fields spelled out. +// func (rc *RecordConfig) GetTargetDebug() string { +// content := fmt.Sprintf("%s %s %s %d", rc.Type, rc.NameFQDN, rc.Target, rc.TTL) +// switch rc.Type { // #rtype_variations +// case "A", "AAAA", "CNAME", "NS", "PTR", "TXT": +// // Nothing special. +// case "MX": +// content += fmt.Sprintf(" pref=%d", rc.MxPreference) +// case "SOA": +// content = fmt.Sprintf("%s %s %s %d", rc.Type, rc.Name, rc.Target, rc.TTL) +// case "SRV": +// content += fmt.Sprintf(" srvpriority=%d srvweight=%d srvport=%d", rc.SrvPriority, rc.SrvWeight, rc.SrvPort) +// case "TLSA": +// content += fmt.Sprintf(" tlsausage=%d tlsaselector=%d tlsamatchingtype=%d", rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType) +// case "CAA": +// content += fmt.Sprintf(" caatag=%s caaflag=%d", rc.CaaTag, rc.CaaFlag) +// case "R53_ALIAS": +// content += fmt.Sprintf(" type=%s zone_id=%s", rc.R53Alias["type"], rc.R53Alias["zone_id"]) +// default: +// panic(errors.Errorf("rc.String rtype %v unimplemented", rc.Type)) +// // We panic so that we quickly find any switch statements +// // that have not been updated for a new RR type. +// } +// for k, v := range rc.Metadata { +// content += fmt.Sprintf(" %s=%s", k, v) +// } +// return content +// } + +// SetTarget sets the target (assumes that the rtype is appropriate). +func (rc *RecordConfig) SetTarget(fqdn string) error { + rc.Target = fqdn + return nil +} + +// SetTargetIP sets the target to an IP, verifying this is an appropriate rtype. +func (rc *RecordConfig) SetTargetIP(ip net.IP) error { + // TODO(tlim): Verify the rtype is appropriate for an IP. + rc.Target = ip.String() + return nil +} + +// // SetTargetFQDN sets the target to an IP, verifying this is an appropriate rtype. +// func (rc *RecordConfig) SetTargetFQDN(target string) error { +// // TODO(tlim): Verify the rtype is appropriate for an hostname. +// rc.Target = target +// return nil +// } diff --git a/models/util.go b/models/util.go new file mode 100644 index 000000000..8cb78186f --- /dev/null +++ b/models/util.go @@ -0,0 +1,29 @@ +package models + +import ( + "bytes" + "encoding/gob" + "strconv" + + "github.com/pkg/errors" +) + +func copyObj(input interface{}, output interface{}) error { + buf := &bytes.Buffer{} + enc := gob.NewEncoder(buf) + dec := gob.NewDecoder(buf) + if err := enc.Encode(input); err != nil { + return err + } + return dec.Decode(output) +} + +// atou32 converts a string to uint32 or panics. +// DEPRECATED: This will go away when SOA record handling is rewritten. +func atou32(s string) uint32 { + i64, err := strconv.ParseUint(s, 10, 32) + if err != nil { + panic(errors.Errorf("atou32 failed (%v) (err=%v", s, err)) + } + return uint32(i64) +} diff --git a/pkg/normalize/flatten.go b/pkg/normalize/flatten.go index b19a821b1..6c1393518 100644 --- a/pkg/normalize/flatten.go +++ b/pkg/normalize/flatten.go @@ -35,7 +35,11 @@ func flattenSPFs(cfg *models.DNSConfig) []error { } if flatten, ok := txt.Metadata["flatten"]; ok && strings.HasPrefix(txt.Target, "v=spf1") { rec = rec.Flatten(flatten) - txt.SetTxt(rec.TXT()) + err = txt.SetTargetTXT(rec.TXT()) + if err != nil { + errs = append(errs, err) + continue + } } // now split if needed if split, ok := txt.Metadata["split"]; ok { @@ -46,10 +50,10 @@ func flattenSPFs(cfg *models.DNSConfig) []error { recs := rec.TXTSplit(split + "." + domain.Name) for k, v := range recs { if k == "@" { - txt.SetTxt(v) + txt.SetTargetTXT(v) } else { cp, _ := txt.Copy() - cp.SetTxt(v) + cp.SetTargetTXT(v) cp.NameFQDN = k cp.Name = dnsutil.TrimDomainName(k, domain.Name) domain.Records = append(domain.Records, cp) diff --git a/pkg/normalize/importTransform_test.go b/pkg/normalize/importTransform_test.go index 68df3a23b..bb49d8b7b 100644 --- a/pkg/normalize/importTransform_test.go +++ b/pkg/normalize/importTransform_test.go @@ -1,8 +1,9 @@ package normalize import ( - "github.com/StackExchange/dnscontrol/models" "testing" + + "github.com/StackExchange/dnscontrol/models" ) func TestImportTransform(t *testing.T) { @@ -13,14 +14,14 @@ func TestImportTransform(t *testing.T) { Name: "stackexchange.com", Records: []*models.RecordConfig{ {Type: "A", Name: "*", NameFQDN: "*.stackexchange.com", Target: "0.0.2.2"}, - {Type: "A", Name: "www", NameFQDN: "", Target: "0.0.1.1"}, + {Type: "A", Name: "www", NameFQDN: "www.stackexchange.com", Target: "0.0.1.1"}, }, } dst := &models.DomainConfig{ Name: "internal", Records: []*models.RecordConfig{ {Type: "A", Name: "*.stackexchange.com", NameFQDN: "*.stackexchange.com.internal", Target: "0.0.3.3", Metadata: map[string]string{"transform_table": transformSingle}}, - {Type: "IMPORT_TRANSFORM", Name: "@", Target: "stackexchange.com", Metadata: map[string]string{"transform_table": transformDouble}}, + {Type: "IMPORT_TRANSFORM", Name: "@", NameFQDN: "internal", Target: "stackexchange.com", Metadata: map[string]string{"transform_table": transformDouble}}, }, } cfg := &models.DNSConfig{ diff --git a/pkg/normalize/validate_test.go b/pkg/normalize/validate_test.go index 96064f0b4..8ae596c53 100644 --- a/pkg/normalize/validate_test.go +++ b/pkg/normalize/validate_test.go @@ -196,7 +196,7 @@ func TestCAAValidation(t *testing.T) { Name: "example.com", RegistrarName: "BIND", Records: []*models.RecordConfig{ - {Name: "@", Type: "CAA", CaaTag: "invalid", Target: "example.com"}, + {Name: "@", NameFQDN: "example.com", Type: "CAA", CaaTag: "invalid", Target: "example.com"}, }, }, }, @@ -214,7 +214,7 @@ func TestTLSAValidation(t *testing.T) { Name: "_443._tcp.example.com", RegistrarName: "BIND", Records: []*models.RecordConfig{ - {Name: "_443._tcp", Type: "TLSA", TlsaUsage: 4, TlsaSelector: 1, TlsaMatchingType: 1, Target: "abcdef0"}, + {Name: "_443._tcp", NameFQDN: "_443._tcp._443._tcp.example.com", Type: "TLSA", TlsaUsage: 4, TlsaSelector: 1, TlsaMatchingType: 1, Target: "abcdef0"}, }, }, }, diff --git a/providers/activedir/domains.go b/providers/activedir/domains.go index 7ddebebb4..448b55f02 100644 --- a/providers/activedir/domains.go +++ b/providers/activedir/domains.go @@ -8,12 +8,11 @@ import ( "strings" "time" + "github.com/StackExchange/dnscontrol/models" + "github.com/StackExchange/dnscontrol/providers/diff" "github.com/TomOnTime/utfutil" "github.com/miekg/dns/dnsutil" "github.com/pkg/errors" - - "github.com/StackExchange/dnscontrol/models" - "github.com/StackExchange/dnscontrol/providers/diff" ) const zoneDumpFilenamePrefix = "adzonedump" diff --git a/providers/bind/bindProvider.go b/providers/bind/bindProvider.go index b849ab1f3..e357a7352 100644 --- a/providers/bind/bindProvider.go +++ b/providers/bind/bindProvider.go @@ -23,7 +23,7 @@ import ( "strings" "github.com/miekg/dns" - "github.com/miekg/dns/dnsutil" + "github.com/pkg/errors" "github.com/StackExchange/dnscontrol/models" "github.com/StackExchange/dnscontrol/providers" @@ -99,29 +99,26 @@ func rrToRecord(rr dns.RR, origin string, replaceSerial uint32) (models.RecordCo // If one is found, we replace it with serial=1. var oldSerial, newSerial uint32 header := rr.Header() - rc := models.RecordConfig{} - rc.Type = dns.TypeToString[header.Rrtype] - rc.NameFQDN = strings.ToLower(strings.TrimSuffix(header.Name, ".")) - rc.Name = strings.ToLower(dnsutil.TrimDomainName(header.Name, origin)) - rc.TTL = header.Ttl + rc := models.RecordConfig{ + Type: dns.TypeToString[header.Rrtype], + TTL: header.Ttl, + } + rc.SetLabelFromFQDN(strings.TrimSuffix(header.Name, "."), origin) switch v := rr.(type) { // #rtype_variations case *dns.A: - rc.Target = v.A.String() + panicInvalid(rc.SetTarget(v.A.String())) case *dns.AAAA: - rc.Target = v.AAAA.String() + panicInvalid(rc.SetTarget(v.AAAA.String())) case *dns.CAA: - rc.CaaTag = v.Tag - rc.CaaFlag = v.Flag - rc.Target = v.Value + panicInvalid(rc.SetTargetCAA(v.Flag, v.Tag, v.Value)) case *dns.CNAME: - rc.Target = v.Target + panicInvalid(rc.SetTarget(v.Target)) case *dns.MX: - rc.Target = v.Mx - rc.MxPreference = v.Preference + panicInvalid(rc.SetTargetMX(v.Preference, v.Mx)) case *dns.NS: - rc.Target = v.Ns + panicInvalid(rc.SetTarget(v.Ns)) case *dns.PTR: - rc.Target = v.Ptr + panicInvalid(rc.SetTarget(v.Ptr)) case *dns.SOA: oldSerial = v.Serial if oldSerial == 0 { @@ -129,37 +126,39 @@ func rrToRecord(rr dns.RR, origin string, replaceSerial uint32) (models.RecordCo oldSerial = 1 } newSerial = v.Serial - if (dnsutil.TrimDomainName(rc.Name, origin+".") == "@") && replaceSerial != 0 { + //if (dnsutil.TrimDomainName(rc.Name, origin+".") == "@") && replaceSerial != 0 { + if rc.Name == "@" && replaceSerial != 0 { newSerial = replaceSerial } - rc.Target = fmt.Sprintf("%v %v %v %v %v %v %v", - v.Ns, v.Mbox, newSerial, v.Refresh, v.Retry, v.Expire, v.Minttl) + panicInvalid(rc.SetTarget( + fmt.Sprintf("%v %v %v %v %v %v %v", + v.Ns, v.Mbox, newSerial, v.Refresh, v.Retry, v.Expire, v.Minttl), + )) + // FIXME(tlim): SOA should be handled by splitting out the fields. case *dns.SRV: - rc.Target = v.Target - rc.SrvPort = v.Port - rc.SrvWeight = v.Weight - rc.SrvPriority = v.Priority + panicInvalid(rc.SetTargetSRV(v.Priority, v.Weight, v.Port, v.Target)) case *dns.TLSA: - rc.TlsaUsage = v.Usage - rc.TlsaSelector = v.Selector - rc.TlsaMatchingType = v.MatchingType - rc.Target = v.Certificate + panicInvalid(rc.SetTargetTLSA(v.Usage, v.Selector, v.MatchingType, v.Certificate)) case *dns.TXT: - rc.Target = strings.Join(v.Txt, " ") - rc.TxtStrings = v.Txt + panicInvalid(rc.SetTargetTXTs(v.Txt)) default: log.Fatalf("rrToRecord: Unimplemented zone record type=%s (%v)\n", rc.Type, rr) } return rc, oldSerial } +func panicInvalid(err error) { + if err != nil { + panic(errors.Wrap(err, "unparsable record received from BIND")) + } +} + func makeDefaultSOA(info SoaInfo, origin string) *models.RecordConfig { // Make a default SOA record in case one isn't found: soaRec := models.RecordConfig{ Type: "SOA", - Name: "@", } - soaRec.NameFQDN = dnsutil.AddOrigin(soaRec.Name, origin) + soaRec.SetLabel("@", origin) if len(info.Ns) == 0 { info.Ns = "DEFAULT_NOT_SET." } @@ -181,7 +180,7 @@ func makeDefaultSOA(info SoaInfo, origin string) *models.RecordConfig { if info.Minttl == 0 { info.Minttl = 1440 } - soaRec.Target = info.String() + soaRec.SetTarget(info.String()) return &soaRec } diff --git a/providers/bind/prettyzone.go b/providers/bind/prettyzone.go index 15f147aa5..d71bc4f4b 100644 --- a/providers/bind/prettyzone.go +++ b/providers/bind/prettyzone.go @@ -18,7 +18,7 @@ import ( type zoneGenData struct { Origin string - DefaultTtl uint32 + DefaultTTL uint32 Records []dns.RR } @@ -146,7 +146,7 @@ func WriteZoneFile(w io.Writer, records []dns.RR, origin string) error { z := &zoneGenData{ Origin: dnsutil.AddOrigin(origin, "."), - DefaultTtl: defaultTTL, + DefaultTTL: defaultTTL, } z.Records = nil for _, r := range records { @@ -161,7 +161,7 @@ func (z *zoneGenData) generateZoneFileHelper(w io.Writer) error { nameShortPrevious := "" sort.Sort(z) - fmt.Fprintln(w, "$TTL", z.DefaultTtl) + fmt.Fprintln(w, "$TTL", z.DefaultTTL) for i, rr := range z.Records { line := rr.String() if line[0] == ';' { @@ -187,7 +187,7 @@ func (z *zoneGenData) generateZoneFileHelper(w io.Writer) error { // items[1]: ttl ttl := "" - if hdr.Ttl != z.DefaultTtl && hdr.Ttl != 0 { + if hdr.Ttl != z.DefaultTTL && hdr.Ttl != 0 { ttl = items[1] } diff --git a/providers/cloudflare/cloudflareProvider.go b/providers/cloudflare/cloudflareProvider.go index c3cf97ae5..fcb512034 100644 --- a/providers/cloudflare/cloudflareProvider.go +++ b/providers/cloudflare/cloudflareProvider.go @@ -121,8 +121,8 @@ func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models if rec.Type == "ALIAS" { rec.Type = "CNAME" } - if labelMatches(rec.Name, c.ignoredLabels) { - log.Fatalf("FATAL: dnsconfig contains label that matches ignored_labels: %#v is in %v)\n", rec.Name, c.ignoredLabels) + if labelMatches(rec.GetLabel(), c.ignoredLabels) { + log.Fatalf("FATAL: dnsconfig contains label that matches ignored_labels: %#v is in %v)\n", rec.GetLabel(), c.ignoredLabels) } } checkNSModifications(dc) @@ -151,7 +151,7 @@ func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models if des.Type == "PAGE_RULE" { corrections = append(corrections, &models.Correction{ Msg: d.String(), - F: func() error { return c.createPageRule(id, des.Target) }, + F: func() error { return c.createPageRule(id, des.GetTargetField()) }, }) } else { corrections = append(corrections, c.createRec(des, id)...) @@ -164,7 +164,7 @@ func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models if rec.Type == "PAGE_RULE" { corrections = append(corrections, &models.Correction{ Msg: d.String(), - F: func() error { return c.updatePageRule(ex.Original.(*pageRule).ID, id, rec.Target) }, + F: func() error { return c.updatePageRule(ex.Original.(*pageRule).ID, id, rec.GetTargetField()) }, }) } else { e := ex.Original.(*cfRecord) @@ -181,9 +181,9 @@ func (c *CloudflareApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models func checkNSModifications(dc *models.DomainConfig) { newList := make([]*models.RecordConfig, 0, len(dc.Records)) for _, rec := range dc.Records { - if rec.Type == "NS" && rec.NameFQDN == dc.Name { - if !strings.HasSuffix(rec.Target, ".ns.cloudflare.com.") { - log.Printf("Warning: cloudflare does not support modifying NS records on base domain. %s will not be added.", rec.Target) + if rec.Type == "NS" && rec.GetLabelFQDN() == dc.Name { + if !strings.HasSuffix(rec.GetTargetField(), ".ns.cloudflare.com.") { + log.Printf("Warning: cloudflare does not support modifying NS records on base domain. %s will not be added.", rec.GetTargetField()) } continue } @@ -240,7 +240,7 @@ func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error { } if rec.Type != "A" && rec.Type != "CNAME" && rec.Type != "AAAA" && rec.Type != "ALIAS" { if rec.Metadata[metaProxy] != "" { - return errors.Errorf("cloudflare_proxy set on %v record: %#v cloudflare_proxy=%#v", rec.Type, rec.Name, rec.Metadata[metaProxy]) + return errors.Errorf("cloudflare_proxy set on %v record: %#v cloudflare_proxy=%#v", rec.Type, rec.GetLabel(), rec.Metadata[metaProxy]) } // Force it to off. rec.Metadata[metaProxy] = "off" @@ -260,7 +260,7 @@ func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error { if !c.manageRedirects { return errors.Errorf("you must add 'manage_redirects: true' metadata to cloudflare provider to use CF_REDIRECT records") } - parts := strings.Split(rec.Target, ",") + parts := strings.Split(rec.GetTargetField(), ",") if len(parts) != 2 { return errors.Errorf("Invalid data specified for cloudflare redirect record") } @@ -268,7 +268,7 @@ func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error { if rec.Type == "CF_TEMP_REDIRECT" { code = 302 } - rec.Target = fmt.Sprintf("%s,%d,%d", rec.Target, currentPrPrio, code) + rec.SetTarget(fmt.Sprintf("%s,%d,%d", rec.GetTargetField(), currentPrPrio, code)) currentPrPrio++ rec.Type = "PAGE_RULE" } @@ -283,16 +283,16 @@ func (c *CloudflareApi) preprocessConfig(dc *models.DomainConfig) error { if rec.Metadata[metaProxy] != "full" { continue } - ip := net.ParseIP(rec.Target) + ip := net.ParseIP(rec.GetTargetField()) if ip == nil { - return errors.Errorf("%s is not a valid ip address", rec.Target) + return errors.Errorf("%s is not a valid ip address", rec.GetTargetField()) } newIP, err := transform.TransformIP(ip, c.ipConversions) if err != nil { return err } - rec.Metadata[metaOriginalIP] = rec.Target - rec.Target = newIP.String() + rec.Metadata[metaOriginalIP] = rec.GetTargetField() + rec.SetTarget(newIP.String()) } return nil @@ -371,39 +371,32 @@ type cfRecord struct { Priority uint16 `json:"priority"` } -func (c *cfRecord) toRecord(domain string) *models.RecordConfig { +func (c *cfRecord) nativeToRecord(domain string) *models.RecordConfig { // normalize cname,mx,ns records with dots to be consistent with our config format. if c.Type == "CNAME" || c.Type == "MX" || c.Type == "NS" || c.Type == "SRV" { c.Content = dnsutil.AddOrigin(c.Content+".", domain) } + rc := &models.RecordConfig{ - NameFQDN: c.Name, - Type: c.Type, - Target: c.Content, TTL: c.TTL, Original: c, } - switch c.Type { // #rtype_variations - case "A", "AAAA", "ANAME", "CNAME", "NS", "TXT": - // nothing additional needed. - case "CAA": - var err error - rc.CaaTag, rc.CaaFlag, rc.Target, err = models.SplitCombinedCaaValue(c.Content) - if err != nil { - panic(err) - } + rc.SetLabelFromFQDN(c.Name, domain) + switch rType := c.Type; rType { // #rtype_variations case "MX": - rc.MxPreference = c.Priority + if err := rc.SetTargetMX(c.Priority, c.Content); err != nil { + panic(errors.Wrap(err, "unparsable MX record received from cloudflare")) + } case "SRV": data := *c.Data - rc.SrvPriority = data.Priority - rc.SrvWeight = data.Weight - rc.SrvPort = data.Port - rc.Target = dnsutil.AddOrigin(data.Target+".", domain) - default: - panic(fmt.Sprintf("toRecord unimplemented rtype %v", c.Type)) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. + if err := rc.SetTargetSRV(data.Priority, data.Weight, data.Port, + dnsutil.AddOrigin(data.Target+".", domain)); err != nil { + panic(errors.Wrap(err, "unparsable SRV record received from cloudflare")) + } + default: // "A", "AAAA", "ANAME", "CAA", "CNAME", "NS", "PTR", "TXT" + if err := rc.PopulateFromString(rType, c.Content, domain); err != nil { + panic(errors.Wrap(err, "unparsable record received from cloudflare")) + } } return rc diff --git a/providers/cloudflare/rest.go b/providers/cloudflare/rest.go index b3ef98815..43be22799 100644 --- a/providers/cloudflare/rest.go +++ b/providers/cloudflare/rest.go @@ -5,11 +5,9 @@ import ( "encoding/json" "fmt" "net/http" - "time" - - "strings" - "strconv" + "strings" + "time" "github.com/StackExchange/dnscontrol/models" "github.com/pkg/errors" @@ -69,7 +67,7 @@ func (c *CloudflareApi) getRecordsForDomain(id string, domain string) ([]*models } for _, rec := range data.Result { // fmt.Printf("REC: %+v\n", rec) - records = append(records, rec.toRecord(domain)) + records = append(records, rec.nativeToRecord(domain)) } ri := data.ResultInfo if len(data.Result) == 0 || ri.Page*ri.PerPage >= ri.TotalCount { @@ -120,7 +118,7 @@ func (c *CloudflareApi) createZone(domainName string) (string, error) { } func cfSrvData(rec *models.RecordConfig) *cfRecData { - serverParts := strings.Split(rec.NameFQDN, ".") + serverParts := strings.Split(rec.GetLabelFQDN(), ".") return &cfRecData{ Service: serverParts[0], Proto: serverParts[1], @@ -128,7 +126,7 @@ func cfSrvData(rec *models.RecordConfig) *cfRecData { Port: rec.SrvPort, Priority: rec.SrvPriority, Weight: rec.SrvWeight, - Target: rec.Target, + Target: rec.GetTargetField(), } } @@ -136,7 +134,7 @@ func cfCaaData(rec *models.RecordConfig) *cfRecData { return &cfRecData{ Tag: rec.CaaTag, Flags: rec.CaaFlag, - Value: rec.Target, + Value: rec.GetTargetField(), } } @@ -150,7 +148,7 @@ func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []* Data *cfRecData `json:"data"` } var id string - content := rec.Target + content := rec.GetTargetField() if rec.Metadata[metaOriginalIP] != "" { content = rec.Metadata[metaOriginalIP] } @@ -159,11 +157,11 @@ func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []* prio = fmt.Sprintf(" %d ", rec.MxPreference) } arr := []*models.Correction{{ - Msg: fmt.Sprintf("CREATE record: %s %s %d%s %s", rec.Name, rec.Type, rec.TTL, prio, content), + Msg: fmt.Sprintf("CREATE record: %s %s %d%s %s", rec.GetLabel(), rec.Type, rec.TTL, prio, content), F: func() error { cf := &createRecord{ - Name: rec.Name, + Name: rec.GetLabel(), Type: rec.Type, TTL: rec.TTL, Content: content, @@ -171,10 +169,10 @@ func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []* } if rec.Type == "SRV" { cf.Data = cfSrvData(rec) - cf.Name = rec.NameFQDN + cf.Name = rec.GetLabelFQDN() } else if rec.Type == "CAA" { cf.Data = cfCaaData(rec) - cf.Name = rec.NameFQDN + cf.Name = rec.GetLabelFQDN() cf.Content = "" } endpoint := fmt.Sprintf(recordsURL, domainID) @@ -194,7 +192,7 @@ func (c *CloudflareApi) createRec(rec *models.RecordConfig, domainID string) []* }} if rec.Metadata[metaProxy] != "off" { arr = append(arr, &models.Correction{ - Msg: fmt.Sprintf("ACTIVATE PROXY for new record %s %s %d %s", rec.Name, rec.Type, rec.TTL, rec.Target), + Msg: fmt.Sprintf("ACTIVATE PROXY for new record %s %s %d %s", rec.GetLabel(), rec.Type, rec.TTL, rec.GetTargetField()), F: func() error { return c.modifyRecord(domainID, id, true, rec) }, }) } @@ -215,13 +213,22 @@ func (c *CloudflareApi) modifyRecord(domainID, recID string, proxied bool, rec * TTL uint32 `json:"ttl"` Data *cfRecData `json:"data"` } - r := record{recID, proxied, rec.Name, rec.Type, rec.Target, rec.MxPreference, rec.TTL, nil} + r := record{ + ID: recID, + Proxied: proxied, + Name: rec.GetLabel(), + Type: rec.Type, + Content: rec.GetTargetField(), + Priority: rec.MxPreference, + TTL: rec.TTL, + Data: nil, + } if rec.Type == "SRV" { r.Data = cfSrvData(rec) - r.Name = rec.NameFQDN + r.Name = rec.GetLabelFQDN() } else if rec.Type == "CAA" { r.Data = cfCaaData(rec) - r.Name = rec.NameFQDN + r.Name = rec.GetLabelFQDN() r.Content = "" } endpoint := fmt.Sprintf(singleRecordURL, domainID, recID) @@ -303,15 +310,18 @@ func (c *CloudflareApi) getPageRules(id string, domain string) ([]*models.Record return nil, err } var thisPr = pr - recs = append(recs, &models.RecordConfig{ - Name: "@", - NameFQDN: domain, + r := &models.RecordConfig{ Type: "PAGE_RULE", - // $FROM,$TO,$PRIO,$CODE - Target: fmt.Sprintf("%s,%s,%d,%d", pr.Targets[0].Constraint.Value, pr.ForwardingInfo.URL, pr.Priority, pr.ForwardingInfo.StatusCode), Original: thisPr, TTL: 1, - }) + } + r.SetLabel("@", domain) + r.SetTarget(fmt.Sprintf("%s,%s,%d,%d", // $FROM,$TO,$PRIO,$CODE + pr.Targets[0].Constraint.Value, + pr.ForwardingInfo.URL, + pr.Priority, + pr.ForwardingInfo.StatusCode)) + recs = append(recs, r) } return recs, nil } diff --git a/providers/diff/diff.go b/providers/diff/diff.go index 60c3e12e6..cd13ada0f 100644 --- a/providers/diff/diff.go +++ b/providers/diff/diff.go @@ -43,7 +43,7 @@ type differ struct { // get normalized content for record. target, ttl, mxprio, and specified metadata func (d *differ) content(r *models.RecordConfig) string { - content := fmt.Sprintf("%v ttl=%d", r.Content(), r.TTL) + content := fmt.Sprintf("%v ttl=%d", r.GetTargetCombined(), r.TTL) for _, f := range d.extraValues { // sort the extra values map keys to perform a deterministic // comparison since Golang maps iteration order is not guaranteed diff --git a/providers/digitalocean/digitaloceanProvider.go b/providers/digitalocean/digitaloceanProvider.go index 45d64f896..41a49610f 100644 --- a/providers/digitalocean/digitaloceanProvider.go +++ b/providers/digitalocean/digitaloceanProvider.go @@ -197,10 +197,11 @@ func toRc(dc *models.DomainConfig, r *godo.DomainRecord) *models.RecordConfig { target = dc.Name } target = dnsutil.AddOrigin(target+".", dc.Name) + // FIXME(tlim): The AddOrigin should be a no-op. + // Test whether or not it is actually needed. } - return &models.RecordConfig{ - NameFQDN: name, + t := &models.RecordConfig{ Type: r.Type, Target: target, TTL: uint32(r.TTL), @@ -210,25 +211,37 @@ func toRc(dc *models.DomainConfig, r *godo.DomainRecord) *models.RecordConfig { SrvPort: uint16(r.Port), Original: r, } + t.SetLabelFromFQDN(name, dc.Name) + switch rtype := r.Type; rtype { + case "TXT": + t.SetTargetTXTString(target) + default: + // nothing additional required + } + return t } func toReq(dc *models.DomainConfig, rc *models.RecordConfig) *godo.DomainRecordEditRequest { - // DO wants the short name, e.g. @ - name := dnsutil.TrimDomainName(rc.NameFQDN, dc.Name) + name := rc.GetLabel() // DO wants the short name or "@" for apex. + target := rc.GetTargetField() // DO uses the target field only for a single value + priority := 0 // DO uses the same property for MX and SRV priority - // DO uses the same property for MX and SRV priority - priority := 0 switch rc.Type { // #rtype_variations case "MX": priority = int(rc.MxPreference) case "SRV": priority = int(rc.SrvPriority) + case "TXT": + // TXT records are the one place where DO combines many items into one field. + target = rc.GetTargetCombined() + default: + // no action required } return &godo.DomainRecordEditRequest{ Type: rc.Type, Name: name, - Data: rc.Target, + Data: target, TTL: int(rc.TTL), Priority: priority, Port: int(rc.SrvPort), diff --git a/providers/dnsimple/dnsimpleProvider.go b/providers/dnsimple/dnsimpleProvider.go index 64abb3abb..b171d6578 100644 --- a/providers/dnsimple/dnsimpleProvider.go +++ b/providers/dnsimple/dnsimpleProvider.go @@ -79,25 +79,29 @@ func (c *DnsimpleApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.C continue } rec := &models.RecordConfig{ - NameFQDN: dnsutil.AddOrigin(r.Name, dc.Name), - Type: r.Type, - Target: r.Content, - TTL: uint32(r.TTL), - MxPreference: uint16(r.Priority), - Original: r, + TTL: uint32(r.TTL), + Original: r, } - if r.Type == "CAA" || r.Type == "SRV" { - rec.CombinedTarget = true + rec.SetLabel(r.Name, dc.Name) + switch rtype := r.Type; rtype { + case "MX": + if err := rec.SetTargetMX(uint16(r.Priority), r.Content); err != nil { + panic(errors.Wrap(err, "unparsable record received from dnsimple")) + } + default: + if err := rec.PopulateFromString(r.Type, r.Content, dc.Name); err != nil { + panic(errors.Wrap(err, "unparsable record received from dnsimple")) + } } actual = append(actual, rec) } removeOtherNS(dc) - dc.Filter(func(r *models.RecordConfig) bool { - if r.Type == "CAA" || r.Type == "SRV" { - r.MergeToTarget() - } - return true - }) + // dc.Filter(func(r *models.RecordConfig) bool { + // if r.Type == "CAA" || r.Type == "SRV" { + // r.MergeToTarget() + // } + // return true + // }) // Normalize models.PostProcessRecords(actual) @@ -276,7 +280,7 @@ func (c *DnsimpleApi) createRecordFunc(rc *models.RecordConfig, domainName strin record := dnsimpleapi.ZoneRecord{ Name: dnsutil.TrimDomainName(rc.NameFQDN, domainName), Type: rc.Type, - Content: rc.Target, + Content: rc.GetTargetCombined(), TTL: int(rc.TTL), Priority: int(rc.MxPreference), } @@ -322,7 +326,7 @@ func (c *DnsimpleApi) updateRecordFunc(old *dnsimpleapi.ZoneRecord, rc *models.R record := dnsimpleapi.ZoneRecord{ Name: dnsutil.TrimDomainName(rc.NameFQDN, domainName), Type: rc.Type, - Content: rc.Target, + Content: rc.GetTargetCombined(), TTL: int(rc.TTL), Priority: int(rc.MxPreference), } @@ -367,10 +371,10 @@ func removeOtherNS(dc *models.DomainConfig) { for _, rec := range dc.Records { if rec.Type == "NS" { // apex NS inside dnsimple are expected. - if rec.NameFQDN == dc.Name && strings.HasSuffix(rec.Target, ".dnsimple.com.") { + if rec.NameFQDN == dc.Name && strings.HasSuffix(rec.GetTargetField(), ".dnsimple.com.") { continue } - fmt.Printf("Warning: dnsimple.com does not allow NS records to be modified. %s will not be added.\n", rec.Target) + fmt.Printf("Warning: dnsimple.com does not allow NS records to be modified. %s will not be added.\n", rec.GetTargetField()) continue } newList = append(newList, rec) diff --git a/providers/gandi/gandiProvider.go b/providers/gandi/gandiProvider.go index 5e9fe2212..1dda1aad1 100644 --- a/providers/gandi/gandiProvider.go +++ b/providers/gandi/gandiProvider.go @@ -79,9 +79,6 @@ func (c *GandiApi) GetNameservers(domain string) ([]*models.Nameserver, error) { // GetDomainCorrections returns a list of corrections recommended for this domain. func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() - dc.CombineSRVs() - dc.CombineCAAs() - dc.CombineMXs() domaininfo, err := c.getDomainInfo(dc.Name) if err != nil { return nil, err @@ -95,7 +92,7 @@ func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Corr recordsToKeep := make([]*models.RecordConfig, 0, len(dc.Records)) for _, rec := range dc.Records { if rec.TTL < 300 { - log.Printf("WARNING: Gandi does not support ttls < 300. %s will not be set to %d.", rec.NameFQDN, rec.TTL) + log.Printf("WARNING: Gandi does not support ttls < 300. Setting %s from %d to 300", rec.GetLabelFQDN(), rec.TTL) rec.TTL = 300 } if rec.TTL > 2592000 { @@ -104,7 +101,7 @@ func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Corr if rec.Type == "TXT" { rec.Target = "\"" + rec.Target + "\"" // FIXME(tlim): Should do proper quoting. } - if rec.Type == "NS" && rec.Name == "@" { + if rec.Type == "NS" && rec.GetLabel() == "@" { if !strings.HasSuffix(rec.Target, ".gandi.net.") { log.Printf("WARNING: Gandi does not support changing apex NS records. %s will not be added.", rec.Target) } @@ -112,8 +109,8 @@ func (c *GandiApi) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Corr } rs := gandirecord.RecordSet{ "type": rec.Type, - "name": rec.Name, - "value": rec.Target, + "name": rec.GetLabel(), + "value": rec.GetTargetCombined(), "ttl": rec.TTL, } expectedRecordSets = append(expectedRecordSets, rs) diff --git a/providers/gandi/protocol.go b/providers/gandi/protocol.go index f09e80096..894db3f40 100644 --- a/providers/gandi/protocol.go +++ b/providers/gandi/protocol.go @@ -3,6 +3,7 @@ package gandi import ( "fmt" + "github.com/pkg/errors" gandiclient "github.com/prasmussen/gandi-api/client" gandidomain "github.com/prasmussen/gandi-api/domain" gandinameservers "github.com/prasmussen/gandi-api/domain/nameservers" @@ -12,7 +13,6 @@ import ( gandioperation "github.com/prasmussen/gandi-api/operation" "github.com/StackExchange/dnscontrol/models" - "github.com/miekg/dns/dnsutil" ) // fetchDomainList gets list of domains for account. Cache ids for easy lookup. @@ -58,11 +58,32 @@ func (c *GandiApi) getZoneRecords(zoneid int64, origin string) ([]*models.Record } rcs := make([]*models.RecordConfig, 0, len(recs)) for _, r := range recs { - rcs = append(rcs, convert(r, origin)) + rcs = append(rcs, nativeToRecord(r, origin)) } return rcs, nil } +// convert takes a DNS record from Gandi and returns our native RecordConfig format. +func nativeToRecord(r *gandirecord.RecordInfo, origin string) *models.RecordConfig { + + rc := &models.RecordConfig{ + //NameFQDN: dnsutil.AddOrigin(r.Name, origin), + //Name: r.Name, + //Type: r.Type, + TTL: uint32(r.Ttl), + Original: r, + //Target: r.Value, + } + rc.SetLabel(r.Name, origin) + switch rtype := r.Type; rtype { + default: // "A", "AAAA", "CAA", "NS", "CNAME", "MX", "PTR", "SRV", "TXT" + if err := rc.PopulateFromString(rtype, r.Value, origin); err != nil { + panic(errors.Wrap(err, "unparsable record received from gandi")) + } + } + return rc +} + // listZones retrieves the list of zones. func (c *GandiApi) listZones() ([]*gandizone.ZoneInfoBase, error) { gc := gandiclient.New(c.ApiKey, gandiclient.Production) @@ -150,7 +171,6 @@ func (c *GandiApi) createGandiZone(domainname string, zoneID int64, records []ga if err != nil { return err } - // fmt.Println("ZONEINFO:", zoneinfo) zoneID, err = c.getEditableZone(domainname, zoneinfo) if err != nil { return err @@ -180,44 +200,3 @@ func (c *GandiApi) createGandiZone(domainname string, zoneID int64, records []ga return nil } - -// convert takes a DNS record from Gandi and returns our native RecordConfig format. -func convert(r *gandirecord.RecordInfo, origin string) *models.RecordConfig { - rc := &models.RecordConfig{ - NameFQDN: dnsutil.AddOrigin(r.Name, origin), - Name: r.Name, - Type: r.Type, - Original: r, - Target: r.Value, - TTL: uint32(r.Ttl), - } - switch r.Type { - case "A", "AAAA", "NS", "CNAME", "PTR": - // no-op - case "TXT": - rc.SetTxtParse(r.Value) - case "CAA": - var err error - rc.CaaTag, rc.CaaFlag, rc.Target, err = models.SplitCombinedCaaValue(r.Value) - if err != nil { - panic(fmt.Sprintf("gandi.convert bad caa value format: %#v (%s)", r.Value, err)) - } - case "SRV": - var err error - rc.SrvPriority, rc.SrvWeight, rc.SrvPort, rc.Target, err = models.SplitCombinedSrvValue(r.Value) - if err != nil { - panic(fmt.Sprintf("gandi.convert bad srv value format: %#v (%s)", r.Value, err)) - } - case "MX": - var err error - rc.MxPreference, rc.Target, err = models.SplitCombinedMxValue(r.Value) - if err != nil { - panic(fmt.Sprintf("gandi.convert bad mx value format: %#v", r.Value)) - } - default: - panic(fmt.Sprintf("gandi.convert unimplemented rtype %v", r.Type)) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. - } - return rc -} diff --git a/providers/gcloud/gcloudProvider.go b/providers/gcloud/gcloudProvider.go index 914e7920d..1bb4afef4 100644 --- a/providers/gcloud/gcloudProvider.go +++ b/providers/gcloud/gcloudProvider.go @@ -12,6 +12,7 @@ import ( "github.com/StackExchange/dnscontrol/models" "github.com/StackExchange/dnscontrol/providers" "github.com/StackExchange/dnscontrol/providers/diff" + "github.com/pkg/errors" ) var features = providers.DocumentationNotes{ @@ -118,27 +119,12 @@ func (g *gcloud) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correc existingRecords := []*models.RecordConfig{} oldRRs := map[key]*dns.ResourceRecordSet{} for _, set := range rrs { - nameWithoutDot := set.Name - if strings.HasSuffix(nameWithoutDot, ".") { - nameWithoutDot = nameWithoutDot[:len(nameWithoutDot)-1] - } oldRRs[keyFor(set)] = set for _, rec := range set.Rrdatas { - r := &models.RecordConfig{ - NameFQDN: nameWithoutDot, - Type: set.Type, - Target: rec, - TTL: uint32(set.Ttl), - CombinedTarget: true, - } - existingRecords = append(existingRecords, r) + existingRecords = append(existingRecords, nativeToRecord(set, rec, dc.Name)) } } - for _, want := range dc.Records { - want.MergeToTarget() - } - // Normalize models.PostProcessRecords(existingRecords) @@ -176,7 +162,7 @@ func (g *gcloud) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correc } for _, r := range dc.Records { if keyForRec(r) == ck { - newRRs.Rrdatas = append(newRRs.Rrdatas, r.Target) + newRRs.Rrdatas = append(newRRs.Rrdatas, r.GetTargetCombined()) newRRs.Ttl = int64(r.TTL) } } @@ -195,6 +181,16 @@ func (g *gcloud) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correc }}, nil } +func nativeToRecord(set *dns.ResourceRecordSet, rec, origin string) *models.RecordConfig { + r := &models.RecordConfig{} + r.SetLabelFromFQDN(set.Name, origin) + r.TTL = uint32(set.Ttl) + if err := r.PopulateFromString(set.Type, rec, origin); err != nil { + panic(errors.Wrap(err, "unparsable record received from GCLOUD")) + } + return r +} + func (g *gcloud) getRecords(domain string) ([]*dns.ResourceRecordSet, string, error) { zone, err := g.getZone(domain) if err != nil { diff --git a/providers/namedotcom/namedotcomProvider.go b/providers/namedotcom/namedotcomProvider.go index 45de4ab3d..0dd8c8991 100644 --- a/providers/namedotcom/namedotcomProvider.go +++ b/providers/namedotcom/namedotcomProvider.go @@ -11,6 +11,7 @@ import ( const defaultAPIBase = "api.name.com" +// NameCom describes a connection to the NDC API. type NameCom struct { APIUrl string `json:"apiurl"` APIUser string `json:"apiuser"` diff --git a/providers/namedotcom/nameservers.go b/providers/namedotcom/nameservers.go index 47ce0a9e4..14a1f1a42 100644 --- a/providers/namedotcom/nameservers.go +++ b/providers/namedotcom/nameservers.go @@ -12,6 +12,7 @@ import ( var nsRegex = regexp.MustCompile(`ns([1-4])[a-z]{3}\.name\.com`) +// GetNameservers gets the nameservers set on a domain. func (n *NameCom) GetNameservers(domain string) ([]*models.Nameserver, error) { // This is an interesting edge case. Name.com expects you to SET the nameservers to ns[1-4].name.com, // but it will internally set it to ns1xyz.name.com, where xyz is a uniqueish 3 letters. @@ -44,6 +45,7 @@ func (n *NameCom) getNameserversRaw(domain string) ([]string, error) { return response.Nameservers, nil } +// GetRegistrarCorrections gathers corrections that would being n to match dc. func (n *NameCom) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { nss, err := n.getNameserversRaw(dc.Name) if err != nil { diff --git a/providers/namedotcom/records.go b/providers/namedotcom/records.go index dcb9249dd..a045e9d4a 100644 --- a/providers/namedotcom/records.go +++ b/providers/namedotcom/records.go @@ -3,10 +3,10 @@ package namedotcom import ( "fmt" "regexp" - "strconv" "strings" "github.com/namedotcom/go/namecom" + "github.com/pkg/errors" "github.com/miekg/dns/dnsutil" @@ -21,6 +21,7 @@ var defaultNameservers = []*models.Nameserver{ {Name: "ns4.name.com"}, } +// GetDomainCorrections gathers correctios that would bring n to match dc. func (n *NameCom) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() records, err := n.getRecords(dc.Name) @@ -29,7 +30,7 @@ func (n *NameCom) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Corre } actual := make([]*models.RecordConfig, len(records)) for i, r := range records { - actual[i] = toRecord(r) + actual[i] = toRecord(r, dc.Name) } for _, rec := range dc.Records { @@ -83,43 +84,32 @@ func checkNSModifications(dc *models.DomainConfig) { dc.Records = newList } -// finds a string surrounded by quotes that might contain an escaped quote charactor. -var quotedStringRegexp = regexp.MustCompile("\"((?:[^\"\\\\]|\\\\.)*)\"") - -func toRecord(r *namecom.Record) *models.RecordConfig { +func toRecord(r *namecom.Record, origin string) *models.RecordConfig { rc := &models.RecordConfig{ - NameFQDN: strings.TrimSuffix(r.Fqdn, "."), Type: r.Type, - Target: r.Answer, TTL: r.TTL, Original: r, } - switch r.Type { // #rtype_variations - case "A", "AAAA", "ANAME", "CNAME", "NS": - // nothing additional. + if !strings.HasSuffix(r.Fqdn, ".") { + panic(errors.Errorf("namedotcom suddenly changed protocol. Bailing. (%v)", r.Fqdn)) + } + fqdn := r.Fqdn[:len(r.Fqdn)-1] + rc.SetLabelFromFQDN(fqdn, origin) + switch rtype := r.Type; rtype { // #rtype_variations case "TXT": - if r.Answer[0] == '"' && r.Answer[len(r.Answer)-1] == '"' { - txtStrings := []string{} - for _, t := range quotedStringRegexp.FindAllStringSubmatch(r.Answer, -1) { - txtStrings = append(txtStrings, t[1]) - } - rc.SetTxts(txtStrings) - } + rc.SetTargetTXTs(decodeTxt(r.Answer)) case "MX": - rc.MxPreference = uint16(r.Priority) + if err := rc.SetTargetMX(uint16(r.Priority), r.Answer); err != nil { + panic(errors.Wrap(err, "unparsable MX record received from ndc")) + } case "SRV": - parts := strings.Split(r.Answer, " ") - weight, _ := strconv.ParseInt(parts[0], 10, 32) - port, _ := strconv.ParseInt(parts[1], 10, 32) - rc.SrvWeight = uint16(weight) - rc.SrvPort = uint16(port) - rc.SrvPriority = uint16(r.Priority) - rc.MxPreference = 0 - rc.Target = parts[2] + "." - default: - panic(fmt.Sprintf("toRecord unimplemented rtype %v", r.Type)) - // We panic so that we quickly find any switch statements - // that have not been updated for a new RR type. + if err := rc.SetTargetSRVPriorityString(uint16(r.Priority), r.Answer+"."); err != nil { + panic(errors.Wrap(err, "unparsable SRV record received from ndc")) + } + default: // "A", "AAAA", "ANAME", "CNAME", "NS" + if err := rc.PopulateFromString(rtype, r.Answer, r.Fqdn); err != nil { + panic(errors.Wrap(err, "unparsable record received from ndc")) + } } return rc } @@ -163,17 +153,11 @@ func (n *NameCom) createRecord(rc *models.RecordConfig, domain string) error { TTL: rc.TTL, Priority: uint32(rc.MxPreference), } - switch rc.Type { // #rtype_variations case "A", "AAAA", "ANAME", "CNAME", "MX", "NS": // nothing case "TXT": - if len(rc.TxtStrings) > 1 { - record.Answer = "" - for _, t := range rc.TxtStrings { - record.Answer += "\"" + strings.Replace(t, "\"", "\\\"", -1) + "\"" - } - } + record.Answer = encodeTxt(rc.TxtStrings) case "SRV": record.Answer = fmt.Sprintf("%d %d %v", rc.SrvWeight, rc.SrvPort, rc.Target) record.Priority = uint32(rc.SrvPriority) @@ -186,6 +170,37 @@ func (n *NameCom) createRecord(rc *models.RecordConfig, domain string) error { return err } +// makeTxt encodes TxtStrings for sending in the CREATE/MODIFY API: +func encodeTxt(txts []string) string { + ans := txts[0] + + if len(txts) > 1 { + ans = "" + for _, t := range txts { + ans += `"` + strings.Replace(t, `"`, `\"`, -1) + `"` + } + } + return ans +} + +// finds a string surrounded by quotes that might contain an escaped quote charactor. +var quotedStringRegexp = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`) + +// decodeTxt decodes the TXT record as received from name.com and +// returns the list of strings. +func decodeTxt(s string) []string { + + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + txtStrings := []string{} + for _, t := range quotedStringRegexp.FindAllStringSubmatch(s, -1) { + txtString := strings.Replace(t[1], `\"`, `"`, -1) + txtStrings = append(txtStrings, txtString) + } + return txtStrings + } + return []string{s} +} + func (n *NameCom) deleteRecord(id int32, domain string) error { request := &namecom.DeleteRecordRequest{ DomainName: domain, diff --git a/providers/namedotcom/records_test.go b/providers/namedotcom/records_test.go new file mode 100644 index 000000000..e9984a224 --- /dev/null +++ b/providers/namedotcom/records_test.go @@ -0,0 +1,51 @@ +package namedotcom + +import ( + "strings" + "testing" +) + +var txtData = []struct { + decoded []string + encoded string +}{ + {[]string{`simple`}, `simple`}, + {[]string{`changed`}, `changed`}, + {[]string{`with spaces`}, `with spaces`}, + {[]string{`with whitespace`}, `with whitespace`}, + {[]string{"one", "two"}, `"one""two"`}, + {[]string{"eh", "bee", "cee"}, `"eh""bee""cee"`}, + {[]string{"o\"ne", "tw\"o"}, `"o\"ne""tw\"o"`}, + {[]string{"dimple"}, `dimple`}, + {[]string{"fun", "two"}, `"fun""two"`}, + {[]string{"eh", "bzz", "cee"}, `"eh""bzz""cee"`}, +} + +func TestEncodeTxt(t *testing.T) { + // Test encoded the lists of strings into a string: + for i, test := range txtData { + enc := encodeTxt(test.decoded) + if enc != test.encoded { + t.Errorf("%v: txt\n data: []string{%v}\nexpected: %s\n got: %s", + i, "`"+strings.Join(test.decoded, "`, `")+"`", test.encoded, enc) + } + } +} + +func TestDecodeTxt(t *testing.T) { + // Test decoded a string into the list of strings: + for i, test := range txtData { + data := test.encoded + got := decodeTxt(data) + wanted := test.decoded + if len(got) != len(wanted) { + t.Errorf("%v: txt\n decode: %v\nexpected: `%v`\n got: `%v`\n", i, data, strings.Join(wanted, "`, `"), strings.Join(got, "`, `")) + } else { + for j := range got { + if got[j] != wanted[j] { + t.Errorf("%v: txt\n decode: %v\nexpected: `%v`\n got: `%v`\n", i, data, strings.Join(wanted, "`, `"), strings.Join(got, "`, `")) + } + } + } + } +} diff --git a/providers/ns1/ns1provider.go b/providers/ns1/ns1provider.go index 1d819b73b..fb1a1fb87 100644 --- a/providers/ns1/ns1provider.go +++ b/providers/ns1/ns1provider.go @@ -50,7 +50,7 @@ func (n *nsone) GetNameservers(domain string) ([]*models.Nameserver, error) { func (n *nsone) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() - dc.CombineMXs() + //dc.CombineMXs() z, _, err := n.Zones.Get(dc.Name) if err != nil { return nil, err @@ -120,7 +120,7 @@ func (n *nsone) modify(recs models.Records, domain string) error { func buildRecord(recs models.Records, domain string, id string) *dns.Record { r := recs[0] rec := &dns.Record{ - Domain: r.NameFQDN, + Domain: r.GetLabelFQDN(), Type: r.Type, ID: id, TTL: int(r.TTL), @@ -128,11 +128,11 @@ func buildRecord(recs models.Records, domain string, id string) *dns.Record { } for _, r := range recs { if r.Type == "TXT" { - rec.AddAnswer(&dns.Answer{Rdata: []string{r.Target}}) + rec.AddAnswer(&dns.Answer{Rdata: r.TxtStrings}) } else if r.Type == "SRV" { - rec.AddAnswer(&dns.Answer{Rdata: strings.Split(fmt.Sprintf("%d %d %d %v", r.SrvPriority, r.SrvWeight, r.SrvPort, r.Target), " ")}) + rec.AddAnswer(&dns.Answer{Rdata: strings.Split(fmt.Sprintf("%d %d %d %v", r.SrvPriority, r.SrvWeight, r.SrvPort, r.GetTargetField()), " ")}) } else { - rec.AddAnswer(&dns.Answer{Rdata: strings.Split(r.Target, " ")}) + rec.AddAnswer(&dns.Answer{Rdata: strings.Split(r.GetTargetField(), " ")}) } } return rec @@ -142,15 +142,15 @@ func convert(zr *dns.ZoneRecord, domain string) ([]*models.RecordConfig, error) found := []*models.RecordConfig{} for _, ans := range zr.ShortAns { rec := &models.RecordConfig{ - NameFQDN: zr.Domain, - Name: dnsutil.TrimDomainName(zr.Domain, domain), TTL: uint32(zr.TTL), - Target: ans, Original: zr, - Type: zr.Type, } - if zr.Type == "MX" || zr.Type == "SRV" { - rec.CombinedTarget = true + rec.SetLabelFromFQDN(zr.Domain, domain) + switch rtype := zr.Type; rtype { + default: + if err := rec.PopulateFromString(rtype, ans, domain); err != nil { + panic(errors.Wrap(err, "unparsable record received from ns1")) + } } found = append(found, rec) } diff --git a/providers/ovh/ovhProvider.go b/providers/ovh/ovhProvider.go index 545bb49c0..e56e442ff 100644 --- a/providers/ovh/ovhProvider.go +++ b/providers/ovh/ovhProvider.go @@ -9,7 +9,6 @@ import ( "github.com/StackExchange/dnscontrol/models" "github.com/StackExchange/dnscontrol/providers" "github.com/StackExchange/dnscontrol/providers/diff" - "github.com/miekg/dns/dnsutil" "github.com/pkg/errors" "github.com/xlucas/go-ovh/ovh" ) @@ -83,7 +82,7 @@ func (e errNoExist) Error() string { func (c *ovhProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { dc.Punycode() - dc.CombineMXs() + //dc.CombineMXs() if !c.zones[dc.Name] { return nil, errNoExist{dc.Name} @@ -96,34 +95,10 @@ func (c *ovhProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.C var actual []*models.RecordConfig for _, r := range records { - if r.FieldType == "SOA" { - continue + rec := nativeToRecord(r, dc.Name) + if rec != nil { + actual = append(actual, rec) } - - if r.SubDomain == "" { - r.SubDomain = "@" - } - - // ovh uses a custom type for SPF and DKIM - if r.FieldType == "SPF" || r.FieldType == "DKIM" { - r.FieldType = "TXT" - } - - // ovh default is 3600 - if r.TTL == 0 { - r.TTL = 3600 - } - - rec := &models.RecordConfig{ - NameFQDN: dnsutil.AddOrigin(r.SubDomain, dc.Name), - Name: r.SubDomain, - Type: r.FieldType, - Target: r.Target, - TTL: uint32(r.TTL), - CombinedTarget: true, - Original: r, - } - actual = append(actual, rec) } // Normalize @@ -171,6 +146,33 @@ func (c *ovhProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.C return corrections, nil } +func nativeToRecord(r *Record, origin string) *models.RecordConfig { + if r.FieldType == "SOA" { + return nil + } + rec := &models.RecordConfig{ + TTL: uint32(r.TTL), + Original: r, + } + rtype := r.FieldType + rec.SetLabel(r.SubDomain, origin) + if err := rec.PopulateFromString(rtype, r.Target, origin); err != nil { + panic(errors.Wrap(err, "unparsable record received from ovh")) + } + + // ovh uses a custom type for SPF and DKIM + if rtype == "SPF" || rtype == "DKIM" { + rec.Type = "TXT" + } + + // ovh default is 3600 + if rec.TTL == 0 { + rec.TTL = 3600 + } + + return rec +} + func (c *ovhProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { ns, err := c.fetchRegistrarNS(dc.Name) diff --git a/providers/ovh/protocol.go b/providers/ovh/protocol.go index 90571737d..7f99b40c0 100644 --- a/providers/ovh/protocol.go +++ b/providers/ovh/protocol.go @@ -114,7 +114,7 @@ func (c *ovhProvider) createRecordFunc(rc *models.RecordConfig, fqdn string) fun record := Record{ SubDomain: dnsutil.TrimDomainName(rc.NameFQDN, fqdn), FieldType: rc.Type, - Target: rc.Content(), + Target: rc.GetTargetCombined(), TTL: rc.TTL, } if record.SubDomain == "@" { @@ -132,7 +132,7 @@ func (c *ovhProvider) updateRecordFunc(old *Record, rc *models.RecordConfig, fqd record := Record{ SubDomain: dnsutil.TrimDomainName(rc.NameFQDN, fqdn), FieldType: rc.Type, - Target: rc.Content(), + Target: rc.GetTargetCombined(), TTL: rc.TTL, Zone: fqdn, ID: old.ID, diff --git a/providers/route53/route53Provider.go b/providers/route53/route53Provider.go index 3680a5887..ff0f831a6 100644 --- a/providers/route53/route53Provider.go +++ b/providers/route53/route53Provider.go @@ -110,7 +110,7 @@ type key struct { } func getKey(r *models.RecordConfig) key { - return key{r.NameFQDN, r.Type} + return key{r.GetLabelFQDN(), r.Type} } type errNoExist struct { @@ -157,37 +157,9 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode var existingRecords = []*models.RecordConfig{} for _, set := range records { - if set.AliasTarget == nil { - for _, rec := range set.ResourceRecords { - if *set.Type == "SOA" { - continue - } - r := &models.RecordConfig{ - NameFQDN: unescape(set.Name), - Type: *set.Type, - Target: *rec.Value, - TTL: uint32(*set.TTL), - CombinedTarget: true, - } - existingRecords = append(existingRecords, r) - } - } else { - r := &models.RecordConfig{ - NameFQDN: unescape(set.Name), - Type: "R53_ALIAS", - Target: aws.StringValue(set.AliasTarget.DNSName), - CombinedTarget: true, - TTL: 300, - R53Alias: map[string]string{ - "type": *set.Type, - "zone_id": *set.AliasTarget.HostedZoneId, - }, - } - existingRecords = append(existingRecords, r) - } + existingRecords = append(existingRecords, nativeToRecords(set, dc.Name)...) } for _, want := range dc.Records { - want.MergeToTarget() // update zone_id to current zone.id if not specified by the user if want.Type == "R53_ALIAS" && want.R53Alias["zone_id"] == "" { want.R53Alias["zone_id"] = getZoneID(zone, want) @@ -255,7 +227,7 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode Type: sPtr(k.Type), } for _, r := range recs { - val := r.Target + val := r.GetTargetCombined() if r.Type != "R53_ALIAS" { rr := &r53.ResourceRecord{ Value: &val, @@ -303,6 +275,38 @@ func (r *route53Provider) GetDomainCorrections(dc *models.DomainConfig) ([]*mode } +func nativeToRecords(set *r53.ResourceRecordSet, origin string) []*models.RecordConfig { + results := []*models.RecordConfig{} + if set.AliasTarget != nil { + rc := &models.RecordConfig{ + Type: "R53_ALIAS", + TTL: 300, + R53Alias: map[string]string{ + "type": *set.Type, + "zone_id": *set.AliasTarget.HostedZoneId, + }, + } + rc.SetLabelFromFQDN(unescape(set.Name), origin) + rc.SetTarget(aws.StringValue(set.AliasTarget.DNSName)) + results = append(results, rc) + } else { + for _, rec := range set.ResourceRecords { + switch rtype := *set.Type; rtype { + case "SOA": + continue + default: + rc := &models.RecordConfig{TTL: uint32(*set.TTL)} + rc.SetLabelFromFQDN(unescape(set.Name), origin) + if err := rc.PopulateFromString(*set.Type, *rec.Value, origin); err != nil { + panic(errors.Wrap(err, "unparsable record received from R53")) + } + results = append(results, rc) + } + } + } + return results +} + func getAliasMap(r *models.RecordConfig) map[string]string { if r.Type != "R53_ALIAS" { return nil @@ -312,13 +316,14 @@ func getAliasMap(r *models.RecordConfig) map[string]string { func aliasToRRSet(zone *r53.HostedZone, r *models.RecordConfig) *r53.ResourceRecordSet { rrset := &r53.ResourceRecordSet{ - Name: sPtr(r.NameFQDN), + Name: sPtr(r.GetLabelFQDN()), Type: sPtr(r.R53Alias["type"]), } zoneID := getZoneID(zone, r) targetHealth := false + target := r.GetTargetField() rrset.AliasTarget = &r53.AliasTarget{ - DNSName: &r.Target, + DNSName: &target, HostedZoneId: aws.String(zoneID), EvaluateTargetHealth: &targetHealth, } diff --git a/providers/vultr/vultrProvider.go b/providers/vultr/vultrProvider.go index 27e3ea24d..625acf7e3 100644 --- a/providers/vultr/vultrProvider.go +++ b/providers/vultr/vultrProvider.go @@ -3,7 +3,6 @@ package vultr import ( "encoding/json" "fmt" - "strconv" "strings" vultr "github.com/JamesClonk/vultr/lib" @@ -194,109 +193,54 @@ func (api *VultrApi) isDomainInAccount(domain string) (bool, error) { // toRecordConfig converts a Vultr DNSRecord to a RecordConfig #rtype_variations func toRecordConfig(dc *models.DomainConfig, r *vultr.DNSRecord) (*models.RecordConfig, error) { - // Turns r.Name into a FQDN - // Vultr uses "" as the apex domain, instead of "@", and this handles it fine. - name := dnsutil.AddOrigin(r.Name, dc.Name) - + origin := dc.Name data := r.Data - // Make target into a FQDN if it is a CNAME, NS, MX, or SRV - if r.Type == "CNAME" || r.Type == "NS" || r.Type == "MX" { - if !strings.HasSuffix(data, ".") { - data = data + "." - } - data = dnsutil.AddOrigin(data, dc.Name) - } - // Remove quotes if it is a TXT - if r.Type == "TXT" { - if !strings.HasPrefix(data, `"`) || !strings.HasSuffix(data, `"`) { - return nil, errors.New("Unexpected lack of quotes in TXT record from Vultr") - } - data = data[1 : len(data)-1] - } - rc := &models.RecordConfig{ - NameFQDN: name, - Type: r.Type, - Target: data, TTL: uint32(r.TTL), Original: r, } + rc.SetLabel(r.Name, dc.Name) - if r.Type == "MX" { - rc.MxPreference = uint16(r.Priority) - } - - if r.Type == "SRV" { - rc.SrvPriority = uint16(r.Priority) - - // Vultr returns in the format "[weight] [port] [target]" - splitData := strings.SplitN(rc.Target, " ", 3) - if len(splitData) != 3 { - return nil, errors.Errorf("Unexpected data for SRV record returned by Vultr") + switch rtype := r.Type; rtype { + case "CNAME", "NS": + rc.Type = r.Type + // Make target into a FQDN if it is a CNAME, NS, MX, or SRV + if !strings.HasSuffix(data, ".") { + data = data + "." } - - weight, err := strconv.ParseUint(splitData[0], 10, 16) - if err != nil { - return nil, err - } - rc.SrvWeight = uint16(weight) - - port, err := strconv.ParseUint(splitData[1], 10, 16) - if err != nil { - return nil, err - } - rc.SrvPort = uint16(port) - - target := splitData[2] - if !strings.HasSuffix(target, ".") { - target = target + "." - } - rc.Target = dnsutil.AddOrigin(target, dc.Name) - } - - if r.Type == "CAA" { + // FIXME(tlim): the AddOrigin() might be unneeded. Please test. + return rc, rc.SetTarget(dnsutil.AddOrigin(data, origin)) + case "CAA": // Vultr returns in the format "[flag] [tag] [value]" - // TODO(tal): I copied this code into models/dns.go. At this point - // we can probably replace the code below with: - // rc.CaaFlag, rc.CaaTag, rc.Target, err := models.SplitCombinedCaaValue(rc.Target) - // return rc, err - - splitData := strings.SplitN(rc.Target, " ", 3) - if len(splitData) != 3 { - return nil, errors.Errorf("Unexpected data for CAA record returned by Vultr") + return rc, rc.SetTargetCAAString(data) + case "MX": + if !strings.HasSuffix(data, ".") { + data = data + "." } - - flag, err := strconv.ParseUint(splitData[0], 10, 8) - if err != nil { - return nil, err + return rc, rc.SetTargetMX(uint16(r.Priority), data) + case "SRV": + // Vultr returns in the format "[weight] [port] [target]" + return rc, rc.SetTargetSRVPriorityString(uint16(r.Priority), data) + case "TXT": + // Remove quotes if it is a TXT + if !strings.HasPrefix(data, `"`) || !strings.HasSuffix(data, `"`) { + return nil, errors.New("Unexpected lack of quotes in TXT record from Vultr") } - rc.CaaFlag = uint8(flag) - - rc.CaaTag = splitData[1] - - value := splitData[2] - if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) { - value = value[1 : len(value)-1] - } - if strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`) { - value = value[1 : len(value)-1] - } - rc.Target = value + return rc, rc.SetTargetTXT(data[1 : len(data)-1]) + default: + return rc, rc.PopulateFromString(rtype, r.Data, origin) } - - return rc, nil } // toVultrRecord converts a RecordConfig converted by toRecordConfig back to a Vultr DNSRecord #rtype_variations func toVultrRecord(dc *models.DomainConfig, rc *models.RecordConfig) *vultr.DNSRecord { name := dnsutil.TrimDomainName(rc.NameFQDN, dc.Name) - // Vultr uses a blank string to represent the apex domain if name == "@" { name = "" } - data := rc.Target + data := rc.GetTargetField() // Vultr does not use a period suffix for the server for CNAME, NS, or MX if strings.HasSuffix(data, ".") { @@ -312,7 +256,6 @@ func toVultrRecord(dc *models.DomainConfig, rc *models.RecordConfig) *vultr.DNSR if rc.Type == "MX" { priority = int(rc.MxPreference) } - if rc.Type == "SRV" { priority = int(rc.SrvPriority) } @@ -330,12 +273,11 @@ func toVultrRecord(dc *models.DomainConfig, rc *models.RecordConfig) *vultr.DNSR if strings.HasSuffix(target, ".") { target = target[:len(target)-1] } - r.Data = fmt.Sprintf("%v %v %s", rc.SrvWeight, rc.SrvPort, target) } if rc.Type == "CAA" { - r.Data = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.Target) + r.Data = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, rc.GetTargetField()) } return r