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
+}