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/scaleway.com #899

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions docs/scaleway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 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": "<SECRET_KEY>",
"ip_version": "ipv4",
"ipv6_suffix": "",
"field_name": "www",
"ttl": 450

francesco086 marked this conversation as resolved.
Show resolved Hide resolved
}
]
}
```

### 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 fields corresponds to the `dns-zone` in the scaleway API documentation.
francesco086 marked this conversation as resolved.
Show resolved Hide resolved
- `"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 `""`.
- `"field_type"` is the type of DNS record to update. It can be `"A"` or `"AAAA"`. It defaults to `"A"`.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not need this, it should be deducted from the public IP address obtained and "ip_version"

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhm... again can you help me here? Do you have already a function that is able to distinguish between the two?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just found it in one of your comment below. Will change this in a moment!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

field_type is still in the documentation 😉 Please remove 🙏

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

argh sorry... missed the second entry...

Now it is removed for good!

- `"field_name"` is the name of the DNS record to update. For example, it could be `"www"`, `"@"` or `"*"` for the wildcard. It defaults to to `""` (equivalent to `"@"`).
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not need this, it should be deducted from the "domain" field

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please help me out here. How should I extract this? The only way I can imagine is to count how many . are in the domain string, and if there are two take the first part of the domain string before the first . as name (according to the scaleway API). Is that correct?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upper layers of ddns-updater takes care of extracting it:

func extractFromDomainField(domainField string) (domainRegistered string,

It's a bit more complicated than the number of dots, it uses the "effective TLD + 1" mechanism to do this (i.e. some effective TLDs are com., others are duckdns.org.)

So just use the domain and owner strings from calling layers 😉 - it's already parsed and ready to use for you.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, it took me a while to understand what you mean and especialyl how this maps to the scaleway api, but I think I sorted out! I removed the field_name both in the code and documentation, and tested that everything works correctly.

- `"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/).
2 changes: 2 additions & 0 deletions internal/provider/constants/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -103,6 +104,7 @@ func ProviderChoices() []models.Provider {
OVH,
Porkbun,
Route53,
Scaleway,
SelfhostDe,
Spdyn,
Strato,
Expand Down
4 changes: 4 additions & 0 deletions internal/provider/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
188 changes: 188 additions & 0 deletions internal/provider/providers/scaleway/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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
field_name 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"`
FieldName string `json:"field_name"`
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,
field_name: providerSpecificSettings.FieldName,
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)
}

switch {
case secretKey == "":
return fmt.Errorf("%w", errors.ErrSecretKeyNotSet)
}
francesco086 marked this conversation as resolved.
Show resolved Hide resolved

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("<a href=\"http://%s\">%s</a>", p.BuildDomainName(), p.BuildDomainName()),
Owner: p.Owner(),
Provider: "<a href=\"https://www.scaleway.com/\">Scaleway</a>",
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
francesco086 marked this conversation as resolved.
Show resolved Hide resolved
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),
}

field_type := "A"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit rename to fieldType (Go convention is no snake_case 🤷 Only camelCase)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true! Should have noticed... changed accordingly!

if ip.Is6() {
field_type = "AAAA"
}
type recordJSON struct {
Data string `json:"data"`
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.field_name
change.Set.IDFields.Type = field_type
change.Set.Records = []recordJSON{{
Data: ip.String(),
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 {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s))
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any useful information in the body? if it's just for the error case, let's use instead:

Suggested change
s, err := utils.ReadAndCleanBody(response.Body)
if err != nil {
return netip.Addr{}, fmt.Errorf("reading response: %w", err)
}
if response.StatusCode != http.StatusOK {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s))
}
if response.StatusCode != http.StatusOK {
return netip.Addr{}, fmt.Errorf("%w: %d: %s",
errors.ErrHTTPStatusNotValid, response.StatusCode, utils.BodyToSingleLine(response.Body))
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the body looks something like

{
    "records": [
        {
            "id": "aa051759-72e6-4034-8af5-dafba3eebb79",
            "data": "127.1.2.3",
            "name": "",
            "priority": 0,
            "ttl": 300,
            "type": "A",
            "comment": null
        }
    ]
}

I think there is nothing useful, let me know if you disagree. Otherwise I will apply your change.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if you send a request with the wrong secret key or an owner that doesn't exist, what's the json body you get in the response? It might be a standardized error format which we could parse and use to build a better error to log to the user 😉 Not compulsory, but nice to have if you have the time

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the secret key is wrong the response is:

{
    "message": "authentication is denied",
    "method": "api_key",
    "reason": "invalid_argument",
    "type": "denied_authentication"
}

If the owner doesn't exist then the record with the right domain is added. The response is indistinguishable from one where the owner already exists.

So, I changed the code into:

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

Is that ok?


return ip, nil
}
Loading