From fce03798b58704e44ccf6789f64a0f33586a11d7 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 14 Sep 2026 21:46:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E5=8A=A8=E6=80=81=E5=88=B7=E6=96=B0=E4=B8=8E?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/certificate/service.go | 43 +++++ backend/internal/certificate/service_test.go | 58 ++++++ .../dnsprovider/aliyun_authorization.go | 169 +++++++++++++----- .../dnsprovider/aliyun_authorization_test.go | 123 +++++++++++++ 4 files changed, 350 insertions(+), 43 deletions(-) create mode 100644 backend/internal/dnsprovider/aliyun_authorization_test.go diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 63f933f..a39502f 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -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("证书导入服务未初始化") diff --git a/backend/internal/certificate/service_test.go b/backend/internal/certificate/service_test.go index 2f7666c..3b72ce8 100644 --- a/backend/internal/certificate/service_test.go +++ b/backend/internal/certificate/service_test.go @@ -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) diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index e64176c..7cbe875 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -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 { diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go new file mode 100644 index 0000000..e05f75e --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -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 + } +}