refactor(provider): use lego to implement dns-01 challenger of dynadot

This commit is contained in:
Fu Diwei
2026-05-27 23:51:41 +08:00
committed by RHQYZ
parent 7fce3c7c3e
commit 51dfdf55bc
6 changed files with 4 additions and 423 deletions
@@ -4,8 +4,9 @@ import (
"fmt"
"time"
"github.com/go-acme/lego/v5/providers/dns/dynadot"
"github.com/certimate-go/certimate/pkg/core/certifier"
"github.com/certimate-go/certimate/pkg/core/certifier/challengers/dns01/dynadot/internal"
)
type ChallengerConfig struct {
@@ -20,7 +21,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
return nil, fmt.Errorf("the configuration of the acme challenge provider is nil")
}
providerConfig := internal.NewDefaultConfig()
providerConfig := dynadot.NewDefaultConfig()
providerConfig.APIKey = config.ApiKey
providerConfig.APISecret = config.ApiSecret
if config.DnsPropagationTimeout != 0 {
@@ -30,7 +31,7 @@ func NewChallenger(config *ChallengerConfig) (certifier.ACMEChallenger, error) {
providerConfig.TTL = config.DnsTTL
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
provider, err := dynadot.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
@@ -1,155 +0,0 @@
package internal
import (
"context"
"fmt"
"time"
"github.com/go-acme/lego/v5/challenge"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/platform/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(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, 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.SetDnsWithContext(ctx, dns01.UnFqdn(authZone), request); err != nil {
return fmt.Errorf("dynadot: error when create record: %w", err)
}
return nil
}
func (d *DNSProvider) CleanUp(ctx context.Context, domain, token, keyAuth string) error {
info := dns01.GetChallengeInfo(ctx, domain, keyAuth)
authZone, err := dns01.DefaultClient().FindZoneByFqdn(ctx, 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.RemoveDnsWithContext(ctx, 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
}
-40
View File
@@ -1,40 +0,0 @@
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
}
-42
View File
@@ -1,42 +0,0 @@
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
}
-149
View File
@@ -1,149 +0,0 @@
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))
}
-34
View File
@@ -1,34 +0,0 @@
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"`
}