From e4b456b1ee6752b37616e8ce29076488d8d2d534 Mon Sep 17 00:00:00 2001 From: v-me-50 Date: Wed, 17 Sep 2025 15:36:41 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E6=96=B0=E5=A2=9E=E3=80=91=E5=AE=9D?= =?UTF-8?q?=E5=A1=94dns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cert/apply/lego/bt/client.go | 81 +++++++ backend/internal/cert/apply/lego/bt/lego.go | 197 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 backend/internal/cert/apply/lego/bt/client.go create mode 100644 backend/internal/cert/apply/lego/bt/lego.go diff --git a/backend/internal/cert/apply/lego/bt/client.go b/backend/internal/cert/apply/lego/bt/client.go new file mode 100644 index 0000000..a1280f8 --- /dev/null +++ b/backend/internal/cert/apply/lego/bt/client.go @@ -0,0 +1,81 @@ +package bt + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 生成 API 签名 +func (c *Config) generateSignature(method, path string, body string) (string, string) { + timestamp := fmt.Sprintf("%d", time.Now().Unix()) + + signingString := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + c.AccountID, + timestamp, + strings.ToUpper(method), + path, + body, + ) + + h := hmac.New(sha256.New, []byte(c.SecretKey)) + h.Write([]byte(signingString)) + signature := hex.EncodeToString(h.Sum(nil)) + + return timestamp, signature +} + +// 发起 API 请求 +func (c *Config) MakeRequest(method, path string, data interface{}) (map[string]interface{}, error) { + url := strings.TrimRight(c.BaseURL, "/") + path + + var bodyStr string + var bodyBytes []byte + if data != nil { + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + bodyStr = string(b) + bodyBytes = b + } + + timestamp, signature := c.generateSignature(method, path, bodyStr) + + req, err := http.NewRequest(method, url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Account-ID", c.AccountID) + req.Header.Set("X-Access-Key", c.AccessKey) + req.Header.Set("X-Timestamp", timestamp) + req.Header.Set("X-Signature", signature) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBytes, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/backend/internal/cert/apply/lego/bt/lego.go b/backend/internal/cert/apply/lego/bt/lego.go new file mode 100644 index 0000000..050bafe --- /dev/null +++ b/backend/internal/cert/apply/lego/bt/lego.go @@ -0,0 +1,197 @@ +package bt + +import ( + "fmt" + "github.com/go-acme/lego/v4/challenge" + "github.com/go-acme/lego/v4/challenge/dns01" + "github.com/go-acme/lego/v4/platform/config/env" + "time" +) + +const ( + envNamespace = "BTDOMAIN_" + + EnvAccountID = envNamespace + "ACCOUNT_ID" + EnvAccessKey = envNamespace + "ACCESS_KEY" + EnvSecretKey = envNamespace + "SECRET_KEY" + EnvBaseURL = envNamespace + "BASE_URL" + + EnvTTL = envNamespace + "TTL" + EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" + EnvPollingInterval = envNamespace + "POLLING_INTERVAL" + EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" +) + +var _ challenge.ProviderTimeout = (*DNSProvider)(nil) + +type Config struct { + AccountID string + AccessKey string + SecretKey string + BaseURL string + + PropagationTimeout time.Duration + PollingInterval time.Duration + TTL int + HTTPTimeout time.Duration +} + +func NewConfig(accountID, accessKey, secretKey, baseURL string) *Config { + + return &Config{ + AccountID: accountID, + AccessKey: accessKey, + SecretKey: secretKey, + BaseURL: baseURL, + TTL: 600, + PropagationTimeout: dns01.DefaultPropagationTimeout, + PollingInterval: dns01.DefaultPollingInterval, + HTTPTimeout: 30 * time.Second, + } +} + +func NewDefaultConfig() *Config { + return &Config{ + BaseURL: env.GetOrDefaultString(EnvBaseURL, "https://dmp.bt.cn"), + + TTL: env.GetOrDefaultInt(EnvTTL, 600), + PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), + PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), + HTTPTimeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), + } +} + +type DNSProvider struct { + config *Config +} + +func NewDNSProvider() (*DNSProvider, error) { + values, err := env.Get(EnvAccountID, EnvAccessKey, EnvSecretKey) + if err != nil { + return nil, fmt.Errorf("westcn: %w", err) + } + + config := NewDefaultConfig() + config.AccountID = values[EnvAccountID] + config.AccessKey = values[EnvAccessKey] + config.SecretKey = values[EnvSecretKey] + + return NewDNSProviderConfig(config) +} + +func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { + if config == nil { + return nil, nil + } + return &DNSProvider{config: config}, nil +} + +func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { + return d.config.PropagationTimeout, d.config.PollingInterval +} + +func (d *DNSProvider) Present(domain, token, keyAuth string) error { + return d.config.addDNSRecord(domain, keyAuth) +} + +func (d *DNSProvider) CleanUp(domain, token, keyAuth string) error { + return d.config.removeDNSRecord(domain, keyAuth) +} + +func (c *Config) GetDomainId(domain string) (int, int) { + domain = dns01.UnFqdn(domain) + resp, err := c.MakeRequest("POST", "/api/v1/dns/manage/list_domains", map[string]interface{}{ + "p": "1", + "rows": "100", + "keyword": domain, + }) + if err != nil { + return 0, 0 + } + if !resp["status"].(bool) { + return 0, 0 + } + data := resp["data"].(map[string]interface{}) + list := data["data"].([]interface{}) + for _, item := range list { + d := item.(map[string]interface{}) + if d["full_domain"].(string) == domain { + return int(d["local_id"].(float64)), int(d["domain_type"].(float64)) + } + } + return 0, 0 +} + +func (c *Config) addDNSRecord(domain, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + EffectiveFQDN := dns01.UnFqdn(info.EffectiveFQDN) + rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("无法获取域名的根域名: %w", err) + } + subDomain, err := dns01.ExtractSubDomain(EffectiveFQDN, rootDomain) + if err != nil { + return fmt.Errorf("无法获取域名的子域名: %w", err) + } + + domainId, domainType := c.GetDomainId(rootDomain) + if domainId == 0 { + return nil + } + _, err = c.MakeRequest("POST", "/api/v1/dns/record/create", map[string]interface{}{ + "domain_id": domainId, + "domain_type": domainType, + "record": subDomain, + "value": info.Value, + "type": "TXT", + }) + return err +} + +func (c *Config) removeDNSRecord(domain, keyAuth string) error { + info := dns01.GetChallengeInfo(domain, keyAuth) + + EffectiveFQDN := dns01.UnFqdn(info.EffectiveFQDN) + rootDomain, err := dns01.FindZoneByFqdn(info.EffectiveFQDN) + if err != nil { + return fmt.Errorf("无法获取域名的根域名: %w", err) + } + subDomain, err := dns01.ExtractSubDomain(EffectiveFQDN, rootDomain) + if err != nil { + return fmt.Errorf("无法获取域名的子域名: %w", err) + } + + domainId, domainType := c.GetDomainId(rootDomain) + if domainId == 0 { + return nil + } + + resp, err := c.MakeRequest("POST", "/api/v1/dns/record/list", map[string]interface{}{ + "domain_id": domainId, + "domain_type": domainType, + "p": "1", + "rows": "100", + "searchKey": subDomain, + }) + if err != nil { + return err + } + if !resp["status"].(bool) { + return nil + } + data := resp["data"].(map[string]interface{}) + list := data["data"].([]interface{}) + for _, item := range list { + d := item.(map[string]interface{}) + if d["record"].(string) == subDomain && d["type"].(string) == "TXT" && d["value"].(string) == info.Value { + _, err = c.MakeRequest("POST", "/api/v1/dns/record/delete", map[string]interface{}{ + "domain_id": domainId, + "domain_type": domainType, + "record_id": int(d["record_id"].(float64)), + }) + return err + } + } + return nil +}