diff --git a/.gitignore b/.gitignore
index 249cda967..464e55b5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-/data
\ No newline at end of file
+/data
+.idea
diff --git a/README.md b/README.md
index ff7a4fa4b..120ec71f2 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- Servercow.de
- Spdyn
- Strato.de
+ - TransIP
- Variomedia.de
- Vultr
- Zoneedit
@@ -260,6 +261,7 @@ Check the documentation for your DNS provider:
- [Servercow.de](docs/servercow.md)
- [Spdyn](docs/spdyn.md)
- [Strato.de](docs/strato.md)
+- [TransIP](docs/transip.md)
- [Variomedia.de](docs/variomedia.md)
- [Vultr](docs/vultr.md)
- [Zoneedit](docs/zoneedit.md)
diff --git a/docs/transip.md b/docs/transip.md
new file mode 100644
index 000000000..a2cb9c9d6
--- /dev/null
+++ b/docs/transip.md
@@ -0,0 +1,42 @@
+# TransIP
+
+## Configuration
+
+### Example
+
+```json
+{
+ "settings": [
+ {
+ "provider": "transip",
+ "domain": "example.com",
+ "ip_version": "ipv4",
+ "ipv6_suffix": "",
+ "username": "username",
+ "key": "-----BEGIN PRIVATE KEY-----...-----END PRIVATE KEY-----"
+ }
+ ]
+}
+```
+
+### Compulsory parameters
+
+- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard.
+- `"username"`
+- `"key"`
+
+### 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
+
+1. Log in to the [TransIP control panel](https://www.transip.nl/cp/).
+2. Under your account, go to the [API settings](https://www.transip.nl/cp/account/api/).
+3. Enable the API.\
+![A toggle showing the API status as on](../readme/transip1.png)
+4. Add a key pair. Make sure to uncheck the checkbox to only accept IP addresses from the whitelist.\
+![A table listing the key pairs](../readme/transip2.png)
+5. Copy your private key, and store it in your config file.\
+![A snippet of a generated private key](../readme/transip3.png)
diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go
index 71b48c9d1..8d0d065dd 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -53,6 +53,7 @@ const (
Servercow models.Provider = "servercow"
Spdyn models.Provider = "spdyn"
Strato models.Provider = "strato"
+ TransIP models.Provider = "transip"
Variomedia models.Provider = "variomedia"
Vultr models.Provider = "vultr"
Zoneedit models.Provider = "zoneedit"
@@ -106,6 +107,7 @@ func ProviderChoices() []models.Provider {
SelfhostDe,
Spdyn,
Strato,
+ TransIP,
Variomedia,
Vultr,
Zoneedit,
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 06e002845..ec6687afd 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -59,6 +59,7 @@ import (
"github.com/qdm12/ddns-updater/internal/provider/providers/servercow"
"github.com/qdm12/ddns-updater/internal/provider/providers/spdyn"
"github.com/qdm12/ddns-updater/internal/provider/providers/strato"
+ "github.com/qdm12/ddns-updater/internal/provider/providers/transip"
"github.com/qdm12/ddns-updater/internal/provider/providers/variomedia"
"github.com/qdm12/ddns-updater/internal/provider/providers/vultr"
"github.com/qdm12/ddns-updater/internal/provider/providers/zoneedit"
@@ -182,6 +183,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return spdyn.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Strato:
return strato.New(data, domain, owner, ipVersion, ipv6Suffix)
+ case constants.TransIP:
+ return transip.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Variomedia:
return variomedia.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Vultr:
diff --git a/internal/provider/providers/transip/provider.go b/internal/provider/providers/transip/provider.go
new file mode 100644
index 000000000..a93624137
--- /dev/null
+++ b/internal/provider/providers/transip/provider.go
@@ -0,0 +1,285 @@
+package transip
+
+import (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha512"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "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"
+ "net/http"
+ "net/netip"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Provider struct {
+ domain string
+ owner string
+ ipVersion ipversion.IPVersion
+ ipv6Suffix netip.Prefix
+ username string
+ key string
+}
+
+func New(data json.RawMessage, domain, owner string,
+ ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
+ p *Provider, err error,
+) {
+ extraSettings := struct {
+ Username string `json:"username"`
+ Key string `json:"key"`
+ }{}
+ err = json.Unmarshal(data, &extraSettings)
+ if err != nil {
+ return nil, err
+ }
+
+ err = validateSettings(domain, extraSettings.Username, extraSettings.Key)
+ if err != nil {
+ return nil, fmt.Errorf("validating provider specific settings: %w", err)
+ }
+
+ return &Provider{
+ domain: domain,
+ owner: owner,
+ ipVersion: ipVersion,
+ ipv6Suffix: ipv6Suffix,
+ username: extraSettings.Username,
+ key: extraSettings.Key,
+ }, nil
+}
+
+func validateSettings(domain, username string, key string) (err error) {
+ err = utils.CheckDomain(domain)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
+ }
+
+ if username == "" {
+ return errors.ErrUsernameNotSet
+ }
+
+ if key == "" {
+ return errors.ErrKeyNotSet
+ }
+
+ if _, err := parsePrivateKey(key); err != nil {
+ return fmt.Errorf("%w: %w", errors.ErrKeyNotValid, err)
+ }
+
+ return nil
+}
+
+func (p *Provider) String() string {
+ return utils.ToString(p.domain, p.owner, constants.TransIP, 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: "TransIP",
+ IPVersion: p.ipVersion.String(),
+ }
+}
+
+func parsePrivateKey(keyString string) (*rsa.PrivateKey, error) {
+ // Remove the begin and end markers, remove whitespace, and trim.
+ pemData := strings.ReplaceAll(keyString, "\n", "")
+ pemData = strings.ReplaceAll(pemData, "-----BEGIN PRIVATE KEY-----", "")
+ pemData = strings.ReplaceAll(pemData, "-----END PRIVATE KEY-----", "")
+ pemData = strings.TrimSpace(pemData)
+
+ decodedKey, err := base64.StdEncoding.DecodeString(pemData)
+ if err != nil {
+ return nil, err
+ }
+
+ key, err := x509.ParsePKCS8PrivateKey(decodedKey)
+ if err != nil {
+ return nil, err
+ }
+
+ if rsaKey, ok := key.(*rsa.PrivateKey); ok {
+ return rsaKey, nil
+ }
+
+ return nil, fmt.Errorf("not an RSA private key")
+}
+
+func (p *Provider) createAccessToken(ctx context.Context, client *http.Client) (string, error) {
+ requestBody, err := json.Marshal(map[string]any{
+ "login": p.username,
+ "nonce": strconv.FormatInt(time.Now().UnixNano(), 10),
+ "global_key": true,
+ "read_only": false,
+ "label": fmt.Sprintf("ddns-updater %d", time.Now().Unix()),
+ })
+ if err != nil {
+ return "", fmt.Errorf("json encoding request body: %w", err)
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.transip.nl/v6/auth", bytes.NewReader(requestBody))
+ if err != nil {
+ return "", fmt.Errorf("creating http request: %w", err)
+ }
+ headers.SetUserAgent(request)
+ headers.SetContentType(request, "application/json")
+
+ privateKey, err := parsePrivateKey(p.key)
+ if err != nil {
+ return "", fmt.Errorf("parsing private key: %w", err)
+ }
+
+ // Sign the request body, put the signature in a header.
+ hashedBody := sha512.Sum512(requestBody)
+ signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA512, hashedBody[:])
+ if err != nil {
+ return "", fmt.Errorf("signing request: %w", err)
+ }
+ request.Header.Set("Signature", base64.StdEncoding.EncodeToString(signature))
+
+ response, err := client.Do(request)
+ if err != nil {
+ return "", err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode != http.StatusCreated {
+ return "", fmt.Errorf("%w: %d: %s",
+ errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body))
+ }
+
+ var result struct {
+ Token string `json:"token"`
+ }
+ err = json.NewDecoder(response.Body).Decode(&result)
+ if err != nil {
+ return "", fmt.Errorf("json decoding response body: %w", err)
+ }
+
+ return result.Token, nil
+}
+
+func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) {
+ recordType := constants.A
+ if ip.Is6() {
+ recordType = constants.AAAA
+ }
+
+ token, err := p.createAccessToken(ctx, client)
+ if err != nil {
+ return netip.Addr{}, err
+ }
+
+ dnsApiUrl := fmt.Sprintf("https://api.transip.nl/v6/domains/%s/dns", p.domain)
+
+ // List the existing DNS entries.
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, dnsApiUrl, nil)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
+ }
+ headers.SetUserAgent(request)
+ headers.SetAuthBearer(request, token)
+ response, err := client.Do(request)
+ if err != nil {
+ return netip.Addr{}, 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))
+ }
+ var entries struct {
+ DnsEntries []dnsEntry `json:"dnsEntries"`
+ }
+ err = json.NewDecoder(response.Body).Decode(&entries)
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("json decoding response body: %w", err)
+ }
+
+ // Construct the basis of the new/updated entry.
+ updatedEntry := dnsEntry{
+ Name: p.owner,
+ Expire: 300,
+ Type: recordType,
+ Content: ip.String(),
+ }
+ postOrPatch := http.MethodPost
+
+ // Check if there is a matching entry, based on the name and type.
+ for _, entry := range entries.DnsEntries {
+ if entry.Name == p.owner && entry.Type == recordType {
+ postOrPatch = http.MethodPatch
+ updatedEntry.Expire = entry.Expire
+ }
+ }
+
+ // Create or update the entry.
+ requestBody, err := json.Marshal(map[string]any{
+ "dnsEntry": updatedEntry,
+ })
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("json encoding request body: %w", err)
+ }
+ request, err = http.NewRequestWithContext(ctx, postOrPatch, dnsApiUrl, bytes.NewReader(requestBody))
+ if err != nil {
+ return netip.Addr{}, fmt.Errorf("creating http request: %w", err)
+ }
+ headers.SetUserAgent(request)
+ headers.SetContentType(request, "application/json")
+ headers.SetAuthBearer(request, token)
+ response, err = client.Do(request)
+ if err != nil {
+ return netip.Addr{}, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusCreated {
+ return netip.Addr{}, fmt.Errorf("%w: %d: %s",
+ errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body))
+ }
+
+ return ip, nil
+}
+
+type dnsEntry struct {
+ Name string `json:"name"`
+ Expire int `json:"expire"`
+ Type string `json:"type"`
+ Content string `json:"content"`
+}
diff --git a/internal/update/service.go b/internal/update/service.go
index 843e7e6b7..81e98b68f 100644
--- a/internal/update/service.go
+++ b/internal/update/service.go
@@ -77,6 +77,7 @@ func (s *Service) lookupIPsResilient(ctx context.Context, hostname string, tries
results <- result{network: network, ips: ips, err: err}
return
}
+ results <- result{network: network} // retries exceeded
}(lookupCtx, network, results)
}
diff --git a/readme/transip1.png b/readme/transip1.png
new file mode 100644
index 000000000..d85d2191c
Binary files /dev/null and b/readme/transip1.png differ
diff --git a/readme/transip2.png b/readme/transip2.png
new file mode 100644
index 000000000..e13af5e0d
Binary files /dev/null and b/readme/transip2.png differ
diff --git a/readme/transip3.png b/readme/transip3.png
new file mode 100644
index 000000000..c2c2a00c6
Binary files /dev/null and b/readme/transip3.png differ