From 07ced1719853856098892220b2ea61fb3be69fe7 Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Mon, 9 Sep 2024 20:08:00 +0200 Subject: [PATCH 1/9] add domeneshop as supported DNS provider --- README.md | 2 + docs/domeneshop.md | 33 ++++ internal/provider/constants/providers.go | 2 + internal/provider/provider.go | 3 + .../provider/providers/domeneshop/provider.go | 160 ++++++++++++++++++ 5 files changed, 200 insertions(+) create mode 100644 docs/domeneshop.md create mode 100644 internal/provider/providers/domeneshop/provider.go 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..b6f8d65e6 --- /dev/null +++ b/docs/domeneshop.md @@ -0,0 +1,33 @@ +# 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 [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. + +## Domain setup 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..693d5f8e3 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, 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..ca1e09e99 --- /dev/null +++ b/internal/provider/providers/domeneshop/provider.go @@ -0,0 +1,160 @@ +package domeneshop + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "net/url" + "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/headers" + "github.com/qdm12/ddns-updater/internal/provider/utils" + "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" +) + +type Provider struct { + domain string + token string + secret string + ipVersion ipversion.IPVersion + ipv6Suffix netip.Prefix +} + +func New(data json.RawMessage, domain 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, + providerSpecificSettings.Token, providerSpecificSettings.Secret) + if err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) + } + + return &Provider{ + domain: domain, + token: providerSpecificSettings.Token, + secret: providerSpecificSettings.Secret, + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + }, nil +} + +func validateSettings(domain, token, secret string) (err error) { + err = utils.CheckDomain(domain) + if err != nil { + return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) + } + + switch { + 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 "" +} + +func (p *Provider) IPVersion() ipversion.IPVersion { + return p.ipVersion +} + +func (p *Provider) IPv6Suffix() netip.Prefix { + return netip.Prefix{} +} + +func (p *Provider) Proxied() bool { + return false +} + +func (p *Provider) BuildDomainName() string { + return 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", 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) + + defer response.Body.Close() + + switch response.StatusCode { + case http.StatusNoContent: + 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)) + } + + switch { + case strings.HasPrefix(s, "Successful operation"): + return ip, nil + case strings.HasPrefix(s, "Domain not found"): + return netip.Addr{}, fmt.Errorf("%w", errors.ErrDomainNotFound) + default: + return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnknownResponse, s) + } +} From a9687bba03cbeb6c60ee9ece8c60bff87afacdf7 Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Mon, 9 Sep 2024 20:20:11 +0200 Subject: [PATCH 2/9] add owner back in --- .../provider/providers/domeneshop/provider.go | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index ca1e09e99..9ac374d08 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -20,13 +20,14 @@ import ( type Provider struct { domain string + owner string token string secret string ipVersion ipversion.IPVersion ipv6Suffix netip.Prefix } -func New(data json.RawMessage, domain string, +func New(data json.RawMessage, domain, owner string, ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( provider *Provider, err error) { var providerSpecificSettings struct { @@ -38,7 +39,7 @@ func New(data json.RawMessage, domain string, return nil, fmt.Errorf("json decoding provider specific settings: %w", err) } - err = validateSettings(domain, + err = validateSettings(domain, owner, providerSpecificSettings.Token, providerSpecificSettings.Secret) if err != nil { return nil, fmt.Errorf("validating provider specific settings: %w", err) @@ -46,6 +47,7 @@ func New(data json.RawMessage, domain string, return &Provider{ domain: domain, + owner: owner, token: providerSpecificSettings.Token, secret: providerSpecificSettings.Secret, ipVersion: ipVersion, @@ -53,13 +55,17 @@ func New(data json.RawMessage, domain string, }, nil } -func validateSettings(domain, token, secret string) (err error) { +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 == "": @@ -69,7 +75,7 @@ func validateSettings(domain, token, secret string) (err error) { } func (p *Provider) String() string { - return utils.ToString(p.domain, p.Owner(), constants.Domeneshop, p.ipVersion) + return utils.ToString(p.domain, p.owner, constants.Domeneshop, p.ipVersion) } func (p *Provider) Domain() string { @@ -77,7 +83,7 @@ func (p *Provider) Domain() string { } func (p *Provider) Owner() string { - return "" + return p.owner } func (p *Provider) IPVersion() ipversion.IPVersion { @@ -93,7 +99,7 @@ func (p *Provider) Proxied() bool { } func (p *Provider) BuildDomainName() string { - return p.domain + return utils.BuildDomainName(p.owner, p.domain) } func (p *Provider) HTML() models.HTMLRow { @@ -114,7 +120,7 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add Path: "/v0/dyndns/update", } values := url.Values{} - values.Set("hostname", p.domain) + values.Set("hostname", utils.BuildURLQueryHostname(p.owner, p.domain)) values.Set("myip", ip.String()) u.RawQuery = values.Encode() From 2673c99de7ff0708ec3e21275b849721673e8bdc Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Mon, 9 Sep 2024 20:23:18 +0200 Subject: [PATCH 3/9] fix provider.go domeneshop entry --- internal/provider/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 693d5f8e3..6e4af23a4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -102,7 +102,7 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin case constants.DNSPod: return dnspod.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Domeneshop: - return domeneshop.New(data, domain, ipVersion, ipv6Suffix) + return domeneshop.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.DonDominio: return dondominio.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Dreamhost: From f243272dce1ed31e69c5319c6ab154b2a7d75123 Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Mon, 9 Sep 2024 18:32:24 +0000 Subject: [PATCH 4/9] correct status code handling --- internal/provider/providers/domeneshop/provider.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index 9ac374d08..e2cfb8b7d 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -8,7 +8,6 @@ import ( "net/http" "net/netip" "net/url" - "strings" "github.com/qdm12/ddns-updater/internal/models" "github.com/qdm12/ddns-updater/internal/provider/constants" @@ -20,7 +19,7 @@ import ( type Provider struct { domain string - owner string + owner string token string secret string ipVersion ipversion.IPVersion @@ -148,6 +147,7 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add 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: @@ -155,12 +155,4 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s)) } - switch { - case strings.HasPrefix(s, "Successful operation"): - return ip, nil - case strings.HasPrefix(s, "Domain not found"): - return netip.Addr{}, fmt.Errorf("%w", errors.ErrDomainNotFound) - default: - return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnknownResponse, s) - } } From 89c21fda57696f6e7aa9f1c6615cf83778311f9a Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Thu, 12 Sep 2024 11:27:26 +0200 Subject: [PATCH 5/9] Update docs/domeneshop.md Co-authored-by: Quentin McGaw --- docs/domeneshop.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/domeneshop.md b/docs/domeneshop.md index b6f8d65e6..d69ee699a 100644 --- a/docs/domeneshop.md +++ b/docs/domeneshop.md @@ -30,4 +30,3 @@ - `"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 From 9218c3584ff435b7c2c3081286f6dadddfe5945f Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Thu, 12 Sep 2024 11:27:55 +0200 Subject: [PATCH 6/9] Update internal/provider/providers/domeneshop/provider.go Co-authored-by: Quentin McGaw --- internal/provider/providers/domeneshop/provider.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index e2cfb8b7d..d1f5870b6 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -143,7 +143,6 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add } s := string(b) - defer response.Body.Close() switch response.StatusCode { case http.StatusNoContent: From d27ad2d38c1cc25eb98f7378e1d5bd73e0ec8452 Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Thu, 12 Sep 2024 11:28:01 +0200 Subject: [PATCH 7/9] Update internal/provider/providers/domeneshop/provider.go Co-authored-by: Quentin McGaw --- internal/provider/providers/domeneshop/provider.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index d1f5870b6..5614418f2 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -153,5 +153,4 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add return netip.Addr{}, fmt.Errorf("%w: %d: %s", errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s)) } - } From c62cdfed260195bfe221e36514d6d7bc81811368 Mon Sep 17 00:00:00 2001 From: Jonas Jacobsen Date: Thu, 12 Sep 2024 11:37:56 +0200 Subject: [PATCH 8/9] Fix ipv6suffix field --- internal/provider/providers/domeneshop/provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index 5614418f2..827c1a6fd 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -90,7 +90,7 @@ func (p *Provider) IPVersion() ipversion.IPVersion { } func (p *Provider) IPv6Suffix() netip.Prefix { - return netip.Prefix{} + return p.ipv6Suffix } func (p *Provider) Proxied() bool { From c3b6ed904e011cdd4937622951518d2c52c5f07f Mon Sep 17 00:00:00 2001 From: Quentin McGaw Date: Tue, 8 Oct 2024 07:04:24 +0000 Subject: [PATCH 9/9] chore: fix CI errors --- docs/domeneshop.md | 3 +-- internal/provider/providers/domeneshop/provider.go | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/domeneshop.md b/docs/domeneshop.md index d69ee699a..ed09a3833 100644 --- a/docs/domeneshop.md +++ b/docs/domeneshop.md @@ -22,11 +22,10 @@ ### 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 [https://api.domeneshop.no/docs/] for instructions on how to generate credentials. +- `"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/providers/domeneshop/provider.go b/internal/provider/providers/domeneshop/provider.go index 827c1a6fd..023e2aa5a 100644 --- a/internal/provider/providers/domeneshop/provider.go +++ b/internal/provider/providers/domeneshop/provider.go @@ -143,7 +143,6 @@ func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Add } s := string(b) - switch response.StatusCode { case http.StatusNoContent: return ip, nil