dnscontrol/providers/hexonet/records.go
Tom Limoncelli b0f2945510
REFACTOR: Add diff2 enable flag to all DNS providers (#1851)
This should enable the diff2 code to be inserted with good "git blame" results for new code.  I'm adding this early to catch any problems early.
2022-12-11 15:02:58 -05:00

317 lines
9.4 KiB
Go

package hexonet
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
"github.com/StackExchange/dnscontrol/v3/pkg/diff2"
"github.com/StackExchange/dnscontrol/v3/pkg/printer"
"github.com/StackExchange/dnscontrol/v3/pkg/txtutil"
)
// HXRecord covers an individual DNS resource record.
type HXRecord struct {
// Raw api value of that RR
Raw string
// DomainName is the zone that the record belongs to.
DomainName string
// Host is the hostname relative to the zone: e.g. for a record for blog.example.org, domain would be "example.org" and host would be "blog".
// An apex record would be specified by either an empty host "" or "@".
// A SRV record would be specified by "_{service}._{protocol}.{host}": e.g. "_sip._tcp.phone" for _sip._tcp.phone.example.org.
Host string
// FQDN is the Fully Qualified Domain Name. It is the combination of the host and the domain name. It always ends in a ".". FQDN is ignored in CreateRecord, specify via the Host field instead.
Fqdn string
// Type is one of the following: A, AAAA, ANAME, CNAME, MX, NS, SRV, or TXT.
Type string
// Answer is either the IP address for A or AAAA records; the target for ANAME, CNAME, MX, or NS records; the text for TXT records.
// For SRV records, answer has the following format: "{weight} {port} {target}" e.g. "1 5061 sip.example.org".
Answer string
// TTL is the time this record can be cached for in seconds.
TTL uint32
// Priority is only required for MX and SRV records, it is ignored for all others.
Priority uint32
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (n *HXClient) GetZoneRecords(domain string) (models.Records, error) {
records, err := n.getRecords(domain)
if err != nil {
return nil, err
}
actual := make([]*models.RecordConfig, len(records))
for i, r := range records {
actual[i] = toRecord(r, domain)
}
for _, rec := range actual {
if rec.Type == "ALIAS" {
return nil, fmt.Errorf("we support realtime ALIAS RR over our X-DNS service, please get in touch with us")
}
}
return actual, nil
}
// GetDomainCorrections gathers correctios that would bring n to match dc.
func (n *HXClient) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
dc.Punycode()
actual, err := n.GetZoneRecords(dc.Name)
if err != nil {
return nil, err
}
//checkNSModifications(dc)
// Normalize
models.PostProcessRecords(actual)
txtutil.SplitSingleLongTxt(dc.Records)
var corrections []*models.Correction
if !diff2.EnableDiff2 || true { // Remove "|| true" when diff2 version arrives
differ := diff.New(dc)
_, create, del, mod, err := differ.IncrementalDiff(actual)
if err != nil {
return nil, err
}
corrections := []*models.Correction{}
buf := &bytes.Buffer{}
// Print a list of changes. Generate an actual change that is the zone
changes := false
params := map[string]interface{}{}
delrridx := 0
addrridx := 0
for _, cre := range create {
changes = true
fmt.Fprintln(buf, cre)
rec := cre.Desired
recordString, err := n.createRecordString(rec, dc.Name)
if err != nil {
return corrections, err
}
params[fmt.Sprintf("ADDRR%d", addrridx)] = recordString
addrridx++
}
for _, d := range del {
changes = true
fmt.Fprintln(buf, d)
rec := d.Existing.Original.(*HXRecord)
params[fmt.Sprintf("DELRR%d", delrridx)] = n.deleteRecordString(rec, dc.Name)
delrridx++
}
for _, chng := range mod {
changes = true
fmt.Fprintln(buf, chng)
old := chng.Existing.Original.(*HXRecord)
new := chng.Desired
params[fmt.Sprintf("DELRR%d", delrridx)] = n.deleteRecordString(old, dc.Name)
newRecordString, err := n.createRecordString(new, dc.Name)
if err != nil {
return corrections, err
}
params[fmt.Sprintf("ADDRR%d", addrridx)] = newRecordString
addrridx++
delrridx++
}
msg := fmt.Sprintf("GENERATE_ZONEFILE: %s\n", dc.Name) + buf.String()
if changes {
corrections = append(corrections, &models.Correction{
Msg: msg,
F: func() error {
return n.updateZoneBy(params, dc.Name)
},
})
}
return corrections, nil
}
// Insert Future diff2 version here.
return corrections, nil
}
func toRecord(r *HXRecord, origin string) *models.RecordConfig {
rc := &models.RecordConfig{
Type: r.Type,
TTL: r.TTL,
Original: r,
}
fqdn := r.Fqdn[:len(r.Fqdn)-1]
rc.SetLabelFromFQDN(fqdn, origin)
switch rtype := r.Type; rtype {
case "TXT":
rc.SetTargetTXTs(decodeTxt(r.Answer))
case "MX":
if err := rc.SetTargetMX(uint16(r.Priority), r.Answer); err != nil {
panic(fmt.Errorf("unparsable MX record received from hexonet api: %w", err))
}
case "SRV":
if err := rc.SetTargetSRVPriorityString(uint16(r.Priority), r.Answer); err != nil {
panic(fmt.Errorf("unparsable SRV record received from hexonet api: %w", err))
}
default: // "A", "AAAA", "ANAME", "CNAME", "NS"
if err := rc.PopulateFromString(rtype, r.Answer, r.Fqdn); err != nil {
panic(fmt.Errorf("unparsable record received from hexonet api: %w", err))
}
}
return rc
}
func (n *HXClient) showCommand(cmd map[string]string) error {
b, err := json.MarshalIndent(cmd, "", " ")
if err != nil {
return fmt.Errorf("error: %w", err)
}
printer.Printf(string(b))
return nil
}
func (n *HXClient) updateZoneBy(params map[string]interface{}, domain string) error {
zone := domain + "."
cmd := map[string]interface{}{
"COMMAND": "UpdateDNSZone",
"DNSZONE": zone,
"INCSERIAL": "1",
}
for key, val := range params {
cmd[key] = val
}
// n.showCommand(cmd)
r := n.client.Request(cmd)
if !r.IsSuccess() {
return n.GetHXApiError("Error while updating zone", zone, r)
}
return nil
}
func (n *HXClient) getRecords(domain string) ([]*HXRecord, error) {
var records []*HXRecord
zone := domain + "."
cmd := map[string]interface{}{
"COMMAND": "QueryDNSZoneRRList",
"DNSZONE": zone,
"SHORT": "1",
"EXTENDED": "0",
}
r := n.client.Request(cmd)
if !r.IsSuccess() {
if r.GetCode() == 545 {
return nil, n.GetHXApiError("Use `dnscontrol create-domains` to create not-existing zone", domain, r)
}
return nil, n.GetHXApiError("Failed loading resource records for zone", domain, r)
}
rrColumn := r.GetColumn("RR")
if rrColumn == nil {
return nil, fmt.Errorf("failed getting RR column for domain: %s", domain)
}
rrs := rrColumn.GetData()
for _, rr := range rrs {
spl := strings.Split(rr, " ")
if spl[3] != "SOA" {
record := &HXRecord{
Raw: rr,
DomainName: domain,
Host: spl[0],
Fqdn: domain + ".",
Type: spl[3],
}
ttl, _ := strconv.ParseUint(spl[1], 10, 32)
record.TTL = uint32(ttl)
if record.Host != "@" {
record.Fqdn = spl[0] + "." + record.Fqdn
}
if record.Type == "MX" || record.Type == "SRV" {
prio, _ := strconv.ParseUint(spl[4], 10, 32)
record.Priority = uint32(prio)
record.Answer = strings.Join(spl[5:], " ")
} else {
record.Answer = strings.Join(spl[4:], " ")
}
records = append(records, record)
}
}
return records, nil
}
func (n *HXClient) createRecordString(rc *models.RecordConfig, domain string) (string, error) {
record := &HXRecord{
DomainName: domain,
Host: rc.GetLabel(),
Type: rc.Type,
Answer: rc.GetTargetField(),
TTL: rc.TTL,
Priority: uint32(rc.MxPreference),
}
switch rc.Type { // #rtype_variations
case "A", "AAAA", "ANAME", "CNAME", "MX", "NS", "PTR":
// nothing
case "TLSA":
record.Answer = fmt.Sprintf(`%v %v %v %s`, rc.TlsaUsage, rc.TlsaSelector, rc.TlsaMatchingType, rc.GetTargetField())
case "CAA":
record.Answer = fmt.Sprintf(`%v %s "%s"`, rc.CaaFlag, rc.CaaTag, record.Answer)
case "TXT":
record.Answer = encodeTxt(rc.TxtStrings)
case "SRV":
if rc.GetTargetField() == "." {
return "", fmt.Errorf("SRV records with empty targets are not supported (as of 2020-02-27, the API returns 'Invalid attribute value syntax')")
}
record.Answer = fmt.Sprintf("%d %d %v", rc.SrvWeight, rc.SrvPort, record.Answer)
record.Priority = uint32(rc.SrvPriority)
default:
panic(fmt.Sprintf("createRecordString 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.
}
str := record.Host + " " + fmt.Sprint(record.TTL) + " IN " + record.Type + " "
if record.Type == "MX" || record.Type == "SRV" {
str += fmt.Sprint(record.Priority) + " "
}
str += record.Answer
return str, nil
}
func (n *HXClient) deleteRecordString(record *HXRecord, domain string) string {
return record.Raw
}
// encodeTxt encodes TxtStrings for sending in the CREATE/MODIFY API:
func encodeTxt(txts []string) string {
var r []string
for _, txt := range txts {
n := `"` + strings.Replace(txt, `"`, `\"`, -1) + `"`
r = append(r, n)
}
return strings.Join(r, " ")
}
// finds a string surrounded by quotes that might contain an escaped quote character.
var quotedStringRegexp = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
// decodeTxt decodes the TXT record as received from hexonet api 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}
}