完善阿里云授权动态刷新与失效处理

This commit is contained in:
xuans 2026-09-14 21:46:47 +08:00
parent b5733b0e7c
commit fce03798b5
4 changed files with 350 additions and 43 deletions

View File

@ -33,6 +33,10 @@ type DNSAuthorizationChecker interface {
VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error
}
type DNSAuthorizationCacheInvalidator interface {
InvalidateDomain(providerID, requestedDomain string, credentials map[string]string)
}
type AccountManager interface {
LoadOrCreate(ctx context.Context, environment, email string) (*AccountUser, error)
Save(ctx context.Context, environment string, user *AccountUser) error
@ -160,6 +164,9 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro
resource, issueErr := issuer.Obtain(ctx, ObtainRequest{
Domains: domains, AccountEmail: email, Environment: item.ACMEEnvironment, PreferredChain: s.preferredChain,
})
if issueErr != nil {
s.invalidateDNSAuthorizationOnFailure(providerID, domains, credentials, issueErr)
}
if saveErr := s.accounts.Save(ctx, item.ACMEEnvironment, user); saveErr != nil {
if issueErr != nil {
return fail(fmt.Errorf("%v;保存 ACME 账户状态失败:%w", issueErr, saveErr))
@ -234,6 +241,9 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
resource, issueErr := issuer.Obtain(ctx, ObtainRequest{
Domains: domains, AccountEmail: accountEmail, Environment: environment, PreferredChain: s.preferredChain,
})
if issueErr != nil {
s.invalidateDNSAuthorizationOnFailure(providerID, domains, credentials, issueErr)
}
if saveErr := s.accounts.Save(ctx, environment, user); saveErr != nil {
if issueErr != nil {
return SavedResource{}, fmt.Errorf("%v;保存 ACME 账户状态失败:%w", issueErr, saveErr)
@ -270,6 +280,39 @@ func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string,
return nil
}
func (s *Service) invalidateDNSAuthorization(providerID string, domains []string, credentials map[string]string) {
invalidator, ok := s.authorization.(DNSAuthorizationCacheInvalidator)
if !ok {
return
}
for _, requestedDomain := range domains {
invalidator.InvalidateDomain(providerID, requestedDomain, credentials)
}
}
func (s *Service) invalidateDNSAuthorizationOnFailure(providerID string, domains []string, credentials map[string]string, issueErr error) {
if !looksLikeDNSAuthorizationFailure(issueErr) {
return
}
s.invalidateDNSAuthorization(providerID, domains, credentials)
}
func looksLikeDNSAuthorizationFailure(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{
"dns-01", "dns challenge", "txt record", "present", "cleanup", "clean up",
"unauthorized", "forbidden", "permission", "accessdenied", "权限", "无权", "授权",
} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func (s *Service) Import(ctx context.Context, request ImportRequest) (IssueResult, error) {
if s == nil || s.repository == nil {
return IssueResult{}, errors.New("证书导入服务未初始化")

View File

@ -58,6 +58,20 @@ func (serviceProviderFactory) Create(string, map[string]string) (challenge.Provi
return serviceProvider{}, nil
}
type serviceAuthorizationChecker struct {
verifyCalls int
invalidateCalls int
}
func (c *serviceAuthorizationChecker) VerifyDomain(context.Context, string, string, map[string]string) error {
c.verifyCalls++
return nil
}
func (c *serviceAuthorizationChecker) InvalidateDomain(string, string, map[string]string) {
c.invalidateCalls++
}
type recordingProviderFactory struct {
providerID string
credentials map[string]string
@ -157,6 +171,50 @@ func TestServiceFailureKeepsExistingCertificate(t *testing.T) {
}
}
func TestServiceInvalidatesDNSAuthorizationAfterACMEFailure(t *testing.T) {
root := t.TempDir()
cfg := certificateTestConfig(root)
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, true, domain.ACMEEnvironmentStaging, "cloudflare")
if err != nil {
t.Fatal(err)
}
repository := &serviceRepository{item: item}
issuer := &serviceIssuer{err: errors.New("模拟 DNS 权限已被收回")}
authorization := &serviceAuthorizationChecker{}
service := NewService(cfg, repository, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
service.SetDNSAuthorizationChecker(authorization)
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer }
if _, err := service.Issue(context.Background(), item.ID); err == nil {
t.Fatal("ACME 申请失败时应返回错误")
}
if authorization.verifyCalls != 1 || authorization.invalidateCalls != 1 {
t.Fatalf("ACME 失败后的授权缓存处理错误:verify=%d invalidate=%d", authorization.verifyCalls, authorization.invalidateCalls)
}
}
func TestServiceDoesNotInvalidateDNSAuthorizationForUnrelatedACMEFailure(t *testing.T) {
root := t.TempDir()
cfg := certificateTestConfig(root)
item, err := domain.NewDomainWithOptions("example.com", domain.CertificateSingle, nil, true, domain.ACMEEnvironmentStaging, "cloudflare")
if err != nil {
t.Fatal(err)
}
repository := &serviceRepository{item: item}
issuer := &serviceIssuer{err: errors.New("ACME 服务暂时不可用")}
authorization := &serviceAuthorizationChecker{}
service := NewService(cfg, repository, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
service.SetDNSAuthorizationChecker(authorization)
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer { return issuer }
if _, err := service.Issue(context.Background(), item.ID); err == nil {
t.Fatal("ACME 申请失败时应返回错误")
}
if authorization.invalidateCalls != 0 {
t.Fatalf("无关的 ACME 错误不应清除授权缓存:invalidate=%d", authorization.invalidateCalls)
}
}
func TestServiceImportsOwnedCertificate(t *testing.T) {
root := t.TempDir()
cfg := certificateTestConfig(root)

View File

@ -3,11 +3,13 @@ package dnsprovider
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
@ -17,9 +19,10 @@ import (
)
const (
aliyunDefaultRegion = "cn-hangzhou"
aliyunAuthorizationTTL = 60
aliyunAuthorizationTimeout = 30 * time.Second
aliyunDefaultRegion = "cn-hangzhou"
aliyunAuthorizationTTL = 60
aliyunAuthorizationTimeout = 30 * time.Second
aliyunAuthorizationCacheTTL = 10 * time.Minute
)
// AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。
@ -44,11 +47,14 @@ type AuthorizationReport struct {
// AliyunAuthorizationChecker 负责发现并证明 RAM 账号对阿里云 DNS 根域名的实际写权限。
type AliyunAuthorizationChecker struct {
now func() time.Time
mu sync.Mutex
now func() time.Time
cache map[string]time.Time
verifyDomainFunc func(context.Context, string, map[string]string) error
}
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
return &AliyunAuthorizationChecker{now: time.Now}
return &AliyunAuthorizationChecker{now: time.Now, cache: make(map[string]time.Time)}
}
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
@ -74,57 +80,51 @@ func (c *AliyunAuthorizationChecker) Discover(ctx context.Context, credentials m
return report, nil
}
// Verify 对目标域名所属的最长托管根域名执行最小写入和精确清理。
// 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
}
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
if !domain.IsValidName(requestedDomain) {
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
}
matched := matchManagedDomain(requestedDomain, report.Domains)
if matched == "" {
return report, fmt.Errorf("阿里云 RAM 账号未发现域名 %s 所属的托管根域名", requestedDomain)
for _, candidate := range authorizationZoneCandidates(requestedDomain) {
report.Domains = append(report.Domains, AuthorizationDomain{
Domain: candidate, Status: "待验证",
})
}
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 = "可操作"
var lastErr error
for index, candidate := range authorizationZoneCandidates(requestedDomain) {
report.Domains[index].Status = "正在证明写入权限"
recordID, probeErr := addAuthorizationRecord(ctx, client, candidate)
if probeErr != nil {
lastErr = probeErr
report.Domains[index].Status = "不可操作"
report.Domains[index].Error = probeErr.Error()
continue
}
report.Domains[index].Writable = true
report.MatchedDomain = candidate
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
cleanupErr := deleteAuthorizationRecord(cleanupContext, client, recordID)
cancel()
if cleanupErr != nil {
markAuthorizationFailure(&report, candidate, fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr))
return report, cleanupErr
}
report.Domains[index].Cleanup = true
report.Domains[index].Status = "可操作"
report.Verified = true
return report, nil
}
report.Verified = true
return report, nil
if lastErr == nil {
lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名")
}
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w", requestedDomain, lastErr)
}
// VerifyDomain 实现证书服务使用的最小授权检查接口。
@ -132,6 +132,17 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
if !c.Supports(providerID) {
return nil
}
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
if c.cached(requestedDomain, credentials) {
return nil
}
if c.verifyDomainFunc != nil {
if err := c.verifyDomainFunc(ctx, requestedDomain, credentials); err != nil {
return err
}
c.remember(requestedDomain, credentials)
return nil
}
report, err := c.Verify(ctx, requestedDomain, credentials)
if err != nil {
return err
@ -139,9 +150,25 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
if !report.Verified {
return errors.New("阿里云 DNS 域名授权未通过")
}
c.remember(requestedDomain, credentials)
return nil
}
// InvalidateDomain 在实际 ACME 写入失败后清除短时授权缓存,确保权限收缩能够被重新探测。
func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) {
if c == nil || !c.Supports(providerID) {
return
}
key := authorizationCacheKey(providerID, requestedDomain, credentials)
c.mu.Lock()
if c.cache == nil {
c.mu.Unlock()
return
}
delete(c.cache, key)
c.mu.Unlock()
}
func (c *AliyunAuthorizationChecker) currentTime() time.Time {
if c != nil && c.now != nil {
return c.now()
@ -250,6 +277,62 @@ func matchManagedDomain(requested string, domains []AuthorizationDomain) string
return best
}
func authorizationZoneCandidates(requested string) []string {
requested = strings.TrimPrefix(normalizeAuthorizationDomain(requested), "*.")
labels := strings.Split(requested, ".")
result := make([]string, 0, len(labels)-1)
for index := 0; index < len(labels)-1; index++ {
candidate := strings.Join(labels[index:], ".")
if domain.IsValidName(candidate) {
result = append(result, candidate)
}
}
return result
}
func (c *AliyunAuthorizationChecker) cached(requestedDomain string, credentials map[string]string) bool {
if c == nil {
return false
}
now := c.currentTime().UTC()
key := authorizationCacheKey(Aliyun, requestedDomain, credentials)
c.mu.Lock()
defer c.mu.Unlock()
expiresAt, ok := c.cache[key]
if !ok || !expiresAt.After(now) {
delete(c.cache, key)
return false
}
return true
}
func (c *AliyunAuthorizationChecker) remember(requestedDomain string, credentials map[string]string) {
if c == nil {
return
}
key := authorizationCacheKey(Aliyun, requestedDomain, credentials)
c.mu.Lock()
if c.cache == nil {
c.cache = make(map[string]time.Time)
}
c.cache[key] = c.currentTime().UTC().Add(aliyunAuthorizationCacheTTL)
c.mu.Unlock()
}
func authorizationCacheKey(providerID, requestedDomain string, credentials map[string]string) string {
cleaned, _ := ValidateCredentials(providerID, credentials)
keys := make([]string, 0, len(cleaned))
for key := range cleaned {
keys = append(keys, key)
}
sort.Strings(keys)
hash := sha256.New()
for _, key := range keys {
fmt.Fprintf(hash, "%d:%s=%d:%s\n", len(key), key, len(cleaned[key]), cleaned[key])
}
return fmt.Sprintf("%s|%s|%x", strings.ToLower(strings.TrimSpace(providerID)), normalizeAuthorizationDomain(requestedDomain), hash.Sum(nil))
}
func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
for index := range report.Domains {
if report.Domains[index].Domain == domainName {

View File

@ -0,0 +1,123 @@
package dnsprovider
import (
"context"
"errors"
"testing"
"time"
)
func TestAuthorizationZoneCandidatesPreferSpecificZones(t *testing.T) {
got := authorizationZoneCandidates("*.www.example.com.")
want := []string{"www.example.com", "example.com"}
if len(got) != len(want) {
t.Fatalf("候选托管区数量错误:got=%v want=%v", got, want)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("候选托管区顺序错误:got=%v want=%v", got, want)
}
}
}
func TestAliyunAuthorizationCheckerCachesSuccessfulAuthorization(t *testing.T) {
now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
calls := 0
checker := NewAliyunAuthorizationChecker()
checker.now = func() time.Time { return now }
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
calls++
return nil
}
credentials := map[string]string{
"access_key_id": "test-id",
"access_key_secret": "test-secret",
}
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
t.Fatalf("首次授权检查失败:%v", err)
}
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
t.Fatalf("缓存授权检查失败:%v", err)
}
if calls != 1 {
t.Fatalf("短时授权缓存未生效:calls=%d", calls)
}
now = now.Add(aliyunAuthorizationCacheTTL + time.Second)
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
t.Fatalf("缓存过期后的授权检查失败:%v", err)
}
if calls != 2 {
t.Fatalf("授权缓存过期后未重新探测:calls=%d", calls)
}
}
func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing.T) {
calls := 0
checker := NewAliyunAuthorizationChecker()
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
calls++
return nil
}
credentials := map[string]string{
"access_key_id": "test-id",
"access_key_secret": "test-secret",
}
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err != nil {
t.Fatalf("首次授权检查失败:%v", err)
}
checker.InvalidateDomain(Aliyun, "example.com", credentials)
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err != nil {
t.Fatalf("权限失败后的重新授权检查失败:%v", err)
}
if calls != 2 {
t.Fatalf("授权缓存未在权限失败后失效:calls=%d", calls)
}
}
func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T) {
calls := 0
checker := NewAliyunAuthorizationChecker()
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
calls++
return errors.New("权限不足")
}
credentials := map[string]string{
"access_key_id": "test-id",
"access_key_secret": "test-secret",
}
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err == nil {
t.Fatal("权限失败时应返回错误")
}
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err == nil {
t.Fatal("权限失败不应被缓存为成功")
}
if calls != 2 {
t.Fatalf("失败授权检查被错误缓存:calls=%d", calls)
}
}
func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) {
checker := NewAliyunAuthorizationChecker()
checker.verifyDomainFunc = func(context.Context, string, map[string]string) error {
return nil
}
credentials := map[string]string{
"access_key_id": "test-id",
"access_key_secret": "test-secret",
}
done := make(chan struct{})
for index := 0; index < 20; index++ {
go func() {
defer func() { done <- struct{}{} }()
_ = checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials)
checker.InvalidateDomain(Aliyun, "example.com", credentials)
}()
}
for index := 0; index < 20; index++ {
<-done
}
}