1
0
mirror of https://github.com/StackExchange/dnscontrol.git synced 2024-05-11 05:55:12 +00:00

New provider and new registrar: hosting.de (#1041)

* Add http.net provider

* Rename httpnetProvider

* Add SSHFP capability

* Add paging for records

* Sort documentation notes alphabetically

* Add custom base URL

* Extend documentation for custom base URL

* - renamed to hosting.de
- Fix EnsureDomainExists
- GetNameservers read from NS Records

* Replaced http.net with hosting.de
Contributor Support from hosting.de

* baseURL for hosting.de in documentation
replaced %v with %w for errors
special handling for txt records using .TxtStrings

* removed last references to rc.Target
fixed Trim of last dot

* Re-engineer TXT records for simplicity and better compliance (#1063)

Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
Co-authored-by: Oliver Dick <o.dick@hosting.de>
Co-authored-by: Oliver Dick <31733320+membero@users.noreply.github.com>
This commit is contained in:
Julius Rickert
2021-03-09 01:25:55 +01:00
committed by GitHub
parent 18933436cf
commit c883c1ac68
12 changed files with 772 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v3/providers/hedns"
_ "github.com/StackExchange/dnscontrol/v3/providers/hetzner"
_ "github.com/StackExchange/dnscontrol/v3/providers/hexonet"
_ "github.com/StackExchange/dnscontrol/v3/providers/hostingde"
_ "github.com/StackExchange/dnscontrol/v3/providers/internetbs"
_ "github.com/StackExchange/dnscontrol/v3/providers/inwx"
_ "github.com/StackExchange/dnscontrol/v3/providers/linode"

273
providers/hostingde/api.go Normal file
View File

@@ -0,0 +1,273 @@
package hostingde
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
"golang.org/x/net/idna"
)
const endpoint = "%s/api/%s/v1/json/%s"
type hostingdeProvider struct {
authToken string
ownerAccountID string
baseURL string
}
func (hp *hostingdeProvider) getDomainConfig(domain string) (*domainConfig, error) {
zc, err := hp.getZoneConfig(domain)
if err != nil {
return nil, fmt.Errorf("error getting zone config: %w", err)
}
params := request{
Filter: filter{
Field: "domainName",
Value: zc.Name,
},
}
resp, err := hp.get("domain", "domainsFind", params)
if err != nil {
return nil, fmt.Errorf("error getting domain info: %w", err)
}
domainConf := []*domainConfig{}
if err := json.Unmarshal(resp.Data, &domainConf); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}
if len(domainConf) == 0 {
return nil, fmt.Errorf("could not get domain config: %s", domain)
}
return domainConf[0], nil
}
func (hp *hostingdeProvider) createZone(domain string) error {
t, err := idna.ToASCII(domain)
if err != nil {
return err
}
records := []*record{}
for _, ns := range defaultNameservers {
records = append(records, &record{
Name: domain,
Type: "NS",
Content: ns,
TTL: 86400,
})
}
params := request{
ZoneConfig: &zoneConfig{
Name: t,
Type: "NATIVE",
},
Records: records,
}
_, err = hp.get("dns", "zoneCreate", params)
if err != nil {
return fmt.Errorf("error creating zone: %w", err)
}
return nil
}
func (hp *hostingdeProvider) getNameservers(domain string) ([]string, error) {
t, err := idna.ToASCII(domain)
if err != nil {
return nil, err
}
domainConf, err := hp.getDomainConfig(t)
if err != nil {
return nil, fmt.Errorf("error getting domain config: %w", err)
}
nss := []string{}
for _, ns := range domainConf.Nameservers {
// Currently does not support glued IP addresses
if len(ns.IPs) > 0 {
return nil, fmt.Errorf("domain %s has glued IP addresses which are not supported", domain)
}
nss = append(nss, ns.Name)
}
return nss, nil
}
func (hp *hostingdeProvider) updateNameservers(nss []string, domain string) func() error {
return func() error {
domainConf, err := hp.getDomainConfig(domain)
if err != nil {
return err
}
nameservers := []nameserver{}
for _, ns := range nss {
nameservers = append(nameservers, nameserver{Name: ns})
}
domainConf.Nameservers = nameservers
params := request{
Domain: domainConf,
}
if _, err := hp.get("domain", "domainUpdate", params); err != nil {
return err
}
return nil
}
}
func (hp *hostingdeProvider) getRecords(domain string) ([]*record, error) {
zc, err := hp.getZoneConfig(domain)
if err != nil {
return nil, err
}
records := []*record{}
page := uint(1)
for {
params := request{
Filter: filter{
Field: "ZoneConfigId",
Value: zc.ID,
},
Limit: 1000,
Page: page,
}
resp, err := hp.get("dns", "recordsFind", params)
if err != nil {
return nil, err
}
newRecords := []*record{}
if err := json.Unmarshal(resp.Data, &newRecords); err != nil {
return nil, err
}
records = append(records, newRecords...)
if page >= resp.TotalPages {
break
}
page++
}
return records, nil
}
func (hp *hostingdeProvider) updateRecords(domain string, create, del, mod diff.Changeset) error {
zc, err := hp.getZoneConfig(domain)
if err != nil {
return err
}
toAdd := []*record{}
for _, c := range create {
r := recordToNative(c.Desired)
toAdd = append(toAdd, r)
}
toDelete := []*record{}
for _, d := range del {
r := recordToNative(d.Existing)
r.ID = d.Existing.Original.(*record).ID
toDelete = append(toDelete, r)
}
toModify := []*record{}
for _, m := range mod {
r := recordToNative(m.Desired)
r.ID = m.Existing.Original.(*record).ID
toModify = append(toModify, r)
}
params := request{
ZoneConfig: zc,
RecordsToAdd: toAdd,
RecordsToDelete: toDelete,
RecordsToModify: toModify,
}
_, err = hp.get("dns", "zoneUpdate", params)
if err != nil {
return err
}
return nil
}
func (hp *hostingdeProvider) getZoneConfig(domain string) (*zoneConfig, error) {
t, err := idna.ToASCII(domain)
if err != nil {
return nil, err
}
params := request{
Filter: filter{
Field: "ZoneName",
Value: t,
},
}
resp, err := hp.get("dns", "zoneConfigsFind", params)
if err != nil {
return nil, fmt.Errorf("could not get zone config: %w", err)
}
zc := []*zoneConfig{}
if err := json.Unmarshal(resp.Data, &zc); err != nil {
return nil, fmt.Errorf("could not parse response: %w", err)
}
if len(zc) == 0 {
return nil, errZoneNotFound
}
return zc[0], nil
}
func (hp *hostingdeProvider) get(service, method string, params request) (*responseData, error) {
params.AuthToken = hp.authToken
params.OwnerAccountID = hp.ownerAccountID
reqBody, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("could not marshal request body: %w", err)
}
url := fmt.Sprintf(endpoint, hp.baseURL, service, method)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
return nil, fmt.Errorf("could not carry out request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("error occurred: %s", resp.Status)
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("could not read response body: %w", err)
}
respData := &response{}
if err := json.Unmarshal(bodyBytes, &respData); err != nil {
return nil, fmt.Errorf("could not unmarshal response body: %w", err)
}
if len(respData.Errors) > 0 && respData.Status == "error" {
return nil, fmt.Errorf("%+v", respData.Errors)
}
return respData.Response, nil
}

