diff --git a/README.md b/README.md index ff7a4fa4b..6ef21e96a 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ This readme and the [docs/](docs/) directory are **versioned** to match the prog - Updates periodically A records for different DNS providers: - Aliyun - AllInkl + - ArvanCloud - Changeip - Cloudflare - DD24 @@ -216,6 +217,7 @@ Check the documentation for your DNS provider: - [Aliyun](docs/aliyun.md) - [Allinkl](docs/allinkl.md) +- [ArvanCloud](docs/arvancloud.md) - [ChangeIP](docs/changeip.md) - [Cloudflare](docs/cloudflare.md) - [Custom](docs/custom.md) diff --git a/docs/arvancloud.md b/docs/arvancloud.md new file mode 100644 index 000000000..96b7acf8f --- /dev/null +++ b/docs/arvancloud.md @@ -0,0 +1,28 @@ +# Arvancloud.ir + +## Configuration + +### Example + +```json +{ + "settings": [ + { + "provider": "arvancloud", + "domain": "sub.domain.com", + "token": "apikey ..." + } + ] +} +``` + +### Compulsory parameters + +- `"domain"` is the domain to update. It cannot be `example.com` (root domain) and should be like `sub.example.com` (subdomain of `example.com`). +- `"token"` like "apikey ...". + +## Domain setup + +- Create a policy for managing DNS in [Policies](https://panel.arvancloud.ir/profile/iam/policies) +- Create a token in [ArvanCloud profile](https://panel.arvancloud.ir/profile/iam/machine-users) +- Give access of the policy to the token diff --git a/internal/provider/constants/providers.go b/internal/provider/constants/providers.go index 71b48c9d1..77a645748 100644 --- a/internal/provider/constants/providers.go +++ b/internal/provider/constants/providers.go @@ -6,6 +6,7 @@ import "github.com/qdm12/ddns-updater/internal/models" const ( Aliyun models.Provider = "aliyun" AllInkl models.Provider = "allinkl" + Arvancloud models.Provider = "arvancloud" Changeip models.Provider = "changeip" Cloudflare models.Provider = "cloudflare" Custom models.Provider = "custom" diff --git a/internal/provider/headers/headers.go b/internal/provider/headers/headers.go index 18dabf4b4..5c849f3a3 100644 --- a/internal/provider/headers/headers.go +++ b/internal/provider/headers/headers.go @@ -22,6 +22,10 @@ func SetAuthSSOKey(request *http.Request, key, secret string) { request.Header.Set("Authorization", "sso-key "+key+":"+secret) } +func SetAuthorization(request *http.Request, token string) { + request.Header.Set("Authorization", token) +} + func SetOauth(request *http.Request, value string) { request.Header.Set("Oauth", value) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 06e002845..e0de0bfea 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,7 @@ import ( "github.com/qdm12/ddns-updater/internal/provider/constants" "github.com/qdm12/ddns-updater/internal/provider/providers/aliyun" "github.com/qdm12/ddns-updater/internal/provider/providers/allinkl" + "github.com/qdm12/ddns-updater/internal/provider/providers/arvancloud" "github.com/qdm12/ddns-updater/internal/provider/providers/changeip" "github.com/qdm12/ddns-updater/internal/provider/providers/cloudflare" "github.com/qdm12/ddns-updater/internal/provider/providers/custom" @@ -88,6 +89,8 @@ func New(providerName models.Provider, data json.RawMessage, domain, owner strin return aliyun.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.AllInkl: return allinkl.New(data, domain, owner, ipVersion, ipv6Suffix) + case constants.Arvancloud: + return arvancloud.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Changeip: return changeip.New(data, domain, owner, ipVersion, ipv6Suffix) case constants.Cloudflare: diff --git a/internal/provider/providers/arvancloud/provider.go b/internal/provider/providers/arvancloud/provider.go new file mode 100644 index 000000000..061140643 --- /dev/null +++ b/internal/provider/providers/arvancloud/provider.go @@ -0,0 +1,224 @@ +package arvancloud + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/netip" + "net/url" + "strings" + + "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 + token string + ipVersion ipversion.IPVersion + ipv6Suffix netip.Prefix +} + +func New(data json.RawMessage, domain, owner string, + ipVersion ipversion.IPVersion, ipv6Suffix netip.Prefix) ( + provider *Provider, err error, +) { + var providerSpecificSettings struct { + Token string `json:"token"` + } + err = json.Unmarshal(data, &providerSpecificSettings) + if err != nil { + return nil, fmt.Errorf("json decoding provider specific settings: %w", err) + } + + err = validateSettings(domain, owner, providerSpecificSettings.Token) + if err != nil { + return nil, fmt.Errorf("validating provider specific settings: %w", err) + } + + return &Provider{ + domain: domain, + owner: owner, + token: providerSpecificSettings.Token, + }, nil +} + +func validateSettings(domain, owner, token string) (err error) { + err = utils.CheckDomain(domain) + if err != nil { + return fmt.Errorf("%w: %w", errors.ErrDomainNotValid, err) + } + + switch { + case owner == "*": + return fmt.Errorf("%w", errors.ErrOwnerWildcard) + case owner == "": + return fmt.Errorf("%w", errors.ErrDomainNotValid) + case token == "": + return fmt.Errorf("%w ", errors.ErrKeyNotValid) + case !strings.HasPrefix(token, "apikey "): + return fmt.Errorf("%w: token should be like `apikey `", errors.ErrKeyNotValid) + } + return nil +} + +func (p *Provider) String() string { + return utils.ToString(p.domain, p.owner, constants.Arvancloud, 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: "ArvanCloud", + IPVersion: p.ipVersion.String(), + } +} + +// https://www.arvancloud.ir/api/cdn/4.0#tag/DNS-Management/operation/dns-records.show +func (p *Provider) Update(ctx context.Context, client *http.Client, ip netip.Addr) (newIP netip.Addr, err error) { + domainID, err := p.getDomainID(ctx, client) + if err != nil { + return netip.Addr{}, err + } + + u := url.URL{ + Scheme: "https", + Host: "napi.arvancloud.ir", + Path: fmt.Sprintf("/cdn/4.0/domains/%s/dns-records/%s", p.domain, domainID), + } + + payload, err := json.Marshal(struct { + Name string `json:"name"` + Type string `json:"type"` + Value []struct { + IP string `json:"ip"` + } `json:"value"` + }{ + Name: p.owner, + Type: "a", + Value: []struct { + IP string `json:"ip"` + }{ + { + IP: ip.String(), + }, + }, + }) + + if err != nil { + return netip.Addr{}, err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bytes.NewReader(payload)) + if err != nil { + return netip.Addr{}, fmt.Errorf("creating http request: %w", err) + } + headers.SetUserAgent(request) + headers.SetContentType(request, "application/json") + headers.SetAccept(request, "application/json") + headers.SetAuthorization(request, p.token) + + response, err := client.Do(request) + if err != nil { + return netip.Addr{}, 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)) + } + + return ip, nil +} + +func (p *Provider) getDomainID(ctx context.Context, client *http.Client) (string, error) { + u := url.URL{ + Scheme: "https", + Host: "napi.arvancloud.ir", + Path: fmt.Sprintf("/cdn/4.0/domains/%s/dns-records", p.domain), + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("creating http request: %w", err) + } + headers.SetUserAgent(request) + headers.SetContentType(request, "application/json") + headers.SetAccept(request, "application/json") + headers.SetAuthorization(request, p.token) + + response, err := client.Do(request) + + if err != nil { + return "", err + } + defer response.Body.Close() + + s, err := utils.ReadAndCleanBody(response.Body) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("%w: %d: %s", + errors.ErrHTTPStatusNotValid, response.StatusCode, utils.ToSingleLine(s)) + } + + var parsedJSON struct { + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + } `json:"data"` + } + + err = json.Unmarshal([]byte(s), &parsedJSON) + if err != nil { + return "", fmt.Errorf("%w: cannot parse json", errors.ErrReceivedNoResult) + } + + for _, subdomain := range parsedJSON.Data { + if subdomain.Name == p.owner { + return subdomain.ID, nil + } + } + return "", fmt.Errorf("%w: domain not found", errors.ErrDomainNotFound) +}