231 lines
7.7 KiB
Go
231 lines
7.7 KiB
Go
package ssl
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha1" // #nosec G505 -- 阿里云 DNS RPC API 要求 HMAC-SHA1
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.pcux.cn/xuans/Smail/config"
|
||
"github.com/go-acme/lego/v4/challenge"
|
||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||
)
|
||
|
||
type aliDNSRecord struct {
|
||
ID string
|
||
Name string
|
||
Value string
|
||
}
|
||
|
||
type aliDNSListedRecord struct {
|
||
ID string `json:"RecordId"`
|
||
Name string `json:"RR"`
|
||
Type string `json:"Type"`
|
||
Value string `json:"Value"`
|
||
}
|
||
|
||
// 每次签发独立记录创建结果,清理时只删除本实例创建且内容未被修改的记录。
|
||
type aliDNSChallenge struct {
|
||
mu sync.Mutex
|
||
zone string
|
||
accessKey string
|
||
secretKey string
|
||
endpoint string
|
||
client *http.Client
|
||
records map[string]aliDNSRecord
|
||
}
|
||
|
||
func automaticDNSProvider(cfg *config.Config) (challenge.Provider, error) {
|
||
if cfg == nil || cfg.EffectiveSSLType() != config.SSLTypeAutoDNS {
|
||
return nil, newUserCertificateError("当前配置未启用项目 DNS 证书")
|
||
}
|
||
if cfg.SSL.DNSProvider == "" {
|
||
return GetDnsChallengeInstance(), nil
|
||
}
|
||
accessKey, secretKey, err := config.DNSProviderCredentials(cfg.SSL)
|
||
if err != nil {
|
||
return nil, newUserCertificateError("DNS 服务商凭据不可用,请在 SSL 配置中重新设置")
|
||
}
|
||
zone := cfg.SSL.DNSZone
|
||
if zone == "" {
|
||
zone = strings.ToLower(strings.TrimSuffix(cfg.Domain, "."))
|
||
}
|
||
if cfg.Domain != zone && !strings.HasSuffix(cfg.Domain, "."+zone) {
|
||
return nil, newUserCertificateError("DNS 托管区域必须包含邮件主域名")
|
||
}
|
||
return &aliDNSChallenge{
|
||
zone: zone, accessKey: accessKey, secretKey: secretKey,
|
||
endpoint: "https://alidns.aliyuncs.com/",
|
||
client: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error {
|
||
return http.ErrUseLastResponse
|
||
}},
|
||
records: make(map[string]aliDNSRecord),
|
||
}, nil
|
||
}
|
||
|
||
func (provider *aliDNSChallenge) Timeout() (time.Duration, time.Duration) {
|
||
return 5 * time.Minute, 5 * time.Second
|
||
}
|
||
|
||
func (provider *aliDNSChallenge) record(domain, keyAuth string) (aliDNSRecord, error) {
|
||
domain = strings.TrimPrefix(strings.TrimSuffix(strings.ToLower(domain), "."), "*.")
|
||
info := dns01.GetChallengeInfo(domain, keyAuth)
|
||
fqdn := strings.TrimSuffix(info.EffectiveFQDN, ".")
|
||
if !strings.HasSuffix(fqdn, "."+provider.zone) {
|
||
return aliDNSRecord{}, errors.New("DNS 挑战不属于配置的托管区域")
|
||
}
|
||
name := strings.TrimSuffix(fqdn, "."+provider.zone)
|
||
if name != "_acme-challenge" && !strings.HasPrefix(name, "_acme-challenge.") {
|
||
return aliDNSRecord{}, errors.New("DNS 挑战目标不是允许的 ACME 记录")
|
||
}
|
||
return aliDNSRecord{Name: name, Value: info.Value}, nil
|
||
}
|
||
|
||
func (provider *aliDNSChallenge) Present(domain, token, keyAuth string) error {
|
||
provider.mu.Lock()
|
||
defer provider.mu.Unlock()
|
||
key := domain + ":" + keyAuth
|
||
if _, exists := provider.records[key]; exists {
|
||
return nil
|
||
}
|
||
record, err := provider.record(domain, keyAuth)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var response struct {
|
||
RecordID string `json:"RecordId"`
|
||
}
|
||
err = provider.call("AddDomainRecord", url.Values{
|
||
"DomainName": {provider.zone}, "RR": {record.Name}, "Type": {"TXT"},
|
||
"Value": {record.Value}, "TTL": {"600"},
|
||
}, &response)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if response.RecordID == "" {
|
||
return errors.New("DNS 服务商未返回新建记录编号")
|
||
}
|
||
record.ID = response.RecordID
|
||
provider.records[key] = record
|
||
return nil
|
||
}
|
||
|
||
func (provider *aliDNSChallenge) CleanUp(domain, token, keyAuth string) error {
|
||
provider.mu.Lock()
|
||
defer provider.mu.Unlock()
|
||
// 清理使用创建时保存的目标,避免 CNAME 变化后误删或遗漏原记录。
|
||
key := domain + ":" + keyAuth
|
||
created, exists := provider.records[key]
|
||
if !exists {
|
||
return nil
|
||
}
|
||
current, err := provider.findCreatedRecord(created)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if current.ID != created.ID || current.Name != created.Name || current.Type != "TXT" || current.Value != created.Value {
|
||
return errors.New("DNS 挑战记录已被修改,保留记录并停止自动清理")
|
||
}
|
||
if err := provider.call("DeleteDomainRecord", url.Values{"RecordId": {created.ID}}, nil); err != nil {
|
||
return err
|
||
}
|
||
delete(provider.records, key)
|
||
return nil
|
||
}
|
||
|
||
// 使用指定托管区域的记录列表复核,不要求额外的跨区域单记录查询权限。
|
||
func (provider *aliDNSChallenge) findCreatedRecord(created aliDNSRecord) (aliDNSListedRecord, error) {
|
||
const pageSize = 500
|
||
for page := 1; page <= 20; page++ {
|
||
var response struct {
|
||
Total int `json:"TotalCount"`
|
||
Records struct {
|
||
Record []aliDNSListedRecord `json:"Record"`
|
||
} `json:"DomainRecords"`
|
||
}
|
||
if err := provider.call("DescribeDomainRecords", url.Values{
|
||
"DomainName": {provider.zone}, "RRKeyWord": {created.Name}, "Type": {"TXT"},
|
||
"PageSize": {strconv.Itoa(pageSize)}, "PageNumber": {strconv.Itoa(page)},
|
||
}, &response); err != nil {
|
||
return aliDNSListedRecord{}, err
|
||
}
|
||
for _, record := range response.Records.Record {
|
||
if record.ID == created.ID {
|
||
return record, nil
|
||
}
|
||
}
|
||
if page*pageSize >= response.Total || len(response.Records.Record) == 0 {
|
||
break
|
||
}
|
||
}
|
||
return aliDNSListedRecord{}, errors.New("无法核对本次 DNS 挑战记录,已停止自动清理")
|
||
}
|
||
|
||
func aliDNSPercentEncode(value string) string {
|
||
return strings.ReplaceAll(url.QueryEscape(value), "+", "%20")
|
||
}
|
||
|
||
func (provider *aliDNSChallenge) call(action string, values url.Values, target any) error {
|
||
// 每次请求使用独立副本,重试不能把上一次的签名带入新的签名计算。
|
||
parameters := make(url.Values, len(values))
|
||
for key, entries := range values {
|
||
if key != "Signature" {
|
||
parameters[key] = append([]string(nil), entries...)
|
||
}
|
||
}
|
||
values = parameters
|
||
nonce := make([]byte, 16)
|
||
if _, err := rand.Read(nonce); err != nil {
|
||
return errors.New("DNS 请求随机数生成失败")
|
||
}
|
||
values.Set("Action", action)
|
||
values.Set("Version", "2015-01-09")
|
||
values.Set("Format", "JSON")
|
||
values.Set("AccessKeyId", provider.accessKey)
|
||
values.Set("SignatureMethod", "HMAC-SHA1")
|
||
values.Set("SignatureVersion", "1.0")
|
||
values.Set("SignatureNonce", hex.EncodeToString(nonce))
|
||
values.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
||
canonical := strings.ReplaceAll(values.Encode(), "+", "%20")
|
||
// 阿里云 DNS RPC API 的当前签名协议固定使用 HMAC-SHA1;此处不能自行替换为 SHA-256,
|
||
// 否则服务商会拒绝请求。请求通过 HTTPS 发送,密钥只用于内存中的签名计算。
|
||
mac := hmac.New(sha1.New, []byte(provider.secretKey+"&"))
|
||
_, _ = mac.Write([]byte("POST&%2F&" + aliDNSPercentEncode(canonical)))
|
||
values.Set("Signature", base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||
request, err := http.NewRequest(http.MethodPost, provider.endpoint, strings.NewReader(values.Encode()))
|
||
if err != nil {
|
||
return errors.New("DNS 请求创建失败")
|
||
}
|
||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
response, err := provider.client.Do(request)
|
||
if err != nil {
|
||
return errors.New("DNS 服务商连接失败或超时")
|
||
}
|
||
defer response.Body.Close()
|
||
data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||
if err != nil || len(data) > 1<<20 {
|
||
return errors.New("DNS 服务商响应无效")
|
||
}
|
||
var status struct {
|
||
Code string `json:"Code"`
|
||
}
|
||
if json.Unmarshal(data, &status) != nil || response.StatusCode != http.StatusOK || status.Code != "" {
|
||
// 服务商错误可能包含凭据、签名或请求原文,不能直接交给日志。
|
||
return errors.New("DNS 服务商拒绝请求,请检查凭据、区域和记录管理权限")
|
||
}
|
||
if target != nil && json.Unmarshal(data, target) != nil {
|
||
return errors.New("DNS 服务商响应解析失败")
|
||
}
|
||
return nil
|
||
}
|