package hexonet import ( "bytes" "encoding/json" "fmt" "regexp" "strconv" "strings" "github.com/StackExchange/dnscontrol/v3/models" "github.com/StackExchange/dnscontrol/v3/pkg/diff" ) // 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) 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 } 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) { b, err := json.MarshalIndent(cmd, "", " ") if err != nil { fmt.Println("error:", err) } fmt.Print(string(b)) } 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 { 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 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} }