mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-01 05:01:46 +08:00
fb14cea91e
* simple facility for registering provider capabilities * support for cloudflare. tests. * js and docs * docs * generate
362 lines
10 KiB
Go
362 lines
10 KiB
Go
package normalize
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
"github.com/StackExchange/dnscontrol/models"
|
|
"github.com/StackExchange/dnscontrol/providers"
|
|
"github.com/StackExchange/dnscontrol/transform"
|
|
"github.com/miekg/dns"
|
|
"github.com/miekg/dns/dnsutil"
|
|
)
|
|
|
|
// Returns false if target does not validate.
|
|
func checkIPv4(label string) error {
|
|
if net.ParseIP(label).To4() == nil {
|
|
return fmt.Errorf("WARNING: target (%v) is not an IPv4 address", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Returns false if target does not validate.
|
|
func checkIPv6(label string) error {
|
|
if net.ParseIP(label).To16() == nil {
|
|
return fmt.Errorf("WARNING: target (%v) is not an IPv6 address", label)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// make sure target is valid reference for cnames, mx, etc.
|
|
func checkTarget(target string) error {
|
|
if target == "@" {
|
|
return nil
|
|
}
|
|
if len(target) < 1 {
|
|
return fmt.Errorf("empty target")
|
|
}
|
|
// If it containts a ".", it must end in a ".".
|
|
if strings.ContainsRune(target, '.') && target[len(target)-1] != '.' {
|
|
return fmt.Errorf("target (%v) must end with a (.) [Required if target is not single label]", target)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateRecordTypes list of valid rec.Type values. Returns true if this is a real DNS record type, false means it is a pseudo-type used internally.
|
|
func validateRecordTypes(rec *models.RecordConfig, domain string) error {
|
|
var validTypes = map[string]bool{
|
|
"A": true,
|
|
"AAAA": true,
|
|
"CNAME": true,
|
|
"IMPORT_TRANSFORM": false,
|
|
"MX": true,
|
|
"TXT": true,
|
|
"NS": true,
|
|
"ALIAS": false,
|
|
}
|
|
|
|
if _, ok := validTypes[rec.Type]; !ok {
|
|
return fmt.Errorf("Unsupported record type (%v) domain=%v name=%v", rec.Type, domain, rec.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// underscores in names are often used erroneously. They are valid for dns records, but invalid for urls.
|
|
// here we list common records expected to have underscores. Anything else containing an underscore will print a warning.
|
|
var expectedUnderscores = []string{"_domainkey", "_dmarc"}
|
|
|
|
func checkLabel(label string, rType string, domain string) error {
|
|
if label == "@" {
|
|
return nil
|
|
}
|
|
if len(label) < 1 {
|
|
return fmt.Errorf("empty %s label in %s", rType, domain)
|
|
}
|
|
if label[len(label)-1] == '.' {
|
|
return fmt.Errorf("label %s.%s ends with a (.)", label, domain)
|
|
}
|
|
|
|
//underscores are warnings
|
|
if strings.ContainsRune(label, '_') {
|
|
//unless it is in our exclusion list
|
|
ok := false
|
|
for _, ex := range expectedUnderscores {
|
|
if strings.Contains(label, ex) {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
return Warning{fmt.Errorf("label %s.%s contains an underscore", label, domain)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkTargets returns true if rec.Target is valid for the rec.Type.
|
|
func checkTargets(rec *models.RecordConfig, domain string) (errs []error) {
|
|
label := rec.Name
|
|
target := rec.Target
|
|
check := func(e error) {
|
|
if e != nil {
|
|
err := fmt.Errorf("In %s %s.%s: %s", rec.Type, rec.Name, domain, e.Error())
|
|
if _, ok := e.(Warning); ok {
|
|
err = Warning{err}
|
|
}
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
switch rec.Type {
|
|
case "A":
|
|
check(checkIPv4(target))
|
|
case "AAAA":
|
|
check(checkIPv6(target))
|
|
case "CNAME":
|
|
check(checkTarget(target))
|
|
if label == "@" {
|
|
check(fmt.Errorf("cannot create CNAME record for bare domain"))
|
|
}
|
|
case "MX":
|
|
check(checkTarget(target))
|
|
case "NS":
|
|
check(checkTarget(target))
|
|
if label == "@" {
|
|
check(fmt.Errorf("cannot create NS record for bare domain. Use NAMESERVER instead"))
|
|
}
|
|
case "ALIAS":
|
|
check(checkTarget(target))
|
|
case "TXT", "IMPORT_TRANSFORM":
|
|
default:
|
|
errs = append(errs, fmt.Errorf("Unimplemented record type (%v) domain=%v name=%v",
|
|
rec.Type, domain, rec.Name))
|
|
}
|
|
return
|
|
}
|
|
|
|
func transformCNAME(target, oldDomain, newDomain string) string {
|
|
// Canonicalize. If it isn't a FQDN, add the newDomain.
|
|
result := dnsutil.AddOrigin(target, oldDomain)
|
|
if dns.IsFqdn(result) {
|
|
result = result[:len(result)-1]
|
|
}
|
|
return dnsutil.AddOrigin(result, newDomain) + "."
|
|
}
|
|
|
|
// import_transform imports the records of one zone into another, modifying records along the way.
|
|
func importTransform(srcDomain, dstDomain *models.DomainConfig, transforms []transform.IpConversion, ttl uint32) error {
|
|
// Read srcDomain.Records, transform, and append to dstDomain.Records:
|
|
// 1. Skip any that aren't A or CNAMEs.
|
|
// 2. Append destDomainname to the end of the label.
|
|
// 3. For CNAMEs, append destDomainname to the end of the target.
|
|
// 4. For As, change the target as described the transforms.
|
|
|
|
for _, rec := range srcDomain.Records {
|
|
if dstDomain.HasRecordTypeName(rec.Type, rec.NameFQDN) {
|
|
continue
|
|
}
|
|
newRec := func() *models.RecordConfig {
|
|
rec2, _ := rec.Copy()
|
|
rec2.Name = rec2.NameFQDN
|
|
rec2.NameFQDN = dnsutil.AddOrigin(rec2.Name, dstDomain.Name)
|
|
if ttl != 0 {
|
|
rec2.TTL = ttl
|
|
}
|
|
return rec2
|
|
}
|
|
switch rec.Type {
|
|
case "A":
|
|
trs, err := transform.TransformIPToList(net.ParseIP(rec.Target), transforms)
|
|
if err != nil {
|
|
return fmt.Errorf("import_transform: TransformIP(%v, %v) returned err=%s", rec.Target, transforms, err)
|
|
}
|
|
for _, tr := range trs {
|
|
r := newRec()
|
|
r.Target = tr.String()
|
|
dstDomain.Records = append(dstDomain.Records, r)
|
|
}
|
|
case "CNAME":
|
|
r := newRec()
|
|
r.Target = transformCNAME(r.Target, srcDomain.Name, dstDomain.Name)
|
|
dstDomain.Records = append(dstDomain.Records, r)
|
|
case "MX", "NS", "TXT":
|
|
// Not imported.
|
|
continue
|
|
default:
|
|
return fmt.Errorf("import_transform: Unimplemented record type %v (%v)",
|
|
rec.Type, rec.Name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteImportTransformRecords deletes any IMPORT_TRANSFORM records from a domain.
|
|
func deleteImportTransformRecords(domain *models.DomainConfig) {
|
|
for i := len(domain.Records) - 1; i >= 0; i-- {
|
|
rec := domain.Records[i]
|
|
if rec.Type == "IMPORT_TRANSFORM" {
|
|
domain.Records = append(domain.Records[:i], domain.Records[i+1:]...)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warning is a wrapper around error that can be used to indicate it should not
|
|
// stop execution, but is still likely a problem.
|
|
type Warning struct {
|
|
error
|
|
}
|
|
|
|
func NormalizeAndValidateConfig(config *models.DNSConfig) (errs []error) {
|
|
|
|
for _, domain := range config.Domains {
|
|
// Normalize Nameservers.
|
|
for _, ns := range domain.Nameservers {
|
|
ns.Name = dnsutil.AddOrigin(ns.Name, domain.Name)
|
|
ns.Name = strings.TrimRight(ns.Name, ".")
|
|
}
|
|
|
|
// Normalize Records.
|
|
for _, rec := range domain.Records {
|
|
if rec.TTL == 0 {
|
|
rec.TTL = models.DefaultTTL
|
|
}
|
|
// Validate the unmodified inputs:
|
|
if err := validateRecordTypes(rec, domain.Name); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if err := checkLabel(rec.Name, rec.Type, domain.Name); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if errs2 := checkTargets(rec, domain.Name); errs2 != nil {
|
|
errs = append(errs, errs2...)
|
|
}
|
|
|
|
// Canonicalize Targets.
|
|
if rec.Type == "CNAME" || rec.Type == "MX" || rec.Type == "NS" {
|
|
rec.Target = dnsutil.AddOrigin(rec.Target, domain.Name+".")
|
|
}
|
|
// Populate FQDN:
|
|
rec.NameFQDN = dnsutil.AddOrigin(rec.Name, domain.Name)
|
|
}
|
|
}
|
|
|
|
// Process any pseudo-records:
|
|
for _, domain := range config.Domains {
|
|
for _, rec := range domain.Records {
|
|
if rec.Type == "IMPORT_TRANSFORM" {
|
|
table, err := transform.DecodeTransformTable(rec.Metadata["transform_table"])
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
continue
|
|
}
|
|
err = importTransform(config.FindDomain(rec.Target), domain, table, rec.TTL)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Clean up:
|
|
for _, domain := range config.Domains {
|
|
deleteImportTransformRecords(domain)
|
|
}
|
|
|
|
// Run record transforms
|
|
for _, domain := range config.Domains {
|
|
if err := applyRecordTransforms(domain); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
|
|
//Check that CNAMES don't have to co-exist with any other records
|
|
for _, d := range config.Domains {
|
|
errs = append(errs, checkCNAMEs(d)...)
|
|
}
|
|
|
|
//Check that if any aliases are used in a domain, every provider for that domain supports them
|
|
for _, d := range config.Domains {
|
|
err := checkALIASes(d, config.DNSProviders)
|
|
if err != nil {
|
|
errs = append(errs, nil)
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
func checkCNAMEs(dc *models.DomainConfig) (errs []error) {
|
|
cnames := map[string]bool{}
|
|
for _, r := range dc.Records {
|
|
if r.Type == "CNAME" {
|
|
if cnames[r.Name] {
|
|
errs = append(errs, fmt.Errorf("Cannot have multiple CNAMEs with same name: %s", r.NameFQDN))
|
|
}
|
|
cnames[r.Name] = true
|
|
}
|
|
}
|
|
for _, r := range dc.Records {
|
|
if cnames[r.Name] && r.Type != "CNAME" {
|
|
errs = append(errs, fmt.Errorf("Cannot have CNAME and %s record with same name: %s", r.Type, r.NameFQDN))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func checkALIASes(dc *models.DomainConfig, pList []*models.DNSProviderConfig) error {
|
|
hasAlias := false
|
|
for _, r := range dc.Records {
|
|
if r.Type == "ALIAS" {
|
|
hasAlias = true
|
|
break
|
|
}
|
|
}
|
|
if !hasAlias {
|
|
return nil
|
|
}
|
|
for pName := range dc.DNSProviders {
|
|
for _, p := range pList {
|
|
if p.Name == pName {
|
|
if !providers.ProviderHasCabability(p.Type, providers.CanUseAlias) {
|
|
return fmt.Errorf("Domain %s uses ALIAS records, but DNS provider type %s does not support them", dc.Name, p.Type)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyRecordTransforms(domain *models.DomainConfig) error {
|
|
for _, rec := range domain.Records {
|
|
if rec.Type != "A" {
|
|
continue
|
|
}
|
|
tt, ok := rec.Metadata["transform"]
|
|
if !ok {
|
|
continue
|
|
}
|
|
table, err := transform.DecodeTransformTable(tt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(rec.Target) //ip already validated above
|
|
newIPs, err := transform.TransformIPToList(net.ParseIP(rec.Target), table)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, newIP := range newIPs {
|
|
if i == 0 && !newIP.Equal(ip) {
|
|
rec.Target = newIP.String() //replace target of first record if different
|
|
} else if i > 0 {
|
|
// any additional ips need identical records with the alternate ip added to the domain
|
|
copy, err := rec.Copy()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
copy.Target = newIP.String()
|
|
domain.Records = append(domain.Records, copy)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|