diff --git a/.github/workflows/configs/mlc-config.json b/.github/workflows/configs/mlc-config.json
index 37f50fcb8..f053b2bfa 100644
--- a/.github/workflows/configs/mlc-config.json
+++ b/.github/workflows/configs/mlc-config.json
@@ -29,6 +29,9 @@
},
{
"pattern": "^https://www.duckdns.org/$"
+ },
+ {
+ "pattern": "^https://my.vultr.com/settings/#settingsapi$"
}
],
"timeout": "20s",
diff --git a/README.md b/README.md
index 70d586931..645be7be0 100644
--- a/README.md
+++ b/README.md
@@ -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:
diff --git a/docs/vultr.md b/docs/vultr.md
new file mode 100644
index 000000000..9ff5f32ca
--- /dev/null
+++ b/docs/vultr.md
@@ -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.
diff --git a/go.sum b/go.sum
index 67804f723..e5437a26d 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go
index bd27312c0..30ce24a50 100644
--- a/internal/provider/constants/providers.go
+++ b/internal/provider/constants/providers.go
@@ -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,
}
}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 2d710177d..9807679b8 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -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:
diff --git a/internal/provider/providers/vultr/createrecord.go b/internal/provider/providers/vultr/createrecord.go
new file mode 100644
index 000000000..bf55c6bd5
--- /dev/null
+++ b/internal/provider/providers/vultr/createrecord.go
@@ -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
+}
diff --git a/internal/provider/providers/vultr/getrecord.go b/internal/provider/providers/vultr/getrecord.go
new file mode 100644
index 000000000..ad8dc42ae
--- /dev/null
+++ b/internal/provider/providers/vultr/getrecord.go
@@ -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))
+}
diff --git a/internal/provider/providers/vultr/provider.go b/internal/provider/providers/vultr/provider.go
new file mode 100644
index 000000000..e5469f955
--- /dev/null
+++ b/internal/provider/providers/vultr/provider.go
@@ -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("%s", p.BuildDomainName(), p.BuildDomainName()),
+ Owner: p.Owner(),
+ Provider: fmt.Sprintf("Vultr", 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
+}
diff --git a/internal/provider/providers/vultr/updaterecord.go b/internal/provider/providers/vultr/updaterecord.go
new file mode 100644
index 000000000..e03720d1c
--- /dev/null
+++ b/internal/provider/providers/vultr/updaterecord.go
@@ -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
+}