From 35818299c0c80c1581c590cd6e51c9d91352c0a0 Mon Sep 17 00:00:00 2001 From: Yuhui Xu Date: Tue, 15 Nov 2022 11:40:08 -0600 Subject: [PATCH] NEW PROVIDER: Gcore DNS (#1816) --- OWNERS | 1 + README.md | 1 + docs/_includes/matrix.html | 58 +++++++- docs/_providers/gcore.md | 43 ++++++ docs/provider-list.md | 1 + go.mod | 2 + go.sum | 3 + integrationTest/providers.json | 4 + providers/_all/all.go | 1 + providers/gcore/auditrecords.go | 15 ++ providers/gcore/convert.go | 107 ++++++++++++++ providers/gcore/gcoreProvider.go | 236 +++++++++++++++++++++++++++++++ 12 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 docs/_providers/gcore.md create mode 100644 providers/gcore/auditrecords.go create mode 100644 providers/gcore/convert.go create mode 100644 providers/gcore/gcoreProvider.go diff --git a/OWNERS b/OWNERS index 958f5ca2b..cf200136f 100644 --- a/OWNERS +++ b/OWNERS @@ -15,6 +15,7 @@ providers/domainnameshop @SimenBai providers/easyname @tresni providers/exoscale @pierre-emmanuelJ providers/gandi_v5 @TomOnTime +providers/gcore @xddxdd providers/gcloud @riyadhalnur providers/hedns @rblenkinsopp providers/hetzner @das7pad diff --git a/README.md b/README.md index 61f532602..48a295010 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Currently supported DNS providers: - Domainnameshop (Domeneshop) - Exoscale - Gandi +- Gcore - Google DNS - Hetzner - HEXONET diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html index 23c99a608..23c0d8b5e 100644 --- a/docs/_includes/matrix.html +++ b/docs/_includes/matrix.html @@ -23,6 +23,7 @@
EXOSCALE
GANDI_V5
GCLOUD
+
GCORE
HEDNS
HETZNER
HEXONET
@@ -111,6 +112,9 @@ + + + @@ -243,6 +247,9 @@ + + + @@ -363,6 +370,9 @@ + + + @@ -469,6 +479,9 @@ + + + @@ -560,6 +573,9 @@ + + + @@ -624,8 +640,8 @@ - - + + @@ -659,6 +675,9 @@ + + + @@ -753,6 +772,9 @@ + + + @@ -847,6 +869,9 @@ + + + @@ -938,6 +963,7 @@ + SRV @@ -991,6 +1017,9 @@ + + + @@ -1101,6 +1130,9 @@ + + + @@ -1199,6 +1231,9 @@ + + + @@ -1286,6 +1321,7 @@ + R53_ALIAS @@ -1309,6 +1345,7 @@ + @@ -1362,6 +1399,7 @@ + @@ -1431,6 +1469,9 @@ + + + @@ -1514,6 +1555,7 @@ + dual host @@ -1573,6 +1615,9 @@ + + + @@ -1689,6 +1734,9 @@ + + + @@ -1827,6 +1875,9 @@ + + + @@ -1931,6 +1982,9 @@ + + + diff --git a/docs/_providers/gcore.md b/docs/_providers/gcore.md new file mode 100644 index 000000000..8312d34a2 --- /dev/null +++ b/docs/_providers/gcore.md @@ -0,0 +1,43 @@ +--- +name: Gcore +title: Gcore Provider +layout: default +jsId: GCORE +--- +# Gcore Provider +## Configuration + +To use this provider, add an entry to `creds.json` with `TYPE` set to `GCORE` +along with a Gcore account API token. + +Example: + +```json +{ + "gcore": { + "TYPE": "GCORE", + "api-key": "your-gcore-api-key" + } +} +``` + +## Metadata +This provider does not recognize any special metadata fields unique to Gcore. + +## Usage +An example `dnsconfig.js` configuration: + +```js +var REG_NONE = NewRegistrar("none"); // No registrar. +var DSP_GCORE = NewDnsProvider("gcore"); // Gcore + +D("example.tld", REG_NONE, DnsProvider(DSP_GCORE), + A("test", "1.2.3.4") +); +``` + +## Activation + +DNSControl depends on a Gcore account API token. + +You can obtain your API token on this page: diff --git a/docs/provider-list.md b/docs/provider-list.md index 4722da648..c3ab05a07 100644 --- a/docs/provider-list.md +++ b/docs/provider-list.md @@ -85,6 +85,7 @@ Providers in this category and their maintainers are: * `EASYNAME` @tresni * `EXOSCALE` @pierre-emmanuelJ * `GANDI_V5` @TomOnTime +* `GCORE` @xddxdd * `HEDNS` @rblenkinsopp * `HETZNER` @das7pad * `HEXONET` @papakai diff --git a/go.mod b/go.mod index 8547570c2..3b0e0fbc1 100644 --- a/go.mod +++ b/go.mod @@ -60,6 +60,7 @@ require ( ) require ( + github.com/G-Core/gcore-dns-sdk-go v0.2.3 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/mattn/go-isatty v0.0.16 github.com/vultr/govultr/v2 v2.17.2 @@ -152,6 +153,7 @@ require ( go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.1.0 // indirect golang.org/x/mod v0.6.0 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.1.0 // indirect golang.org/x/text v0.4.0 // indirect golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect diff --git a/go.sum b/go.sum index 3de216e6b..41add5817 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/DisposaBoy/JsonConfigReader v0.0.0-20201129172854-99cf318d67e7 h1:AJKJCKcb/psppPl/9CUiQQnTG+Bce0/cIweD5w5Q7aQ= github.com/DisposaBoy/JsonConfigReader v0.0.0-20201129172854-99cf318d67e7/go.mod h1:GCzqZQHydohgVLSIqRKZeTt8IGb1Y4NaFfim3H40uUI= +github.com/G-Core/gcore-dns-sdk-go v0.2.3 h1:WODi+qWlZyF7E7SH8rq/DCACa/Zhsuhu1h0DuFJc2Yg= +github.com/G-Core/gcore-dns-sdk-go v0.2.3/go.mod h1:TM+VaDvBPObF+x085lS3i0kc2OPAkuW2c4Leg7Pe6jI= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/TomOnTime/utfutil v0.0.0-20210710122150-437f72b26edf h1:+GdVyvpzTy3UFAS1+hbTqm9Mk0U1Xrocm28s/E2GWz0= @@ -573,6 +575,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/integrationTest/providers.json b/integrationTest/providers.json index 42f839381..95a8f95e3 100644 --- a/integrationTest/providers.json +++ b/integrationTest/providers.json @@ -94,6 +94,10 @@ "project_id": "$GCLOUD_PROJECT", "type": "$GCLOUD_TYPE" }, + "GCORE": { + "api-key": "$GCORE_API_KEY", + "domain": "$GCORE_DOMAIN" + }, "HEDNS": { "domain": "$HEDNS_DOMAIN", "password": "$HEDNS_PASSWORD", diff --git a/providers/_all/all.go b/providers/_all/all.go index 206bf006d..c7d4452d7 100644 --- a/providers/_all/all.go +++ b/providers/_all/all.go @@ -21,6 +21,7 @@ import ( _ "github.com/StackExchange/dnscontrol/v3/providers/exoscale" _ "github.com/StackExchange/dnscontrol/v3/providers/gandiv5" _ "github.com/StackExchange/dnscontrol/v3/providers/gcloud" + _ "github.com/StackExchange/dnscontrol/v3/providers/gcore" _ "github.com/StackExchange/dnscontrol/v3/providers/hedns" _ "github.com/StackExchange/dnscontrol/v3/providers/hetzner" _ "github.com/StackExchange/dnscontrol/v3/providers/hexonet" diff --git a/providers/gcore/auditrecords.go b/providers/gcore/auditrecords.go new file mode 100644 index 000000000..de207ccc5 --- /dev/null +++ b/providers/gcore/auditrecords.go @@ -0,0 +1,15 @@ +package gcore + +import ( + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/rejectif" +) + +// AuditRecords returns a list of errors corresponding to the records +// that aren't supported by this provider. If all records are +// supported, an empty list is returned. +func AuditRecords(records []*models.RecordConfig) []error { + a := rejectif.Auditor{} + a.Add("SRV", rejectif.SrvHasNullTarget) + return a.Audit(records) +} diff --git a/providers/gcore/convert.go b/providers/gcore/convert.go new file mode 100644 index 000000000..3c950ca96 --- /dev/null +++ b/providers/gcore/convert.go @@ -0,0 +1,107 @@ +package gcore + +// Convert the provider's native record description to models.RecordConfig. + +import ( + "errors" + "fmt" + + dnssdk "github.com/G-Core/gcore-dns-sdk-go" + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/printer" +) + +// nativeToRecord takes a DNS record from G-Core and returns a native RecordConfig struct. +func nativeToRecords(n dnssdk.RRSet, zoneName string, recName string, recType string) ([]*models.RecordConfig, error) { + var rcs []*models.RecordConfig + + // Split G-Core's RRset into individual records + for _, value := range n.Records { + rc := &models.RecordConfig{ + TTL: uint32(n.TTL), + Original: n, + } + rc.SetLabelFromFQDN(recName, zoneName) + switch recType { + case "CAA": // G-Core API don't need quotes around CAA with whitespace + if len(value.Content) != 3 { + return nil, errors.New("incorrect number of fields in G-Core's CAA record") + } + + parts := make([]string, len(value.Content)) + for i := range value.Content { + parts[i] = fmt.Sprint(value.Content[i]) + } + + flag, tag, target := parts[0], parts[1], parts[2] + if err := rc.SetTargetCAAStrings(flag, tag, target); err != nil { + return nil, fmt.Errorf("unparsable record received from G-Core: %w", err) + } + + default: // "A", "AAAA", "CAA", "NS", "CNAME", "MX", "PTR", "SRV", "TXT" + if err := rc.PopulateFromString(recType, value.ContentToString(), zoneName); err != nil { + return nil, fmt.Errorf("unparsable record received from G-Core: %w", err) + } + } + rcs = append(rcs, rc) + } + + return rcs, nil +} + +func recordsToNative(rcs []*models.RecordConfig, expectedKey models.RecordKey) *dnssdk.RRSet { + // Merge DNSControl records into G-Core RRsets + + var result *dnssdk.RRSet + + for _, r := range rcs { + label := r.GetLabel() + if label == "@" { + label = "" + } + key := r.Key() + + if key != expectedKey { + continue + } + + var rr dnssdk.ResourceRecord + switch key.Type { + case "CAA": // G-Core API don't need quotes around CAA with whitespace + rr = dnssdk.ResourceRecord{ + Content: []interface{}{ + int64(r.CaaFlag), + r.CaaTag, + r.GetTargetField(), + }, + Meta: nil, + Enabled: true, + } + default: + rr = dnssdk.ResourceRecord{ + Content: dnssdk.ContentFromValue(key.Type, r.GetTargetCombined()), + Meta: nil, + Enabled: true, + } + } + + if result == nil { + result = &dnssdk.RRSet{ + TTL: int(r.TTL), + Filters: nil, + Records: []dnssdk.ResourceRecord{rr}, + } + } else { + result.Records = append(result.Records, rr) + + if int(r.TTL) != result.TTL { + printer.Warnf("All TTLs for a rrset (%v) must be the same. Using smaller of %v and %v.\n", key, r.TTL, result.TTL) + if int(r.TTL) < result.TTL { + result.TTL = int(r.TTL) + } + } + } + } + + return result +} diff --git a/providers/gcore/gcoreProvider.go b/providers/gcore/gcoreProvider.go new file mode 100644 index 000000000..635fa713e --- /dev/null +++ b/providers/gcore/gcoreProvider.go @@ -0,0 +1,236 @@ +package gcore + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/StackExchange/dnscontrol/v3/models" + "github.com/StackExchange/dnscontrol/v3/pkg/diff" + "github.com/StackExchange/dnscontrol/v3/providers" + + dnssdk "github.com/G-Core/gcore-dns-sdk-go" +) + +/* +G-Core API DNS provider: +Info required in `creds.json`: + - api-key +*/ + +type gcoreProvider struct { + provider *dnssdk.Client + ctx context.Context +} + +// NewGCore creates the provider. +func NewGCore(m map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) { + if m["api-key"] == "" { + return nil, fmt.Errorf("missing G-Core API key") + } + + c := &gcoreProvider{ + provider: dnssdk.NewClient(dnssdk.PermanentAPIKeyAuth(m["api-key"])), + ctx: context.TODO(), + } + + return c, nil +} + +var features = providers.DocumentationNotes{ + providers.CanAutoDNSSEC: providers.Cannot(), + providers.CanGetZones: providers.Can(), + providers.CanUseAlias: providers.Cannot(), + providers.CanUseCAA: providers.Can(), + providers.CanUseDS: providers.Cannot(), + providers.CanUseNAPTR: providers.Cannot(), + providers.CanUsePTR: providers.Cannot(), + providers.CanUseSRV: providers.Can("G-Core doesn't support SRV records with empty targets"), + providers.CanUseSSHFP: providers.Cannot(), + providers.CanUseTLSA: providers.Cannot(), + providers.DocCreateDomains: providers.Can(), + providers.DocDualHost: providers.Can(), + providers.DocOfficiallySupported: providers.Cannot(), +} + +var defaultNameServerNames = []string{ + "ns1.gcorelabs.net", + "ns2.gcdn.services", +} + +func init() { + fns := providers.DspFuncs{ + Initializer: NewGCore, + RecordAuditor: AuditRecords, + } + providers.RegisterDomainServiceProviderType("GCORE", fns, features) +} + +// GetNameservers returns the nameservers for a domain. +func (c *gcoreProvider) GetNameservers(domain string) ([]*models.Nameserver, error) { + return models.ToNameservers(defaultNameServerNames) +} + +func (c *gcoreProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) { + existing, err := c.GetZoneRecords(dc.Name) + if err != nil { + return nil, err + } + models.PostProcessRecords(existing) + clean := PrepFoundRecords(existing) + PrepDesiredRecords(dc) + return c.GenerateDomainCorrections(dc, clean) +} + +// GetZoneRecords gets the records of a zone and returns them in RecordConfig format. +func (c *gcoreProvider) GetZoneRecords(domain string) (models.Records, error) { + zone, err := c.provider.Zone(c.ctx, domain) + if err != nil { + return nil, err + } + + // Convert RRsets to DNSControl format on the fly + existingRecords := []*models.RecordConfig{} + + // We cannot directly use Zone's ShortAnswers + // they aren't complete for CAA & SRV + for _, rec := range zone.Records { + rrset, err := c.provider.RRSet(c.ctx, zone.Name, rec.Name, rec.Type) + if err != nil { + return nil, err + } + nativeRecords, err := nativeToRecords(rrset, zone.Name, rec.Name, rec.Type) + if err != nil { + return nil, err + } + existingRecords = append(existingRecords, nativeRecords...) + } + + return existingRecords, nil +} + +// EnsureDomainExists returns an error if domain doesn't exist. +func (c *gcoreProvider) EnsureDomainExists(domain string) error { + zones, err := c.provider.Zones(c.ctx) + if err != nil { + return err + } + + for _, zone := range zones { + if zone.Name == domain { + return nil + } + } + + _, err = c.provider.CreateZone(c.ctx, domain) + return err +} + +// PrepFoundRecords munges any records to make them compatible with +// this provider. Usually this is a no-op. +func PrepFoundRecords(recs models.Records) models.Records { + // If there are records that need to be modified, removed, etc. we + // do it here. Usually this is a no-op. + return recs +} + +// PrepDesiredRecords munges any records to best suit this provider. +func PrepDesiredRecords(dc *models.DomainConfig) { + dc.Punycode() +} + +func generateChangeMsg(updates []string) string { + return strings.Join(updates, "\n") +} + +// GenerateDomainCorrections takes the desired and existing records +// and produces a Correction list. The correction list is simply +// a list of functions to call to actually make the desired +// correction, and a message to output to the user when the change is +// made. +func (c *gcoreProvider) GenerateDomainCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, error) { + + var corrections = []*models.Correction{} + + // diff existing vs. current. + differ := diff.New(dc) + keysToUpdate, err := differ.ChangedGroups(existing) + if err != nil { + return nil, err + } + if len(keysToUpdate) == 0 { + return nil, nil + } + + desiredRecords := dc.Records.GroupedByKey() + existingRecords := existing.GroupedByKey() + + // First pass: delete records to avoid coexisting of conflicting types + for label := range keysToUpdate { + if _, ok := desiredRecords[label]; !ok { + // record deleted in update + // Copy all params to avoid overwrites + zone := dc.Name + name := label.NameFQDN + typ := label.Type + msg := generateChangeMsg(keysToUpdate[label]) + corrections = append(corrections, &models.Correction{ + Msg: msg, + F: func() error { + return c.provider.DeleteRRSet(c.ctx, zone, name, typ) + }, + }) + } + } + + // Second pass: create and update records + for label := range keysToUpdate { + if _, ok := desiredRecords[label]; !ok { + // record deleted in update + // do nothing here + + } else if _, ok := existingRecords[label]; !ok { + // record created in update + record := recordsToNative(desiredRecords[label], label) + if record == nil { + panic("No records matching label") + } + + // Copy all params to avoid overwrites + zone := dc.Name + name := label.NameFQDN + typ := label.Type + rec := *record + msg := generateChangeMsg(keysToUpdate[label]) + corrections = append(corrections, &models.Correction{ + Msg: msg, + F: func() error { + return c.provider.CreateRRSet(c.ctx, zone, name, typ, rec) + }, + }) + + } else { + // record modified in update + record := recordsToNative(desiredRecords[label], label) + if record == nil { + panic("No records matching label") + } + + // Copy all params to avoid overwrites + zone := dc.Name + name := label.NameFQDN + typ := label.Type + rec := *record + msg := generateChangeMsg(keysToUpdate[label]) + corrections = append(corrections, &models.Correction{ + Msg: msg, + F: func() error { + return c.provider.UpdateRRSet(c.ctx, zone, name, typ, rec) + }, + }) + } + } + + return corrections, nil +}