feat(provider): vultr.com (#829)

This commit is contained in:
Amr Essam
2024-10-20 21:45:34 +04:00
committed by GitHub
parent bad113b290
commit 949dcd9a30
10 changed files with 478 additions and 2 deletions

View File

@@ -29,6 +29,9 @@
},
{
"pattern": "^https://www.duckdns.org/$"
},
{
"pattern": "^https://my.vultr.com/settings/#settingsapi$"
}
],
"timeout": "20s",

View File

@@ -93,6 +93,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
- Spdyn
- Strato.de
- Variomedia.de
- Vultr
- Zoneedit
- **Want more?** [Create an issue for it](https://github.com/qdm12/ddns-updater/issues/new/choose)!
- Web user interface (Desktop)
@@ -256,6 +257,7 @@ Check the documentation for your DNS provider:
- [Spdyn](docs/spdyn.md)
- [Strato.de](docs/strato.md)
- [Variomedia.de](docs/variomedia.md)
- [Vultr](docs/vultr.md)
- [Zoneedit](docs/zoneedit.md)
Note that:

30
docs/vultr.md Normal file
View File

@@ -0,0 +1,30 @@
# Vultr
## Configuration
### Example
```json
{
"settings": [
{
"provider": "vultr",
"domain": "potato.example.com",
"apikey": "AAAAAAAAAAAAAAA",
"ttl": 300,
"ip_version": "ipv4"
}
]
}
```
### Compulsory parameters
- `"domain"` is the domain to update. It can be a root domain (i.e. `example.com`) or a subdomain (i.e. `potato.example.com`) or a wildcard (i.e. `*.example.com`). In case of a wildcard, it only works if there is no existing wildcard records of any record type.
- `"apikey"` is your API key which can be obtained from [my.vultr.com/settings/](https://my.vultr.com/settings/#settingsapi).
### 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.
- `"ttl"` is the record TTL which defaults to 900 seconds.

2
go.sum
View File

@@ -43,8 +43,6 @@ github.com/qdm12/gosettings v0.4.4-rc1 h1:VT+6O6ww3Cn5v5/LgY2zlXoiCkZzbaLDWaA8uf
github.com/qdm12/gosettings v0.4.4-rc1/go.mod h1:CPrt2YC4UsURTrslmhxocVhMCW03lIrqdH2hzIf5prg=
github.com/qdm12/gosplash v0.2.0 h1:DOxCEizbW6ZG+FgpH2oK1atT6bM8MHL9GZ2ywSS4zZY=
github.com/qdm12/gosplash v0.2.0/go.mod h1:k+1PzhO0th9cpX4q2Nneu4xTsndXqrM/x7NTIYmJ4jo=
github.com/qdm12/gotree v0.2.0 h1:+58ltxkNLUyHtATFereAcOjBVfY6ETqRex8XK90Fb/c=
github.com/qdm12/gotree v0.2.0/go.mod h1:1SdFaqKZuI46U1apbXIf25pDMNnrPuYLEqMF/qL4lY4=
github.com/qdm12/gotree v0.3.0 h1:Q9f4C571EFK7ZEsPkEL2oGZX7I+ZhVxhh1ZSydW+5yI=
github.com/qdm12/gotree v0.3.0/go.mod h1:iz06uXmRR4Aq9v6tX7mosXStO/yGHxRA1hbyD0UVeYw=
github.com/qdm12/log v0.1.0 h1:jYBd/xscHYpblzZAd2kjZp2YmuYHjAAfbTViJWxoPTw=

View File

@@ -52,6 +52,7 @@ const (
Spdyn models.Provider = "spdyn"
Strato models.Provider = "strato"
Variomedia models.Provider = "variomedia"
Vultr models.Provider = "vultr"
Zoneedit models.Provider = "zoneedit"
)
@@ -102,6 +103,7 @@ func ProviderChoices() []models.Provider {
Spdyn,
Strato,
Variomedia,
Vultr,
Zoneedit,
}
}

View File

@@ -58,6 +58,7 @@ import (
"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/variomedia"
"github.com/qdm12/ddns-updater/internal/provider/providers/vultr"
"github.com/qdm12/ddns-updater/internal/provider/providers/zoneedit"
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
)
@@ -177,6 +178,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
return strato.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Variomedia:
return variomedia.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Vultr:
return vultr.New(data, domain, owner, ipVersion, ipv6Suffix)
case constants.Zoneedit:
return zoneedit.New(data, domain, owner, ipVersion, ipv6Suffix)
default:

View File

@@ -0,0 +1,118 @@
package vultr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/constants"
"github.com/qdm12/ddns-updater/internal/provider/errors"
"github.com/qdm12/ddns-updater/internal/provider/utils"
)
// https://www.vultr.com/api/#tag/dns/operation/create-dns-domain-record
func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) {
recordType := constants.A
if ip.Is6() {
recordType = constants.AAAA
}
u := url.URL{
Scheme: "https",
Host: "api.vultr.com",
Path: fmt.Sprintf("/v2/domains/%s/records", p.domain),
}
requestData := struct {
Type string `json:"type"`
Data string `json:"data"`
Name string `json:"name"`
TTL uint32 `json:"ttl,omitempty"`
}{
Type: recordType,
Data: ip.String(),
Name: p.owner,
TTL: p.ttl,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
err = encoder.Encode(requestData)
if err != nil {
return fmt.Errorf("json encoding request data: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
if err != nil {
return fmt.Errorf("creating http request: %w", err)
}
p.setHeaders(request)
response, err := client.Do(request)
if err != nil {
return err
}
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
return fmt.Errorf("reading response body: %w", err)
}
err = response.Body.Close()
if err != nil {
return fmt.Errorf("closing response body: %w", err)
}
switch response.StatusCode {
case http.StatusCreated:
case http.StatusBadRequest:
return fmt.Errorf("%w: %s", errors.ErrBadRequest, parseJSONErrorOrFullBody(bodyBytes))
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("%w: %s", errors.ErrAuth, parseJSONErrorOrFullBody(bodyBytes))
case http.StatusNotFound:
return fmt.Errorf("%w: %s", errors.ErrDomainNotFound, parseJSONErrorOrFullBody(bodyBytes))
default:
return fmt.Errorf("%w: %s: %s", errors.ErrHTTPStatusNotValid,
response.Status, parseJSONErrorOrFullBody(bodyBytes))
}
errorMessage := parseJSONError(bodyBytes)
if errorMessage != "" {
return fmt.Errorf("%w: %s", errors.ErrUnsuccessful, errorMessage)
}
return nil
}
// parseJSONErrorOrFullBody parses the json error from a response body
// and returns it if it is not empty. If the json decoding fails OR
// the error parsed is empty, the entire body is returned on a single line.
func parseJSONErrorOrFullBody(body []byte) (message string) {
var parsedJSON struct {
Error string `json:"error"`
}
err := json.Unmarshal(body, &parsedJSON)
if err != nil || parsedJSON.Error == "" {
return utils.ToSingleLine(string(body))
}
return parsedJSON.Error
}
// parseJSONError parses the json error from a response body
// and returns it directly. If the json decoding fails, the
// entire body is returned on a single line.
func parseJSONError(body []byte) (message string) {
var parsedJSON struct {
Error string `json:"error"`
}
err := json.Unmarshal(body, &parsedJSON)
if err != nil {
return utils.ToSingleLine(string(body))
}
return parsedJSON.Error
}

View File

@@ -0,0 +1,100 @@
package vultr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
// https://www.vultr.com/api/#tag/dns/operation/list-dns-domain-records
func (p *Provider) getRecord(ctx context.Context, client *http.Client,
recordType string) (recordID string, recordIP netip.Addr, err error,
) {
u := url.URL{
Scheme: "https",
Host: "api.vultr.com",
Path: fmt.Sprintf("/v2/domains/%s/records", p.domain),
}
// max return of get records is 500 records
values := url.Values{}
values.Set("per_page", "500")
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)
}
p.setHeaders(request)
response, err := client.Do(request)
if err != nil {
return "", netip.Addr{}, err
}
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
return "", netip.Addr{}, fmt.Errorf("reading response body: %w", err)
}
err = response.Body.Close()
if err != nil {
return "", netip.Addr{}, fmt.Errorf("closing response body: %w", err)
}
// todo: implement pagination
var parsedJSON struct {
Error string `json:"error"`
Records []struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Data string `json:"data"`
} `json:"records"`
Meta struct {
Total uint32 `json:"total"`
Links struct {
Next string `json:"next"`
Previous string `json:"prev"`
} `json:"links"`
} `json:"meta"`
}
err = json.Unmarshal(bodyBytes, &parsedJSON)
switch {
case err != nil:
return "", netip.Addr{}, fmt.Errorf("json decoding response body: %w", err)
case response.StatusCode == http.StatusBadRequest:
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrBadRequest, parsedJSON.Error)
case response.StatusCode == http.StatusUnauthorized:
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrAuth, parsedJSON.Error)
case response.StatusCode == http.StatusNotFound:
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrDomainNotFound, parsedJSON.Error)
case response.StatusCode != http.StatusOK:
return "", netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, parseJSONErrorOrFullBody(bodyBytes))
case parsedJSON.Error != "":
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnsuccessful, parsedJSON.Error)
}
// Status is OK (200) and error field is empty
for _, record := range parsedJSON.Records {
if record.Name != p.owner || record.Type != recordType {
continue
}
recordIP, err = netip.ParseAddr(record.Data)
if err != nil {
return "", netip.Addr{}, fmt.Errorf("parsing existing IP: %w", err)
}
return record.ID, recordIP, nil
}
return "", netip.Addr{}, fmt.Errorf("%w: in %d records", errors.ErrRecordNotFound, len(parsedJSON.Records))
}

