diff --git a/README.md b/README.md
index 79ab3e96f..0be26d57f 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- DDNSS.de
- deSEC
- DigitalOcean
+ - Domeneshop
- DonDominio
- DNSOMatic
- DNSPod
@@ -218,6 +219,7 @@ Check the documentation for your DNS provider:
- [deSEC](docs/desec.md)
- [DigitalOcean](docs/digitalocean.md)
- [DD24](docs/dd24.md)
+- [Domeneshop](docs/domeneshop.md)
- [DonDominio](docs/dondominio.md)
- [DNSOMatic](docs/dnsomatic.md)
- [DNSPod](docs/dnspod.md)
diff --git a/docs/domeneshop.md b/docs/domeneshop.md
new file mode 100644
index 000000000..ed09a3833
--- /dev/null
+++ b/docs/domeneshop.md
@@ -0,0 +1,31 @@
+# Domeneshop.no
+
+## Configuration
+
+### Example
+
+```json
+{
+ "settings": [
+ {
+ "provider": "domeneshop",
+ "domain": "domain.com,seconddomain.com",
+ "token": "token",
+ "secret": "secret",
+ "ip_version": "ipv4",
+ "ipv6_suffix": ""
+ }
+ ]
+}
+```
+
+### Compulsory parameters
+
+- `"domain"` is the domain to update. It can be `example.com` (root domain) or `sub.example.com` (subdomain of `example.com`)
+- `"token"` See [api.domeneshop.no/docs/](https://api.domeneshop.no/docs/) for instructions on how to generate credentials.
+- `"secret"`
+
+### 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.
diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go
index d7050a65a..e0e1602d6 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -15,6 +15,7 @@ const (
DigitalOcean models.Provider = "digitalocean"
DNSOMatic models.Provider = "dnsomatic"
DNSPod models.Provider = "dnspod"
+ Domeneshop models.Provider = "domeneshop"
DonDominio models.Provider = "dondominio"
Dreamhost models.Provider = "dreamhost"
DuckDNS models.Provider = "duckdns"
@@ -65,6 +66,7 @@ func ProviderChoices() []models.Provider {
DigitalOcean,
DNSOMatic,
DNSPod,
+ Domeneshop,
DonDominio,
Dreamhost,
DuckDNS,
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index db750bb6a..6e4af23a4 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -21,6 +21,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/digitalocean"
"github.com/qdm12/ddns-updater/internal/provider/providers/dnsomatic"
"github.com/qdm12/ddns-updater/internal/provider/providers/dnspod"
+ "github.com/qdm12/ddns-updater/internal/provider/providers/domeneshop"
"github.com/qdm12/ddns-updater/internal/provider/providers/dondominio"
"github.com/qdm12/ddns-updater/internal/provider/providers/dreamhost"
"github.com/qdm12/ddns-updater/internal/provider/providers/duckdns"
@@ -100,6 +101,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return dnsomatic.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.DNSPod:
return dnspod.New(data, domain, owner, ipVersion, ipv6Suffix)
+ case constants.Domeneshop:
+ return domeneshop.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.DonDominio:
return dondominio.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Dreamhost:
diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go
new file mode 100644
index 000000000..023e2aa5a
--- /dev/null
+++ b/internal/provider/providers/domeneshop/provider.go
@@ -0,0 +1,155 @@
+package domeneshop
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/netip"
+ "net/url"
+
+ "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/headers"
+ "github.com/qdm12/ddns-updater/internal/provider/utils"
+ "github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
+)
+
+type Provider struct {
+ domain string
+ owner string
+ token string
+ secret string
+ ipVersion ipversion.IPVersion
+ ipv6Suffix netip.Prefix
+}
+
+func New(data json.RawMessage, domain, owner string,
+ ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
+ provider *Provider, err error) {
+ var providerSpecificSettings struct {
+ Token string `json:"token"`
+ Secret string `json:"secret"`
+ }
+ err = json.Unmarshal(data, &providerSpecificSettings)
+ if err != nil {
+ return nil, fmt.Errorf("json decoding provider specific settings: %w", err)
+ }
+
+ err = validateSettings(domain, owner,
+ providerSpecificSettings.Token, providerSpecificSettings.Secret)
+ if err != nil {
+ return nil, fmt.Errorf("validating provider specific settings: %w", err)
+ }
+
+ return &Provider{
+ domain: domain,
+ owner: owner,
+ token: providerSpecificSettings.Token,
+ secret: providerSpecificSettings.Secret,
+ ipVersion: ipVersion,
+ ipv6Suffix: ipv6Suffix,
+ }, nil
+}
+
+func validateSettings(domain, owner, token, secret string) (err error) {
+ err = utils.CheckDomain(domain)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
+ }
+
+ switch {
+ case owner == "":
+ return fmt.Errorf("%w", errors.ErrOwnerNotSet)
+ case owner == "*":
+ return fmt.Errorf("%w", errors.ErrOwnerWildcard)
+ case token == "":
+ return fmt.Errorf("%w", errors.ErrTokenNotSet)
+ case secret == "":
+ return fmt.Errorf("%w", errors.ErrSecretNotSet)
+ }
+ return nil
+}
+
+func (p *Provider) String() string {
+ return utils.ToString(p.domain, p.owner, constants.Domeneshop, 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: "Domeneshop",
+ IPVersion: p.ipVersion.String(),
+ }
+}
+
+// Link to documentation:
+// https://api.domeneshop.no/docs/#tag/ddns/paths/~1dyndns~1update/get
+func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
+ u := url.URL{
+ Scheme: "https",
+ Host: "api.domeneshop.no",
+ Path: "/v0/dyndns/update",
+ }
+ values := url.Values{}
+ values.Set("hostname", utils.BuildURLQueryHostname(p.owner, p.domain))
+ values.Set("myip", ip.String())
+ u.RawQuery = values.Encode()
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
+ }
+
+ request.SetBasicAuth(p.token, p.secret)
+ headers.SetUserAgent(request)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return netip.Addr{}, err
+ }
+ defer response.Body.Close()
+
+ b, err := io.ReadAll(response.Body)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("reading response body: %w", err)
+ }
+ s := string(b)
+
+ switch response.StatusCode {
+ case http.StatusNoContent:
+ return ip, nil
+ case http.StatusNotFound:
+ return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrHostnameNotExists, utils.ToSingleLine(s))
+ default:
+ return netip.Addr{}, fmt.Errorf("%w: %d: %s",
+ errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s))
+ }
+}