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 @@ + SRV @@ -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), + } +}