diff --git a/README.md b/README.md index ff7a4fa4b..6f2b3ff57 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog - OVH - Porkbun - Route53 + - Scaleway - Selfhost.de - Servercow.de - Spdyn @@ -256,6 +257,7 @@ Check the documentation for your DNS provider: - [OpenDNS](docs/opendns.md) - [OVH](docs/ovh.md) - [Porkbun](docs/porkbun.md) +- [Scaleway](docs/scaleway.md) - [Selfhost.de](docs/selfhost.de.md) - [Servercow.de](docs/servercow.md) - [Spdyn](docs/spdyn.md) diff --git a/docs/scaleway.md b/docs/scaleway.md new file mode 100644 index 000000000..319f9ffea --- /dev/null +++ b/docs/scaleway.md @@ -0,0 +1,37 @@ +# Example.com + +## Configuration + +If something is unclear in the documentation below, please refer to the [scaleway API documentation](https://www.scaleway.com/en/developers/api/domains-and-dns/#path-records-update-records-within-a-dns-zone). + +### Example + +```json +{ + "settings": [ + { + "provider": "scaleway", + "domain": "munchkin-academia.eu", + "secret_key": "", + "ip_version": "ipv4", + "ipv6_suffix": "", + "ttl": 450 + } + ] +} +``` + +### Compulsory parameters + +- `"domain"` is the domain to update. It can be `example.com` (root domain), `sub.example.com` (subdomain of `example.com`) or `*.example.com` for the wildcard. This field is used to extract the `dns-zone`, `id_fields.name`, and `records.name`, and used to make the scaleway API call. For example. if your domain is `example.com`, and you set as `"domain`" `sub.example.com`, then the API call will be made with `dns-zone = example.com`, `id_fields.name = sub`, and `records.name = sub`. +- `"secret_key"` + +### Optional parameters + +- `"ip_version"` can be `"ipv4"` or `"ipv6"`. It defaults to `"ipv4"`. +- `"ipv6_suffix"` is the suffix to append to the IPv6 address. It defaults to `""`. +- `"ttl"` is the TTL of the DNS record to update. It defaults to `3600`. + +## Domain setup + +If you need more information about how to configure your domain, you can check the [scaleway official documentation](https://www.scaleway.com/en/docs/network/domains-and-dns/). diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 71b48c9d1..ac8f5e7be 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -49,6 +49,7 @@ const ( OVH models.Provider = "ovh" Porkbun models.Provider = "porkbun" Route53 models.Provider = "route53" + Scaleway models.Provider = "scaleway" SelfhostDe models.Provider = "selfhost.de" Servercow models.Provider = "servercow" Spdyn models.Provider = "spdyn" @@ -103,6 +104,7 @@ func ProviderChoices() []models.Provider { OVH, Porkbun, Route53, + Scaleway, SelfhostDe, Spdyn, Strato, diff --git a/internal/provider/headers/headers.go b/internal/provider/headers/headers.go index 18dabf4b4..84317033a 100644 --- a/internal/provider/headers/headers.go +++ b/internal/provider/headers/headers.go @@ -30,6 +30,10 @@ func SetXFilter(request *http.Request, value string) { request.Header.Set("X-Filter", value) } +func SetXAuthToken(request *http.Request, value string) { + request.Header.Set("X-Auth-Token", value) +} + func SetXAuthUsername(request *http.Request, value string) { request.Header.Set("X-Auth-Username", value) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 06e002845..7a03a331c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -55,6 +55,7 @@ import ( "github.com/qdm12/ddns-updater/internal/provider/providers/ovh" "github.com/qdm12/ddns-updater/internal/provider/providers/porkbun" "github.com/qdm12/ddns-updater/internal/provider/providers/route53" + "github.com/qdm12/ddns-updater/internal/provider/providers/scaleway" "github.com/qdm12/ddns-updater/internal/provider/providers/selfhostde" "github.com/qdm12/ddns-updater/internal/provider/providers/servercow" "github.com/qdm12/ddns-updater/internal/provider/providers/spdyn" @@ -174,6 +175,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin return porkbun.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Route53: return route53.New(data, domain, owner, ipVersion, ipv6Suffix) + case constants.Scaleway: + return scaleway.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.SelfhostDe: return selfhostde.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Servercow: diff --git a/internal/provider/providers/scaleway/provider.go b/internal/provider/providers/scaleway/provider.go new file mode 100644 index 000000000..9dd4ab18f --- /dev/null +++ b/internal/provider/providers/scaleway/provider.go @@ -0,0 +1,195 @@ +package scaleway + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "net/url" + + "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 + secretKey string + ttl uint16 +} + +func New(data json.RawMessage, domain, owner string, + ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( + provider *Provider, err error, +) { + var providerSpecificSettings struct { + SecretKey string `json:"secret_key"` + TTL uint16 `json:"ttl"` + } + err = json.Unmarshal(data, &providerSpecificSettings) + if err != nil { + return nil, fmt.Errorf("json decoding provider specific settings: %w", err) + } + + if providerSpecificSettings.TTL == 0 { + providerSpecificSettings.TTL = 3600 + } + + err = validateSettings(domain, + providerSpecificSettings.SecretKey) + if err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) + } + + return &Provider{ + domain: domain, + owner: owner, + ipVersion: ipVersion, + ipv6Suffix: ipv6Suffix, + secretKey: providerSpecificSettings.SecretKey, + ttl: providerSpecificSettings.TTL, + }, nil +} + +func validateSettings(domain, secretKey string) (err error) { + err = utils.CheckDomain(domain) + if err != nil { + return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) + } + + if secretKey == "" { + return fmt.Errorf("%w", errors.ErrSecretKeyNotSet) + } + + return nil +} + +func (p *Provider) String() string { + return utils.ToString(p.domain, p.owner, constants.Dyn, 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: "Scaleway", + IPVersion: p.ipVersion.String(), + } +} + +// Update updates the DNS record for the domain using Scaleway's API. +// See https://www.scaleway.com/en/developers/api/domains-and-dns/#path-records-update-records-within-a-dns-zone +func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { + u := url.URL{ + Scheme: "https", + Host: "api.scaleway.com", + Path: fmt.Sprintf("/domain/v2beta1/dns-zones/%s/records", p.domain), + } + + fieldType := "A" + if ip.Is6() { + fieldType = "AAAA" + } + type recordJSON struct { + Data string `json:"data"` + Name string `json:"name"` + TTL uint16 `json:"ttl"` + } + type changeJSON struct { + Set struct { + IDFields struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"id_fields"` + Records []recordJSON `json:"records"` + } `json:"set"` + } + var change changeJSON + change.Set.IDFields.Name = p.owner + change.Set.IDFields.Type = fieldType + change.Set.Records = []recordJSON{{ + Data: ip.String(), + Name: p.owner, + TTL: p.ttl, + }} + requestBody := struct { + Changes []changeJSON `json:"changes"` + }{ + Changes: []changeJSON{change}, + } + + buffer := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buffer) + err = encoder.Encode(requestBody) + if err != nil { + return netip.Addr{}, fmt.Errorf("json encoding request body: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPatch, u.String(), buffer) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating http request: %w", err) + } + headers.SetContentType(request, "application/json") + headers.SetAccept(request, "application/json") + headers.SetXAuthToken(request, p.secretKey) + headers.SetUserAgent(request) + + response, err := client.Do(request) + if err != nil { + return netip.Addr{}, fmt.Errorf("doing http request: %w", err) + } + defer response.Body.Close() + + s, err := utils.ReadAndCleanBody(response.Body) + if err != nil { + return netip.Addr{}, fmt.Errorf("reading response: %w", err) + } + + if response.StatusCode != http.StatusOK { + var errorResponse struct { + Message string `json:"message"` + Type string `json:"type"` + } + if jsonErr := json.Unmarshal([]byte(s), &errorResponse); jsonErr == nil { + if errorResponse.Type == "denied_authentication" { + return netip.Addr{}, fmt.Errorf("%w: %s", errors.ErrAuth, errorResponse.Message) + } + } + return netip.Addr{}, fmt.Errorf("%w: %d: %s", + errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s)) + } + + return ip, nil +}