diff --git a/README.md b/README.md
index ea77a6229..46b3b562f 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,7 @@ Light container updating DNS A and/or AAAA records periodically for multiple DNS
- Gandi
- GCP
- GoDaddy
+ - GoIP.de
- He.net
- Hetzner
- Infomaniak
@@ -189,6 +190,7 @@ Check the documentation for your DNS provider:
- [Gandi](docs/gandi.md)
- [GCP](docs/gcp.md)
- [GoDaddy](docs/godaddy.md)
+- [GoIP.de](docs/goip.md)
- [He.net](docs/he.net.md)
- [Infomaniak](docs/infomaniak.md)
- [INWX](docs/inwx.md)
diff --git a/docs/goip.md b/docs/goip.md
new file mode 100644
index 000000000..9dd7eff12
--- /dev/null
+++ b/docs/goip.md
@@ -0,0 +1,36 @@
+# GoIP.de
+
+## Configuration
+
+### Example
+
+```json
+{
+ "settings": [
+ {
+ "provider": "goip",
+ "domain": "goip.de",
+ "host": "mysubdomain",
+ "username": "username",
+ "password": "password",
+ "provider_ip": true,
+ "ip_version": "",
+ "ipv6_suffix": ""
+
+ }
+ ]
+}
+```
+
+### Compulsory parameters
+
+- `"host"` is the full FQDN of your ddns address. sample.goip.de or something.goip.it
+- `"username"` is your goip.de username listed under "Routers"
+- `"password"` is your router account password
+
+### Optional parameters
+
+- `"domain"` is the domain name which can be `goip.de` or `goip.it`, and defaults to `goip.de` if left unset.
+- `"provider_ip"` can be set to `true` to let your DNS provider determine your IPv4 address (and/or IPv6 address) automatically when you send an update request, without sending the new IP address detected by the program in the request. This is automatically disabled for an IPv6 public address since it is not supported.
+- `"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`.
+- `"ipv6_suffix"` is the IPv6 interface identifiersuffix 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 f7ceb913f..38e0d0db1 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -25,6 +25,7 @@ const (
Gandi models.Provider = "gandi"
GCP models.Provider = "gcp"
GoDaddy models.Provider = "godaddy"
+ GoIP models.Provider = "goip"
HE models.Provider = "he"
Hetzner models.Provider = "hetzner"
Infomaniak models.Provider = "infomaniak"
@@ -71,6 +72,7 @@ func ProviderChoices() []models.Provider {
Gandi,
GCP,
GoDaddy,
+ GoIP,
HE,
Hetzner,
Infomaniak,
diff --git a/internal/provider/errors/validation.go b/internal/provider/errors/validation.go
index c98bc2b5f..bf61a62c8 100644
--- a/internal/provider/errors/validation.go
+++ b/internal/provider/errors/validation.go
@@ -14,6 +14,7 @@ var (
ErrEmailNotSet = errors.New("email is not set")
ErrEmailNotValid = errors.New("email address is not valid")
ErrGCPProjectNotSet = errors.New("GCP project is not set")
+ ErrDomainNotValid = errors.New("domain is not valid")
ErrHostOnlySubdomain = errors.New("host can only be a subdomain")
ErrHostWildcard = errors.New(`host cannot be a "*"`)
ErrIPv4KeyNotSet = errors.New("IPv4 key is not set")
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index cd01912de..a3e5e76c4 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -31,6 +31,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/gandi"
"github.com/qdm12/ddns-updater/internal/provider/providers/gcp"
"github.com/qdm12/ddns-updater/internal/provider/providers/godaddy"
+ "github.com/qdm12/ddns-updater/internal/provider/providers/goip"
"github.com/qdm12/ddns-updater/internal/provider/providers/he"
"github.com/qdm12/ddns-updater/internal/provider/providers/hetzner"
"github.com/qdm12/ddns-updater/internal/provider/providers/infomaniak"
@@ -116,6 +117,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, host string
return gcp.New(data, domain, host, ipVersion, ipv6Suffix)
case constants.GoDaddy:
return godaddy.New(data, domain, host, ipVersion, ipv6Suffix)
+ case constants.GoIP:
+ return goip.New(data, domain, host, ipVersion, ipv6Suffix)
case constants.HE:
return he.New(data, domain, host, ipVersion, ipv6Suffix)
case constants.Hetzner:
diff --git a/internal/provider/providers/goip/provider.go b/internal/provider/providers/goip/provider.go
new file mode 100644
index 000000000..791a53e88
--- /dev/null
+++ b/internal/provider/providers/goip/provider.go
@@ -0,0 +1,171 @@
+package goip
+
+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
+ host string
+ ipVersion ipversion.IPVersion
+ ipv6Suffix netip.Prefix
+ username string
+ password string
+ useProviderIP bool
+}
+
+const defaultDomain = "goip.de"
+
+func New(data json.RawMessage, domain, host string,
+ ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
+ p *Provider, err error) {
+ if domain == "" {
+ domain = defaultDomain
+ }
+
+ extraSettings := struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ UseProviderIP bool `json:"provider_ip"`
+ }{}
+ err = json.Unmarshal(data, &extraSettings)
+ if err != nil {
+ return nil, err
+ }
+ p = &Provider{
+ domain: domain,
+ host: host,
+ ipVersion: ipVersion,
+ ipv6Suffix: ipv6Suffix,
+ username: extraSettings.Username,
+ password: extraSettings.Password,
+ useProviderIP: extraSettings.UseProviderIP,
+ }
+ err = p.isValid()
+ if err != nil {
+ return nil, err
+ }
+ return p, nil
+}
+
+func (p *Provider) isValid() error {
+ switch {
+ case p.username == "":
+ return fmt.Errorf("%w", errors.ErrUsernameNotSet)
+ case p.password == "":
+ return fmt.Errorf("%w", errors.ErrPasswordNotSet)
+ case p.domain != defaultDomain && p.domain != "goip.it":
+ return fmt.Errorf(`%w: %q must be "goip.de" or "goip.it"`,
+ errors.ErrDomainNotValid, p.domain)
+ case p.host == "@" || p.host == "*":
+ return fmt.Errorf("%w: host %q is not valid", errors.ErrHostOnlySubdomain, p.host)
+ }
+ return nil
+}
+
+func (p *Provider) String() string {
+ return utils.ToString(p.host, p.domain, constants.GoIP, p.ipVersion)
+}
+
+func (p *Provider) Domain() string {
+ return p.domain
+}
+
+func (p *Provider) Host() string {
+ return p.host
+}
+
+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.host, p.domain)
+}
+
+func (p *Provider) HTML() models.HTMLRow {
+ return models.HTMLRow{
+ Domain: fmt.Sprintf("%s", p.BuildDomainName(), p.BuildDomainName()),
+ Host: p.Host(),
+ Provider: "" + p.domain + "",
+ IPVersion: p.ipVersion.String(),
+ }
+}
+
+// See https://www.goip.de/update-url.html
+func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
+ u := url.URL{
+ Scheme: "https",
+ User: url.UserPassword(p.username, p.password),
+ Host: "www.goip.de",
+ Path: "/setip",
+ }
+ values := url.Values{}
+ values.Set("subdomain", p.BuildDomainName())
+ values.Set("username", p.username)
+ values.Set("password", p.password)
+ values.Set("shortResponse", "true")
+ if ip.Is4() {
+ if !p.useProviderIP {
+ values.Set("ip", ip.String())
+ }
+ } else {
+ // IPv6 cannot be automatically detected
+ values.Set("ip6", 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)
+ }
+ headers.SetUserAgent(request)
+
+ response, err := client.Do(request)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("doing http request: %w", err)
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusOK {
+ return netip.Addr{}, fmt.Errorf("%w: %d: %s",
+ errors.ErrHTTPStatusNotValid, response.StatusCode,
+ utils.BodyToSingleLine(response.Body))
+ }
+
+ b, err := io.ReadAll(response.Body)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("reading response body: %w", err)
+ }
+ s := string(b)
+ switch {
+ case strings.HasPrefix(s, p.BuildDomainName()+" ("+ip.String()+")"):
+ return ip, nil
+ case strings.HasPrefix(strings.ToLower(s), "zugriff verweigert"):
+ return netip.Addr{}, fmt.Errorf("%w", errors.ErrAuth)
+ default:
+ return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnknownResponse, utils.ToSingleLine(s))
+ }
+}