Skip to content

Commit 949dcd9

Browse files
authored
feat(provider): vultr.com (#829)
1 parent bad113b commit 949dcd9

File tree

10 files changed

+478
-2
lines changed

10 files changed

+478
-2
lines changed

.github/workflows/configs/mlc-config.json

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
},
3030
{
3131
"pattern": "^https://www.duckdns.org/$"
32+
},
33+
{
34+
"pattern": "^https://my.vultr.com/settings/#settingsapi$"
3235
}
3336
],
3437
"timeout": "20s",

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog
9393
- Spdyn
9494
- Strato.de
9595
- Variomedia.de
96+
- Vultr
9697
- Zoneedit
9798
- **Want more?** [Create an issue for it](https://github.com/qdm12/ddns-updater/issues/new/choose)!
9899
- Web user interface (Desktop)
@@ -256,6 +257,7 @@ Check the documentation for your DNS provider:
256257
- [Spdyn](docs/spdyn.md)
257258
- [Strato.de](docs/strato.md)
258259
- [Variomedia.de](docs/variomedia.md)
260+
- [Vultr](docs/vultr.md)
259261
- [Zoneedit](docs/zoneedit.md)
260262

261263
Note that:

docs/vultr.md

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Vultr
2+
3+
## Configuration
4+
5+
### Example
6+
7+
```json
8+
{
9+
"settings": [
10+
{
11+
"provider": "vultr",
12+
"domain": "potato.example.com",
13+
"apikey": "AAAAAAAAAAAAAAA",
14+
"ttl": 300,
15+
"ip_version": "ipv4"
16+
}
17+
]
18+
}
19+
```
20+
21+
### Compulsory parameters
22+
23+
- `"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.
24+
- `"apikey"` is your API key which can be obtained from [my.vultr.com/settings/](https://my.vultr.com/settings/#settingsapi).
25+
26+
### Optional parameters
27+
28+
- `"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`.
29+
- `"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.
30+
- `"ttl"` is the record TTL which defaults to 900 seconds.

go.sum

-2
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ github.com/qdm12/gosettings v0.4.4-rc1 h1:VT+6O6ww3Cn5v5/LgY2zlXoiCkZzbaLDWaA8uf
4343
github.com/qdm12/gosettings v0.4.4-rc1/go.mod h1:CPrt2YC4UsURTrslmhxocVhMCW03lIrqdH2hzIf5prg=
4444
github.com/qdm12/gosplash v0.2.0 h1:DOxCEizbW6ZG+FgpH2oK1atT6bM8MHL9GZ2ywSS4zZY=
4545
github.com/qdm12/gosplash v0.2.0/go.mod h1:k+1PzhO0th9cpX4q2Nneu4xTsndXqrM/x7NTIYmJ4jo=
46-
github.com/qdm12/gotree v0.2.0 h1:+58ltxkNLUyHtATFereAcOjBVfY6ETqRex8XK90Fb/c=
47-
github.com/qdm12/gotree v0.2.0/go.mod h1:1SdFaqKZuI46U1apbXIf25pDMNnrPuYLEqMF/qL4lY4=
4846
github.com/qdm12/gotree v0.3.0 h1:Q9f4C571EFK7ZEsPkEL2oGZX7I+ZhVxhh1ZSydW+5yI=
4947
github.com/qdm12/gotree v0.3.0/go.mod h1:iz06uXmRR4Aq9v6tX7mosXStO/yGHxRA1hbyD0UVeYw=
5048
github.com/qdm12/log v0.1.0 h1:jYBd/xscHYpblzZAd2kjZp2YmuYHjAAfbTViJWxoPTw=

internal/provider/constants/providers.go

+2
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const (
5252
Spdyn models.Provider = "spdyn"
5353
Strato models.Provider = "strato"
5454
Variomedia models.Provider = "variomedia"
55+
Vultr models.Provider = "vultr"
5556
Zoneedit models.Provider = "zoneedit"
5657
)
5758

@@ -102,6 +103,7 @@ func ProviderChoices() []models.Provider {
102103
Spdyn,
103104
Strato,
104105
Variomedia,
106+
Vultr,
105107
Zoneedit,
106108
}
107109
}

internal/provider/provider.go

+3
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import (
5858
"github.com/qdm12/ddns-updater/internal/provider/providers/spdyn"
5959
"github.com/qdm12/ddns-updater/internal/provider/providers/strato"
6060
"github.com/qdm12/ddns-updater/internal/provider/providers/variomedia"
61+
"github.com/qdm12/ddns-updater/internal/provider/providers/vultr"
6162
"github.com/qdm12/ddns-updater/internal/provider/providers/zoneedit"
6263
"github.com/qdm12/ddns-updater/pkg/publicip/ipversion"
6364
)
@@ -177,6 +178,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin
177178
return strato.New(data, domain, owner, ipVersion, ipv6Suffix)
178179
case constants.Variomedia:
179180
return variomedia.New(data, domain, owner, ipVersion, ipv6Suffix)
181+
case constants.Vultr:
182+
return vultr.New(data, domain, owner, ipVersion, ipv6Suffix)
180183
case constants.Zoneedit:
181184
return zoneedit.New(data, domain, owner, ipVersion, ipv6Suffix)
182185
default:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package vultr
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/netip"
11+
"net/url"
12+
13+
"github.com/qdm12/ddns-updater/internal/provider/constants"
14+
"github.com/qdm12/ddns-updater/internal/provider/errors"
15+
"github.com/qdm12/ddns-updater/internal/provider/utils"
16+
)
17+
18+
// https://www.vultr.com/api/#tag/dns/operation/create-dns-domain-record
19+
func (p *Provider) createRecord(ctx context.Context, client *http.Client, ip netip.Addr) (err error) {
20+
recordType := constants.A
21+
if ip.Is6() {
22+
recordType = constants.AAAA
23+
}
24+
25+
u := url.URL{
26+
Scheme: "https",
27+
Host: "api.vultr.com",
28+
Path: fmt.Sprintf("/v2/domains/%s/records", p.domain),
29+
}
30+
31+
requestData := struct {
32+
Type string `json:"type"`
33+
Data string `json:"data"`
34+
Name string `json:"name"`
35+
TTL uint32 `json:"ttl,omitempty"`
36+
}{
37+
Type: recordType,
38+
Data: ip.String(),
39+
Name: p.owner,
40+
TTL: p.ttl,
41+
}
42+
43+
buffer := bytes.NewBuffer(nil)
44+
encoder := json.NewEncoder(buffer)
45+
err = encoder.Encode(requestData)
46+
if err != nil {
47+
return fmt.Errorf("json encoding request data: %w", err)
48+
}
49+
50+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), buffer)
51+
if err != nil {
52+
return fmt.Errorf("creating http request: %w", err)
53+
}
54+
p.setHeaders(request)
55+
56+
response, err := client.Do(request)
57+
if err != nil {
58+
return err
59+
}
60+
61+
bodyBytes, err := io.ReadAll(response.Body)
62+
if err != nil {
63+
_ = response.Body.Close()
64+
return fmt.Errorf("reading response body: %w", err)
65+
}
66+
67+
err = response.Body.Close()
68+
if err != nil {
69+
return fmt.Errorf("closing response body: %w", err)
70+
}
71+
72+
switch response.StatusCode {
73+
case http.StatusCreated:
74+
case http.StatusBadRequest:
75+
return fmt.Errorf("%w: %s", errors.ErrBadRequest, parseJSONErrorOrFullBody(bodyBytes))
76+
case http.StatusUnauthorized, http.StatusForbidden:
77+
return fmt.Errorf("%w: %s", errors.ErrAuth, parseJSONErrorOrFullBody(bodyBytes))
78+
case http.StatusNotFound:
79+
return fmt.Errorf("%w: %s", errors.ErrDomainNotFound, parseJSONErrorOrFullBody(bodyBytes))
80+
default:
81+
return fmt.Errorf("%w: %s: %s", errors.ErrHTTPStatusNotValid,
82+
response.Status, parseJSONErrorOrFullBody(bodyBytes))
83+
}
84+
85+
errorMessage := parseJSONError(bodyBytes)
86+
if errorMessage != "" {
87+
return fmt.Errorf("%w: %s", errors.ErrUnsuccessful, errorMessage)
88+
}
89+
return nil
90+
}
91+
92+
// parseJSONErrorOrFullBody parses the json error from a response body
93+
// and returns it if it is not empty. If the json decoding fails OR
94+
// the error parsed is empty, the entire body is returned on a single line.
95+
func parseJSONErrorOrFullBody(body []byte) (message string) {
96+
var parsedJSON struct {
97+
Error string `json:"error"`
98+
}
99+
err := json.Unmarshal(body, &parsedJSON)
100+
if err != nil || parsedJSON.Error == "" {
101+
return utils.ToSingleLine(string(body))
102+
}
103+
return parsedJSON.Error
104+
}
105+
106+
// parseJSONError parses the json error from a response body
107+
// and returns it directly. If the json decoding fails, the
108+
// entire body is returned on a single line.
109+
func parseJSONError(body []byte) (message string) {
110+
var parsedJSON struct {
111+
Error string `json:"error"`
112+
}
113+
err := json.Unmarshal(body, &parsedJSON)
114+
if err != nil {
115+
return utils.ToSingleLine(string(body))
116+
}
117+
return parsedJSON.Error
118+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package vultr
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/netip"
10+
"net/url"
11+
12+
"github.com/qdm12/ddns-updater/internal/provider/errors"
13+
)
14+
15+
// https://www.vultr.com/api/#tag/dns/operation/list-dns-domain-records
16+
func (p *Provider) getRecord(ctx context.Context, client *http.Client,
17+
recordType string) (recordID string, recordIP netip.Addr, err error,
18+
) {
19+
u := url.URL{
20+
Scheme: "https",
21+
Host: "api.vultr.com",
22+
Path: fmt.Sprintf("/v2/domains/%s/records", p.domain),
23+
}
24+
25+
// max return of get records is 500 records
26+
values := url.Values{}
27+
values.Set("per_page", "500")
28+
u.RawQuery = values.Encode()
29+
30+
request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
31+
if err != nil {
32+
return "", netip.Addr{}, fmt.Errorf("creating http request: %w", err)
33+
}
34+
p.setHeaders(request)
35+
36+
response, err := client.Do(request)
37+
if err != nil {
38+
return "", netip.Addr{}, err
39+
}
40+
41+
bodyBytes, err := io.ReadAll(response.Body)
42+
if err != nil {
43+
_ = response.Body.Close()
44+
return "", netip.Addr{}, fmt.Errorf("reading response body: %w", err)
45+
}
46+
47+
err = response.Body.Close()
48+
if err != nil {
49+
return "", netip.Addr{}, fmt.Errorf("closing response body: %w", err)
50+
}
51+
52+
// todo: implement pagination
53+
var parsedJSON struct {
54+
Error string `json:"error"`
55+
Records []struct {
56+
ID string `json:"id"`
57+
Name string `json:"name"`
58+
Type string `json:"type"`
59+
Data string `json:"data"`
60+
} `json:"records"`
61+
Meta struct {
62+
Total uint32 `json:"total"`
63+
Links struct {
64+
Next string `json:"next"`
65+
Previous string `json:"prev"`
66+
} `json:"links"`
67+
} `json:"meta"`
68+
}
69+
err = json.Unmarshal(bodyBytes, &parsedJSON)
70+
switch {
71+
case err != nil:
72+
return "", netip.Addr{}, fmt.Errorf("json decoding response body: %w", err)
73+
case response.StatusCode == http.StatusBadRequest:
74+
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrBadRequest, parsedJSON.Error)
75+
case response.StatusCode == http.StatusUnauthorized:
76+
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrAuth, parsedJSON.Error)
77+
case response.StatusCode == http.StatusNotFound:
78+
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrDomainNotFound, parsedJSON.Error)
79+
case response.StatusCode != http.StatusOK:
80+
return "", netip.Addr{}, fmt.Errorf("%w: %d: %s",
81+
errors.ErrHTTPStatusNotValid, response.StatusCode, parseJSONErrorOrFullBody(bodyBytes))
82+
case parsedJSON.Error != "":
83+
return "", netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrUnsuccessful, parsedJSON.Error)
84+
}
85+
86+
// Status is OK (200) and error field is empty
87+
88+
for _, record := range parsedJSON.Records {
89+
if record.Name != p.owner || record.Type != recordType {
90+
continue
91+
}
92+
recordIP, err = netip.ParseAddr(record.Data)
93+
if err != nil {
94+
return "", netip.Addr{}, fmt.Errorf("parsing existing IP: %w", err)
95+
}
96+
return record.ID, recordIP, nil
97+
}
98+
99+
return "", netip.Addr{}, fmt.Errorf("%w: in %d records", errors.ErrRecordNotFound, len(parsedJSON.Records))
100+
}

0 commit comments

Comments
 (0)