Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TransIP provider #921

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/data
/data
.idea
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions docs/transip.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -106,6 +107,7 @@ func ProviderChoices() []models.Provider {
SelfhostDe,
Spdyn,
Strato,
TransIP,
Variomedia,
Vultr,
Zoneedit,
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
285 changes: 285 additions & 0 deletions internal/provider/providers/transip/provider.go
Original file line number Diff line number Diff line change
@@ -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("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Owner: p.Owner(),
Provider: "<a href=\"https://www.transip.nl/\">TransIP</a>",
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"`
}
1 change: 1 addition & 0 deletions internal/update/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Binary file added readme/transip1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added readme/transip2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added readme/transip3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.