View File

@@ -0,0 +1,143 @@
package vultr
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/netip"
"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
owner string
ipVersion ipversion.IPVersion
ipv6Suffix netip.Prefix
apiKey string
ttl uint32
}
func New(data json.RawMessage, domain, owner string,
ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) (
provider *Provider, err error,
) {
var providerSpecificSettings struct {
APIKey string `json:"apikey"`
TTL uint32 `json:"ttl"`
}
err = json.Unmarshal(data, &providerSpecificSettings)
if err != nil {
return nil, fmt.Errorf("json decoding provider specific settings: %w", err)
}
err = validateSettings(domain, providerSpecificSettings.APIKey)
if err != nil {
return nil, fmt.Errorf("validating provider specific settings: %w", err)
}
return &Provider{
domain: domain,
owner: owner,
ipVersion: ipVersion,
ipv6Suffix: ipv6Suffix,
apiKey: providerSpecificSettings.APIKey,
ttl: providerSpecificSettings.TTL,
}, nil
}
func validateSettings(domain, apiKey string) (err error) {
err = utils.CheckDomain(domain)
if err != nil {
return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err)
}
if apiKey == "" {
return fmt.Errorf("%w", errors.ErrAPIKeyNotSet)
}
return nil
}
func (p *Provider) String() string {
return utils.ToString(p.domain, p.owner, constants.Vultr, 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: fmt.Sprintf("<a href=\"https://my.vultr.com/dns/edit/?domain=%s#dns-records\">Vultr</a>", p.domain),
IPVersion: p.ipVersion.String(),
}
}
func (p *Provider) setHeaders(request *http.Request) {
headers.SetUserAgent(request)
headers.SetContentType(request, "application/json")
headers.SetAccept(request, "application/json")
headers.SetAuthBearer(request, p.apiKey)
}
// Update does the following:
// 1. if there's no record, create it.
// 2. if it exists and ip is different, update it.
// 3. if it exists and ip is the same, do nothing.
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
}
recordID, existingIP, err := p.getRecord(ctx, client, recordType)
if err != nil {
if !stderrors.Is(err, errors.ErrRecordNotFound) {
return netip.Addr{}, fmt.Errorf("error getting records for %s: %w", p.domain, err)
}
err = p.createRecord(ctx, client, ip)
if err != nil {
return netip.Addr{}, fmt.Errorf("error creating record: %w", err)
}
return ip, nil
}
if existingIP == ip {
return ip, nil
}
err = p.updateRecord(ctx, client, recordID, ip)
if err != nil {
return netip.Addr{}, fmt.Errorf("error updating record %s: %w", p.BuildDomainName(), err)
}
return ip, nil
}

