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

feat(provider): add support for Vultr #829

Merged
merged 40 commits into from
Oct 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dff1ba7
chore(deps): tidy go modules
qdm12 Oct 20, 2024
694319a
Add dev files to gitignore
amroessam Oct 2, 2024
c7117ee
Add vultr provider
amroessam Oct 2, 2024
faac595
Add vultr docs
amroessam Oct 2, 2024
ad3116a
Add vultr to provider choice
amroessam Oct 2, 2024
bf3680c
Remove debug message
amroessam Oct 3, 2024
e01f8bc
Update docs/vultr.md
amroessam Oct 8, 2024
425200b
Update docs/vultr.md
amroessam Oct 8, 2024
b31be3c
Update internal/provider/providers/vultr/provider.go
amroessam Oct 8, 2024
e7f0710
Update internal/provider/providers/vultr/provider.go
amroessam Oct 8, 2024
80942df
Update internal/provider/providers/vultr/provider.go
amroessam Oct 8, 2024
2694d6f
Update error for token not set
amroessam Oct 8, 2024
0cc3a71
Remove checking for ttl
amroessam Oct 8, 2024
c43db65
Revert gitignore
amroessam Oct 8, 2024
947193f
Added info about wildcard
amroessam Oct 8, 2024
a68afad
Switch to apiKey instead of Token
amroessam Oct 8, 2024
ce6ef3f
Update internal/provider/providers/vultr/provider.go
amroessam Oct 9, 2024
f2dd4bc
Update docs/vultr.md
amroessam Oct 9, 2024
77ada26
Update docs/vultr.md
amroessam Oct 9, 2024
af8aedf
Add update record and define record type
amroessam Oct 17, 2024
8d6d608
Add switch case for create record response
amroessam Oct 17, 2024
0364369
Add pagination comment
amroessam Oct 17, 2024
85eb99b
vultr.md: small rewording here and there
qdm12 Oct 20, 2024
4339602
formatting: use gofumpt
qdm12 Oct 20, 2024
e2626b7
chore: add API documentation url for each function
qdm12 Oct 20, 2024
c6222bd
chore: use `Data` struct field name for `"data"` json fields
qdm12 Oct 20, 2024
52e59a7
fix: getrecord to match a single record type
qdm12 Oct 20, 2024
1c34925
fix: return an error if record is not found
qdm12 Oct 20, 2024
74c2127
chore: updateRecord does not return a newIP
qdm12 Oct 20, 2024
9219ef0
chore: get rid of record struct in favor of narrower fields
qdm12 Oct 20, 2024
9d46d56
chore: remove the use of undocumented field `Status`
qdm12 Oct 20, 2024
eb5001f
fix: associate error fields with `error` json key
qdm12 Oct 20, 2024
f263792
feat: parseJSONError returns the whole body if decoding fails or pars…
qdm12 Oct 20, 2024
33fd1f7
fix: check error field even on successful status codes
qdm12 Oct 20, 2024
4c3e064
chore: fix last lint error
qdm12 Oct 20, 2024
e46309e
chore: createRecord does not check response ip against sent ip anymore
qdm12 Oct 20, 2024
459e397
chore: minor changes and formatting
qdm12 Oct 20, 2024
a7e9b0a
fix: getRecord handles response bodies after decoding it once
qdm12 Oct 20, 2024
4e8b71b
fix: getRecord handles all possible status codes
qdm12 Oct 20, 2024
33b90ba
last ci fixes
qdm12 Oct 20, 2024
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: 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