diff --git a/docs/nextdns.md b/docs/nextdns.md new file mode 100644 index 000000000..e4158e8da --- /dev/null +++ b/docs/nextdns.md @@ -0,0 +1,40 @@ +# NextDNS + +## Configuration + +### Example + +```json +{ + "settings": [ + { + "provider": "nextdns", + "domain": "link-ip.nextdns.io", + "endpoint": "endpoint", + "ip_version": "ipv4", + "ipv6_suffix": "" + } + ] +} +``` + +### Compulsory parameters + +- `"domain"` is the domain to update. For now, it must be "link-ip.nextdns.io". +- `"endpoint"` + +### 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 the 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 the record updating. + +## Domain setup + +> NextDNS supports updating Linked IP via a DDNS hostname. If you're already using a DDNS service, configure your DDNS domain in the Linked IP card instead. + +- Create an account on the [nextdns website](https://nextdns.io/) +- Go to your [account page](https://my.nextdns.io/), login and setup Linked IP +- Click `Show advanced options` button and copy the endpoint from the Linked IP card +- Update the configuration file with the endpoint + +_See the [nextdns website](https://nextdns.io/)_ diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 71b48c9d1..7e7001dbb 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -42,6 +42,7 @@ const ( NameCom models.Provider = "name.com" NameSilo models.Provider = "namesilo" Netcup models.Provider = "netcup" + NextDNS models.Provider = "nextdns" Njalla models.Provider = "njalla" NoIP models.Provider = "noip" NowDNS models.Provider = "nowdns" @@ -96,6 +97,7 @@ func ProviderChoices() []models.Provider { Namecheap, NameCom, NameSilo, + NextDNS, Njalla, NoIP, NowDNS, diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 06e002845..6ade48210 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/nextdns" "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" @@ -160,6 +161,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin return namesilo.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Netcup: return netcup.New(data, domain, owner, ipVersion, ipv6Suffix) + case constants.NextDNS: + return nextdns.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Njalla: return njalla.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.NoIP: diff --git a/internal/provider/providers/nextdns/provider.go b/internal/provider/providers/nextdns/provider.go new file mode 100644 index 000000000..fe8c8f86a --- /dev/null +++ b/internal/provider/providers/nextdns/provider.go @@ -0,0 +1,144 @@ +package nextdns + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "net/url" + "regexp" + "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" +) + +type Provider struct { + domain string + owner string + ipVersion ipversion.IPVersion + ipv6Suffix netip.Prefix + endpoint string +} + +func New(data json.RawMessage, domain, owner string, + ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( + provider *Provider, err error, +) { + if owner == "link-ip" || domain == "" { + domain = "link-ip.nextdns.io" + owner = "@" + } + var providerSpecificSettings struct { + Endpoint string `json:"endpoint"` + } + err = json.Unmarshal(data, &providerSpecificSettings) + if err != nil { + return nil, fmt.Errorf("json decoding provider specific settings: %w", err) + } + + err = validateSettings(domain, owner, providerSpecificSettings.Endpoint) + if err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) + } + + return &Provider{ + domain: domain, + owner: owner, + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + endpoint: providerSpecificSettings.Endpoint, + }, nil +} + +var ( + endpointRegex = regexp.MustCompile(`^[0-9a-fA-F]{6}\/[0-9a-fA-F]{16}$`) +) + +func validateSettings(domain, owner, endpoint string) (err error) { + err = utils.CheckDomain(domain) + if err != nil { + return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) + } + switch { + case !strings.HasSuffix(domain, "nextdns.io"): + return fmt.Errorf(`%w: %q must end with "%s"`, + errors.ErrDomainNotValid, domain, "nextdns.io") + case owner == "*": + return fmt.Errorf("%w: %s", errors.ErrOwnerWildcard, owner) + case !endpointRegex.MatchString(endpoint): + return fmt.Errorf("%w: endpoint %q does not match regex %q", + errors.ErrTokenNotValid, endpoint, endpointRegex) + } + return nil +} + +func (p *Provider) String() string { + return utils.ToString(p.domain, p.owner, constants.NextDNS, p.ipVersion) +} + +func (p *Provider) Domain() string { + return p.domain +} + +func (p *Provider) Owner() string { + return p.owner +} + +func (p *Provider) IPVersion() ipversion.IPVersion { + return p.ipVersion +} + +func (p *Provider) IPv6Suffix() netip.Prefix { + return p.ipv6Suffix +} + +func (p *Provider) Proxied() bool { + return false +} + +func (p *Provider) BuildDomainName() string { + return utils.BuildDomainName(p.owner, p.domain) +} + +func (p *Provider) HTML() models.HTMLRow { + return models.HTMLRow{ + Domain: fmt.Sprintf("%s", p.BuildDomainName(), p.BuildDomainName()), + Owner: p.Owner(), + Provider: "NextDNS", + IPVersion: p.ipVersion.String(), + } +} + +func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { + u := url.URL{ + Scheme: "https", + Host: p.Domain(), + Path: p.endpoint, + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating http request: %w", err) + } + + response, err := client.Do(request) + if err != nil { + return netip.Addr{}, err + } + defer response.Body.Close() + + s, err := utils.ReadAndCleanBody(response.Body) + if err != nil { + return netip.Addr{}, fmt.Errorf("reading response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return netip.Addr{}, fmt.Errorf("%w: %d: %s", + errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s)) + } + return ip, nil +}