Skip to content

Commit

Permalink
feat(provider): vultr.com (#829)
Browse files Browse the repository at this point in the history
  • Loading branch information
amroessam authored Oct 20, 2024
1 parent bad113b commit 949dcd9
Show file tree
Hide file tree
Showing 10 changed files with 478 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/configs/mlc-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
},
{
"pattern": "^https://www.duckdns.org/$"
},
{
"pattern": "^https://my.vultr.com/settings/#settingsapi$"
}
],
"timeout": "20s",
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions docs/vultr.md
Original file line number Diff line number Diff line change
@@ -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 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -102,6 +103,7 @@ func ProviderChoices() []models.Provider {
Spdyn,
Strato,
Variomedia,
Vultr,
Zoneedit,
}
}
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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:
Expand Down
118 changes: 118 additions & 0 deletions internal/provider/providers/vultr/createrecord.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions internal/provider/providers/vultr/getrecord.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading

0 comments on commit 949dcd9

Please sign in to comment.