diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 3a63bad..5e6cc7a 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -49,78 +49,86 @@ type AliyunAuthorizationChecker struct { now func() time.Time verifyDomainFunc func(context.Context, string, map[string]string) error recordAPI func(map[string]string) (aliyunRecordAPI, error) + zoneResolver AliyunZoneResolver } func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker { - return &AliyunAuthorizationChecker{now: time.Now, recordAPI: newAliyunAuthorizationRecordAPI} + return &AliyunAuthorizationChecker{ + now: time.Now, + recordAPI: newAliyunAuthorizationRecordAPI, + zoneResolver: newDefaultAliyunZoneResolver(), + } } func (c *AliyunAuthorizationChecker) Supports(providerID string) bool { return strings.EqualFold(strings.TrimSpace(providerID), Aliyun) } -// Verify 仅针对本次输入所推导的唯一注册根域执行一次写入和精确清理。 +// Verify 仅针对本次 SOA 识别出的唯一权威托管区执行一次写入和精确清理。 func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (report AuthorizationReport, resultErr error) { requestedDomain = normalizeAuthorizationDomain(requestedDomain) report = AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()} - zone, err := ResolveAliyunTargetDomain(requestedDomain) + if c == nil { + return report, errors.New("阿里云权限探测器未初始化") + } + verificationCtx, cancel := permissionVerificationContext(ctx) + defer cancel() + zone, err := c.resolveTargetDomain(verificationCtx, requestedDomain) if err != nil { return report, err } + return c.verifyResolved(verificationCtx, requestedDomain, zone, credentials, report) +} + +func (c *AliyunAuthorizationChecker) verifyResolved(ctx context.Context, requestedDomain, zone string, credentials map[string]string, report AuthorizationReport) (result AuthorizationReport, resultErr error) { + result = report if err := ctx.Err(); err != nil { - return report, err + return result, err } - verificationCtx, cancel := context.WithTimeout(ctx, PermissionVerificationTimeoutFor(Aliyun)) - defer cancel() - report.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}} + result.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}} if c == nil || c.recordAPI == nil { - return report, errors.New("阿里云权限探测器未初始化") + return result, errors.New("阿里云权限探测器未初始化") } cleaned, err := ValidateCredentials(Aliyun, credentials) if err != nil { - return report, err + return result, err } api, err := c.recordAPI(cleaned) if err != nil { - return report, err + return result, err } suffix, err := randomAuthorizationValue(18) if err != nil { - return report, err + return result, err } value, err := randomAuthorizationValue(32) if err != nil { - return report, err + return result, err } rr := "_scert-authorization-" + suffix base := strings.TrimPrefix(requestedDomain, "*.") if base != zone { rr += "." + strings.TrimSuffix(base, "."+zone) } - report.RecordName = rr + "." + zone + "." - addCtx, addCancel := context.WithTimeout(verificationCtx, aliyunAPICallTimeout) + result.RecordName = rr + "." + zone + "." + addCtx, addCancel := context.WithTimeout(ctx, aliyunAPICallTimeout) recordID, err := api.Add(addCtx, zone, rr, value) addCancel() if err != nil { - markAuthorizationFailure(&report, zone, err) - report.Diagnosis = DiagnoseAliyunError(err, zone) - return report, err + markAuthorizationFailure(&result, zone, err) + result.Diagnosis = DiagnoseAliyunError(err, zone) + return result, err } - report.Domains[0].Writable = true - report.MatchedDomain = zone + result.Domains[0].Writable = true + result.MatchedDomain = zone defer func() { - cleanupBase := context.WithoutCancel(verificationCtx) - if deadline, ok := verificationCtx.Deadline(); ok { - var cleanupDeadlineCancel context.CancelFunc - cleanupBase, cleanupDeadlineCancel = context.WithDeadline(cleanupBase, deadline) - defer cleanupDeadlineCancel() - } + cleanupBase := context.WithoutCancel(ctx) cleanupContext, cleanupCancel := context.WithTimeout(cleanupBase, aliyunAPICallTimeout) defer cleanupCancel() if cleanupErr := api.Delete(cleanupContext, recordID); cleanupErr != nil { cleanupErr = fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr) - markAuthorizationFailure(&report, zone, cleanupErr) - report.Verified = false + markAuthorizationFailure(&result, zone, cleanupErr) + result.Verified = false if resultErr == nil { resultErr = cleanupErr } else { @@ -128,26 +136,26 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain } return } - report.Domains[0].Cleanup = true + result.Domains[0].Cleanup = true }() - describeCtx, describeCancel := context.WithTimeout(verificationCtx, aliyunAPICallTimeout) + describeCtx, describeCancel := context.WithTimeout(ctx, aliyunAPICallTimeout) info, err := api.Describe(describeCtx, recordID) describeCancel() if err != nil { resultErr = fmt.Errorf("查询授权探测 TXT 记录失败:%w", err) - markAuthorizationFailure(&report, zone, resultErr) - report.Diagnosis = DiagnoseAliyunError(err, zone) - return report, resultErr + markAuthorizationFailure(&result, zone, resultErr) + result.Diagnosis = DiagnoseAliyunError(err, zone) + return result, resultErr } if !matchesAliyunAuthorizationRecord(info, recordID, zone, rr, value) { resultErr = errors.New("阿里云返回的临时 TXT 记录与本次验证不一致") - markAuthorizationFailure(&report, zone, resultErr) - return report, resultErr + markAuthorizationFailure(&result, zone, resultErr) + return result, resultErr } - report.Domains[0].Readable = true - report.Domains[0].Status = "可操作" - report.Verified = true - return report, nil + result.Domains[0].Readable = true + result.Domains[0].Status = "可操作" + result.Verified = true + return result, nil } func matchesAliyunAuthorizationRecord(info aliyunRecordInfo, recordID, zone, rr, value string) bool { @@ -166,20 +174,24 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI if c == nil { return errors.New("阿里云权限探测器未初始化") } - if err := ctx.Err(); err != nil { + verificationCtx, cancel := permissionVerificationContext(ctx) + defer cancel() + if err := verificationCtx.Err(); err != nil { return err } requestedDomain = normalizeAuthorizationDomain(requestedDomain) - if _, err := ResolveAliyunTargetDomain(requestedDomain); err != nil { + zone, err := c.resolveTargetDomain(verificationCtx, requestedDomain) + if err != nil { return err } if _, err := ValidateCredentials(Aliyun, credentials); err != nil { return err } if c.verifyDomainFunc != nil { - return c.verifyDomainFunc(ctx, requestedDomain, credentials) + return c.verifyDomainFunc(verificationCtx, requestedDomain, credentials) } - report, err := c.Verify(ctx, requestedDomain, credentials) + report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()} + report, err = c.verifyResolved(verificationCtx, requestedDomain, zone, credentials, report) if err != nil { return err } @@ -189,6 +201,20 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI return nil } +func (c *AliyunAuthorizationChecker) resolveTargetDomain(ctx context.Context, requestedDomain string) (string, error) { + if c == nil { + return "", errors.New("阿里云权限探测器未初始化") + } + return resolveAliyunTargetDomain(ctx, requestedDomain, c.zoneResolver) +} + +func permissionVerificationContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(ctx, PermissionVerificationTimeoutFor(Aliyun)) +} + // InvalidateDomain 保留旧调用方接口;授权检查已不再缓存,无需额外失效操作。 func (c *AliyunAuthorizationChecker) InvalidateDomain(providerID, requestedDomain string, credentials map[string]string) { } @@ -277,6 +303,9 @@ func DiagnoseAliyunError(err error, domainName string) string { } msg := err.Error() lowered := strings.ToLower(msg) + if strings.Contains(msg, "权威 DNS 托管区") || strings.Contains(lowered, "soa lookup") { + return "未能从公开 DNS 的 SOA 记录识别该域名唯一的权威托管区,系统未构造阿里云客户端,也未创建临时 TXT 记录。请检查域名是否已正确委派并可被公网 DNS 查询后重试" + } if strings.Contains(lowered, "forbidden.nopermission") || strings.Contains(lowered, "forbidden.ram") || strings.Contains(lowered, "accessdenied") || strings.Contains(lowered, "not authorized") { return fmt.Sprintf("当前阿里云 RAM 账号未获得域名 %s 所需的 DNS 操作权限。请管理员核对实际托管区资源 acs:alidns:*:*:domain/%s,按失败操作补充必要的读取、新增或删除权限;不要直接扩大为全部操作或全部域名权限", domainName, domainName) } diff --git a/backend/internal/dnsprovider/aliyun_authorization_test.go b/backend/internal/dnsprovider/aliyun_authorization_test.go index fd662f8..7407b3f 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_test.go @@ -39,7 +39,7 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) calls := 0 revoked := false - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() checker.now = func() time.Time { return now } checker.verifyDomainFunc = func(context.Context, string, map[string]string) error { calls++ @@ -76,7 +76,7 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing.T) { calls := 0 - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() checker.verifyDomainFunc = func(context.Context, string, map[string]string) error { calls++ return nil @@ -102,7 +102,7 @@ func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing. func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T) { calls := 0 - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() checker.verifyDomainFunc = func(context.Context, string, map[string]string) error { calls++ return errors.New("权限不足") @@ -126,7 +126,7 @@ func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T) } func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) { - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() checker.verifyDomainFunc = func(context.Context, string, map[string]string) error { return nil } @@ -175,6 +175,11 @@ func TestDiagnoseAliyunError(t *testing.T) { domain: "solit.cn", expected: "阿里云 AccessKey ID 或 AccessKey Secret 无效,请检查系统 DNS 凭据配置", }, + { + err: errors.New("无法识别域名 www.example.com 的权威 DNS 托管区,已停止验证:SOA lookup failed"), + domain: "www.example.com", + expected: "未能从公开 DNS 的 SOA 记录识别该域名唯一的权威托管区,系统未构造阿里云客户端,也未创建临时 TXT 记录。请检查域名是否已正确委派并可被公网 DNS 查询后重试", + }, { err: nil, domain: "solit.cn", diff --git a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go index 8176819..2c5b94a 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go @@ -75,7 +75,7 @@ func (a *authorizationRecordAPI) recordCall(name, id string, ctx context.Context } func newAuthorizationCheckerForTest(api aliyunRecordAPI) *AliyunAuthorizationChecker { - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { return api, nil } diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go index 6764db0..3154ee5 100644 --- a/backend/internal/dnsprovider/aliyun_provider.go +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -93,8 +93,14 @@ type scopedAliyunProvider struct { newAPI func(map[string]string) (aliyunRecordAPI, error) api aliyunRecordAPI mu sync.Mutex - records map[[32]byte]string + records map[[32]byte]aliyunPendingRecord challengeInfo func(context.Context, string, string) (string, string, string) + zoneResolver AliyunZoneResolver +} + +type aliyunPendingRecord struct { + ID string + Zone string } func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvider, error) { @@ -104,7 +110,9 @@ func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvid } return &scopedAliyunProvider{ credentials: cleaned, newAPI: newAliyunRecordAPI, - records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo, + records: make(map[[32]byte]aliyunPendingRecord), + challengeInfo: aliyunChallengeInfo, + zoneResolver: newDefaultAliyunZoneResolver(), }, nil } @@ -115,7 +123,13 @@ func aliyunChallengeInfo(ctx context.Context, domainName, keyAuth string) (strin func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth string) error { name = normalizeAuthorizationDomain(name) - zone, err := ResolveAliyunTargetDomain(name) + if p == nil { + return errors.New("阿里云 DNS Provider 未初始化") + } + if ctx == nil { + ctx = context.Background() + } + zone, err := resolveAliyunTargetDomain(ctx, name, p.zoneResolver) if err != nil { return err } @@ -152,18 +166,21 @@ func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth if err != nil { return err } - p.records[key] = id + p.records[key] = aliyunPendingRecord{ID: id, Zone: zone} return nil } func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error { - if _, err := ResolveAliyunTargetDomain(name); err != nil { - return err + if p == nil { + return errors.New("阿里云 DNS Provider 未初始化") + } + if ctx == nil { + ctx = context.Background() } key := sha256.Sum256([]byte(normalizeAuthorizationDomain(name) + "\x00" + token + "\x00" + keyAuth)) p.mu.Lock() defer p.mu.Unlock() - id, ok := p.records[key] + record, ok := p.records[key] if !ok { return nil } @@ -172,7 +189,7 @@ func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth } cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout) defer cancel() - if err := p.api.Delete(cleanupCtx, id); err != nil { + if err := p.api.Delete(cleanupCtx, record.ID); err != nil { return err } delete(p.records, key) diff --git a/backend/internal/dnsprovider/aliyun_scope.go b/backend/internal/dnsprovider/aliyun_scope.go index 2a0fdda..2700467 100644 --- a/backend/internal/dnsprovider/aliyun_scope.go +++ b/backend/internal/dnsprovider/aliyun_scope.go @@ -1,19 +1,109 @@ package dnsprovider import ( + "context" "errors" "fmt" "strings" + "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" - "golang.org/x/net/publicsuffix" + "github.com/go-acme/lego/v5/challenge/dns01" ) -const protectedAliyunDomain = "pcux.cn" +const ( + protectedAliyunDomain = "pcux.cn" + aliyunZoneResolutionTimeout = 2 * time.Second +) -// ResolveAliyunTargetDomain 从本次输入中得到唯一可写入的注册根域。 -// 它不枚举账号域名、不猜测托管区,也绝不因一次失败改写为父域或其他候选域名。 +// AliyunZoneResolver 只读识别用户域名当前的权威 DNS 托管区。 +// 它绝不能枚举 RAM 账号域名,也不能触发任何阿里云写操作。 +type AliyunZoneResolver interface { + Resolve(context.Context, string) (string, error) +} + +type aliyunZoneResolverFunc func(context.Context, string) (string, error) + +func (f aliyunZoneResolverFunc) Resolve(ctx context.Context, requested string) (string, error) { + return f(ctx, requested) +} + +type authoritativeAliyunZoneResolver struct { + findZone func(context.Context, string) (string, error) + timeout time.Duration +} + +func newDefaultAliyunZoneResolver() AliyunZoneResolver { + return authoritativeAliyunZoneResolver{ + findZone: func(ctx context.Context, fqdn string) (string, error) { + client := dns01.NewClient(&dns01.Options{Timeout: aliyunZoneResolutionTimeout}) + return client.FindZoneByFqdn(ctx, fqdn) + }, + timeout: aliyunZoneResolutionTimeout, + } +} + +func (r authoritativeAliyunZoneResolver) Resolve(ctx context.Context, requested string) (string, error) { + base, err := validateAliyunRequestedDomain(requested) + if err != nil { + return "", err + } + if r.findZone == nil { + return "", errors.New("阿里云权威 DNS 托管区识别器未初始化") + } + if ctx == nil { + ctx = context.Background() + } + timeout := r.timeout + if timeout <= 0 { + timeout = aliyunZoneResolutionTimeout + } + resolveCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + zone, err := r.findZone(resolveCtx, base+".") + if err != nil { + return "", fmt.Errorf("无法识别域名 %s 的权威 DNS 托管区,已停止验证:%w", requested, err) + } + return normalizeAuthorizationDomain(zone), nil +} + +// ResolveAliyunTargetDomain 根据公开 DNS 的 SOA 结果识别唯一权威托管区。 +// SOA 查询可能读取父标签以定位权威区,但整个过程从不向候选父域写入 TXT 记录。 func ResolveAliyunTargetDomain(requested string) (string, error) { + return resolveAliyunTargetDomain(context.Background(), requested, newDefaultAliyunZoneResolver()) +} + +func resolveAliyunTargetDomain(ctx context.Context, requested string, resolver AliyunZoneResolver) (string, error) { + base, err := validateAliyunRequestedDomain(requested) + if err != nil { + return "", err + } + if resolver == nil { + return "", errors.New("阿里云权威 DNS 托管区识别器未初始化") + } + if ctx == nil { + ctx = context.Background() + } + zone, err := resolver.Resolve(ctx, base) + if err != nil { + return "", err + } + zone = normalizeAuthorizationDomain(zone) + if !domain.IsValidName(zone) || strings.HasPrefix(zone, "*.") { + return "", fmt.Errorf("权威 DNS 托管区识别结果无效:%s", zone) + } + if isProtectedAliyunDomain(zone) { + return "", fmt.Errorf("域名 %s 的权威 DNS 托管区 %s 受本机保护策略限制", requested, zone) + } + if base != zone && !strings.HasSuffix(base, "."+zone) { + return "", fmt.Errorf("权威 DNS 托管区 %s 不属于待操作域名 %s,已停止验证", zone, requested) + } + return zone, nil +} + +// validateAliyunRequestedDomain 仅做不访问网络的本地格式和保护策略检查。 +// 这使受保护域在读取凭据、构造客户端或进行 SOA 查询前被拒绝。 +func validateAliyunRequestedDomain(requested string) (string, error) { requested = normalizeAuthorizationDomain(requested) base := strings.TrimPrefix(requested, "*.") if !domain.IsValidName(requested) || base == "" { @@ -22,11 +112,7 @@ func ResolveAliyunTargetDomain(requested string) (string, error) { if isProtectedAliyunDomain(base) { return "", fmt.Errorf("域名 %s 受本机保护策略限制,未构造阿里云客户端或调用云 API", requested) } - zone, err := publicsuffix.EffectiveTLDPlusOne(base) - if err != nil { - return "", fmt.Errorf("待操作域名必须包含可注册根域:%s", requested) - } - return normalizeAuthorizationDomain(zone), nil + return base, nil } func isProtectedAliyunDomain(name string) bool { @@ -44,7 +130,7 @@ func ValidateAliyunTargetDomains(providerID string, domains []string) error { return errors.New("至少需要一个待操作域名") } for _, name := range domains { - if _, err := ResolveAliyunTargetDomain(name); err != nil { + if _, err := validateAliyunRequestedDomain(name); err != nil { return err } } diff --git a/backend/internal/dnsprovider/aliyun_scope_test.go b/backend/internal/dnsprovider/aliyun_scope_test.go index 0d83bf6..9fd03ed 100644 --- a/backend/internal/dnsprovider/aliyun_scope_test.go +++ b/backend/internal/dnsprovider/aliyun_scope_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" ) func scopeCredentials() map[string]string { @@ -14,34 +15,100 @@ func scopeCredentials() map[string]string { } } -func TestResolveAliyunTargetDomainUsesOnlyRegistrableRoot(t *testing.T) { - tests := []struct { - name string - want string - }{ - {name: "www.sub.example.com", want: "example.com"}, - {name: "WWW.EXAMPLE.COM.", want: "example.com"}, - {name: "*.example.com", want: "example.com"}, - {name: "example.co.uk", want: "example.co.uk"}, +func staticAliyunZoneResolver(zone string) AliyunZoneResolver { + return aliyunZoneResolverFunc(func(context.Context, string) (string, error) { + return zone, nil + }) +} + +func newAliyunAuthorizationCheckerForTest() *AliyunAuthorizationChecker { + checker := NewAliyunAuthorizationChecker() + checker.zoneResolver = staticAliyunZoneResolver("example.com") + return checker +} + +func TestResolveAliyunTargetDomainUsesOnlyAuthoritativeZone(t *testing.T) { + resolverCalls := make([]string, 0, 1) + resolver := aliyunZoneResolverFunc(func(_ context.Context, requested string) (string, error) { + resolverCalls = append(resolverCalls, requested) + if requested != "www.sub.example.com" { + t.Fatalf("托管区识别收到了错误域名:%s", requested) + } + return "sub.example.com.", nil + }) + + got, err := resolveAliyunTargetDomain(t.Context(), "www.sub.example.com", resolver) + if err != nil || got != "sub.example.com" { + t.Fatalf("权威托管区识别结果错误:got=%q err=%v", got, err) } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - got, err := ResolveAliyunTargetDomain(test.name) - if err != nil || got != test.want { - t.Fatalf("ResolveAliyunTargetDomain(%q) = %q, %v; want %q", test.name, got, err, test.want) - } - }) + if len(resolverCalls) != 1 { + t.Fatalf("不应枚举候选父域:resolverCalls=%v", resolverCalls) } } -func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomains(t *testing.T) { +func TestAuthoritativeAliyunZoneResolverUsesFQDNAndBoundedDeadline(t *testing.T) { + const timeout = 80 * time.Millisecond + called := false + resolver := authoritativeAliyunZoneResolver{ + timeout: timeout, + findZone: func(ctx context.Context, fqdn string) (string, error) { + called = true + if fqdn != "www.sub.example.com." { + t.Fatalf("权威托管区查询必须使用完整域名:%q", fqdn) + } + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("权威托管区查询必须具有截止时间") + } + remaining := time.Until(deadline) + if remaining <= 0 || remaining > timeout { + t.Fatalf("权威托管区查询截止时间未受限:%s", remaining) + } + return "sub.example.com.", nil + }, + } + + zone, err := resolver.Resolve(t.Context(), "WWW.Sub.Example.Com.") + if err != nil || zone != "sub.example.com" { + t.Fatalf("权威托管区解析结果错误:zone=%q err=%v", zone, err) + } + if !called { + t.Fatal("未执行权威托管区查询") + } +} + +func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomainsBeforeResolver(t *testing.T) { + resolverCalls := 0 + resolver := aliyunZoneResolverFunc(func(context.Context, string) (string, error) { + resolverCalls++ + return "example.com", nil + }) for _, name := range []string{"pcux.cn", "*.pcux.cn", "api.pcux.cn", "com", "*.com", ""} { t.Run(name, func(t *testing.T) { - if _, err := ResolveAliyunTargetDomain(name); err == nil { - t.Fatalf("ResolveAliyunTargetDomain(%q) unexpectedly succeeded", name) + if _, err := resolveAliyunTargetDomain(t.Context(), name, resolver); err == nil { + t.Fatalf("resolveAliyunTargetDomain(%q) unexpectedly succeeded", name) } }) } + if resolverCalls != 0 { + t.Fatalf("受保护或无效域名不应发起 SOA 查询:%d", resolverCalls) + } +} + +func TestResolveAliyunTargetDomainFailsClosedWhenAuthoritativeZoneCannotBeIdentified(t *testing.T) { + resolver := aliyunZoneResolverFunc(func(context.Context, string) (string, error) { + return "", errors.New("SOA lookup failed") + }) + if _, err := resolveAliyunTargetDomain(t.Context(), "www.example.com", resolver); err == nil || !strings.Contains(err.Error(), "SOA lookup failed") { + t.Fatalf("权威托管区识别失败必须阻止后续操作:%v", err) + } +} + +func TestResolveAliyunTargetDomainRejectsUnrelatedAuthoritativeZone(t *testing.T) { + resolver := staticAliyunZoneResolver("unrelated.example.net") + if _, err := resolveAliyunTargetDomain(t.Context(), "www.example.com", resolver); err == nil || !strings.Contains(err.Error(), "不属于") { + t.Fatalf("无关的权威托管区必须失败关闭:%v", err) + } } type fakeAliyunRecords struct { @@ -87,9 +154,10 @@ func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error { return f.deleteErr } -func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) { +func TestAliyunProbeUsesTheSingleAuthoritativeZoneWithoutFallback(t *testing.T) { api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")} - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() + checker.zoneResolver = staticAliyunZoneResolver("sub.example.com") constructed := 0 checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++ @@ -99,8 +167,8 @@ func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) { if _, err := checker.Verify(t.Context(), "www.sub.example.com", scopeCredentials()); err == nil { t.Fatal("RAM denial unexpectedly succeeded") } - if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" { - t.Fatalf("probe did not use only the registrable root: constructed=%d zones=%v", constructed, api.zones) + if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "sub.example.com" { + t.Fatalf("探测未严格使用唯一权威托管区:constructed=%d zones=%v", constructed, api.zones) } if len(api.deleted) != 0 { t.Fatalf("failed Add must not guess a record ID to delete: %v", api.deleted) @@ -109,7 +177,7 @@ func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) { func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) { api := &fakeAliyunRecords{} - checker := NewAliyunAuthorizationChecker() + checker := newAliyunAuthorizationCheckerForTest() constructed := 0 checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++ @@ -126,6 +194,26 @@ func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) { } } +func TestAliyunAuthorizationDoesNotConstructClientWhenZoneResolutionFails(t *testing.T) { + api := &fakeAliyunRecords{} + checker := newAliyunAuthorizationCheckerForTest() + checker.zoneResolver = aliyunZoneResolverFunc(func(context.Context, string) (string, error) { + return "", errors.New("SOA lookup failed") + }) + constructed := 0 + checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { + constructed++ + return api, nil + } + + if _, err := checker.Verify(t.Context(), "www.example.com", scopeCredentials()); err == nil { + t.Fatal("权威托管区识别失败时不应继续探测") + } + if constructed != 0 || len(api.zones) != 0 || len(api.described) != 0 || len(api.deleted) != 0 { + t.Fatalf("权威托管区识别失败时触碰了云端:constructed=%d zones=%v described=%v deleted=%v", constructed, api.zones, api.described, api.deleted) + } +} + func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) { provider, err := newScopedAliyunProvider(scopeCredentials()) if err != nil { @@ -137,6 +225,7 @@ func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) constructed++ return api, nil } + provider.zoneResolver = staticAliyunZoneResolver("sub.example.com") provider.challengeInfo = func(_ context.Context, name, _ string) (string, string, string) { fqdn := "_acme-challenge." + name + "." return fqdn, fqdn, "txt-value" @@ -148,8 +237,8 @@ func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil { t.Fatal(err) } - if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" || api.rrs[0] != "_acme-challenge.www.sub" { - t.Fatalf("provider wrote outside its single registrable root: constructed=%d zones=%v rrs=%v", constructed, api.zones, api.rrs) + if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "sub.example.com" || api.rrs[0] != "_acme-challenge.www" { + t.Fatalf("provider 写入位置不属于唯一权威托管区:constructed=%d zones=%v rrs=%v", constructed, api.zones, api.rrs) } if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil { t.Fatal("duplicate challenge unexpectedly overwrote a pending record") @@ -181,6 +270,7 @@ func TestAliyunProviderRejectsCNAMEBeforeClientConstruction(t *testing.T) { constructed++ return &fakeAliyunRecords{}, nil } + provider.zoneResolver = staticAliyunZoneResolver("example.com") provider.challengeInfo = func(context.Context, string, string) (string, string, string) { return "_acme-challenge.www.sub.example.com.", "_acme-challenge.delegated.example.net.", "txt-value" } @@ -192,3 +282,25 @@ func TestAliyunProviderRejectsCNAMEBeforeClientConstruction(t *testing.T) { t.Fatalf("CNAME redirection constructed a cloud client: %d", constructed) } } + +func TestAliyunProviderDoesNotConstructClientWhenZoneResolutionFails(t *testing.T) { + provider, err := newScopedAliyunProvider(scopeCredentials()) + if err != nil { + t.Fatal(err) + } + provider.zoneResolver = aliyunZoneResolverFunc(func(context.Context, string) (string, error) { + return "", errors.New("SOA lookup failed") + }) + constructed := 0 + provider.newAPI = func(map[string]string) (aliyunRecordAPI, error) { + constructed++ + return &fakeAliyunRecords{}, nil + } + + if err := provider.Present(t.Context(), "www.example.com", "token", "key"); err == nil { + t.Fatal("权威托管区识别失败时不应继续写入") + } + if constructed != 0 { + t.Fatalf("权威托管区识别失败时不应构造阿里云客户端:%d", constructed) + } +}