dnscontrol/providers/axfrddns/axfrddnsProvider.go

541 lines
16 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package axfrddns
/*
axfrddns -
Fetch the zone with an AXFR request (RFC5936) to a given primary master, and
push Dynamic DNS updates (RFC2136) to the same server.
Both the AXFR request and the updates might be authentificated with
a TSIG.
*/
import (
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/pkg/printer"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/miekg/dns"
)
const (
dnsTimeout = 30 * time.Second
dnssecDummyLabel = "__dnssec"
dnssecDummyTxt = "Domain has DNSSec records, not displayed here."
)
var features = providers.DocumentationNotes{
// The default for unlisted capabilities is 'Cannot'.
// See providers/capabilities.go for the entire list of capabilities.
providers.CanAutoDNSSEC: providers.Can("Just warn when DNSSEC is requested but no RRSIG is found in the AXFR or warn when DNSSEC is not requested but RRSIG are found in the AXFR."),
providers.CanConcur: providers.Can(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDHCID: providers.Can(),
providers.CanUseDNAME: providers.Can(),
providers.CanUseDS: providers.Can(),
providers.CanUseHTTPS: providers.Can(),
providers.CanUseLOC: providers.Can(),
providers.CanUseNAPTR: providers.Can(),
providers.CanUsePTR: providers.Can(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Can(),
providers.CanUseSVCB: providers.Can(),
providers.CanUseTLSA: providers.Can(),
providers.DocDualHost: providers.Cannot(),
providers.DocOfficiallySupported: providers.Cannot(),
// Possible to support via catalog zones (RFC 9432), but those are not
// directly supported by DNSControl right now (although nothing is stopping
// you from manually updating a catalog zone using DNSControl if you wish).
providers.CanGetZones: providers.Cannot(),
providers.DocCreateDomains: providers.Cannot(),
// Not a valid RR type, so impossible to encode in an RFC-compliant DNS
// packet.
providers.CanUseAlias: providers.Cannot(),
// These are both supported by RFC 2136 (DDNS), but neither work with
// DNSControl right now.
providers.CanUseSOA: providers.Cannot(),
providers.CanUseDNSKEY: providers.Cannot(),
}
// axfrddnsProvider stores the client info for the provider.
type axfrddnsProvider struct {
master string
updateMode string
transferServer string
transferMode string
nameservers []*models.Nameserver
transferKey *Key
updateKey *Key
mu sync.Mutex // protects hasDnssecRecords during concurrent collection.
hasDnssecRecords map[string]bool
}
func initAxfrDdns(config map[string]string, providermeta json.RawMessage) (providers.DNSServiceProvider, error) {
// config -- the key/values from creds.json
// providermeta -- the json blob from NewReq('name', 'TYPE', providermeta)
var err error
api := &axfrddnsProvider{
hasDnssecRecords: map[string]bool{},
}
param := &Param{}
if len(providermeta) != 0 {
err := json.Unmarshal(providermeta, param)
if err != nil {
return nil, err
}
}
var nss []string
if config["nameservers"] != "" {
nss = strings.Split(config["nameservers"], ",")
}
for _, ns := range param.DefaultNS {
nss = append(nss, ns[0:len(ns)-1])
}
api.nameservers, err = models.ToNameservers(nss)
if err != nil {
return nil, err
}
if config["update-mode"] != "" {
switch config["update-mode"] {
case "tcp", "tcp-tls":
api.updateMode = config["update-mode"]
case "udp":
api.updateMode = ""
default:
printer.Printf("[Warning] AXFRDDNS: Unknown update-mode in `creds.json` (%s)\n", config["update-mode"])
}
} else {
api.updateMode = "tcp"
}
if config["transfer-mode"] != "" {
switch config["transfer-mode"] {
case "tcp", "tcp-tls":
api.transferMode = config["transfer-mode"]
default:
printer.Printf("[Warning] AXFRDDNS: Unknown transfer-mode in `creds.json` (%s)\n", config["transfer-mode"])
}
} else {
api.transferMode = "tcp"
}
if config["master"] != "" {
api.master = config["master"]
if !strings.Contains(api.master, ":") {
api.master = api.master + ":53"
}
} else if len(api.nameservers) != 0 {
api.master = api.nameservers[0].Name + ":53"
} else {
return nil, errors.New("nameservers list is empty: creds.json needs a default `nameservers` or an explicit `master`")
}
if config["transfer-server"] != "" {
api.transferServer = config["transfer-server"]
if !strings.Contains(api.transferServer, ":") {
api.transferServer = api.transferServer + ":53"
}
} else {
api.transferServer = api.master
}
api.updateKey, err = readKey(config["update-key"], "update-key")
if err != nil {
return nil, err
}
api.transferKey, err = readKey(config["transfer-key"], "transfer-key")
if err != nil {
return nil, err
}
switch strings.ToLower(strings.TrimSpace(config["buggy-cname"])) {
case "yes", "true":
printer.Warnf("'buggy-cname' is deprecated as it is no longer necessary.\n")
}
for key := range config {
switch key {
case "master",
"nameservers",
"update-key",
"transfer-key",
"transfer-server",
"update-mode",
"transfer-mode",
"buggy-cname",
"domain",
"TYPE":
continue
default:
printer.Printf("[Warning] AXFRDDNS: unknown key in `creds.json` (%s)\n", key)
}
}
return api, err
}
func init() {
const providerName = "AXFRDDNS"
const providerMaintainer = "@hnrgrgr"
fns := providers.DspFuncs{
Initializer: initAxfrDdns,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType(providerName, fns, features)
providers.RegisterMaintainer(providerName, providerMaintainer)
}
// Param is used to decode extra parameters sent to provider.
type Param struct {
DefaultNS []string `json:"default_ns"`
}
// Key stores the individual parts of a TSIG key.
type Key struct {
algo string
id string
secret string
}
func readKey(raw string, kind string) (*Key, error) {
if raw == "" {
return nil, nil
}
arr := strings.Split(raw, ":")
if len(arr) != 3 {
return nil, fmt.Errorf("invalid key format (%s) in AXFRDDNS.TSIG", kind)
}
var algo string
switch arr[0] {
case "hmac-md5", "md5":
algo = dns.HmacMD5
case "hmac-sha1", "sha1":
algo = dns.HmacSHA1
case "hmac-sha224", "sha224":
algo = dns.HmacSHA224
case "hmac-sha256", "sha256":
algo = dns.HmacSHA256
case "hmac-sha384", "sha384":
algo = dns.HmacSHA384
case "hmac-sha512", "sha512":
algo = dns.HmacSHA512
default:
return nil, fmt.Errorf("unknown algorithm (%s) in AXFRDDNS.TSIG", kind)
}
_, err := base64.StdEncoding.DecodeString(arr[2])
if err != nil {
return nil, fmt.Errorf("cannot decode Base64 secret (%s) in AXFRDDNS.TSIG", kind)
}
id := dns.CanonicalName(arr[1])
return &Key{algo: algo, id: id, secret: arr[2]}, nil
}
// GetNameservers returns the nameservers for a domain.
func (c *axfrddnsProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
return c.nameservers, nil
}
func (c *axfrddnsProvider) getAxfrConnection() (*dns.Transfer, error) {
var con net.Conn
var err error
if c.transferMode == "tcp-tls" {
con, err = tls.Dial("tcp", c.transferServer, &tls.Config{})
} else {
con, err = net.Dial("tcp", c.transferServer)
}
if err != nil {
return nil, err
}
dnscon := &dns.Conn{Conn: con}
transfer := &dns.Transfer{Conn: dnscon}
return transfer, nil
}
// FetchZoneRecords gets the records of a zone and returns them in dns.RR format.
func (c *axfrddnsProvider) FetchZoneRecords(domain string) ([]dns.RR, error) {
transfer, err := c.getAxfrConnection()
if err != nil {
return nil, err
}
transfer.DialTimeout = dnsTimeout
transfer.ReadTimeout = dnsTimeout
request := new(dns.Msg)
request.SetAxfr(domain + ".")
if c.transferKey != nil {
transfer.TsigSecret = map[string]string{c.transferKey.id: c.transferKey.secret}
request.SetTsig(c.transferKey.id, c.transferKey.algo, 300, time.Now().Unix())
if c.transferKey.algo == dns.HmacMD5 {
transfer.TsigProvider = md5Provider(c.transferKey.secret)
}
}
envelope, err := transfer.In(request, c.transferServer)
if err != nil {
return nil, err
}
var rawRecords []dns.RR
for msg := range envelope {
if msg.Error != nil {
// Fragile but more "user-friendly" error-handling
err := msg.Error.Error()
if err == "dns: bad xfr rcode: 9" {
err = "NOT AUTH (9)"
}
return nil, fmt.Errorf("[Error] AXFRDDNS: nameserver refused to transfer the zone %s: %s", domain, err)
}
rawRecords = append(rawRecords, msg.RR...)
}
return rawRecords, nil
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (c *axfrddnsProvider) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
rawRecords, err := c.FetchZoneRecords(domain)
if err != nil {
return nil, err
}
var foundDNSSecRecords *models.RecordConfig
foundRecords := models.Records{}
for _, rr := range rawRecords {
switch rr.Header().Rrtype {
case dns.TypeRRSIG,
dns.TypeDNSKEY,
dns.TypeCDNSKEY,
dns.TypeCDS,
dns.TypeNSEC,
dns.TypeNSEC3,
dns.TypeNSEC3PARAM,
dns.TypeZONEMD,
65534:
// Ignoring DNSSec RRs, but replacing it with a single
// "TXT" placeholder
// Also ignoring spurious TYPE65534, see:
// https://bind9-users.isc.narkive.com/zX29ay0j/rndc-signing-list-not-working#post2
if foundDNSSecRecords == nil {
foundDNSSecRecords = new(models.RecordConfig)
foundDNSSecRecords.Type = "TXT"
foundDNSSecRecords.SetLabel(dnssecDummyLabel, domain)
err = foundDNSSecRecords.SetTargetTXT(dnssecDummyTxt)
if err != nil {
return nil, err
}
}
continue
default:
rec, err := models.RRtoRC(rr, domain)
if err != nil {
return nil, err
}
foundRecords = append(foundRecords, &rec)
}
}
if len(foundRecords) >= 1 && foundRecords[len(foundRecords)-1].Type == "SOA" {
// The SOA is sent two times: as the first and the last record
// See section 2.2 of RFC5936. We remove the later one.
foundRecords = foundRecords[:len(foundRecords)-1]
}
if foundDNSSecRecords != nil {
foundRecords = append(foundRecords, foundDNSSecRecords)
}
if len(foundRecords) >= 1 {
last := foundRecords[len(foundRecords)-1]
if last.Type == "TXT" &&
last.Name == dnssecDummyLabel &&
last.GetTargetTXTSegmentCount() == 1 &&
last.GetTargetTXTSegmented()[0] == dnssecDummyTxt {
c.mu.Lock()
c.hasDnssecRecords[domain] = true
c.mu.Unlock()
foundRecords = foundRecords[0:(len(foundRecords) - 1)]
}
}
return foundRecords, nil
}
// BuildCorrection return a Correction for a given set of DDNS update and the corresponding message.
func (c *axfrddnsProvider) BuildCorrection(dc *models.DomainConfig, msgs []string, update *dns.Msg) *models.Correction {
if update == nil {
return &models.Correction{
Msg: fmt.Sprintf("DDNS UPDATES to '%s' (primary master: '%s'). Changes:\n%s", dc.Name, c.master, strings.Join(msgs, "\n")),
}
}
return &models.Correction{
Msg: fmt.Sprintf("DDNS UPDATES to '%s' (primary master: '%s'). Changes:\n%s", dc.Name, c.master, strings.Join(msgs, "\n")),
F: func() error {
update.Compress = true
client := new(dns.Client)
client.Net = c.updateMode
client.Timeout = dnsTimeout
if c.updateKey != nil {
client.TsigSecret = map[string]string{c.updateKey.id: c.updateKey.secret}
update.SetTsig(c.updateKey.id, c.updateKey.algo, 300, time.Now().Unix())
if c.updateKey.algo == dns.HmacMD5 {
client.TsigProvider = md5Provider(c.updateKey.secret)
}
}
msg, _, err := client.Exchange(update, c.master)
if err != nil {
return err
}
if msg.MsgHdr.Rcode != 0 {
return fmt.Errorf("[Error] AXFRDDNS: nameserver refused to update the zone: %s (%d)",
dns.RcodeToString[msg.MsgHdr.Rcode],
msg.MsgHdr.Rcode)
}
return nil
},
}
}
// hasNSDeletion returns true if there exist a correction that deletes or changes an NS record
func hasNSDeletion(changes diff2.ChangeList) bool {
for _, change := range changes {
switch change.Type {
case diff2.CHANGE:
if change.Old[0].Type == "NS" && change.Old[0].Name == "@" {
return true
}
case diff2.DELETE:
if change.Old[0].Type == "NS" && change.Old[0].Name == "@" {
return true
}
case diff2.CREATE:
case diff2.REPORT:
}
}
return false
}
// GetZoneRecordsCorrections returns a list of corrections that will turn existing records into dc.Records.
func (c *axfrddnsProvider) GetZoneRecordsCorrections(dc *models.DomainConfig, foundRecords models.Records) ([]*models.Correction, int, error) {
// Ignoring the SOA, others providers don't manage it either.
if len(foundRecords) >= 1 && foundRecords[0].Type == "SOA" {
foundRecords = foundRecords[1:]
}
// TODO(tlim): This check should be done on all providers. Move to the global validation code.
c.mu.Lock()
if dc.AutoDNSSEC == "on" && !c.hasDnssecRecords[dc.Name] {
printer.Printf("Warning: AUTODNSSEC is enabled for %s, but no DNSKEY or RRSIG record was found in the AXFR answer!\n", dc.Name)
}
if dc.AutoDNSSEC == "off" && c.hasDnssecRecords[dc.Name] {
printer.Printf("Warning: AUTODNSSEC is disabled for %s, but DNSKEY or RRSIG records were found in the AXFR answer!\n", dc.Name)
}
c.mu.Unlock()
// An RFC2136-compliant server must silently ignore an
// update that inserts a non-CNAME RRset when a CNAME RR
// with the same name is present in the zone (and
// vice-versa). Therefore we prefer to first remove records
// and then insert new ones.
//
// Compliant servers must also silently ignore an update
// that removes the last NS record of a zone. Therefore we
// don't want to remove all NS records before inserting a
// new one. Then, when an update want to change a NS record,
// we first insert a dummy NS record that we will remove
// at the end of the batched update.
var msgs []string
var reports []string
update := new(dns.Msg)
update.SetUpdate(dc.Name + ".")
dummyNs1, err := dns.NewRR(dc.Name + ". IN NS dnscontrol.invalid.")
if err != nil {
return nil, 0, err
}
dummyNs2, err := dns.NewRR(dc.Name + ". IN NS dnscontrol.invalid.")
if err != nil {
return nil, 0, err
}
changes, actualChangeCount, err := diff2.ByRecord(foundRecords, dc, nil)
if err != nil {
return nil, 0, err
}
if changes == nil {
return nil, 0, nil
}
// A DNS server should silently ignore a DDNS update that removes
// the last NS record of a zone. Since modifying a record is
// implemented by successively a deletion of the old record and an
// insertion of the new one, then modifying all the NS record of a
// zone might will fail (even if the deletion and insertion
// are grouped in a single batched update).
//
// To avoid this case, we will first insert a dummy NS record,
// that will be removed at the end of the batched updates. This
// record needs to inserted only when all NS records are touched
// The current implementation insert this dummy record as soon as
// a NS record is deleted or changed.
hasNSDeletion := hasNSDeletion(changes)
if hasNSDeletion {
update.Insert([]dns.RR{dummyNs1})
}
for _, change := range changes {
switch change.Type {
case diff2.DELETE:
msgs = append(msgs, change.Msgs[0])
// It's semantically invalid for any RRs to exist alongside a
// CNAME RR
if change.Old[0].Type == "CNAME" {
update.RemoveName([]dns.RR{change.Old[0].ToRR()})
} else {
update.Remove([]dns.RR{change.Old[0].ToRR()})
}
case diff2.CREATE:
msgs = append(msgs, change.Msgs[0])
// It's semantically invalid for any RRs to exist alongside a
// CNAME RR
if change.New[0].Type == "CNAME" {
update.RemoveName([]dns.RR{change.New[0].ToRR()})
}
update.Insert([]dns.RR{change.New[0].ToRR()})
case diff2.CHANGE:
msgs = append(msgs, change.Msgs[0])
// It's semantically invalid for any RRs to exist alongside a
// CNAME RR
if (change.New[0].Type == "CNAME") || (change.Old[0].Type == "CNAME") {
update.RemoveName([]dns.RR{change.Old[0].ToRR()})
} else {
update.Remove([]dns.RR{change.Old[0].ToRR()})
}
update.Insert([]dns.RR{change.New[0].ToRR()})
case diff2.REPORT:
reports = append(reports, change.Msgs...)
}
}
if hasNSDeletion {
update.Remove([]dns.RR{dummyNs2})
}
returnValue := []*models.Correction{}
if len(msgs) > 0 {
returnValue = append(returnValue, c.BuildCorrection(dc, msgs, update))
}
if len(reports) > 0 {
returnValue = append(returnValue, c.BuildCorrection(dc, reports, nil))
}
return returnValue, actualChangeCount, nil
}