diff --git a/OWNERS b/OWNERS
index 1bb0d852a..0ea67bca9 100644
--- a/OWNERS
+++ b/OWNERS
@@ -28,6 +28,7 @@ providers/linode @koesie10
providers/namecheap @captncraig
# providers/namedotcom NEEDS VOLUNTEER
providers/netcup @kordianbruck
+providers/netlify @SphericalKat
providers/ns1 @costasd
providers/opensrs @philhug
providers/oracle @kallsyms
diff --git a/README.md b/README.md
index a42277f52..2f1a0d87c 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ Currently supported DNS providers:
- Name.com
- Namecheap
- Netcup
+- Netlify
- OVH
- OctoDNS
- Oracle Cloud
diff --git a/docs/_includes/matrix.html b/docs/_includes/matrix.html
index c37779883..27ea3b41b 100644
--- a/docs/_includes/matrix.html
+++ b/docs/_includes/matrix.html
@@ -35,6 +35,7 @@
NAMECHEAP |
NAMEDOTCOM |
NETCUP |
+ NETLIFY |
NS1 |
OCTODNS |
OPENSRS |
@@ -281,6 +282,9 @@
|
+
+
+ |
|
@@ -413,6 +417,9 @@
|
+
+
+ |
|
@@ -522,6 +529,9 @@
|
+
+
+ |
|
|
@@ -603,6 +613,9 @@
| |
|
|
+
+
+ |
|
@@ -714,6 +727,9 @@
|
+
+
+ |
|
|
@@ -823,6 +839,9 @@
|
|
+
+
+ |
|
@@ -913,6 +932,9 @@
|
|
|
+
+
+ |
|
@@ -992,6 +1014,7 @@
|
|
|
+ |
@@ -1083,6 +1106,9 @@
|
+
+
+ |
|
@@ -1183,6 +1209,9 @@
| |
|
|
+
+
+ |
|
|
|
@@ -1287,6 +1316,9 @@
|
|
+
+
+ |
|
|
|
@@ -1527,6 +1559,9 @@
|
|
|
+
+
+ |
|
@@ -1681,6 +1716,9 @@
|
+
+
+ |
|
@@ -1805,6 +1843,9 @@
|
+
+
+ |
|
@@ -2066,6 +2107,9 @@
|
+
+
+ |
|
diff --git a/docs/_providers/netlify.md b/docs/_providers/netlify.md
new file mode 100644
index 000000000..36b9d5d3e
--- /dev/null
+++ b/docs/_providers/netlify.md
@@ -0,0 +1,47 @@
+---
+name: Netlify
+title: Netlify Provider
+layout: default
+jsId: NETLIFY
+---
+# Netlify Provider
+## Configuration
+
+To use this provider, add an entry to `creds.json` with `TYPE` set to `NETLIFY`
+along with a Netlify account personal access token. You can also optionally add an
+account slug. This is _typically_ your username on Netlify.
+
+Examples:
+
+```json
+{
+ "netlify": {
+ "TYPE": "NETLIFY",
+ "token": "your-netlify-account-access-token",
+ "slug": "account-slug" // this is optional
+ }
+}
+```
+
+## Metadata
+This provider does not recognize any special metadata fields unique to Netlify.
+
+## Usage
+An example `dnsconfig.js` configuration:
+
+```js
+var REG_NETLIFY = NewRegistrar("netlify");
+var DSP_NETLIFY = NewDnsProvider("netlify");
+
+D("example.tld", REG_NETLIFY, DnsProvider(DSP_NETLIFY),
+ A("test", "1.2.3.4")
+);
+```
+
+## Activation
+DNSControl depends on a Netlify account personal access token.
+
+## Caveats
+Empty MX records are not supported.
+
+
diff --git a/docs/provider-list.md b/docs/provider-list.md
index c3ab05a07..ec074eeea 100644
--- a/docs/provider-list.md
+++ b/docs/provider-list.md
@@ -95,6 +95,7 @@ Providers in this category and their maintainers are:
* `LINODE` @koesie10
* `NAMECHEAP` VOLUNTEER NEEDED
* `NETCUP` @kordianbruck
+* `NETLIFY` @SphericalKat
* `NS1` @costasd
* `OCTODNS` @TomOnTime
* `OPENSRS` @pierre-emmanuelJ
diff --git a/integrationTest/providers.json b/integrationTest/providers.json
index ceb16beb1..2c10b179f 100644
--- a/integrationTest/providers.json
+++ b/integrationTest/providers.json
@@ -215,5 +215,10 @@
"token": "$DOMAINNAMESHOP_TOKEN",
"secret": "$DOMAINNAMESHOP_SECRET",
"domain": "$DOMAINNAMESHOP_DOMAIN"
+ },
+ "NETLIFY": {
+ "token": "$NETLIFY_TOKEN",
+ "slug": "$NETLIFY_ACCOUNT_SLUG",
+ "domain": "$NETLIFY_DOMAIN"
}
}
diff --git a/providers/_all/all.go b/providers/_all/all.go
index a81002ce7..b31bd8b9f 100644
--- a/providers/_all/all.go
+++ b/providers/_all/all.go
@@ -33,6 +33,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v3/providers/namecheap"
_ "github.com/StackExchange/dnscontrol/v3/providers/namedotcom"
_ "github.com/StackExchange/dnscontrol/v3/providers/netcup"
+ _ "github.com/StackExchange/dnscontrol/v3/providers/netlify"
_ "github.com/StackExchange/dnscontrol/v3/providers/ns1"
_ "github.com/StackExchange/dnscontrol/v3/providers/octodns"
_ "github.com/StackExchange/dnscontrol/v3/providers/opensrs"
diff --git a/providers/netlify/api.go b/providers/netlify/api.go
new file mode 100644
index 000000000..70eaae389
--- /dev/null
+++ b/providers/netlify/api.go
@@ -0,0 +1,169 @@
+package netlify
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+const baseURL = "https://api.netlify.com/api/v1"
+
+type dnsRecord struct {
+ Hostname string `json:"hostname,omitempty"`
+ Type string `json:"type,omitempty"`
+ TTL int64 `json:"ttl,omitempty"`
+ Priority int64 `json:"priority,omitempty"`
+ Flag int64 `json:"flag,omitempty"`
+ Weight uint16 `json:"weight,omitempty"`
+ Port uint16 `json:"port,omitempty"`
+ Tag string `json:"tag,omitempty"`
+ ID string `json:"id,omitempty"`
+ SiteID string `json:"site_id,omitempty"`
+ DNSZoneID string `json:"dns_zone_id,omitempty"`
+ Managed bool `json:"managed,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+type dnsZone struct {
+ AccountID string `json:"account_id,omitempty"`
+ AccountName string `json:"account_name,omitempty"`
+ AccountSlug string `json:"account_slug,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ Dedicated bool `json:"dedicated,omitempty"`
+ DNSServers []string `json:"dns_servers"`
+ Domain string `json:"domain,omitempty"`
+ Errors []string `json:"errors"`
+ ID string `json:"id,omitempty"`
+ IPV6Enabled bool `json:"ipv6_enabled,omitempty"`
+ Name string `json:"name,omitempty"`
+ Records []*dnsRecord `json:"records"`
+ SiteID string `json:"site_id,omitempty"`
+ SupportedRecordTypes []string `json:"supported_record_types"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ UserID string `json:"user_id,omitempty"`
+}
+
+type dnsRecordCreate struct {
+ Flag int64 `json:"flag"`
+ Hostname string `json:"hostname,omitempty"`
+ Port int64 `json:"port,omitempty"`
+ Priority int64 `json:"priority,omitempty"`
+ Tag string `json:"tag,omitempty"`
+ TTL int64 `json:"ttl,omitempty"`
+ Type string `json:"type,omitempty"`
+ Value string `json:"value,omitempty"`
+ Weight int64 `json:"weight"`
+}
+
+func (n *netlifyProvider) getDNSZones() ([]*dnsZone, error) {
+ reqURL := fmt.Sprintf("%s/dns_zones", baseURL)
+
+ req, err := http.NewRequest("GET", reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", n.apiToken))
+
+ if n.accountSlug != "" {
+ q := req.URL.Query()
+ q.Add("account_slug", n.accountSlug)
+ req.URL.RawQuery = q.Encode()
+ }
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ dnsZones := make([]*dnsZone, 0)
+
+ err = json.NewDecoder(res.Body).Decode(&dnsZones)
+ if err != nil {
+ return nil, err
+ }
+
+ return dnsZones, nil
+}
+
+func (n *netlifyProvider) getDNSRecords(zoneID string) ([]*dnsRecord, error) {
+ reqURL := fmt.Sprintf("%s/dns_zones/%s/dns_records", baseURL, zoneID)
+
+ req, err := http.NewRequest("GET", reqURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", n.apiToken))
+
+ if n.accountSlug != "" {
+ q := req.URL.Query()
+ q.Add("account_slug", n.accountSlug)
+ req.URL.RawQuery = q.Encode()
+ }
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ records := make([]*dnsRecord, 0)
+
+ err = json.NewDecoder(res.Body).Decode(&records)
+ if err != nil {
+ return nil, err
+ }
+
+ return records, nil
+}
+
+func (n *netlifyProvider) deleteDNSRecord(zoneID string, recordID string) error {
+ reqURL := fmt.Sprintf("%s/dns_zones/%s/dns_records/%s", baseURL, zoneID, recordID)
+
+ req, err := http.NewRequest("DELETE", reqURL, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", n.apiToken))
+ req.Header.Add("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+
+ return nil
+}
+
+func (n *netlifyProvider) createDNSRecord(zoneID string, rec *dnsRecordCreate) (*dnsRecord, error) {
+ reqURL := fmt.Sprintf("%s/dns_zones/%s/dns_records", baseURL, zoneID)
+
+ data, err := json.Marshal(rec)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("POST", reqURL, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", n.apiToken))
+ req.Header.Add("Content-Type", "application/json")
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ record := &dnsRecord{}
+
+ err = json.NewDecoder(res.Body).Decode(record)
+ if err != nil {
+ return nil, err
+ }
+
+ return record, nil
+}
diff --git a/providers/netlify/auditrecords.go b/providers/netlify/auditrecords.go
new file mode 100644
index 000000000..ac3253b58
--- /dev/null
+++ b/providers/netlify/auditrecords.go
@@ -0,0 +1,17 @@
+package netlify
+
+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("MX", rejectif.MxNull) // Last verified 2022-11-20
+
+ return a.Audit(records)
+}
diff --git a/providers/netlify/netlifyProvider.go b/providers/netlify/netlifyProvider.go
new file mode 100644
index 000000000..5d7c8095e
--- /dev/null
+++ b/providers/netlify/netlifyProvider.go
@@ -0,0 +1,271 @@
+package netlify
+
+import (
+ "encoding/json"
+ "fmt"
+ "github.com/StackExchange/dnscontrol/v3/models"
+ "github.com/StackExchange/dnscontrol/v3/pkg/diff"
+ "github.com/StackExchange/dnscontrol/v3/pkg/printer"
+ "github.com/StackExchange/dnscontrol/v3/pkg/txtutil"
+ "github.com/StackExchange/dnscontrol/v3/providers"
+ "strings"
+)
+
+var nameServerSuffixes = []string{
+ ".nsone.net.",
+}
+
+var features = providers.DocumentationNotes{
+ providers.CanAutoDNSSEC: providers.Cannot(),
+ providers.CanGetZones: providers.Can(),
+ providers.CanUseAlias: providers.Can(),
+ providers.CanUseCAA: providers.Can(),
+ providers.CanUseDS: providers.Cannot(),
+ providers.CanUseDSForChildren: providers.Cannot(),
+ providers.CanUseNAPTR: providers.Cannot(),
+ providers.CanUsePTR: providers.Cannot(),
+ providers.CanUseSRV: providers.Can(),
+ providers.CanUseSSHFP: providers.Cannot(),
+ providers.CanUseTLSA: providers.Cannot(),
+ providers.DocCreateDomains: providers.Cannot(),
+ providers.DocDualHost: providers.Cannot("Netlify does not allow sufficient control over the apex NS records"),
+ providers.DocOfficiallySupported: providers.Cannot(),
+}
+
+func init() {
+ fns := providers.DspFuncs{
+ Initializer: newNetlify,
+ RecordAuditor: AuditRecords,
+ }
+ providers.RegisterDomainServiceProviderType("NETLIFY", fns, features)
+ providers.RegisterCustomRecordType("NETLIFY", "NETLIFY", "")
+ providers.RegisterCustomRecordType("NETLIFYv6", "NETLIFY", "")
+}
+
+type netlifyProvider struct {
+ apiToken string // the account access token
+ accountSlug string // the account identifier slug. optional.
+}
+
+func newNetlify(m map[string]string, message json.RawMessage) (providers.DNSServiceProvider, error) {
+ api := &netlifyProvider{}
+ api.apiToken = m["token"]
+ if api.apiToken == "" {
+ return nil, fmt.Errorf("missing Netlify personal access token")
+ }
+
+ api.accountSlug = m["slug"]
+
+ return api, nil
+}
+
+func (n *netlifyProvider) GetNameservers(domain string) ([]*models.Nameserver, error) {
+ zone, err := n.getZone(domain)
+ if err != nil {
+ return nil, err
+ }
+
+ return models.ToNameservers(zone.DNSServers)
+}
+
+func (n *netlifyProvider) getZone(domain string) (*dnsZone, error) {
+ zones, err := n.getDNSZones()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, zone := range zones {
+ if zone.Name == domain {
+ return zone, nil
+ }
+ }
+
+ return nil, fmt.Errorf("no zones found for this domain")
+}
+
+func (n *netlifyProvider) GetZoneRecords(domain string) (models.Records, error) {
+ zone, err := n.getZone(domain)
+ if err != nil {
+ return nil, err
+ }
+
+ records, err := n.getDNSRecords(zone.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ cleanRecords := make(models.Records, 0)
+
+ for _, r := range records {
+ if r.Type == "SOA" {
+ continue
+ }
+
+ rec := &models.RecordConfig{
+ TTL: uint32(r.TTL),
+ Original: r,
+ }
+
+ rec.SetLabelFromFQDN(r.Hostname, domain) // netlify returns the FQDN
+
+ switch rtype := r.Type; rtype {
+ case "NETLIFY", "NETLIFYv6": // these behave similar to a CNAME
+ continue
+ case "MX":
+ err = rec.SetTargetMX(uint16(r.Priority), r.Value)
+ case "SRV":
+ parts := strings.Fields(r.Value)
+ if len(parts) == 3 {
+ r.Value += "."
+ }
+ err = rec.SetTargetSRV(uint16(r.Priority), r.Weight, r.Port, r.Value)
+ case "TXT":
+ err = rec.SetTargetTXT(r.Value)
+ case "CAA":
+ err = rec.SetTargetCAA(uint8(r.Flag), r.Tag, r.Value)
+ default:
+ err = rec.PopulateFromString(r.Type, r.Value, domain)
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("unparsable record received from Netlify: %w", err)
+ }
+
+ cleanRecords = append(cleanRecords, rec)
+ }
+
+ return cleanRecords, nil
+}
+
+// Return true if the string ends in one of Netlify's name server domains
+// False if anything else
+func isNetlifyNameServerDomain(name string) bool {
+ for _, i := range nameServerSuffixes {
+ if strings.HasSuffix(name, i) {
+ return true
+ }
+ }
+ return false
+}
+
+// remove all non-netlify NS records from our desired state.
+// if any are found, print a warning
+func removeOtherApexNS(dc *models.DomainConfig) {
+ newList := make([]*models.RecordConfig, 0, len(dc.Records))
+ for _, rec := range dc.Records {
+ if rec.Type == "NS" {
+ // apex NS inside netlify are expected.
+ // We ignore them, warning as needed.
+ // Child delegations are supported so, we allow non-apex NS records.
+ if rec.GetLabelFQDN() == dc.Name {
+ if !isNetlifyNameServerDomain(rec.GetTargetField()) {
+ printer.Printf("Warning: Netlify does not allow NS records to be modified. %s will not be added.\n", rec.GetTargetField())
+ }
+ continue
+ }
+ }
+ newList = append(newList, rec)
+ }
+ dc.Records = newList
+}
+
+func (n *netlifyProvider) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
+ var corrections []*models.Correction
+ err := dc.Punycode()
+ if err != nil {
+ return nil, err
+ }
+
+ records, err := n.GetZoneRecords(dc.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ // Normalize
+ models.PostProcessRecords(records)
+ txtutil.SplitSingleLongTxt(dc.Records) // Auto split long TXT records
+ removeOtherApexNS(dc)
+
+ differ := diff.New(dc)
+ _, create, del, modify, err := differ.IncrementalDiff(records)
+ if err != nil {
+ return nil, err
+ }
+
+ zone, err := n.getZone(dc.Name)
+ if err != nil {
+ return nil, err
+ }
+
+ // Deletes first so changing type works etc.
+ for _, m := range del {
+ id := m.Existing.Original.(*dnsRecord).ID
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ return n.deleteDNSRecord(zone.ID, id)
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ for _, m := range create {
+ req := toReq(m.Desired)
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ _, err := n.createDNSRecord(zone.ID, req)
+ return err
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ for _, m := range modify {
+ id := m.Existing.Original.(*dnsRecord).ID
+ req := toReq(m.Desired)
+ corr := &models.Correction{
+ Msg: m.String(),
+ F: func() error {
+ if err := n.deleteDNSRecord(zone.ID, id); err != nil {
+ return err
+ }
+
+ _, err := n.createDNSRecord(zone.ID, req)
+ return err
+ },
+ }
+ corrections = append(corrections, corr)
+ }
+
+ return corrections, nil
+}
+
+func toReq(rc *models.RecordConfig) *dnsRecordCreate {
+ name := rc.GetLabelFQDN() // Netlify wants the FQDN
+ target := rc.GetTargetField()
+ priority := int64(0)
+
+ switch rc.Type {
+ case "MX":
+ priority = int64(rc.MxPreference)
+ case "SRV":
+ priority = int64(rc.SrvPriority)
+ case "TXT":
+ target = rc.GetTargetTXTJoined()
+ default:
+ // no action required
+ }
+
+ return &dnsRecordCreate{
+ Type: rc.Type,
+ Hostname: name,
+ Value: target,
+ TTL: int64(rc.TTL),
+ Priority: priority,
+ Port: int64(rc.SrvPort),
+ Weight: int64(rc.SrvWeight),
+ Tag: rc.CaaTag,
+ Flag: int64(rc.CaaFlag),
+ }
+}