View File

@@ -0,0 +1,77 @@
package vultr
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"github.com/qdm12/ddns-updater/internal/provider/errors"
)
// https://www.vultr.com/api/#tag/dns/operation/update-dns-domain-record
func (p *Provider) updateRecord(ctx context.Context, client *http.Client,
recordID string, ip netip.Addr,
) (err error) {
u := url.URL{
Scheme: "https",
Host: "api.vultr.com",
Path: fmt.Sprintf("/v2/domains/%s/records/%s", p.domain, recordID),
}
requestData := struct {
Data string `json:"data"`
Name string `json:"name"`
TTL uint32 `json:"ttl,omitempty"`
}{
Data: ip.String(),
Name: p.owner,
TTL: p.ttl,
}
buffer := bytes.NewBuffer(nil)
encoder := json.NewEncoder(buffer)
err = encoder.Encode(requestData)
if err != nil {
return fmt.Errorf("json encoding request data: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPatch, u.String(), buffer)
if err != nil {
return fmt.Errorf("creating http request: %w", err)
}
p.setHeaders(request)
response, err := client.Do(request)
if err != nil {
return err
}
bodyBytes, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
return fmt.Errorf("reading response body: %w", err)
}
err = response.Body.Close()
if err != nil {
return fmt.Errorf("closing response body: %w", err)
}
if response.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode,
parseJSONErrorOrFullBody(bodyBytes))
}
errorMessage := parseJSONError(bodyBytes)
if errorMessage != "" {
return fmt.Errorf("%w: %s", errors.ErrUnsuccessful, errorMessage)
}
return nil
}