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 @@
|
|
|
+ |
@@ -991,6 +1017,9 @@
|
+
+
+ |
|
@@ -1101,6 +1130,9 @@
|
+
+
+ |
|
@@ -1199,6 +1231,9 @@
|
+
+
+ |
|
@@ -1286,6 +1321,7 @@
|
|
|
+ |
@@ -1309,6 +1345,7 @@
|
|
|
+ |
|
@@ -1362,6 +1399,7 @@
|
|
|
+ |
|
@@ -1431,6 +1469,9 @@
|
+
+
+ |
|
@@ -1514,6 +1555,7 @@
| |
|
|
+ |
@@ -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
+}