From d8205ae65177910dd2939c3c31e54f03005bd3a3 Mon Sep 17 00:00:00 2001 From: xuans Date: Wed, 16 Sep 2026 00:57:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=20DNS=20=E6=9D=83=E9=99=90=E9=AA=8C=E8=AF=81=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E6=97=B6=E9=95=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/dns.go | 3 +- backend/internal/cli/dns_test.go | 8 +- backend/internal/cli/init_certificate.go | 2 +- backend/internal/cli/root.go | 8 +- .../dnsprovider/aliyun_authorization.go | 71 +++++- .../aliyun_authorization_verify_test.go | 224 ++++++++++++++++++ .../internal/dnsprovider/aliyun_provider.go | 31 +++ .../internal/dnsprovider/aliyun_scope_test.go | 29 ++- backend/internal/dnsprovider/tester.go | 73 +++++- backend/internal/dnsprovider/tester_test.go | 35 +++ backend/internal/httpapi/dns_api_test.go | 17 ++ backend/internal/httpapi/operation_timeout.go | 5 +- backend/internal/httpapi/setup.go | 9 +- backend/internal/httpapi/setup_test.go | 35 ++- frontend/src/views/Dashboard/Dashboard.js | 13 +- .../src/views/Dashboard/Dashboard.test.js | 6 +- frontend/src/views/Dashboard/Dashboard.vue | 5 +- frontend/src/views/SetupWizard/SetupWizard.js | 13 +- .../src/views/SetupWizard/SetupWizard.test.js | 6 +- .../src/views/SetupWizard/SetupWizard.vue | 5 +- 20 files changed, 560 insertions(+), 38 deletions(-) create mode 100644 backend/internal/dnsprovider/aliyun_authorization_verify_test.go diff --git a/backend/internal/cli/dns.go b/backend/internal/cli/dns.go index 1dab4e4..fb09a7a 100644 --- a/backend/internal/cli/dns.go +++ b/backend/internal/cli/dns.go @@ -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 { diff --git a/backend/internal/cli/dns_test.go b/backend/internal/cli/dns_test.go index e0e3ee0..b0814e9 100644 --- a/backend/internal/cli/dns_test.go +++ b/backend/internal/cli/dns_test.go @@ -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": diff --git a/backend/internal/cli/init_certificate.go b/backend/internal/cli/init_certificate.go index 45e9ff2..de8f181 100644 --- a/backend/internal/cli/init_certificate.go +++ b/backend/internal/cli/init_certificate.go @@ -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) diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index effd771..ea36e0a 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -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) { diff --git a/backend/internal/dnsprovider/aliyun_authorization.go b/backend/internal/dnsprovider/aliyun_authorization.go index 7b75499..c4877fa 100644 --- a/backend/internal/dnsprovider/aliyun_authorization.go +++ b/backend/internal/dnsprovider/aliyun_authorization.go @@ -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)) diff --git a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go new file mode 100644 index 0000000..bd8b1da --- /dev/null +++ b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go @@ -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, ",") +} diff --git a/backend/internal/dnsprovider/aliyun_provider.go b/backend/internal/dnsprovider/aliyun_provider.go index a107296..7ba626e 100644 --- a/backend/internal/dnsprovider/aliyun_provider.go +++ b/backend/internal/dnsprovider/aliyun_provider.go @@ -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) } diff --git a/backend/internal/dnsprovider/aliyun_scope_test.go b/backend/internal/dnsprovider/aliyun_scope_test.go index 32e36c7..1e51ace 100644 --- a/backend/internal/dnsprovider/aliyun_scope_test.go +++ b/backend/internal/dnsprovider/aliyun_scope_test.go @@ -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 diff --git a/backend/internal/dnsprovider/tester.go b/backend/internal/dnsprovider/tester.go index dee86f3..1d77bb7 100644 --- a/backend/internal/dnsprovider/tester.go +++ b/backend/internal/dnsprovider/tester.go @@ -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) diff --git a/backend/internal/dnsprovider/tester_test.go b/backend/internal/dnsprovider/tester_test.go index e9c796d..3e7562c 100644 --- a/backend/internal/dnsprovider/tester_test.go +++ b/backend/internal/dnsprovider/tester_test.go @@ -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("权限不足")} diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index bc6196b..9e7af18 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -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 diff --git a/backend/internal/httpapi/operation_timeout.go b/backend/internal/httpapi/operation_timeout.go index 6c8bfeb..0d9da82 100644 --- a/backend/internal/httpapi/operation_timeout.go +++ b/backend/internal/httpapi/operation_timeout.go @@ -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) { diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index b281707..edcaa80 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -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) diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index 572e46c..66352dc 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -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()) } } diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 3b80e18..80b5051 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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", diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 5183139..30ae03a 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index f4e3915..4e61cbc 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -583,7 +583,10 @@ const { 已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待 {{ dnsVerificationTimeoutSeconds }} 秒。 - + + 正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。 + + 系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。 diff --git a/frontend/src/views/SetupWizard/SetupWizard.js b/frontend/src/views/SetupWizard/SetupWizard.js index 598e411..f4c94d9 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.js +++ b/frontend/src/views/SetupWizard/SetupWizard.js @@ -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", diff --git a/frontend/src/views/SetupWizard/SetupWizard.test.js b/frontend/src/views/SetupWizard/SetupWizard.test.js index 92ad944..ebf2bb4 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.test.js +++ b/frontend/src/views/SetupWizard/SetupWizard.test.js @@ -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(); diff --git a/frontend/src/views/SetupWizard/SetupWizard.vue b/frontend/src/views/SetupWizard/SetupWizard.vue index 2509356..e9392c8 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.vue +++ b/frontend/src/views/SetupWizard/SetupWizard.vue @@ -810,7 +810,10 @@ const { 已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待 {{ dnsVerificationTimeoutSeconds }} 秒。 - + + 正在通过阿里云 DNS API 创建、查询并清理临时 TXT 记录,不等待公网 DNS 传播,通常数秒即可完成;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。 + + 系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。