优化阿里云 DNS 权限验证等待时长
This commit is contained in:
parent
91eaf31d15
commit
d8205ae651
@ -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 {
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
|
||||
224
backend/internal/dnsprovider/aliyun_authorization_verify_test.go
Normal file
224
backend/internal/dnsprovider/aliyun_authorization_verify_test.go
Normal 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, ",")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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("权限不足")}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user