feat: new acme dns-01 provider: statecloud smartdns

This commit is contained in:
Fu Diwei
2025-06-12 23:05:18 +08:00
parent fb62f1e105
commit 9c8ab98efb
23 changed files with 837 additions and 65 deletions

View File

@@ -1,7 +1,6 @@
package cmcccloud
import (
"errors"
"time"
"github.com/go-acme/lego/v4/challenge"
@@ -18,7 +17,7 @@ type ChallengeProviderConfig struct {
func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) {
if config == nil {
return nil, errors.New("config is nil")
panic("config is nil")
}
providerConfig := internal.NewDefaultConfig()

View File

@@ -18,8 +18,9 @@ import (
const (
envNamespace = "CMCCCLOUD_"
EnvAccessKey = envNamespace + "ACCESS_KEY"
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvAccessKey = envNamespace + "ACCESS_KEY"
EnvSecretKey = envNamespace + "SECRET_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
@@ -30,13 +31,14 @@ const (
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
AccessKey string
SecretKey string
ReadTimeOut int
ConnectTimeout int
AccessKey string
SecretKey string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int32
ReadTimeOut int
ConnectTimeout int
}
type DNSProvider struct {

View File

@@ -0,0 +1,39 @@
package ctcccloud
import (
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/usual2970/certimate/internal/pkg/core/applicant/acme-dns-01/lego-providers/ctcccloud/internal"
)
type ChallengeProviderConfig struct {
AccessKeyId string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout,omitempty"`
DnsTTL int32 `json:"dnsTTL,omitempty"`
}
func NewChallengeProvider(config *ChallengeProviderConfig) (challenge.Provider, error) {
if config == nil {
panic("config is nil")
}
providerConfig := internal.NewDefaultConfig()
providerConfig.AccessKeyId = config.AccessKeyId
providerConfig.SecretAccessKey = config.SecretAccessKey
if config.DnsTTL != 0 {
providerConfig.TTL = int(config.DnsTTL)
}
if config.DnsPropagationTimeout != 0 {
providerConfig.PropagationTimeout = time.Duration(config.DnsPropagationTimeout) * time.Second
}
provider, err := internal.NewDNSProviderConfig(providerConfig)
if err != nil {
return nil, err
}
return provider, nil
}

View File

@@ -0,0 +1,203 @@
package internal
import (
"errors"
"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"
ctyundns "github.com/usual2970/certimate/internal/pkg/sdk3rd/ctyun/dns"
typeutil "github.com/usual2970/certimate/internal/pkg/utils/type"
)
const (
envNamespace = "CTYUNSMARTDNS_"
EnvAccessKeyID = envNamespace + "ACCESS_KEY_ID"
EnvSecretAccessKey = envNamespace + "SECRET_ACCESS_KEY"
EnvTTL = envNamespace + "TTL"
EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT"
EnvPollingInterval = envNamespace + "POLLING_INTERVAL"
EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT"
)
var _ challenge.ProviderTimeout = (*DNSProvider)(nil)
type Config struct {
AccessKeyId string
SecretAccessKey string
PropagationTimeout time.Duration
PollingInterval time.Duration
TTL int
HTTPTimeout time.Duration
}
type DNSProvider struct {
client *ctyundns.Client
config *Config
}
func NewDefaultConfig() *Config {
return &Config{
TTL: env.GetOrDefaultInt(EnvTTL, 600),
PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, 2*time.Minute),
HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second),
}
}
func NewDNSProvider() (*DNSProvider, error) {
values, err := env.Get(EnvAccessKeyID, EnvSecretAccessKey)
if err != nil {
return nil, fmt.Errorf("ctyun: %w", err)
}
config := NewDefaultConfig()
config.AccessKeyId = values[EnvAccessKeyID]
config.SecretAccessKey = values[EnvSecretAccessKey]
return NewDNSProviderConfig(config)
}
func NewDNSProviderConfig(config *Config) (*DNSProvider, error) {
if config == nil {
return nil, errors.New("ctyun: the configuration of the DNS provider is nil")
}
client, err := ctyundns.NewClient(config.AccessKeyId, config.SecretAccessKey)
if err != nil {
return nil, err
} else {
client.SetTimeout(config.HTTPTimeout)
}
return &DNSProvider{
client: client,
config: config,
}, 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("ctyun: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("ctyun: %w", err)
}
if err := d.addOrUpdateDNSRecord(dns01.UnFqdn(authZone), subDomain, info.Value); err != nil {
return fmt.Errorf("ctyun: %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("ctyun: could not find zone for domain %q: %w", domain, err)
}
subDomain, err := dns01.ExtractSubDomain(info.EffectiveFQDN, authZone)
if err != nil {
return fmt.Errorf("ctyun: %w", err)
}
if err := d.removeDNSRecord(dns01.UnFqdn(authZone), subDomain); err != nil {
return fmt.Errorf("ctyun: %w", err)
}
return nil
}
func (d *DNSProvider) Timeout() (timeout, interval time.Duration) {
return d.config.PropagationTimeout, d.config.PollingInterval
}
func (d *DNSProvider) findDNSRecordId(zoneName, subDomain string) (int32, error) {
// 查询解析记录列表
// REF: https://eop.ctyun.cn/ebp/ctapiDocument/search?sid=122&api=11264&data=181&isNormal=1&vid=259
request := &ctyundns.QueryRecordListRequest{}
request.Domain = typeutil.ToPtr(zoneName)
request.Host = typeutil.ToPtr(subDomain)
request.Type = typeutil.ToPtr("TXT")
response, err := d.client.QueryRecordList(request)
if err != nil {
return 0, err
}
if response.ReturnObj == nil || response.ReturnObj.Records == nil || len(response.ReturnObj.Records) == 0 {
return 0, nil
}
return response.ReturnObj.Records[0].RecordId, nil
}
func (d *DNSProvider) addOrUpdateDNSRecord(zoneName, subDomain, value string) error {
recordId, err := d.findDNSRecordId(zoneName, subDomain)
if err != nil {
return err
}
if recordId == 0 {
// 新增解析记录
// REF: https://eop.ctyun.cn/ebp/ctapiDocument/search?sid=122&api=11259&data=181&isNormal=1&vid=259
request := &ctyundns.AddRecordRequest{
Domain: typeutil.ToPtr(zoneName),
Host: typeutil.ToPtr(subDomain),
Type: typeutil.ToPtr("TXT"),
LineCode: typeutil.ToPtr("Default"),
Value: typeutil.ToPtr(value),
State: typeutil.ToPtr(int32(1)),
TTL: typeutil.ToPtr(int32(d.config.TTL)),
}
_, err := d.client.AddRecord(request)
return err
} else {
// 修改解析记录
// REF: https://eop.ctyun.cn/ebp/ctapiDocument/search?sid=122&api=11261&data=181&isNormal=1&vid=259
request := &ctyundns.UpdateRecordRequest{
RecordId: typeutil.ToPtr(recordId),
Domain: typeutil.ToPtr(zoneName),
Host: typeutil.ToPtr(subDomain),
Type: typeutil.ToPtr("TXT"),
LineCode: typeutil.ToPtr("Default"),
Value: typeutil.ToPtr(value),
State: typeutil.ToPtr(int32(1)),
TTL: typeutil.ToPtr(int32(d.config.TTL)),
}
_, err := d.client.UpdateRecord(request)
return err
}
}
func (d *DNSProvider) removeDNSRecord(zoneName, subDomain string) error {
recordId, err := d.findDNSRecordId(zoneName, subDomain)
if err != nil {
return err
}
if recordId == 0 {
return nil
} else {
// 删除解析记录
// REF: https://eop.ctyun.cn/ebp/ctapiDocument/search?sid=122&api=11262&data=181&isNormal=1&vid=259
request := &ctyundns.DeleteRecordRequest{
RecordId: typeutil.ToPtr(recordId),
}
_, err = d.client.DeleteRecord(request)
return err
}
}

View File

@@ -0,0 +1,46 @@
package dns
import (
"context"
"net/http"
)
type AddRecordRequest struct {
Domain *string `json:"domain,omitempty"`
Host *string `json:"host,omitempty"`
Type *string `json:"type,omitempty"`
LineCode *string `json:"lineCode,omitempty"`
Value *string `json:"value,omitempty"`
TTL *int32 `json:"ttl,omitempty"`
State *int32 `json:"state,omitempty"`
Remark *string `json:"remark"`
}
type AddRecordResponse struct {
baseResult
ReturnObj *struct {
RecordId int32 `json:"recordId"`
} `json:"returnObj,omitempty"`
}
func (c *Client) AddRecord(req *AddRecordRequest) (*AddRecordResponse, error) {
return c.AddRecordWithContext(context.Background(), req)
}
func (c *Client) AddRecordWithContext(ctx context.Context, req *AddRecordRequest) (*AddRecordResponse, error) {
request, err := c.newRequest(http.MethodPost, "/v2/addRecord")
if err != nil {
return nil, err
} else {
request.SetContext(ctx)
request.SetBody(req)
}
result := &AddRecordResponse{}
if _, err := c.doRequestWithResult(request, result); err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,35 @@
package dns
import (
"context"
"net/http"
)
type DeleteRecordRequest struct {
RecordId *int32 `json:"recordId,omitempty"`
}
type DeleteRecordResponse struct {
baseResult
}
func (c *Client) DeleteRecord(req *DeleteRecordRequest) (*DeleteRecordResponse, error) {
return c.DeleteRecordWithContext(context.Background(), req)
}
func (c *Client) DeleteRecordWithContext(ctx context.Context, req *DeleteRecordRequest) (*DeleteRecordResponse, error) {
request, err := c.newRequest(http.MethodPost, "/v2/deleteRecord")
if err != nil {
return nil, err
} else {
request.SetContext(ctx)
request.SetBody(req)
}
result := &DeleteRecordResponse{}
if _, err := c.doRequestWithResult(request, result); err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,53 @@
package dns
import (
"context"
"net/http"
)
type QueryRecordListRequest struct {
Domain *string `json:"domain,omitempty"`
Host *string `json:"host,omitempty"`
Type *string `json:"type,omitempty"`
LineCode *string `json:"lineCode,omitempty"`
Value *string `json:"value,omitempty"`
State *int32 `json:"state,omitempty"`
}
type QueryRecordListResponse struct {
baseResult
ReturnObj *struct {
Records []*struct {
RecordId int32 `json:"recordId"`
Host string `json:"host"`
Type string `json:"type"`
LineCode string `json:"lineCode"`
Value string `json:"value"`
TTL int32 `json:"ttl"`
State int32 `json:"state"`
Remark string `json:"remark"`
} `json:"records,omitempty"`
} `json:"returnObj,omitempty"`
}
func (c *Client) QueryRecordList(req *QueryRecordListRequest) (*QueryRecordListResponse, error) {
return c.QueryRecordListWithContext(context.Background(), req)
}
func (c *Client) QueryRecordListWithContext(ctx context.Context, req *QueryRecordListRequest) (*QueryRecordListResponse, error) {
request, err := c.newRequest(http.MethodGet, "/v2/queryRecordList")
if err != nil {
return nil, err
} else {
request.SetContext(ctx)
request.SetBody(req)
}
result := &QueryRecordListResponse{}
if _, err := c.doRequestWithResult(request, result); err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,47 @@
package dns
import (
"context"
"net/http"
)
type UpdateRecordRequest struct {
RecordId *int32 `json:"recordId,omitempty"`
Domain *string `json:"domain,omitempty"`
Host *string `json:"host,omitempty"`
Type *string `json:"type,omitempty"`
LineCode *string `json:"lineCode,omitempty"`
Value *string `json:"value,omitempty"`
TTL *int32 `json:"ttl,omitempty"`
State *int32 `json:"state,omitempty"`
Remark *string `json:"remark"`
}
type UpdateRecordResponse struct {
baseResult
ReturnObj *struct {
RecordId int32 `json:"recordId"`
} `json:"returnObj,omitempty"`
}
func (c *Client) UpdateRecord(req *UpdateRecordRequest) (*UpdateRecordResponse, error) {
return c.UpdateRecordWithContext(context.Background(), req)
}
func (c *Client) UpdateRecordWithContext(ctx context.Context, req *UpdateRecordRequest) (*UpdateRecordResponse, error) {
request, err := c.newRequest(http.MethodPost, "/v2/updateRecord")
if err != nil {
return nil, err
} else {
request.SetContext(ctx)
request.SetBody(req)
}
result := &UpdateRecordResponse{}
if _, err := c.doRequestWithResult(request, result); err != nil {
return result, err
}
return result, nil
}

View File

@@ -0,0 +1,40 @@
package dns
import (
"time"
"github.com/go-resty/resty/v2"
"github.com/usual2970/certimate/internal/pkg/sdk3rd/ctyun/openapi"
)
const endpoint = "https://smartdns-global.ctapi.ctyun.cn"
type Client struct {
client *openapi.Client
}
func NewClient(accessKeyId, secretAccessKey string) (*Client, error) {
client, err := openapi.NewClient(endpoint, accessKeyId, secretAccessKey)
if err != nil {
return nil, err
}
return &Client{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) (*resty.Request, error) {
return c.client.NewRequest(method, path)
}
func (c *Client) doRequest(request *resty.Request) (*resty.Response, error) {
return c.client.DoRequest(request)
}
func (c *Client) doRequestWithResult(request *resty.Request, result any) (*resty.Response, error) {
return c.client.DoRequestWithResult(request, result)
}

View File

@@ -0,0 +1,11 @@
package dns
import "encoding/json"
type baseResult struct {
StatusCode json.RawMessage `json:"statusCode,omitempty"`
Message *string `json:"message,omitempty"`
Error *string `json:"error,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
RequestId *string `json:"requestId,omitempty"`
}

View File

@@ -0,0 +1,167 @@
package openapi
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
)
type Client struct {
client *resty.Client
}
func NewClient(endpoint, accessKeyId, secretAccessKey string) (*Client, error) {
if endpoint == "" {
return nil, fmt.Errorf("sdk error: unset endpoint")
}
if _, err := url.Parse(endpoint); err != nil {
return nil, fmt.Errorf("sdk error: invalid endpoint: %w", err)
}
if accessKeyId == "" {
return nil, fmt.Errorf("sdk error: unset accessKey")
}
if secretAccessKey == "" {
return nil, fmt.Errorf("sdk error: unset secretKey")
}
client := resty.New().
SetBaseURL(endpoint).
SetHeader("Accept", "application/json").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "certimate").
SetPreRequestHook(func(c *resty.Client, req *http.Request) error {
// 生成时间戳及流水号
now := time.Now()
eopDate := now.Format("20060102T150405Z")
eopReqId := uuid.New().String()
// 获取查询参数
queryStr := ""
if req.URL != nil {
queryStr = req.URL.Query().Encode()
}
// 获取请求正文
payloadStr := ""
if req.Body != nil {
reader, err := req.GetBody()
if err != nil {
return err
}
defer reader.Close()
payload, err := io.ReadAll(reader)
if err != nil {
return err
}
payloadStr = string(payload)
}
// 构造代签字符串
payloadHash := sha256.Sum256([]byte(payloadStr))
payloadHashHex := hex.EncodeToString(payloadHash[:])
dataToSign := fmt.Sprintf("ctyun-eop-request-id:%s\neop-date:%s\n\n%s\n%s", eopReqId, eopDate, queryStr, payloadHashHex)
// 生成 ktime
hasher := hmac.New(sha256.New, []byte(secretAccessKey))
hasher.Write([]byte(eopDate))
ktime := hasher.Sum(nil)
// 生成 kak
hasher = hmac.New(sha256.New, ktime)
hasher.Write([]byte(accessKeyId))
kak := hasher.Sum(nil)
// 生成 kdata
hasher = hmac.New(sha256.New, kak)
hasher.Write([]byte(now.Format("20060102")))
kdate := hasher.Sum(nil)
// 构造签名
hasher = hmac.New(sha256.New, kdate)
hasher.Write([]byte(dataToSign))
sign := hasher.Sum(nil)
signStr := base64.StdEncoding.EncodeToString(sign)
// 设置请求头
req.Header.Set("ctyun-eop-request-id", eopReqId)
req.Header.Set("eop-date", eopDate)
req.Header.Set("eop-authorization", fmt.Sprintf("%s Headers=ctyun-eop-request-id;eop-date Signature=%s", accessKeyId, signStr))
return nil
})
return &Client{
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) (*resty.Request, error) {
if method == "" {
return nil, fmt.Errorf("sdk error: unset method")
}
if path == "" {
return nil, fmt.Errorf("sdk error: unset path")
}
req := c.client.R()
req.Method = method
req.URL = path
return req, nil
}
func (c *Client) DoRequest(request *resty.Request) (*resty.Response, error) {
if request == nil {
return nil, fmt.Errorf("sdk error: nil request")
}
// WARN:
// PLEASE DO NOT USE `req.SetResult` or `req.SetError` here.
resp, err := request.Send()
if err != nil {
return resp, fmt.Errorf("sdk error: failed to send request: %w", err)
} else if resp.IsError() {
return resp, fmt.Errorf("sdk error: unexpected status code: %d, resp: %s", resp.StatusCode(), resp.String())
}
return resp, nil
}
func (c *Client) DoRequestWithResult(request *resty.Request, result any) (*resty.Response, error) {
if request == nil {
return nil, fmt.Errorf("sdk error: nil request")
}
response, err := c.DoRequest(request)
if err != nil {
if response != nil {
json.Unmarshal(response.Body(), &result)
}
return response, err
}
if len(response.Body()) != 0 {
if err := json.Unmarshal(response.Body(), &result); err != nil {
return response, fmt.Errorf("sdk error: failed to unmarshal response: %w", err)
}
}
return response, nil
}