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 +}