View File

@@ -0,0 +1,11 @@
package hostingde
import (
"github.com/StackExchange/dnscontrol/v3/models"
)
// AuditRecords returns an error if any records are not
// supportable by this provider.
func AuditRecords(records []*models.RecordConfig) error {
return nil
}

View File

@@ -0,0 +1,201 @@
package hostingde
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
"github.com/StackExchange/dnscontrol/v3/providers"
)
var defaultNameservers = []string{"ns1.hosting.de", "ns2.hosting.de", "ns3.hosting.de"}
var features = providers.DocumentationNotes{
providers.CanAutoDNSSEC: providers.Unimplemented("Supported but not implemented yet."),
providers.CanGetZones: providers.Can(),
providers.CanUseAlias: providers.Can(),
providers.CanUseAzureAlias: providers.Cannot(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDS: providers.Can(),
providers.CanUseNAPTR: providers.Cannot(),
providers.CanUsePTR: providers.Can(),
providers.CanUseRoute53Alias: providers.Cannot(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Can(),
providers.CanUseTLSA: providers.Can(),
providers.DocCreateDomains: providers.Can(),
providers.DocDualHost: providers.Can(),
providers.DocOfficiallySupported: providers.Cannot(),
}
func init() {
providers.RegisterRegistrarType("HOSTINGDE", newHostingdeReg)
fns := providers.DspFuncs{
Initializer: newHostingdeDsp,
AuditRecordsor: AuditRecords,
}
providers.RegisterDomainServiceProviderType("HOSTINGDE", fns, features)
}
func newHostingde(m map[string]string) (*hostingdeProvider, error) {
authToken, ownerAccountID, baseURL := m["authToken"], m["ownerAccountId"], m["baseURL"]
if authToken == "" {
return nil, fmt.Errorf("hosting.de: authtoken must be provided")
}
if baseURL == "" {
baseURL = "https://secure.hosting.de"
}
baseURL = strings.TrimSuffix(baseURL, "/")
hp := &hostingdeProvider{
authToken: authToken,
ownerAccountID: ownerAccountID,
baseURL: baseURL,
}
return hp, nil
}
func newHostingdeDsp(m map[string]string, raw json.RawMessage) (providers.DNSServiceProvider, error) {
return newHostingde(m)
}
func newHostingdeReg(m map[string]string) (providers.Registrar, error) {
return newHostingde(m)
}
func (hp *hostingdeProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
src, err := hp.getRecords(domain)
if err != nil {
return nil, err
}
var nameservers []string
for _, record := range src {
if record.Type == "NS" {
nameservers = append(nameservers, record.Content)
}
}
return models.ToNameservers(nameservers)
}
func (hp *hostingdeProvider) GetZoneRecords(domain string) (models.Records, error) {
src, err := hp.getRecords(domain)
if err != nil {
return nil, err
}
records := []*models.RecordConfig{}
for _, r := range src {
if r.Type == "SOA" {
continue
}
records = append(records, r.nativeToRecord(domain))
}
return records, nil
}
func (hp *hostingdeProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
err := dc.Punycode()
if err != nil {
return nil, err
}
// TTL must be between (inclusive) 1m and 1y (in fact, a little bit more)
for _, r := range dc.Records {
if r.TTL < 60 {
r.TTL = 60
}
if r.TTL > 31556926 {
r.TTL = 31556926
}
}
records, err := hp.GetZoneRecords(dc.Name)
if err != nil {
return nil, err
}
differ := diff.New(dc)
_, create, del, mod, err := differ.IncrementalDiff(records)
if err != nil {
return nil, err
}
// NOPURGE
if dc.KeepUnknown {
del = []diff.Correlation{}
}
msg := []string{}
for _, c := range append(del, append(create, mod...)...) {
msg = append(msg, c.String())
}
if len(create) == 0 && len(del) == 0 && len(mod) == 0 {
return nil, nil
}
corrections := []*models.Correction{
{
Msg: fmt.Sprintf("\n%s", strings.Join(msg, "\n")),
F: func() error {
return hp.updateRecords(dc.Name, create, del, mod)
},
},
}
return corrections, nil
}
func (hp *hostingdeProvider) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
err := dc.Punycode()
if err != nil {
return nil, err
}
found, err := hp.getNameservers(dc.Name)
if err != nil {
return nil, fmt.Errorf("error getting nameservers: %w", err)
}
sort.Strings(found)
foundNameservers := strings.Join(found, ",")
expected := []string{}
for _, ns := range dc.Nameservers {
expected = append(expected, ns.Name)
}
sort.Strings(expected)
expectedNameservers := strings.Join(expected, ",")
// We don't care about glued records because we disallowed them
if foundNameservers != expectedNameservers {
return []*models.Correction{
{
Msg: fmt.Sprintf("Update nameservers %s -> %s", foundNameservers, expectedNameservers),
F: hp.updateNameservers(expected, dc.Name),
},
}, nil
}
return nil, nil
// TODO: Handle AutoDNSSEC
}
func (hp *hostingdeProvider) EnsureDomainExists(domain string) error {
_, err := hp.getZoneConfig(domain)
if err == errZoneNotFound {
if err := hp.createZone(domain); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,182 @@
package hostingde
import (
"encoding/json"
"fmt"
"log"
"net"
"strings"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/pkg/errors"
)
var (
errZoneNotFound = errors.Errorf("zone not found")
)
type request struct {
AuthToken string `json:"authToken"`
OwnerAccountID string `json:"ownerAccountId,omitempty"`
Filter filter `json:"filter,omitempty"`
Limit uint `json:"limit,omitempty"`
Page uint `json:"page,omitempty"`
// Update Zone
ZoneConfig *zoneConfig `json:"zoneConfig"`
RecordsToAdd []*record `json:"recordsToAdd"`
RecordsToModify []*record `json:"recordsToModify"`
RecordsToDelete []*record `json:"recordsToDelete"`
// Create Zone
Records []*record `json:"records"`
// Domain
Domain *domainConfig `json:"domain"`
}
type filter struct {
Field string `json:"field"`
Value string `json:"value"`
Relation string `json:"relation,omitempty"`
}
type nameserver struct {
Name string `json:"name"`
IPs []net.IP `json:"ips"`
}
type domainConfig struct {
Name string `json:"name"`
Contacts json.RawMessage `json:"contacts"`
Nameservers []nameserver `json:"nameservers"`
TransferLockEnabled bool `json:"transferLockEnabled"`
}
type zoneConfig struct {
ID string `json:"id"`
DNSSECMode string `json:"dnsSecMode"`
EmailAddress string `json:"emailAddress,omitempty"`
MasterIP string `json:"masterIp"`
Name string `json:"name"` // Not required per docs, but required IRL
NameUnicode string `json:"nameUnicode"`
// SOAValues struct {
// Refresh uint32 `json:"refresh"`
// Retry uint32 `json:"retry"`
// Expire uint32 `json:"expire"`
// TTL uint32 `json:"ttl"`
// NegativeTTL uint32 `json:"negativeTtl"`
// } `json:"soaValues,omitempty"`
Type string `json:"type"`
ZoneTransferWhitelist []string `json:"zoneTransferWhitelist"`
}
type record struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
TTL uint32 `json:"ttl"`
Priority uint16 `json:"priority"`
}
type response struct {
Errors []apiError `json:"errors"`
Response *responseData `json:"response"`
Status string `json:"status"`
}
type apiError struct {
Code int `json:"code"`
ContextObject string `json:"contextObject"`
ContextPath string `json:"contextPath"`
Text string `json:"text"`
Value string `json:"value"`
}
type responseData struct {
Data json.RawMessage `json:"data"`
Type string `json:"type"`
Limit uint `json:"limit"`
Page uint `json:"page"`
TotalPages uint `json:"totalPages"`
}
func (r *record) nativeToRecord(domain string) *models.RecordConfig {
// normalize cname,mx,ns records with dots to be consistent with our config format.
if r.Type == "ALIAS" || r.Type == "CNAME" || r.Type == "MX" || r.Type == "NS" || r.Type == "SRV" {
if r.Content != "." {
r.Content = r.Content + "."
}
}
rc := &models.RecordConfig{
Type: "",
TTL: r.TTL,
MxPreference: r.Priority,
SrvPriority: r.Priority,
Original: r,
}
rc.SetLabelFromFQDN(r.Name, domain)
var err error
switch r.Type {
case "ALIAS":
rc.Type = r.Type
rc.SetTarget(r.Content)
case "NULLMX":
err = rc.PopulateFromString("MX", "0 .", domain)
case "MX":
err = rc.SetTargetMX(uint16(r.Priority), r.Content)
case "SRV":
err = rc.SetTargetSRVPriorityString(uint16(r.Priority), r.Content)
default:
if err := rc.PopulateFromString(r.Type, r.Content, domain); err != nil {
panic(err)
}
}
if err != nil {
panic(err)
}
return rc
}
func recordToNative(rc *models.RecordConfig) *record {
record := &record{
Name: rc.NameFQDN,
Type: rc.Type,
Content: strings.TrimSuffix(rc.GetTargetCombined(), "."),
TTL: rc.TTL,
}
switch rc.Type { // #rtype_variations
case "A", "AAAA", "ALIAS", "CAA", "CNAME", "DNSKEY", "DS", "NS", "NSEC", "NSEC3", "NSEC3PARAM", "PTR", "RRSIG", "SSHFP", "TSLA":
// Nothing special.
case "TXT":
if cap(rc.TxtStrings) == 1 {
record.Content = "\"" + rc.TxtStrings[0] + "\""
} else if cap(rc.TxtStrings) > 1 {
record.Content = ""
for _, str := range rc.TxtStrings {
record.Content = record.Content + " \"" + str + "\""
}
record.Content = record.Content[1:len(record.Content)]
}
case "MX":
record.Priority = rc.MxPreference
record.Content = strings.TrimSuffix(rc.GetTargetField(), ".")
if record.Content == "" {
record.Type = "NULLMX"
record.Priority = 10
}
case "SRV":
record.Priority = rc.SrvPriority
record.Content = fmt.Sprintf("%d %d %s", rc.SrvWeight, rc.SrvPort, strings.TrimSuffix(rc.GetTargetField(), "."))
default:
log.Printf("hosting.de rtype %v unimplemented", rc.Type)
}
return record
}