diff --git a/README.md b/README.md index ff7a4fa4b..5f794e44a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog - Namecheap - NameSilo - Netcup + - Netlify - NoIP - Now-DNS - Njalla @@ -250,6 +251,7 @@ Check the documentation for your DNS provider: - [Namecheap](docs/namecheap.md) - [NameSilo](docs/namesilo.md) - [Netcup](docs/netcup.md) +- [Netlify](docs/netlify.md) - [NoIP](docs/noip.md) - [Now-DNS](docs/nowdns.md) - [Njalla](docs/njalla.md) diff --git a/docs/netlify.md b/docs/netlify.md new file mode 100644 index 000000000..c339dc34b --- /dev/null +++ b/docs/netlify.md @@ -0,0 +1,76 @@ +# Netlify + +## Configuration + +### Example + +```json +{ + "settings": [ + { + "provider": "netlify", + "domain": "domain.com", + "token": "your-netlify-access-token", + "ip_version": "ipv4", + "ipv6_suffix": "" + } + ] +} +``` + +### Compulsory parameters + +- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for wildcard. +- `"token"` is your Netlify personal access token with DNS zone permissions + +### Optional parameters + +- `"ip_version"` can be `ipv4` (A records), or `ipv6` (AAAA records) or `ipv4 or ipv6` (update one of the two, depending on the public IP found). It defaults to `ipv4 or ipv6`. +- `"ipv6_suffix"` is IPv6 interface identifier suffix to use. It can be for example `0:0:0:0:72ad:8fbb:a54e:bedd/64`. If left empty, it defaults to no suffix and the raw public IPv6 address obtained is used in record updating. + +## Domain setup + +[![Netlify Website](../readme/netlify.png)](https://www.netlify.com) + +1. Login to your Netlify account at [https://app.netlify.com/](https://app.netlify.com/) + +2. Navigate to **Site settings** for your site + +3. Go to **Domain management** → **DNS zones** + +4. Ensure your domain is properly configured as a DNS zone in Netlify + +## Token setup + +1. Go to **User settings** → **Applications** → **Personal access tokens** + +2. Click **New access token** + +3. Give the token a descriptive name (e.g., "DDNS Updater") + +4. Select the following scopes: + - **DNS:read** - Read DNS zones and records + - **DNS:edit** - Edit DNS zones and records + +5. Click **Generate token** + +6. Copy the generated token - this is your `"token"` value + +## Testing + +1. Go to your Netlify site's DNS management page + +2. Check the current DNS record for your domain + +3. Run ddns-updater + +4. Refresh the Netlify DNS page to verify the update occurred + +## Notes + +- Netlify's DNS API requires the domain to be configured as a DNS zone in your Netlify account +- The provider automatically finds the appropriate DNS zone for your domain +- If a DNS record doesn't exist, it will be created +- If a DNS record exists with a different IP, it will be deleted and recreated with the new IP +- The default TTL for created records is 3600 seconds (1 hour) +- IPv6 is supported if your Netlify account has IPv6 enabled for the DNS zone \ No newline at end of file diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 71b48c9d1..70605f997 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -41,6 +41,7 @@ const ( Namecheap models.Provider = "namecheap" NameCom models.Provider = "name.com" NameSilo models.Provider = "namesilo" + Netlify models.Provider = "netlify" Netcup models.Provider = "netcup" Njalla models.Provider = "njalla" NoIP models.Provider = "noip" @@ -96,6 +97,7 @@ func ProviderChoices() []models.Provider { Namecheap, NameCom, NameSilo, + Netlify, Njalla, NoIP, NowDNS, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 06e002845..adc968d06 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -48,6 +48,7 @@ import ( "github.com/qdm12/ddns-updater/internal/provider/providers/namecom" "github.com/qdm12/ddns-updater/internal/provider/providers/namesilo" "github.com/qdm12/ddns-updater/internal/provider/providers/netcup" + "github.com/qdm12/ddns-updater/internal/provider/providers/netlify" "github.com/qdm12/ddns-updater/internal/provider/providers/njalla" "github.com/qdm12/ddns-updater/internal/provider/providers/noip" "github.com/qdm12/ddns-updater/internal/provider/providers/nowdns" @@ -158,6 +159,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin return namecom.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.NameSilo: return namesilo.New(data, domain, owner, ipVersion, ipv6Suffix) + case constants.Netlify: + return netlify.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Netcup: return netcup.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Njalla: diff --git a/internal/provider/providers/netlify/provider.go b/internal/provider/providers/netlify/provider.go new file mode 100644 index 000000000..c0b4385f0 --- /dev/null +++ b/internal/provider/providers/netlify/provider.go @@ -0,0 +1,416 @@ +package netlify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "strings" + + "github.com/qdm12/ddns-updater/internal/models" + "github.com/qdm12/ddns-updater/internal/provider/constants" + "github.com/qdm12/ddns-updater/internal/provider/errors" + "github.com/qdm12/ddns-updater/internal/provider/utils" + "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" +) + +const defaultTTL = 3600 + +// Provider is Netlify DNS provider. +type Provider struct { + domain string + owner string + ipVersion ipversion.IPVersion + ipv6Suffix netip.Prefix + token string + httpClient *http.Client +} + +// New creates a new Netlify provider. +func New(data json.RawMessage, domain, owner string, + ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix, +) (p *Provider, err error) { + extraSettings := struct { + Token string `json:"token"` + }{} + + if err := json.Unmarshal(data, &extraSettings); err != nil { + return nil, err + } + + p = &Provider{ + domain: domain, + owner: owner, + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + token: extraSettings.Token, + httpClient: &http.Client{}, + } + + err = p.validateSettings() + if err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) + } + + return p, nil +} + +func (p *Provider) validateSettings() error { + if p.token == "" { + return fmt.Errorf("%w", errors.ErrTokenNotSet) + } + return nil +} // String returns a string representation of provider. +func (p *Provider) String() string { + return utils.ToString(p.domain, p.owner, constants.Netlify, p.ipVersion) +} + +// Domain returns domain of provider. +func (p *Provider) Domain() string { + return p.domain +} + +// Owner returns owner of provider. +func (p *Provider) Owner() string { + return p.owner +} + +// IPVersion returns IP version of provider. +func (p *Provider) IPVersion() ipversion.IPVersion { + return p.ipVersion +} + +// IPv6Suffix returns IPv6 suffix of provider. +func (p *Provider) IPv6Suffix() netip.Prefix { + return p.ipv6Suffix +} + +// BuildDomainName builds the full domain name. +func (p *Provider) BuildDomainName() string { + return utils.BuildDomainName(p.owner, p.domain) +} + +// Proxied returns whether the provider is proxied. +func (p *Provider) Proxied() bool { + return false +} + +// HTML returns HTML representation of provider. +func (p *Provider) HTML() models.HTMLRow { + return models.HTMLRow{ + Domain: fmt.Sprintf("%s", p.BuildDomainName(), p.BuildDomainName()), + Owner: p.Owner(), + Provider: "Netlify", + IPVersion: p.ipVersion.String(), + } +} + +// Update updates DNS record. +func (p *Provider) Update(ctx context.Context, _ *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { + // Find the DNS zone for the domain. + zone, err := p.findZone(ctx) + if err != nil { + return netip.Addr{}, fmt.Errorf("failed to find DNS zone: %w", err) + } + + // List existing DNS records to find the one to update. + records, err := p.listDNSRecords(ctx, zone.ID) + if err != nil { + return netip.Addr{}, fmt.Errorf("failed to list DNS records: %w", err) + } + + // Determine the record type and hostname. + recordType := p.getRecordType() + hostname := p.getHostname() + ipStr := ip.String() + + // Find existing record. + var existingRecord *dnsRecord + for _, record := range records { + if record.Type == recordType && record.Hostname == hostname { + existingRecord = &record + break + } + } + + // If record exists and IP is the same, no update needed. + if existingRecord != nil && existingRecord.Value == ipStr { + return ip, nil + } + + // Create or update the record. + if existingRecord != nil { + err = p.updateDNSRecord(ctx, zone.ID, existingRecord.ID, ipStr) + if err != nil { + return netip.Addr{}, fmt.Errorf("failed to update DNS record: %w", err) + } + } else { + err = p.createDNSRecord(ctx, zone.ID, hostname, recordType, ipStr) + if err != nil { + return netip.Addr{}, fmt.Errorf("failed to create DNS record: %w", err) + } + } + + return ip, nil +} + +func (p *Provider) getRecordType() string { + if p.ipVersion == ipversion.IP4 { + return "A" + } + return "AAAA" +} + +func (p *Provider) getHostname() string { + if p.owner == "@" { + return p.domain + } + return p.owner + "." + p.domain +} + +// findZone finds the DNS zone for the domain. +func (p *Provider) findZone(ctx context.Context) (*dnsZone, error) { + url := "https://api.netlify.com/api/v1/dns_zones" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + p.setAuthHeader(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: status %d: %s", errors.ErrHTTPStatusNotValid, resp.StatusCode, string(body)) + } + + var zones []dnsZone + if err := json.Unmarshal(body, &zones); err != nil { + return nil, err + } + + // Find the zone that matches our domain. + for _, zone := range zones { + if zone.Name == p.domain || strings.HasSuffix(p.domain, "."+zone.Name) { + return &zone, nil + } + } + + return nil, fmt.Errorf("%w: for domain %s", errors.ErrZoneNotFound, p.domain) +} + +// listDNSRecords lists all DNS records for a zone. +func (p *Provider) listDNSRecords(ctx context.Context, zoneID string) ([]dnsRecord, error) { + url := fmt.Sprintf("https://api.netlify.com/api/v1/dns_zones/%s/dns_records", zoneID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + p.setAuthHeader(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: status %d: %s", errors.ErrHTTPStatusNotValid, resp.StatusCode, string(body)) + } + + var records []dnsRecord + if err := json.Unmarshal(body, &records); err != nil { + return nil, err + } + + return records, nil +} + +// createDNSRecord creates a new DNS record. +func (p *Provider) createDNSRecord(ctx context.Context, zoneID, hostname, recordType, value string) error { + url := fmt.Sprintf("https://api.netlify.com/api/v1/dns_zones/%s/dns_records", zoneID) + + record := dnsRecordCreate{ + Type: recordType, + Hostname: hostname, + Value: value, + TTL: defaultTTL, // Default TTL + } + + body, err := json.Marshal(record) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + + p.setAuthHeader(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: status %d: %s", errors.ErrHTTPStatusNotValid, resp.StatusCode, string(body)) + } + + return nil +} + +// updateDNSRecord updates an existing DNS record. +func (p *Provider) updateDNSRecord(ctx context.Context, zoneID, recordID, value string) error { + // Netlify API doesn't have a direct update endpoint for DNS records. + // We need to delete the existing record and create a new one. + + // First, get the existing record details. + record, err := p.getDNSRecord(ctx, zoneID, recordID) + if err != nil { + return fmt.Errorf("failed to get existing DNS record: %w", err) + } + + // Delete the existing record. + if err := p.deleteDNSRecord(ctx, zoneID, recordID); err != nil { + return fmt.Errorf("failed to delete existing DNS record: %w", err) + } + + // Create a new record with the updated value. + return p.createDNSRecord(ctx, zoneID, record.Hostname, record.Type, value) +} + +// getDNSRecord gets a single DNS record. +func (p *Provider) getDNSRecord(ctx context.Context, zoneID, recordID string) (*dnsRecord, error) { + url := fmt.Sprintf("https://api.netlify.com/api/v1/dns_zones/%s/dns_records/%s", zoneID, recordID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + p.setAuthHeader(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: status %d: %s", errors.ErrHTTPStatusNotValid, resp.StatusCode, string(body)) + } + + var record dnsRecord + if err := json.Unmarshal(body, &record); err != nil { + return nil, err + } + + return &record, nil +} + +// deleteDNSRecord deletes a DNS record. +func (p *Provider) deleteDNSRecord(ctx context.Context, zoneID, recordID string) error { + url := fmt.Sprintf("https://api.netlify.com/api/v1/dns_zones/%s/dns_records/%s", zoneID, recordID) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return err + } + + p.setAuthHeader(req) + + resp, err := p.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%w: status %d: %s", errors.ErrHTTPStatusNotValid, resp.StatusCode, string(body)) + } + + return nil +} + +func (p *Provider) setAuthHeader(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+p.token) +} + +// DNS zone structure. +type dnsZone struct { + ID string `json:"id"` + Name string `json:"name"` + Errors []string `json:"errors"` + SupportedRecordTypes []string `json:"supported_record_types"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Records []dnsRecord `json:"records"` + DNSServers []string `json:"dns_servers"` + AccountID string `json:"account_id"` + SiteID string `json:"site_id"` + AccountSlug string `json:"account_slug"` + AccountName string `json:"account_name"` + Domain string `json:"domain"` + IPv6Enabled bool `json:"ipv6_enabled"` + Dedicated bool `json:"dedicated"` +} + +// DNS record structure. +type dnsRecord struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Type string `json:"type"` + Value string `json:"value"` + TTL int64 `json:"ttl"` + Priority int64 `json:"priority"` + DNSZoneID string `json:"dns_zone_id"` + SiteID string `json:"site_id"` + Flag int64 `json:"flag"` + Tag string `json:"tag"` + Managed bool `json:"managed"` +} + +// DNS record creation structure. +type dnsRecordCreate struct { + Type string `json:"type"` + Hostname string `json:"hostname"` + Value string `json:"value"` + TTL int64 `json:"ttl"` + Priority int64 `json:"priority,omitempty"` + Weight int64 `json:"weight,omitempty"` + Port int64 `json:"port,omitempty"` + Flag int64 `json:"flag,omitempty"` + Tag string `json:"tag,omitempty"` +}