From e4a4a1a9b5eb96cc2e9d1cf8a3d96ca665b580e3 Mon Sep 17 00:00:00 2001 From: Rowan Chen <156078944+rowanchen-com@users.noreply.github.com> Date: Fri, 22 May 2026 13:52:51 +0800 Subject: [PATCH] feat(provider): new acme dns-01 provider: dynadot --- internal/certacme/certifiers/sp_dynadot.go | 27 +++ internal/domain/access.go | 5 + internal/domain/provider.go | 2 + .../challengers/dns01/dynadot/dynadot.go | 39 +++++ .../dns01/dynadot/internal/lego.go | 154 ++++++++++++++++++ pkg/sdk3rd/dynadot/api_remove_dns.go | 40 +++++ pkg/sdk3rd/dynadot/api_set_dns.go | 42 +++++ pkg/sdk3rd/dynadot/client.go | 149 +++++++++++++++++ pkg/sdk3rd/dynadot/types.go | 34 ++++ ui/public/imgs/providers/dynadot.svg | 1 + .../forms/AccessConfigFieldsProvider.tsx | 2 + .../AccessConfigFieldsProviderDynadot.tsx | 64 ++++++++ ui/src/domain/provider.ts | 4 + ui/src/i18n/resources/en/nls.access.json | 10 ++ ui/src/i18n/resources/en/nls.provider.json | 1 + ui/src/i18n/resources/zh/nls.access.json | 10 ++ ui/src/i18n/resources/zh/nls.provider.json | 1 + 17 files changed, 585 insertions(+) create mode 100644 internal/certacme/certifiers/sp_dynadot.go create mode 100644 pkg/core/certifier/challengers/dns01/dynadot/dynadot.go create mode 100644 pkg/core/certifier/challengers/dns01/dynadot/internal/lego.go create mode 100644 pkg/sdk3rd/dynadot/api_remove_dns.go create mode 100644 pkg/sdk3rd/dynadot/api_set_dns.go create mode 100644 pkg/sdk3rd/dynadot/client.go create mode 100644 pkg/sdk3rd/dynadot/types.go create mode 100644 ui/public/imgs/providers/dynadot.svg create mode 100644 ui/src/components/access/forms/AccessConfigFieldsProviderDynadot.tsx diff --git a/internal/certacme/certifiers/sp_dynadot.go b/internal/certacme/certifiers/sp_dynadot.go new file mode 100644 index 000000000..2a7168210 --- /dev/null +++ b/internal/certacme/certifiers/sp_dynadot.go @@ -0,0 +1,27 @@ +package certifiers + +import ( + "fmt" + + "github.com/certimate-go/certimate/internal/domain" + "github.com/certimate-go/certimate/pkg/core" + "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/dynadot" + xmaps "github.com/certimate-go/certimate/pkg/utils/maps" +) + +func init() { + ACMEDns01Registries.MustRegister(domain.ACMEDns01ProviderTypeDynadot, func(options *ProviderFactoryOptions) (core.ACMEChallenger, error) { + credentials := domain.AccessConfigForDynadot{} + if err := xmaps.Populate(options.ProviderAccessConfig, &credentials); err != nil { + return nil, fmt.Errorf("failed to populate provider access config: %w", err) + } + + provider, err := dynadot.NewChallenger(&dynadot.ChallengerConfig{ + ApiKey: credentials.ApiKey, + ApiSecret: credentials.ApiSecret, + DnsPropagationTimeout: options.DnsPropagationTimeout, + DnsTTL: options.DnsTTL, + }) + return provider, err + }) +} diff --git a/internal/domain/access.go b/internal/domain/access.go index 2ed71e329..b0e234db8 100644 --- a/internal/domain/access.go +++ b/internal/domain/access.go @@ -225,6 +225,11 @@ type AccessConfigForDuckDNS struct { Token string `json:"token"` } +type AccessConfigForDynadot struct { + ApiKey string `json:"apiKey"` + ApiSecret string `json:"apiSecret"` +} + type AccessConfigForDynu struct { ApiKey string `json:"apiKey"` } diff --git a/internal/domain/provider.go b/internal/domain/provider.go index 14754671b..13de3b476 100644 --- a/internal/domain/provider.go +++ b/internal/domain/provider.go @@ -50,6 +50,7 @@ const ( AccessProviderTypeDogeCloud = AccessProviderType("dogecloud") AccessProviderTypeDokploy = AccessProviderType("dokploy") AccessProviderTypeDuckDNS = AccessProviderType("duckdns") + AccessProviderTypeDynadot = AccessProviderType("dynadot") AccessProviderTypeDynu = AccessProviderType("dynu") AccessProviderTypeDynv6 = AccessProviderType("dynv6") AccessProviderTypeEmail = AccessProviderType("email") @@ -193,6 +194,7 @@ const ( ACMEDns01ProviderTypeDNSLA = ACMEDns01ProviderType(AccessProviderTypeDNSLA) ACMEDns01ProviderTypeDNSMadeEasy = ACMEDns01ProviderType(AccessProviderTypeDNSMadeEasy) ACMEDns01ProviderTypeDuckDNS = ACMEDns01ProviderType(AccessProviderTypeDuckDNS) + ACMEDns01ProviderTypeDynadot = ACMEDns01ProviderType(AccessProviderTypeDynadot) ACMEDns01ProviderTypeDynu = ACMEDns01ProviderType(AccessProviderTypeDynu) ACMEDns01ProviderTypeDynv6 = ACMEDns01ProviderType(AccessProviderTypeDynv6) ACMEDns01ProviderTypeGandinet = ACMEDns01ProviderType(AccessProviderTypeGandinet) diff --git a/pkg/core/certifier/challengers/dns01/dynadot/dynadot.go b/pkg/core/certifier/challengers/dns01/dynadot/dynadot.go new file mode 100644 index 000000000..0b124b3c9 --- /dev/null +++ b/pkg/core/certifier/challengers/dns01/dynadot/dynadot.go @@ -0,0 +1,39 @@ +package dynadot + +import ( + "fmt" + "time" + + "github.com/certimate-go/certimate/pkg/core/certifier" + "github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/dynadot/internal" +) + +type ChallengerConfig struct { + ApiKey string `json:"apiKey"` + ApiSecret string `json:"apiSecret"` + DnsPropagationTimeout int `json:"dnsPropagationTimeout,omitempty"` + DnsTTL int `json:"dnsTTL,omitempty"` +} + +func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) { + if config == nil { + return nil, fmt.Errorf("the configuration of the acme challenge provider is nil") + } + + providerConfig := internal.NewDefaultConfig() + providerConfig.APIKey = config.ApiKey + providerConfig.APISecret = config.ApiSecret + if config.DnsPropagationTimeout != 0 { + providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second + } + if config.DnsTTL != 0 { + providerConfig.TTL = config.DnsTTL + } + + provider, err := internal.NewDNSProviderConfig(providerConfig) + if err != nil { + return nil, err + } + + return provider, nil +} diff --git a/pkg/core/certifier/challengers/dns01/dynadot/internal/lego.go b/pkg/core/certifier/challengers/dns01/dynadot/internal/lego.go new file mode 100644 index 000000000..e70734428 --- /dev/null +++ b/pkg/core/certifier/challengers/dns01/dynadot/internal/lego.go @@ -0,0 +1,154 @@ +package internal + +import ( + "fmt" + "time" + + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "github.com/samber/lo" + + dynadotsdk "github.com/certimate-go/certimate/pkg/sdk3rd/dynadot" +) + +const ( + envNamespace = "DYNADOT_" + + EnvAPIKey = envNamespace + "API_KEY" + EnvAPISecret = envNamespace + "API_SECRET" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +type Config struct { + APIKey string + APISecret string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +type DNSProvider struct { + config *Config + client *dynadotsdk.Client +} + +type dnsRecordCacheEntry struct { + Zone string + SubHost string + Value string +} + +func NewDefaultConfig() *Config { + return &Config{ + TTL: env.GetOrDefaultInt(EnvTTL, dns01.DefaultTTL), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAPIKey, EnvAPISecret) + if err != nil { + return nil, fmt.Errorf("dynadot: %w", err) + } + + config := NewDefaultConfig() + config.APIKey = values[EnvAPIKey] + config.APISecret = values[EnvAPISecret] + + return NewDNSProviderConfig(config) +} + +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, fmt.Errorf("dynadot: the configuration of the DNS provider is nil") + } + + client, err := dynadotsdk.NewClient(config.APIKey, config.APISecret) + if err != nil { + return nil, fmt.Errorf("dynadot: %w", err) + } else { + client.SetTimeout(config.HTTPTimeout) + } + + return &DNSProvider{ + config: config, + client: client, + }, nil +} + +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("dynadot: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("dynadot: %w", err) + } + + // REF: https://www.dynadot.com/domain/api-document (set_dns) + request := &dynadotsdk.SetDnsRequest{ + SubList: []*dynadotsdk.DnsSubRecord{ + { + SubHost: subDomain, + RecordType: "TXT", + RecordValue1: info.Value, + }, + }, + TTL: lo.ToPtr(int64(d.config.TTL)), + AddDnsToCurrentSetting: lo.ToPtr(true), + } + if _, err := d.client.SetDns(dns01.UnFqdn(authZone), request); err != nil { + return fmt.Errorf("dynadot: error when create record: %w", err) + } + + return nil +} + +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + authZone, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("dynadot: could not find zone for domain %q: %w", domain, err) + } + + subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone) + if err != nil { + return fmt.Errorf("dynadot: %w", err) + } + + // REF: https://www.dynadot.com/domain/api-document (remove_dns) + request := &dynadotsdk.RemoveDnsRequest{ + SubList: []*dynadotsdk.DnsSubRecord{ + { + SubHost: subDomain, + RecordType: "TXT", + RecordValue1: info.Value, + }, + }, + } + if _, err := d.client.RemoveDns(dns01.UnFqdn(authZone), request); err != nil { + return fmt.Errorf("dynadot: error when delete record: %w", err) + } + + return nil +} + +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} diff --git a/pkg/sdk3rd/dynadot/api_remove_dns.go b/pkg/sdk3rd/dynadot/api_remove_dns.go new file mode 100644 index 000000000..adc975676 --- /dev/null +++ b/pkg/sdk3rd/dynadot/api_remove_dns.go @@ -0,0 +1,40 @@ +package dynadot + +import ( + "context" + "fmt" + "net/http" +) + +type RemoveDnsRequest struct { + DnsMainList []*DnsMainRecord `json:"dns_main_list,omitempty"` + SubList []*DnsSubRecord `json:"sub_list,omitempty"` +} + +type RemoveDnsResponse struct { + sdkResponseBase +} + +func (c *Client) RemoveDns(domain string, req *RemoveDnsRequest) (*RemoveDnsResponse, error) { + return c.RemoveDnsWithContext(context.Background(), domain, req) +} + +func (c *Client) RemoveDnsWithContext(ctx context.Context, domain string, req *RemoveDnsRequest) (*RemoveDnsResponse, error) { + if domain == "" { + return nil, fmt.Errorf("sdkerr: unset domain") + } + + httpreq, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/restful/v2/domains/%s/records", domain), req) + if err != nil { + return nil, err + } else { + httpreq.SetContext(ctx) + } + + result := &RemoveDnsResponse{} + if _, err := c.doRequestWithResult(httpreq, result); err != nil { + return result, err + } + + return result, nil +} diff --git a/pkg/sdk3rd/dynadot/api_set_dns.go b/pkg/sdk3rd/dynadot/api_set_dns.go new file mode 100644 index 000000000..7fcb4e51a --- /dev/null +++ b/pkg/sdk3rd/dynadot/api_set_dns.go @@ -0,0 +1,42 @@ +package dynadot + +import ( + "context" + "fmt" + "net/http" +) + +type SetDnsRequest struct { + DnsMainList []*DnsMainRecord `json:"dns_main_list,omitempty"` + SubList []*DnsSubRecord `json:"sub_list,omitempty"` + TTL *int64 `json:"ttl,omitempty"` + AddDnsToCurrentSetting *bool `json:"add_dns_to_current_setting,omitempty"` +} + +type SetDnsResponse struct { + sdkResponseBase +} + +func (c *Client) SetDns(domain string, req *SetDnsRequest) (*SetDnsResponse, error) { + return c.SetDnsWithContext(context.Background(), domain, req) +} + +func (c *Client) SetDnsWithContext(ctx context.Context, domain string, req *SetDnsRequest) (*SetDnsResponse, error) { + if domain == "" { + return nil, fmt.Errorf("sdkerr: unset domain") + } + + httpreq, err := c.newRequest(http.MethodPost, fmt.Sprintf("/restful/v2/domains/%s/records", domain), req) + if err != nil { + return nil, err + } else { + httpreq.SetContext(ctx) + } + + result := &SetDnsResponse{} + if _, err := c.doRequestWithResult(httpreq, result); err != nil { + return result, err + } + + return result, nil +} diff --git a/pkg/sdk3rd/dynadot/client.go b/pkg/sdk3rd/dynadot/client.go new file mode 100644 index 000000000..754b898df --- /dev/null +++ b/pkg/sdk3rd/dynadot/client.go @@ -0,0 +1,149 @@ +package dynadot + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/go-resty/resty/v2" + + "github.com/certimate-go/certimate/internal/app" +) + +type Client struct { + apiKey string + apiSecret string + + client *resty.Client +} + +func NewClient(apiKey, apiSecret string) (*Client, error) { + if apiKey == "" { + return nil, fmt.Errorf("sdkerr: unset apiKey") + } + if apiSecret == "" { + return nil, fmt.Errorf("sdkerr: unset apiSecret") + } + + client := resty.New(). + SetBaseURL("https://api.dynadot.com"). + SetHeader("Accept", "application/json"). + SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", app.AppUserAgent) + + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + client: client, + }, nil +} + +func (c *Client) SetTimeout(timeout time.Duration) *Client { + c.client.SetTimeout(timeout) + return c +} + +func (c *Client) newRequest(method string, path string, params any) (*resty.Request, error) { + if method == "" { + return nil, fmt.Errorf("sdkerr: unset method") + } + if path == "" { + return nil, fmt.Errorf("sdkerr: unset path") + } + + var body string + if params != nil { + jsonb, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("sdkerr: failed to marshal request body: %w", err) + } + body = string(jsonb) + } + + req := c.client.R() + req.Method = method + req.URL = path + req.SetHeader("Authorization", "Bearer "+c.apiKey) + req.SetHeader("X-Signature", generateSignature(c.apiKey, c.apiSecret, path, "", body)) + if body != "" { + req.SetBody(body) + } + return req, nil +} + +func (c *Client) doRequest(req *resty.Request) (*resty.Response, error) { + if req == nil { + return nil, fmt.Errorf("sdkerr: nil request") + } + + // WARN: + // PLEASE DO NOT USE `req.SetBody` or `req.SetFormData` HERE! USE `newRequest` INSTEAD. + // PLEASE DO NOT USE `req.SetResult` or `req.SetError` HERE! USE `doRequestWithResult` INSTEAD. + + resp, err := req.Send() + if err != nil { + return resp, fmt.Errorf("sdkerr: failed to send request: %w", err) + } else if resp.IsError() { + return resp, fmt.Errorf("sdkerr: unexpected status code: %d (resp: %s)", resp.StatusCode(), resp.String()) + } + + return resp, nil +} + +func (c *Client) doRequestWithResult(req *resty.Request, res sdkResponse) (*resty.Response, error) { + if req == nil { + return nil, fmt.Errorf("sdkerr: nil request") + } + + resp, err := c.doRequest(req) + if err != nil { + if resp != nil { + json.Unmarshal(resp.Body(), &res) + } + return resp, err + } + + if len(resp.Body()) != 0 { + if err := json.Unmarshal(resp.Body(), &res); err != nil { + return resp, fmt.Errorf("sdkerr: failed to unmarshal response: %w (resp: %s)", err, resp.String()) + } else { + // NOTE: Unlike most providers in this project (which use `0` for success), + // Dynadot mirrors HTTP-style status codes inside the response body and + // always returns `200` on success. + // REF: https://www.dynadot.com/domain/api-document + if tcode := res.GetCode(); tcode != 200 { + return resp, fmt.Errorf("sdkerr: api error: code='%d', message='%s'", tcode, res.GetMessage()) + } + } + } + + return resp, nil +} + +// generateSignature builds the X-Signature header value as required by the +// Dynadot RESTful v2 API. It signs the concatenation of: +// +// apiKey + "\n" + fullPathAndQuery + "\n" + xRequestId + "\n" + requestBody +// +// using HMAC-SHA256 with the API secret as key, then encodes the result with +// standard Base64. +// +// REF: https://www.dynadot.com/domain/api-document +func generateSignature(apiKey, apiSecret, fullPathAndQuery, xRequestId, requestBody string) string { + var sb strings.Builder + sb.WriteString(apiKey) + sb.WriteString("\n") + sb.WriteString(fullPathAndQuery) + sb.WriteString("\n") + sb.WriteString(xRequestId) + sb.WriteString("\n") + sb.WriteString(requestBody) + + mac := hmac.New(sha256.New, []byte(apiSecret)) + mac.Write([]byte(sb.String())) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/pkg/sdk3rd/dynadot/types.go b/pkg/sdk3rd/dynadot/types.go new file mode 100644 index 000000000..56738b4a1 --- /dev/null +++ b/pkg/sdk3rd/dynadot/types.go @@ -0,0 +1,34 @@ +package dynadot + +type sdkResponse interface { + GetCode() int + GetMessage() string +} + +type sdkResponseBase struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (r *sdkResponseBase) GetCode() int { + return r.Code +} + +func (r *sdkResponseBase) GetMessage() string { + return r.Message +} + +var _ sdkResponse = (*sdkResponseBase)(nil) + +type DnsMainRecord struct { + RecordType string `json:"record_type"` + RecordValue1 string `json:"record_value1"` + RecordValue2 string `json:"record_value2,omitempty"` +} + +type DnsSubRecord struct { + SubHost string `json:"sub_host"` + RecordType string `json:"record_type"` + RecordValue1 string `json:"record_value1"` + RecordValue2 string `json:"record_value2,omitempty"` +} diff --git a/ui/public/imgs/providers/dynadot.svg b/ui/public/imgs/providers/dynadot.svg new file mode 100644 index 000000000..ef253a55f --- /dev/null +++ b/ui/public/imgs/providers/dynadot.svg @@ -0,0 +1 @@ + diff --git a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx index 002c1294c..930c3242a 100644 --- a/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx +++ b/ui/src/components/access/forms/AccessConfigFieldsProvider.tsx @@ -42,6 +42,7 @@ import AccessConfigFieldsProviderDNSMadeEasy from "./AccessConfigFieldsProviderD import AccessConfigFieldsProviderDogeCloud from "./AccessConfigFieldsProviderDogeCloud"; import AccessConfigFieldsProviderDokploy from "./AccessConfigFieldsProviderDokploy"; import AccessConfigFieldsProviderDuckDNS from "./AccessConfigFieldsProviderDuckDNS"; +import AccessConfigFieldsProviderDynadot from "./AccessConfigFieldsProviderDynadot"; import AccessConfigFieldsProviderDynu from "./AccessConfigFieldsProviderDynu"; import AccessConfigFieldsProviderDynv6 from "./AccessConfigFieldsProviderDynv6"; import AccessConfigFieldsProviderEmail from "./AccessConfigFieldsProviderEmail"; @@ -159,6 +160,7 @@ const providerComponentMap: Partial { + const { i18n, t } = useTranslation(); + + const { parentNamePath } = useFormNestedFieldsContext(); + const formSchema = z.object({ + [parentNamePath]: getSchema({ i18n }), + }); + const formRule = createSchemaFieldRule(formSchema); + const initialValues = getInitialValues(); + + return ( + <> + } + > + + + + } + > + + + + ); +}; + +const getInitialValues = (): Nullish>> => { + return { + apiKey: "", + apiSecret: "", + }; +}; + +const getSchema = ({ i18n = getI18n() }: { i18n: ReturnType }) => { + const { t: _ } = i18n; + + return z.object({ + apiKey: z.string().nonempty(), + apiSecret: z.string().nonempty(), + }); +}; + +const _default = Object.assign(AccessConfigFormFieldsProviderDynadot, { + getInitialValues, + getSchema, +}); + +export default _default; diff --git a/ui/src/domain/provider.ts b/ui/src/domain/provider.ts index 0f8afafe8..e56de0046 100644 --- a/ui/src/domain/provider.ts +++ b/ui/src/domain/provider.ts @@ -55,6 +55,7 @@ export const ACCESS_PROVIDERS = Object.freeze({ DOGECLOUD: "dogecloud", DOKPLOY: "dokploy", DUCKDNS: "duckdns", + DYNADOT: "dynadot", DYNU: "dynu", DYNV6: "dynv6", EMAIL: "email", @@ -218,6 +219,7 @@ export const accessProvidersMap: Maphttps://www.duckdns.org/spec.jsp" }, + "dynadot_api_key": { + "label": "Dynadot API key", + "placeholder": "Please enter Dynadot API key", + "tooltip": "For more information, see https://www.dynadot.com/domain/api-document" + }, + "dynadot_api_secret": { + "label": "Dynadot API secret", + "placeholder": "Please enter Dynadot API secret", + "tooltip": "For more information, see https://www.dynadot.com/domain/api-document" + }, "dynu_api_key": { "label": "Dynu API key", "placeholder": "Please enter Dynu API key", diff --git a/ui/src/i18n/resources/en/nls.provider.json b/ui/src/i18n/resources/en/nls.provider.json index 4d3c0d54c..828bd211f 100644 --- a/ui/src/i18n/resources/en/nls.provider.json +++ b/ui/src/i18n/resources/en/nls.provider.json @@ -119,6 +119,7 @@ "dogecloud": "Doge Cloud", "dokploy": "Dokploy", "duckdns": "Duck DNS", + "dynadot": "Dynadot", "dynu": "Dynu", "dynv6": "dynv6", "email": "Email (SMTP)", diff --git a/ui/src/i18n/resources/zh/nls.access.json b/ui/src/i18n/resources/zh/nls.access.json index 081699b59..b778ee1b4 100644 --- a/ui/src/i18n/resources/zh/nls.access.json +++ b/ui/src/i18n/resources/zh/nls.access.json @@ -496,6 +496,16 @@ "placeholder": "请输入 DuckDNS Token", "tooltip": "这是什么?请参阅 https://www.duckdns.org/spec.jsp" }, + "dynadot_api_key": { + "label": "Dynadot API Key", + "placeholder": "请输入 Dynadot API Key", + "tooltip": "这是什么?请参阅 https://www.dynadot.com/zh/domain/api-document" + }, + "dynadot_api_secret": { + "label": "Dynadot API Secret", + "placeholder": "请输入 Dynadot API Secret", + "tooltip": "这是什么?请参阅 https://www.dynadot.com/zh/domain/api-document" + }, "dynu_api_key": { "label": "Dynu API Key", "placeholder": "请输入 Dynu API Key", diff --git a/ui/src/i18n/resources/zh/nls.provider.json b/ui/src/i18n/resources/zh/nls.provider.json index 4e32e53e2..ef722a94f 100644 --- a/ui/src/i18n/resources/zh/nls.provider.json +++ b/ui/src/i18n/resources/zh/nls.provider.json @@ -119,6 +119,7 @@ "dogecloud_cdn": "多吉云 - 内容分发网络 CDN", "dokploy": "Dokploy", "duckdns": "Duck DNS", + "dynadot": "Dynadot", "dynu": "Dynu", "dynv6": "dynv6", "email": "邮件(SMTP)",