完成阿里云域名授权检查初版

This commit is contained in:
xuans 2026-09-14 21:27:13 +08:00
parent ca7c2786d8
commit b5733b0e7c
3 changed files with 323 additions and 0 deletions

View File

@ -114,6 +114,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
sshKeys := sshkey.NewService(secretStore)
accounts := certificate.NewAccountRepository(secretStore)
certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts)
certificates.SetDNSAuthorizationChecker(dnsprovider.NewAliyunAuthorizationChecker())
deploymentService := deployment.NewService(deployment.SSHFactory{Secrets: secretStore})
notifier := notification.New(cfg.Notification, secretStore)
remoteValidator := certificate.RemoteValidator{}

View File

@ -28,6 +28,11 @@ type ProviderFactory interface {
Create(providerID string, credentials map[string]string) (challenge.Provider, error)
}
// DNSAuthorizationChecker 在证书申请前确认当前凭据仍能操作目标域名。
type DNSAuthorizationChecker interface {
VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error
}
type AccountManager interface {
LoadOrCreate(ctx context.Context, environment, email string) (*AccountUser, error)
Save(ctx context.Context, environment string, user *AccountUser) error
@ -40,12 +45,19 @@ type Service struct {
repository DomainStateRepository
credentials CredentialReader
providers ProviderFactory
authorization DNSAuthorizationChecker
accounts AccountManager
store FileStore
newIssuer IssuerFactory
preferredChain string
}
func (s *Service) SetDNSAuthorizationChecker(checker DNSAuthorizationChecker) {
if s != nil {
s.authorization = checker
}
}
type IssueResult struct {
DomainID string `json:"domain_id"`
Domains []string `json:"domains"`
@ -129,6 +141,9 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
if err != nil {
return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err))
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return fail(err)
}
provider, err := s.providers.Create(providerID, credentials)
if err != nil {
return fail(fmt.Errorf("创建 DNS Provider 失败:%w", err))
@ -200,6 +215,9 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
if err != nil {
return SavedResource{}, fmt.Errorf("读取初始化证书 DNS 凭据失败:%w", err)
}
if err := s.verifyDNSAuthorization(ctx, providerID, domains, credentials); err != nil {
return SavedResource{}, err
}
provider, err := s.providers.Create(providerID, credentials)
if err != nil {
return SavedResource{}, fmt.Errorf("创建初始化证书 DNS Provider 失败:%w", err)
@ -240,6 +258,18 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
return saved, nil
}
func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error {
if s == nil || s.authorization == nil {
return nil
}
for _, requestedDomain := range domains {
if err := s.authorization.VerifyDomain(ctx, providerID, requestedDomain, credentials); err != nil {
return fmt.Errorf("DNS 凭据无权操作域名 %s:%w", requestedDomain, err)
}
}
return nil
}
func (s *Service) Import(ctx context.Context, request ImportRequest) (IssueResult, error) {
if s == nil || s.repository == nil {
return IssueResult{}, errors.New("证书导入服务未初始化")

View File

@ -0,0 +1,292 @@
package dnsprovider
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
"github.com/alibabacloud-go/tea/dara"
"github.com/go-acme/alidns-20150109/v5/client"
)
const (
aliyunDefaultRegion = "cn-hangzhou"
aliyunAuthorizationTTL = 60
aliyunAuthorizationTimeout = 30 * time.Second
)
// AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。
type AuthorizationDomain struct {
Domain string `json:"domain"`
Listed bool `json:"listed"`
Writable bool `json:"writable"`
Cleanup bool `json:"cleanup"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
// AuthorizationReport 区分 API 可见范围和经过实际写入证明的范围。
type AuthorizationReport struct {
Provider string `json:"provider"`
RequestedDomain string `json:"requested_domain,omitempty"`
MatchedDomain string `json:"matched_domain,omitempty"`
Domains []AuthorizationDomain `json:"domains"`
Verified bool `json:"verified"`
CheckedAt time.Time `json:"checked_at"`
}
// AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。
type AliyunAuthorizationChecker struct {
now func() time.Time
}
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
return &AliyunAuthorizationChecker{now: time.Now}
}
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
return strings.EqualFold(strings.TrimSpace(providerID), Aliyun)
}
// Discover 只读取阿里云账号可见的根域名,不把读取权限误判为写入权限。
func (c *AliyunAuthorizationChecker) Discover(ctx context.Context, credentials map[string]string) (AuthorizationReport, error) {
report := AuthorizationReport{Provider: Aliyun, CheckedAt: c.currentTime().UTC()}
client, err := newAliyunClient(credentials)
if err != nil {
return report, err
}
domains, err := listAliyunDomains(ctx, client)
if err != nil {
return report, fmt.Errorf("读取阿里云 DNS 授权域名失败:%w", err)
}
for _, name := range domains {
report.Domains = append(report.Domains, AuthorizationDomain{
Domain: name, Listed: true, Status: "可见但尚未证明可写",
})
}
return report, nil
}
// Verify 对目标域名所属的最长托管根域名执行最小写入和精确清理。
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
report, err := c.Discover(ctx, credentials)
report.RequestedDomain = requestedDomain
if err != nil {
return report, err
}
if !domain.IsValidName(requestedDomain) {
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
}
matched := matchManagedDomain(requestedDomain, report.Domains)
if matched == "" {
return report, fmt.Errorf("阿里云 RAM 账号未发现域名 %s 所属的托管根域名", requestedDomain)
}
report.MatchedDomain = matched
for index := range report.Domains {
if report.Domains[index].Domain == matched {
report.Domains[index].Status = "正在证明写入权限"
}
}
client, err := newAliyunClient(credentials)
if err != nil {
return report, err
}
recordID, err := addAuthorizationRecord(ctx, client, matched)
if err != nil {
markAuthorizationFailure(&report, matched, fmt.Errorf("创建授权探测 TXT 记录失败:%w", err))
return report, err
}
for index := range report.Domains {
if report.Domains[index].Domain == matched {
report.Domains[index].Writable = true
}
}
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
defer cancel()
if err := deleteAuthorizationRecord(cleanupContext, client, recordID); err != nil {
markAuthorizationFailure(&report, matched, fmt.Errorf("清理授权探测 TXT 记录失败:%w", err))
return report, err
}
for index := range report.Domains {
if report.Domains[index].Domain == matched {
report.Domains[index].Cleanup = true
report.Domains[index].Status = "可操作"
}
}
report.Verified = true
return report, nil
}
// VerifyDomain 实现证书服务使用的最小授权检查接口。
func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
if !c.Supports(providerID) {
return nil
}
report, err := c.Verify(ctx, requestedDomain, credentials)
if err != nil {
return err
}
if !report.Verified {
return errors.New("阿里云 DNS 域名授权未通过")
}
return nil
}
func (c *AliyunAuthorizationChecker) currentTime() time.Time {
if c != nil && c.now != nil {
return c.now()
}
return time.Now()
}
func newAliyunClient(credentials map[string]string) (*client.Client, error) {
cleaned, err := ValidateCredentials(Aliyun, credentials)
if err != nil {
return nil, err
}
config := new(openapi.Config).SetRegionId(aliyunDefaultRegion).
SetAccessKeyId(cleaned["access_key_id"]).
SetAccessKeySecret(cleaned["access_key_secret"])
if token := cleaned["security_token"]; token != "" {
config.SetSecurityToken(token)
}
result, err := client.NewClient(config)
if err != nil {
return nil, fmt.Errorf("创建阿里云 DNS 客户端失败:%w", err)
}
return result, nil
}
func listAliyunDomains(ctx context.Context, dnsClient *client.Client) ([]string, error) {
if dnsClient == nil {
return nil, errors.New("阿里云 DNS 客户端未初始化")
}
var result []string
for page := int64(1); ; page++ {
request := new(client.DescribeDomainsRequest).SetPageNumber(page).SetPageSize(100)
response, err := client.DescribeDomainsWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
if err != nil {
return nil, err
}
if response == nil || response.Body == nil {
return nil, errors.New("阿里云 DNS 返回空的域名列表")
}
if response.Body.Domains != nil {
for _, item := range response.Body.Domains.Domain {
if item == nil || item.DomainName == nil {
continue
}
name := normalizeAuthorizationDomain(*item.DomainName)
if name != "" {
result = append(result, name)
}
}
}
pageSize := dereferenceInt64(response.Body.PageSize)
total := dereferenceInt64(response.Body.TotalCount)
if pageSize <= 0 || page*pageSize >= total {
break
}
}
sort.Strings(result)
return uniqueStrings(result), nil
}
func addAuthorizationRecord(ctx context.Context, dnsClient *client.Client, zone string) (string, error) {
suffix, err := randomAuthorizationValue(18)
if err != nil {
return "", fmt.Errorf("生成授权探测标识失败:%w", err)
}
value, err := randomAuthorizationValue(32)
if err != nil {
return "", fmt.Errorf("生成授权探测值失败:%w", err)
}
request := new(client.AddDomainRecordRequest).
SetDomainName(zone).
SetRR("_scert-authorization-" + suffix).
SetType("TXT").
SetValue(value).
SetTTL(aliyunAuthorizationTTL)
response, err := client.AddDomainRecordWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
if err != nil {
return "", err
}
if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" {
return "", errors.New("阿里云未返回授权探测记录 ID")
}
return strings.TrimSpace(*response.Body.RecordId), nil
}
func deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, recordID string) error {
if strings.TrimSpace(recordID) == "" {
return errors.New("授权探测记录 ID 为空")
}
_, err := client.DeleteDomainRecordWithContext(ctx, dnsClient, new(client.DeleteDomainRecordRequest).SetRecordId(recordID), &dara.RuntimeOptions{})
return err
}
func matchManagedDomain(requested string, domains []AuthorizationDomain) string {
requested = normalizeAuthorizationDomain(requested)
best := ""
for _, item := range domains {
candidate := normalizeAuthorizationDomain(item.Domain)
if candidate == "" || (requested != candidate && !strings.HasSuffix(requested, "."+candidate)) {
continue
}
if len(candidate) > len(best) {
best = candidate
}
}
return best
}
func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
for index := range report.Domains {
if report.Domains[index].Domain == domainName {
report.Domains[index].Status = "不可操作"
report.Domains[index].Error = err.Error()
}
}
}
func normalizeAuthorizationDomain(value string) string {
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(value), "."))
}
func randomAuthorizationValue(size int) (string, error) {
data := make([]byte, size)
if _, err := rand.Read(data); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func dereferenceInt64(value *int64) int64 {
if value == nil {
return 0
}
return *value
}
func uniqueStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}