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