优化阿里云 DNS 权限验证等待时长

This commit is contained in:
xuans 2026-09-16 00:57:09 +08:00
parent 91eaf31d15
commit d8205ae651
20 changed files with 560 additions and 38 deletions

View File

@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
@ -76,7 +75,7 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra
return err
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), 45*time.Second)
ctx, cancel := context.WithTimeout(cmd.Context(), dnsprovider.PermissionVerificationTimeoutFor(dnsprovider.Aliyun))
defer cancel()
report, verifyErr := verify(ctx, requestedDomain, credentials)
if err := printJSON(cmd, report); err != nil {

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
@ -79,9 +80,14 @@ func TestDNSCheckReportsLiveResultWithoutPersistingConfiguration(t *testing.T) {
if name != "example.com" || credentials["access_key_id"] != "test-id" {
t.Fatal("检查目标或凭据来源不正确")
}
if _, ok := ctx.Deadline(); !ok {
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("权限请求必须设置超时")
}
remaining := time.Until(deadline)
if remaining < 14*time.Second || remaining > 16*time.Second {
t.Fatalf("阿里云 CLI 权限验证时限错误:%s", remaining)
}
report := dnsprovider.AuthorizationReport{Provider: "aliyun", RequestedDomain: name, Verified: outcome == "allowed"}
switch outcome {
case "denied":

View File

@ -106,7 +106,7 @@ func verifyExistingDNSCredentials(ctx context.Context, cfg config.Config, input
if err != nil {
return func() {}, fmt.Errorf("读取已有 DNS 凭据失败:%w", err)
}
verifyCtx, cancel := context.WithTimeout(ctx, dnsprovider.PermissionVerificationTimeout)
verifyCtx, cancel := context.WithTimeout(ctx, dnsprovider.PermissionVerificationTimeoutFor(input.DNSProvider))
defer cancel()
if err := checker.VerifyDomain(verifyCtx, input.DNSProvider, target, credentials); err != nil {
return func() {}, fmt.Errorf("已有 DNS 凭据在线权限校验失败:%w", err)

View File

@ -479,11 +479,15 @@ func prepareDNSCredentials(ctx context.Context, cfg config.Config, input *setup.
dnsprovider.NewTester(dnsprovider.NewLegoFactory()),
store,
)
return prepareDNSCredentialsWithManagerAndTimeout(ctx, input, output, manager, store, dnsprovider.PermissionVerificationTimeout)
return prepareDNSCredentialsWithManagerAndTimeout(ctx, input, output, manager, store, dnsprovider.PermissionVerificationTimeoutFor(input.DNSProvider))
}
func prepareDNSCredentialsWithManager(ctx context.Context, input *setup.Input, output io.Writer, manager *setup.DNSVerificationManager, store *secretstore.Store) (func(), error) {
return prepareDNSCredentialsWithManagerAndTimeout(ctx, input, output, manager, store, dnsprovider.PermissionVerificationTimeout)
timeout := dnsprovider.PermissionVerificationTimeout
if input != nil {
timeout = dnsprovider.PermissionVerificationTimeoutFor(input.DNSProvider)
}
return prepareDNSCredentialsWithManagerAndTimeout(ctx, input, output, manager, store, timeout)
}
func prepareDNSCredentialsWithManagerAndTimeout(ctx context.Context, input *setup.Input, output io.Writer, manager *setup.DNSVerificationManager, store *secretstore.Store, timeout time.Duration) (func(), error) {

View File

@ -20,6 +20,7 @@ const (
aliyunDefaultRegion = "cn-hangzhou"
aliyunAuthorizationTTL = 600
aliyunAuthorizationTimeout = 30 * time.Second
aliyunAPICallTimeout = 5 * time.Second
)
// AuthorizationDomain 描述一个阿里云 DNS 根域名的授权探测结果。
@ -27,6 +28,7 @@ type AuthorizationDomain struct {
Domain string `json:"domain"`
Listed bool `json:"listed"`
Writable bool `json:"writable"`
Readable bool `json:"readable"`
Cleanup bool `json:"cleanup"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
@ -37,6 +39,7 @@ type AuthorizationReport struct {
Provider string `json:"provider"`
RequestedDomain string `json:"requested_domain,omitempty"`
MatchedDomain string `json:"matched_domain,omitempty"`
RecordName string `json:"record_name,omitempty"`
Domains []AuthorizationDomain `json:"domains"`
Verified bool `json:"verified"`
Diagnosis string `json:"diagnosis,omitempty"`
@ -120,9 +123,9 @@ func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomai
}
// Verify 只在用户显式允许的域名及托管区中执行一次写入和精确清理。
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
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()}
report = AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
if !domain.IsValidName(requestedDomain) {
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
}
@ -137,6 +140,8 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
if err := ctx.Err(); err != nil {
return report, err
}
verificationCtx, cancel := context.WithTimeout(ctx, PermissionVerificationTimeoutFor(Aliyun))
defer cancel()
report.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}}
if c == nil || c.recordAPI == nil {
return report, errors.New("阿里云权限探测器未初始化")
@ -158,7 +163,10 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
if base != zone {
rr += "." + strings.TrimSuffix(base, "."+zone)
}
recordID, err := api.Add(ctx, zone, rr, value)
report.RecordName = rr + "." + zone + "."
addCtx, addCancel := context.WithTimeout(verificationCtx, aliyunAPICallTimeout)
recordID, err := api.Add(addCtx, zone, rr, value)
addCancel()
if err != nil {
markAuthorizationFailure(&report, zone, err)
report.Diagnosis = DiagnoseAliyunError(err, zone)
@ -166,18 +174,56 @@ func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain
}
report.Domains[0].Writable = true
report.MatchedDomain = zone
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
defer cancel()
if err := api.Delete(cleanupContext, recordID); err != nil {
markAuthorizationFailure(&report, zone, fmt.Errorf("清理授权探测 TXT 记录失败:%w", err))
return report, err
defer func() {
cleanupBase := context.WithoutCancel(verificationCtx)
if deadline, ok := verificationCtx.Deadline(); ok {
var cleanupDeadlineCancel context.CancelFunc
cleanupBase, cleanupDeadlineCancel = context.WithDeadline(cleanupBase, deadline)
defer cleanupDeadlineCancel()
}
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
if resultErr == nil {
resultErr = cleanupErr
} else {
resultErr = errors.Join(resultErr, cleanupErr)
}
return
}
report.Domains[0].Cleanup = true
}()
describeCtx, describeCancel := context.WithTimeout(verificationCtx, 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
}
report.Domains[0].Cleanup = true
if !matchesAliyunAuthorizationRecord(info, recordID, zone, rr, value) {
resultErr = errors.New("阿里云返回的临时 TXT 记录与本次验证不一致")
markAuthorizationFailure(&report, zone, resultErr)
return report, resultErr
}
report.Domains[0].Readable = true
report.Domains[0].Status = "可操作"
report.Verified = true
return report, nil
}
func matchesAliyunAuthorizationRecord(info aliyunRecordInfo, recordID, zone, rr, value string) bool {
return strings.TrimSpace(info.RecordID) == strings.TrimSpace(recordID) &&
normalizeAuthorizationDomain(info.DomainName) == normalizeAuthorizationDomain(zone) &&
strings.TrimSpace(info.RR) == strings.TrimSpace(rr) &&
strings.EqualFold(strings.TrimSpace(info.Type), "TXT") &&
info.Value == value
}
// VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。
func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
if !c.Supports(providerID) {
@ -327,6 +373,13 @@ func dereferenceInt64(value *int64) int64 {
return *value
}
func dereferenceString(value *string) string {
if value == nil {
return ""
}
return *value
}
func uniqueStrings(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))

View File

@ -0,0 +1,224 @@
package dnsprovider
import (
"context"
"errors"
"strings"
"testing"
"time"
)
type authorizationCall struct {
name string
id string
}
type authorizationRecordAPI struct {
calls []authorizationCall
zone string
rr string
value string
addErr error
describeErr error
deleteErr error
describeInfo aliyunRecordInfo
cancelAfterAdd context.CancelFunc
cleanupWasCanceled bool
callTimeouts []time.Duration
}
func (a *authorizationRecordAPI) Add(ctx context.Context, zone, rr, value string) (string, error) {
a.recordCall("add", "", ctx)
a.zone, a.rr, a.value = zone, rr, value
if a.cancelAfterAdd != nil {
a.cancelAfterAdd()
}
if a.addErr != nil {
return "", a.addErr
}
return "authorization-record-id", nil
}
func (a *authorizationRecordAPI) Describe(ctx context.Context, recordID string) (aliyunRecordInfo, error) {
a.recordCall("describe", recordID, ctx)
if a.describeErr != nil {
return aliyunRecordInfo{}, a.describeErr
}
if err := ctx.Err(); err != nil {
return aliyunRecordInfo{}, err
}
if a.describeInfo.RecordID != "" {
return a.describeInfo, nil
}
return aliyunRecordInfo{
RecordID: recordID,
DomainName: a.zone,
RR: a.rr,
Type: "TXT",
Value: a.value,
}, nil
}
func (a *authorizationRecordAPI) Delete(ctx context.Context, recordID string) error {
a.recordCall("delete", recordID, ctx)
a.cleanupWasCanceled = ctx.Err() != nil
return a.deleteErr
}
func (a *authorizationRecordAPI) recordCall(name, id string, ctx context.Context) {
a.calls = append(a.calls, authorizationCall{name: name, id: id})
timeout := time.Duration(0)
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
a.callTimeouts = append(a.callTimeouts, timeout)
}
func newAuthorizationCheckerForTest(api aliyunRecordAPI) *AliyunAuthorizationChecker {
checker := NewAliyunAuthorizationChecker()
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
return api, nil
}
return checker
}
func TestAliyunAuthorizationVerifyClosesWriteReadCleanupLoop(t *testing.T) {
api := &authorizationRecordAPI{}
report, err := newAuthorizationCheckerForTest(api).Verify(t.Context(), "www.sub.example.com", scopeCredentials())
if err != nil {
t.Fatalf("阿里云授权闭环验证失败:%v", err)
}
if !report.Verified || report.RecordName == "" || len(report.Domains) != 1 {
t.Fatalf("授权报告不完整:%+v", report)
}
domain := report.Domains[0]
if !domain.Writable || !domain.Readable || !domain.Cleanup || domain.Status != "可操作" {
t.Fatalf("授权闭环状态不正确:%+v", domain)
}
if got := callNames(api.calls); got != "add,describe,delete" {
t.Fatalf("API 调用顺序错误:%s", got)
}
if api.calls[1].id != "authorization-record-id" || api.calls[2].id != "authorization-record-id" {
t.Fatalf("查询或清理未使用本次创建的记录 ID:%+v", api.calls)
}
if len(api.callTimeouts) != 3 {
t.Fatalf("未记录全部 API 调用时限:%+v", api.callTimeouts)
}
for index, timeout := range api.callTimeouts {
if timeout <= 0 || timeout > aliyunAPICallTimeout {
t.Fatalf("第 %d 次阿里云 API 调用未限制在 %s 内:%s", index+1, aliyunAPICallTimeout, timeout)
}
}
}
func TestAliyunAuthorizationVerifyDoesNotCallCloudOutsideExplicitScope(t *testing.T) {
api := &authorizationRecordAPI{}
checker := newAuthorizationCheckerForTest(api)
constructed := 0
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
constructed++
return api, nil
}
report, err := checker.Verify(t.Context(), "pcux.cn", scopeCredentials())
if err == nil || report.Verified || constructed != 0 || len(api.calls) != 0 {
t.Fatalf("范围外域名不应构造客户端或调用云 API:report=%+v constructed=%d calls=%+v err=%v", report, constructed, api.calls, err)
}
}
func TestAliyunAuthorizationVerifyFailurePathsAlwaysPreserveProofBoundary(t *testing.T) {
tests := []struct {
name string
configure func(*authorizationRecordAPI)
wantCalls string
wantWritable bool
wantReadable bool
wantCleanup bool
wantErrorPart string
}{
{
name: "创建失败",
configure: func(api *authorizationRecordAPI) { api.addErr = errors.New("Forbidden.RAM") },
wantCalls: "add",
wantErrorPart: "Forbidden.RAM",
},
{
name: "查询失败仍清理",
configure: func(api *authorizationRecordAPI) { api.describeErr = errors.New("Forbidden.RAM") },
wantCalls: "add,describe,delete",
wantWritable: true,
wantCleanup: true,
wantErrorPart: "查询授权探测 TXT 记录失败",
},
{
name: "查询内容不一致仍清理",
configure: func(api *authorizationRecordAPI) {
api.describeInfo = aliyunRecordInfo{RecordID: "other-record-id", DomainName: "sub.example.com", RR: "wrong", Type: "TXT", Value: "wrong"}
},
wantCalls: "add,describe,delete",
wantWritable: true,
wantCleanup: true,
wantErrorPart: "不一致",
},
{
name: "清理失败不能通过",
configure: func(api *authorizationRecordAPI) { api.deleteErr = errors.New("Forbidden.RAM") },
wantCalls: "add,describe,delete",
wantWritable: true,
wantReadable: true,
wantErrorPart: "清理授权探测 TXT 记录失败",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
api := &authorizationRecordAPI{}
tc.configure(api)
report, err := newAuthorizationCheckerForTest(api).Verify(t.Context(), "www.sub.example.com", scopeCredentials())
if err == nil || !strings.Contains(err.Error(), tc.wantErrorPart) {
t.Fatalf("错误结果不正确:err=%v", err)
}
if report.Verified || len(report.Domains) != 1 {
t.Fatalf("失败路径不能报告已验证:%+v", report)
}
domain := report.Domains[0]
if domain.Writable != tc.wantWritable || domain.Readable != tc.wantReadable || domain.Cleanup != tc.wantCleanup {
t.Fatalf("失败路径状态错误:%+v", domain)
}
if got := callNames(api.calls); got != tc.wantCalls {
t.Fatalf("失败路径 API 调用顺序错误:got=%s want=%s", got, tc.wantCalls)
}
})
}
}
func TestAliyunAuthorizationVerifyCleansUpAfterRequestCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
api := &authorizationRecordAPI{cancelAfterAdd: cancel}
report, err := newAuthorizationCheckerForTest(api).Verify(ctx, "www.sub.example.com", scopeCredentials())
if !errors.Is(err, context.Canceled) || report.Verified {
t.Fatalf("请求取消必须失败且不能报告已验证:report=%+v err=%v", report, err)
}
if got := callNames(api.calls); got != "add,describe,delete" {
t.Fatalf("请求取消后必须完成受控清理:%s", got)
}
if api.cleanupWasCanceled {
t.Fatal("清理请求不应继承已取消的上下文")
}
}
func TestPermissionVerificationTimeoutForAliyunIsIndependentFromPropagation(t *testing.T) {
if got := PermissionVerificationTimeoutFor(Aliyun); got != 15*time.Second {
t.Fatalf("阿里云权限验证时限错误:%s", got)
}
if got := PermissionVerificationTimeoutFor(Cloudflare); got != 60*time.Second {
t.Fatalf("其他 DNS 服务商权限验证时限错误:%s", got)
}
}
func callNames(calls []authorizationCall) string {
names := make([]string, 0, len(calls))
for _, call := range calls {
names = append(names, call.name)
}
return strings.Join(names, ",")
}

View File

@ -15,9 +15,18 @@ import (
type aliyunRecordAPI interface {
Add(context.Context, string, string, string) (string, error)
Describe(context.Context, string) (aliyunRecordInfo, error)
Delete(context.Context, string) error
}
type aliyunRecordInfo struct {
RecordID string
DomainName string
RR string
Type string
Value string
}
type aliyunRecords struct{ client *client.Client }
func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string, error) {
@ -35,6 +44,28 @@ func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string,
return strings.TrimSpace(*response.Body.RecordId), nil
}
func (a aliyunRecords) Describe(ctx context.Context, recordID string) (aliyunRecordInfo, error) {
response, err := client.DescribeDomainRecordInfoWithContext(
ctx,
a.client,
new(client.DescribeDomainRecordInfoRequest).SetRecordId(recordID),
&dara.RuntimeOptions{},
)
if err != nil {
return aliyunRecordInfo{}, err
}
if response == nil || response.Body == nil {
return aliyunRecordInfo{}, errors.New("阿里云未返回临时 TXT 记录详情")
}
return aliyunRecordInfo{
RecordID: strings.TrimSpace(dereferenceString(response.Body.RecordId)),
DomainName: normalizeAuthorizationDomain(dereferenceString(response.Body.DomainName)),
RR: strings.TrimSpace(dereferenceString(response.Body.RR)),
Type: strings.TrimSpace(dereferenceString(response.Body.Type)),
Value: dereferenceString(response.Body.Value),
}, nil
}
func (a aliyunRecords) Delete(ctx context.Context, id string) error {
return deleteAuthorizationRecord(ctx, a.client, id)
}

View File

@ -50,17 +50,38 @@ func TestAliyunScopeBoundaries(t *testing.T) {
}
type fakeAliyunRecords struct {
zones, rrs, deleted []string
addErr, deleteErr error
canceledCleanup bool
zones, rrs, values, deleted []string
addErr, describeErr, deleteErr error
describeInfo aliyunRecordInfo
canceledCleanup bool
}
func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, _ string) (string, error) {
func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, value string) (string, error) {
f.zones = append(f.zones, zone)
f.rrs = append(f.rrs, rr)
f.values = append(f.values, value)
return "created-record-id", f.addErr
}
func (f *fakeAliyunRecords) Describe(_ context.Context, _ string) (aliyunRecordInfo, error) {
if f.describeErr != nil {
return aliyunRecordInfo{}, f.describeErr
}
if f.describeInfo.RecordID != "" {
return f.describeInfo, nil
}
if len(f.zones) == 0 || len(f.rrs) == 0 || len(f.values) == 0 {
return aliyunRecordInfo{}, errors.New("没有可供查询的临时记录")
}
return aliyunRecordInfo{
RecordID: "created-record-id",
DomainName: f.zones[len(f.zones)-1],
RR: f.rrs[len(f.rrs)-1],
Type: "TXT",
Value: f.values[len(f.values)-1],
}, nil
}
func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error {
f.deleted = append(f.deleted, id)
f.canceledCleanup = ctx.Err() != nil

View File

@ -28,6 +28,7 @@ type TXTResolver interface {
type Tester struct {
factory Factory
resolver TXTResolver
aliyunAuthorization *AliyunAuthorizationChecker
propagationTimeout time.Duration
propagationInterval time.Duration
}
@ -37,11 +38,22 @@ const (
// 它与 ACME DNS-01 的公网传播等待相互独立。
PermissionVerificationTimeout = 60 * time.Second
// AliyunPermissionVerificationTimeout 限制阿里云账号内的创建、查询和清理闭环。
// 该流程不等待公网 DNS 传播。
AliyunPermissionVerificationTimeout = 15 * time.Second
dnsCleanupTimeout = 30 * time.Second
defaultDNSPermissionTXTLookupTimeout = 30 * time.Second
defaultDNSPermissionTXTLookupInterval = 2 * time.Second
)
func PermissionVerificationTimeoutFor(providerID string) time.Duration {
if strings.EqualFold(strings.TrimSpace(providerID), Aliyun) {
return AliyunPermissionVerificationTimeout
}
return PermissionVerificationTimeout
}
func NewTester(factory Factory) *Tester {
return NewTesterWithResolverAndSettings(factory, legoTXTResolver{}, defaultDNSPermissionTXTLookupTimeout, defaultDNSPermissionTXTLookupInterval)
}
@ -67,6 +79,7 @@ func NewTesterWithResolverAndSettings(factory Factory, resolver TXTResolver, tim
return &Tester{
factory: factory,
resolver: resolver,
aliyunAuthorization: NewAliyunAuthorizationChecker(),
propagationTimeout: timeout,
propagationInterval: interval,
}
@ -97,7 +110,7 @@ func (t *Tester) Verify(ctx context.Context, providerID, testDomain string, cred
func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) {
report := setup.DNSVerificationReport{}
if t == nil || t.factory == nil || t.resolver == nil {
if t == nil {
return report, errors.New("DNS 验证服务未初始化")
}
testDomain = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(testDomain), "."))
@ -111,6 +124,12 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
if err := ValidateAliyunDomainScope(providerID, []string{testDomain}, cleaned); err != nil {
return report, err
}
if strings.EqualFold(strings.TrimSpace(providerID), Aliyun) {
return t.verifyAliyun(ctx, testDomain, cleaned)
}
if t.factory == nil || t.resolver == nil {
return report, errors.New("DNS 验证服务未初始化")
}
provider, err := t.factory.Create(providerID, cleaned)
if err != nil {
return report, err
@ -172,6 +191,58 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
return report, nil
}
func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) {
report := setup.DNSVerificationReport{}
if t.aliyunAuthorization == nil {
return report, errors.New("阿里云 DNS 权限验证服务未初始化")
}
authorization, err := t.aliyunAuthorization.Verify(ctx, testDomain, credentials)
report.RecordName = authorization.RecordName
if len(authorization.Domains) == 0 || !authorization.Domains[0].Writable {
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "创建记录", Status: "failed", Message: "调用阿里云 DNS API 创建临时 TXT 记录失败。",
})
return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)}
}
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "创建记录", Status: "completed", Message: "已通过阿里云 DNS API 创建临时 TXT 记录。",
})
if !authorization.Domains[0].Readable {
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "查询记录", Status: "failed", Message: "阿里云 DNS API 未能精确查询并比对本次临时 TXT 记录。",
})
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)}
}
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "查询记录", Status: "completed", Message: "已通过阿里云 DNS API 精确查询并比对临时 TXT 记录,未等待公网 DNS 传播。",
})
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
if err != nil || !authorization.Verified {
return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)}
}
return report, nil
}
func appendAliyunCleanupStage(report *setup.DNSVerificationReport, cleaned bool) {
if cleaned {
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "清理记录", Status: "completed", Message: "已通过阿里云 DNS API 清理本次临时 TXT 记录。",
})
return
}
report.Stages = append(report.Stages, setup.DNSVerificationStage{
Name: "清理记录", Status: "failed", Message: "临时 TXT 记录清理失败,不能确认阿里云 DNS 权限验证通过。",
})
}
func aliyunVerificationError(err error) error {
if err != nil {
return err
}
return errors.New("阿里云 DNS 权限验证未通过")
}
func cleanupChallenge(ctx context.Context, provider challenge.Provider, testDomain, token, keyAuth string) error {
cleanupBase := context.WithoutCancel(ctx)
cleanupCtx, cancel := context.WithTimeout(cleanupBase, dnsCleanupTimeout)

View File

@ -130,6 +130,41 @@ func TestTesterCleansUpWithIndependentContextAfterQueryCancellation(t *testing.T
}
}
type forbiddenTXTResolver struct{}
func (forbiddenTXTResolver) LookupTXT(context.Context, string) ([]string, error) {
return nil, errors.New("阿里云账号内权限验证不应查询公网 TXT 记录")
}
func TestTesterUsesAliyunAPIReadBackWithoutPublicDNSPropagation(t *testing.T) {
api := &authorizationRecordAPI{}
tester := &Tester{
resolver: forbiddenTXTResolver{},
aliyunAuthorization: newAuthorizationCheckerForTest(api),
}
report, err := tester.VerifyWithReport(t.Context(), Aliyun, "www.sub.example.com", scopeCredentials())
if err != nil {
t.Fatalf("阿里云权限验证失败:%v", err)
}
if report.RecordName == "" || len(report.Stages) != 3 {
t.Fatalf("阿里云阶段报告不完整:%+v", report)
}
for _, stage := range report.Stages {
if stage.Status != "completed" {
t.Fatalf("阿里云阶段未完成:%+v", report.Stages)
}
}
if report.Stages[0].Name != "创建记录" || report.Stages[1].Name != "查询记录" || report.Stages[2].Name != "清理记录" {
t.Fatalf("阿里云阶段顺序错误:%+v", report.Stages)
}
if !strings.Contains(report.Stages[1].Message, "未等待公网 DNS 传播") {
t.Fatalf("阿里云查询阶段必须明确不等待公网传播:%+v", report.Stages[1])
}
if got := callNames(api.calls); got != "add,describe,delete" {
t.Fatalf("阿里云不应调用公网 TXT 查询:%s", got)
}
}
func TestTesterReportsCleanupFailureAfterQueryFailure(t *testing.T) {
t.Setenv("LEGO_DISABLE_CNAME_SUPPORT", "true")
provider := &fakeProvider{cleanupErr: errors.New("权限不足")}

View File

@ -69,6 +69,23 @@ func TestAdminDNSTestReturnsVerificationResult(t *testing.T) {
}
}
func TestAdminAliyunDNSTestReportsFastVerificationTimeout(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = true
server := New(Dependencies{Config: cfg, SetupDNS: &fakeSetupDNSVerifier{}})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/dns/test", strings.NewReader(`{
"provider":"aliyun",
"test_domain":"example.com",
"credentials":{"access_key_id":"test-id","access_key_secret":"test-secret","managed_zones":"example.com","allowed_domains":"example.com"}
}`))
request.Header.Set("Content-Type", "application/json")
serveAdminHTTP(server, response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"verification_timeout_secs":15`) {
t.Fatalf("管理端阿里云 DNS 在线测试未返回 15 秒等待上限:%d %s", response.Code, response.Body.String())
}
}
func TestAdminDNSTestRejectsUnavailableService(t *testing.T) {
cfg := config.Default(t.TempDir())
cfg.SetupReady = true

View File

@ -6,6 +6,7 @@ import (
"net/http"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"github.com/gin-gonic/gin"
)
@ -30,7 +31,9 @@ func (s *Server) longOperation(timeout time.Duration) gin.HandlerFunc {
}
func (s *Server) dnsOperation(c *gin.Context) {
s.longOperation(dnsAPIPermissionVerificationTimeout())(c)
// 路由中间件在绑定 JSON 前无法可靠识别服务商,保留兼容服务商的最长响应时限。
// testDNS 会在参数校验后按服务商建立更精确的工作上下文。
s.longOperation(dnsprovider.PermissionVerificationTimeout)(c)
}
func (s *Server) requireSameOrigin(c *gin.Context) {

View File

@ -157,7 +157,7 @@ func (s *Server) testAdminDNS(c *gin.Context) {
}
func (s *Server) testDNS(c *gin.Context) {
verificationTimeoutSecs := int(dnsAPIPermissionVerificationTimeout().Seconds())
verificationTimeoutSecs := int(dnsprovider.PermissionVerificationTimeout.Seconds())
if s.setupDNS == nil {
s.writeDNSTestError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil, verificationTimeoutSecs)
return
@ -167,7 +167,8 @@ func (s *Server) testDNS(c *gin.Context) {
s.writeDNSTestError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err, verificationTimeoutSecs)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), dnsAPIPermissionVerificationTimeout())
verificationTimeoutSecs = int(dnsAPIPermissionVerificationTimeoutFor(request.ProviderID).Seconds())
ctx, cancel := context.WithTimeout(c.Request.Context(), dnsAPIPermissionVerificationTimeoutFor(request.ProviderID))
defer cancel()
result, err := s.setupDNS.Verify(ctx, setup.DNSVerificationInput{
ProviderID: request.ProviderID, TestDomain: request.TestDomain, Credentials: request.Credentials,
@ -220,6 +221,10 @@ func dnsAPIPermissionVerificationTimeout() time.Duration {
return dnsprovider.PermissionVerificationTimeout
}
func dnsAPIPermissionVerificationTimeoutFor(providerID string) time.Duration {
return dnsprovider.PermissionVerificationTimeoutFor(providerID)
}
func (s *Server) applySetup(c *gin.Context) {
if !s.setupMu.TryLock() {
s.writeError(c, http.StatusConflict, "初始化正在执行,请等待当前操作完成", nil)

View File

@ -276,6 +276,39 @@ func TestSetupDNSTestUsesShortPermissionVerificationTimeout(t *testing.T) {
t.Fatalf("DNS 在线测试请求超时不应继承 ACME 传播配置:剩余时间=%s", remaining)
}
}
func TestSetupAliyunDNSTestUsesFastAccountAPITimeout(t *testing.T) {
baseDir := t.TempDir()
cfg := config.Default(baseDir)
cfg.ACME.PropagationTimeoutSecs = 86400
cfg.ACME.PropagationPollingIntervalSecs = 60
verifier := &fakeSetupDNSVerifier{verifyErr: errors.New("测试 DNS 验证失败")}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{}, SetupDNS: verifier})
started := time.Now()
request := httptest.NewRequest(http.MethodPost, "/api/v1/setup/dns/test", strings.NewReader(`{
"provider":"aliyun",
"test_domain":"example.com",
"credentials":{"access_key_id":"test-id","access_key_secret":"test-secret","managed_zones":"example.com","allowed_domains":"example.com"}
}`))
request.RemoteAddr = "127.0.0.1:53000"
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("模拟阿里云 DNS 验证失败应返回 422,实际为 %d:%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"verification_timeout_secs":15`) {
t.Fatalf("阿里云 DNS 验证失败响应应包含 15 秒等待上限:%s", response.Body.String())
}
if !verifier.verifyHasDeadline {
t.Fatal("阿里云 DNS 在线测试应收到带截止时间的上下文")
}
remaining := verifier.verifyDeadline.Sub(started)
if remaining < 14*time.Second || remaining > 16*time.Second {
t.Fatalf("阿里云权限验证不应继承 ACME 传播配置:剩余时间=%s", remaining)
}
}
func TestSetupApplyPersistsConfigurationWithoutPlainPassword(t *testing.T) {
baseDir := t.TempDir()
cfg := config.Default(baseDir)
@ -547,7 +580,7 @@ func TestSetupDNSTestReturnsNotImplementedForUnavailableProvider(t *testing.T) {
if strings.Contains(response.Body.String(), "access_key_secret") || strings.Contains(response.Body.String(), "secret") {
t.Fatal("未开放 Provider 响应不应包含提交的凭据")
}
if !strings.Contains(response.Body.String(), `"verification_timeout_secs":60`) {
if !strings.Contains(response.Body.String(), `"verification_timeout_secs":15`) {
t.Fatalf("未开放 Provider 响应应包含当前最大等待时间:%s", response.Body.String())
}
}

View File

@ -1,6 +1,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 15;
function dnsVerificationTimeoutFor(providerID) {
return String(providerID || "").trim().toLowerCase() === "aliyun"
? aliyunDNSVerificationTimeoutSeconds
: defaultDNSVerificationTimeoutSeconds;
}
export function useDashboard() {
const scertLogo = "/Scert.png";
@ -233,9 +240,9 @@ export function useDashboard() {
dnsVerificationElapsedSeconds.value = 0;
}
function startDNSVerificationTimer(requestVersion) {
function startDNSVerificationTimer(requestVersion, providerID) {
stopDNSVerificationTimer();
dnsVerificationTimeoutSeconds.value = defaultDNSVerificationTimeoutSeconds;
dnsVerificationTimeoutSeconds.value = dnsVerificationTimeoutFor(providerID);
const startedAt = Date.now();
const updateElapsedSeconds = () => {
if (requestVersion !== dnsRequestVersion) return;
@ -801,7 +808,7 @@ export function useDashboard() {
const requestVersion = dnsRequestVersion;
dnsTesting.value = true;
dns.verification_state = "pending";
startDNSVerificationTimer(requestVersion);
startDNSVerificationTimer(requestVersion, dns.provider);
try {
const response = await fetchAdmin("/api/v1/dns/test", {
method: "POST",

View File

@ -103,7 +103,7 @@ test("管理设置 DNS 验证显示等待进度,并在结束后停止计时",
assert.equal(state.dnsTesting.value, true);
assert.equal(state.dnsVerificationElapsedSeconds.value, 0);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 60);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 15);
advanceTime(3200);
runIntervals();
assert.equal(state.dnsVerificationElapsedSeconds.value, 3);
@ -113,12 +113,12 @@ test("管理设置 DNS 验证显示等待进度,并在结束后停止计时",
credential_ref: "secret://test/only",
verification_token: "test-token",
expires_at: new Date(Date.now() + 60000).toISOString(),
verification_timeout_secs: 60,
verification_timeout_secs: 15,
}));
await task;
assert.equal(state.dnsTesting.value, false);
assert.equal(state.dnsVerificationElapsedSeconds.value, 0);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 60);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 15);
advanceTime(1000);
runIntervals();
assert.equal(state.dnsVerificationElapsedSeconds.value, 0);

View File

@ -583,7 +583,10 @@ const {
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>

View File

@ -1,6 +1,13 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 15;
function dnsVerificationTimeoutFor(providerID) {
return String(providerID || "").trim().toLowerCase() === "aliyun"
? aliyunDNSVerificationTimeoutSeconds
: defaultDNSVerificationTimeoutSeconds;
}
const commonEmailDomains = [
{ label: "常用邮箱", value: "" },
@ -170,9 +177,9 @@ export function useSetupWizard(props, emit) {
dnsVerificationElapsedSeconds.value = 0;
}
function startDNSVerificationTimer(requestVersion) {
function startDNSVerificationTimer(requestVersion, providerID) {
stopDNSVerificationTimer();
dnsVerificationTimeoutSeconds.value = defaultDNSVerificationTimeoutSeconds;
dnsVerificationTimeoutSeconds.value = dnsVerificationTimeoutFor(providerID);
const startedAt = Date.now();
const updateElapsedSeconds = () => {
if (requestVersion !== dnsRequestVersion) return;
@ -456,7 +463,7 @@ export function useSetupWizard(props, emit) {
const requestVersion = dnsRequestVersion;
dnsTesting.value = true;
startDNSVerificationTimer(requestVersion);
startDNSVerificationTimer(requestVersion, provider.id);
try {
const response = await fetch("/api/v1/setup/dns/test", {
method: "POST",

View File

@ -34,7 +34,7 @@ test("初始化 DNS 验证显示等待进度,并采用服务端返回的最长
assert.equal(state.dnsTesting.value, true);
assert.equal(state.dnsVerificationElapsedSeconds.value, 0);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 60);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 15);
advanceTime(2500);
runIntervals();
@ -45,12 +45,12 @@ test("初始化 DNS 验证显示等待进度,并采用服务端返回的最长
credential_ref: "secret://test/only",
verification_token: "test-token",
expires_at: new Date(Date.now() + 60000).toISOString(),
verification_timeout_secs: 60,
verification_timeout_secs: 15,
}));
await task;
assert.equal(state.dnsTesting.value, false);
assert.equal(state.dnsVerificationElapsedSeconds.value, 0);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 60);
assert.equal(state.dnsVerificationTimeoutSeconds.value, 15);
advanceTime(1000);
runIntervals();

View File

@ -810,7 +810,10 @@ const {
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small>
<small v-if="selectedDNSProvider?.id === 'aliyun'">
正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
<small v-else>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>