From 8c1471e8fef2ac3374fea1ac2fbe8b8a88db5a90 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 31 Aug 2026 03:01:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5DNS=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=BC=A0=E6=92=AD=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 6 ++- backend/internal/cli/root.go | 22 +++++++-- backend/internal/dnsprovider/tester.go | 51 +++++++++++++++++---- backend/internal/dnsprovider/tester_test.go | 17 +++++++ backend/internal/httpapi/setup.go | 16 ++++++- backend/internal/httpapi/setup_test.go | 49 +++++++++++++++++--- doc/Scert开发任务清单.md | 34 ++++++++++++++ 7 files changed, 175 insertions(+), 20 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0e39fe4..5395861 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -74,7 +74,11 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string _ = logger.Close() return nil, fmt.Errorf("初始化敏感信息存储失败:%w", err) } - dnsTester := dnsprovider.NewTester(dnsprovider.NewLegoFactory()) + dnsTester := dnsprovider.NewTesterWithSettings( + dnsprovider.NewLegoFactory(), + time.Duration(cfg.ACME.PropagationTimeoutSecs)*time.Second, + time.Duration(cfg.ACME.PropagationPollingIntervalSecs)*time.Second, + ) dnsVerification := setup.NewDNSVerificationManager(dnsTester, secretStore) repo := repository.NewSQLDomainRepository(db.DB) serverRepo := repository.NewSQLServerRepository(db.DB) diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index a79ec50..093a46b 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -456,13 +456,29 @@ func prepareDNSCredentials(ctx context.Context, cfg config.Config, input *setup. return func() {}, fmt.Errorf("初始化 DNS 凭据存储失败:%w", err) } manager := setup.NewDNSVerificationManager( - dnsprovider.NewTester(dnsprovider.NewLegoFactory()), + dnsprovider.NewTesterWithSettings( + dnsprovider.NewLegoFactory(), + time.Duration(cfg.ACME.PropagationTimeoutSecs)*time.Second, + time.Duration(cfg.ACME.PropagationPollingIntervalSecs)*time.Second, + ), store, ) - return prepareDNSCredentialsWithManager(ctx, input, output, manager, store) + return prepareDNSCredentialsWithManagerAndTimeout(ctx, input, output, manager, store, dnsVerificationTimeout(cfg)) +} + +func dnsVerificationTimeout(cfg config.Config) time.Duration { + timeout := time.Duration(cfg.ACME.PropagationTimeoutSecs)*time.Second + 30*time.Second + if timeout <= 30*time.Second { + return 45 * time.Second + } + return timeout } 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, 45*time.Second) +} + +func prepareDNSCredentialsWithManagerAndTimeout(ctx context.Context, input *setup.Input, output io.Writer, manager *setup.DNSVerificationManager, store *secretstore.Store, timeout time.Duration) (func(), error) { if input == nil { return func() {}, errors.New("初始化输入不能为空") } @@ -478,7 +494,7 @@ func prepareDNSCredentialsWithManager(ctx context.Context, input *setup.Input, o if strings.TrimSpace(input.DNSTestDomain) == "" { return func() {}, errors.New("DNS 权限测试域名不能为空") } - testContext, cancel := context.WithTimeout(ctx, 45*time.Second) + testContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() result, err := manager.Verify(testContext, setup.DNSVerificationInput{ ProviderID: input.DNSProvider, diff --git a/backend/internal/dnsprovider/tester.go b/backend/internal/dnsprovider/tester.go index 19fd37f..5b7821a 100644 --- a/backend/internal/dnsprovider/tester.go +++ b/backend/internal/dnsprovider/tester.go @@ -26,18 +26,44 @@ type TXTResolver interface { } type Tester struct { - factory Factory - resolver TXTResolver + factory Factory + resolver TXTResolver + propagationTimeout time.Duration + propagationInterval time.Duration } const dnsCleanupTimeout = 30 * time.Second +const defaultDNSPropagationTimeout = 30 * time.Second +const defaultDNSPropagationInterval = 2 * time.Second func NewTester(factory Factory) *Tester { - return NewTesterWithResolver(factory, legoTXTResolver{}) + return NewTesterWithResolverAndSettings(factory, legoTXTResolver{}, defaultDNSPropagationTimeout, defaultDNSPropagationInterval) } func NewTesterWithResolver(factory Factory, resolver TXTResolver) *Tester { - return &Tester{factory: factory, resolver: resolver} + return NewTesterWithResolverAndSettings(factory, resolver, defaultDNSPropagationTimeout, defaultDNSPropagationInterval) +} + +func NewTesterWithSettings(factory Factory, timeout, interval time.Duration) *Tester { + return NewTesterWithResolverAndSettings(factory, legoTXTResolver{}, timeout, interval) +} + +func NewTesterWithResolverAndSettings(factory Factory, resolver TXTResolver, timeout, interval time.Duration) *Tester { + if timeout <= 0 { + timeout = defaultDNSPropagationTimeout + } + if interval <= 0 || interval > timeout { + interval = defaultDNSPropagationInterval + if interval > timeout { + interval = timeout + } + } + return &Tester{ + factory: factory, + resolver: resolver, + propagationTimeout: timeout, + propagationInterval: interval, + } } type VerificationError struct { @@ -100,7 +126,7 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st Name: "创建记录", Status: "completed", Message: "已调用 DNS API 创建临时 TXT 记录。", }) - if err := waitForTXT(ctx, t.resolver, report.RecordName, challengeInfo.Value); err != nil { + if err := waitForTXT(ctx, t.resolver, report.RecordName, challengeInfo.Value, t.propagationTimeout, t.propagationInterval); err != nil { report.Stages = append(report.Stages, setup.DNSVerificationStage{ Name: "查询记录", Status: "failed", Message: "公网 DNS 查询未确认 TXT 记录已生效。", }) @@ -158,13 +184,22 @@ func (legoTXTResolver) LookupTXT(ctx context.Context, fqdn string) ([]string, er return values, nil } -func waitForTXT(ctx context.Context, resolver TXTResolver, fqdn, expected string) error { +func waitForTXT(ctx context.Context, resolver TXTResolver, fqdn, expected string, timeout, interval time.Duration) error { if resolver == nil { return errors.New("TXT 查询服务未初始化") } - deadline := time.NewTimer(30 * time.Second) + if timeout <= 0 { + timeout = defaultDNSPropagationTimeout + } + if interval <= 0 || interval > timeout { + interval = defaultDNSPropagationInterval + if interval > timeout { + interval = timeout + } + } + deadline := time.NewTimer(timeout) defer deadline.Stop() - ticker := time.NewTicker(2 * time.Second) + ticker := time.NewTicker(interval) defer ticker.Stop() for { values, err := resolver.LookupTXT(ctx, fqdn) diff --git a/backend/internal/dnsprovider/tester_test.go b/backend/internal/dnsprovider/tester_test.go index a5653ce..87b07d6 100644 --- a/backend/internal/dnsprovider/tester_test.go +++ b/backend/internal/dnsprovider/tester_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/go-acme/lego/v5/challenge" "github.com/go-acme/lego/v5/challenge/dns01" @@ -79,6 +80,22 @@ func TestTesterCreatesAndCleansChallengeRecord(t *testing.T) { } } +func TestTesterUsesConfiguredPropagationSettings(t *testing.T) { + provider := &fakeProvider{} + tester := NewTesterWithResolverAndSettings(fakeFactory{provider: provider}, missingTXTResolver{}, 20*time.Millisecond, 5*time.Millisecond) + started := time.Now() + _, err := tester.Verify(context.Background(), Cloudflare, "example.com", map[string]string{"api_token": "secret-token"}) + if err == nil { + t.Fatal("missing TXT record should fail") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("configured propagation timeout was ignored: %s", elapsed) + } + if !provider.cleaned { + t.Fatal("failed propagation check should clean up the record") + } +} + func TestTesterDoesNotCleanupWhenPresentFails(t *testing.T) { provider := &fakeProvider{presentErr: errors.New("拒绝访问")} tester := NewTester(fakeFactory{provider: provider}) diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index 6a158f5..ff58816 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -157,7 +157,7 @@ func (s *Server) testDNS(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err) return } - ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second) + ctx, cancel := context.WithTimeout(c.Request.Context(), s.dnsVerificationTimeout()) defer cancel() result, err := s.setupDNS.Verify(ctx, setup.DNSVerificationInput{ ProviderID: request.ProviderID, TestDomain: request.TestDomain, Credentials: request.Credentials, @@ -188,6 +188,20 @@ func (s *Server) testDNS(c *gin.Context) { }) } +func (s *Server) dnsVerificationTimeout() time.Duration { + s.configMu.RLock() + cfg := s.config + s.configMu.RUnlock() + timeout := time.Duration(cfg.ACME.PropagationTimeoutSecs)*time.Second + dnsCleanupRequestBuffer + if timeout <= dnsCleanupRequestBuffer { + return defaultDNSRequestTimeout + } + return timeout +} + +const defaultDNSRequestTimeout = 45 * time.Second +const dnsCleanupRequestBuffer = 30 * time.Second + func (s *Server) applySetup(c *gin.Context) { s.configMu.Lock() defer s.configMu.Unlock() diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index 55936c1..9cdad0e 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -20,12 +20,14 @@ import ( ) type fakeSetupDNSVerifier struct { - verifyResult setup.DNSVerificationResult - verifyErr error - checkErr error - consumeErr error - checked bool - consumed bool + verifyResult setup.DNSVerificationResult + verifyErr error + checkErr error + consumeErr error + checked bool + consumed bool + verifyDeadline time.Time + verifyHasDeadline bool } type fakeSetupSecretWriter struct { @@ -106,7 +108,11 @@ func (f *fakeFirewallManager) Rollback(_ context.Context, result firewall.Result return f.rollbackErr } -func (f *fakeSetupDNSVerifier) Verify(context.Context, setup.DNSVerificationInput) (setup.DNSVerificationResult, error) { +func (f *fakeSetupDNSVerifier) Verify(ctx context.Context, _ setup.DNSVerificationInput) (setup.DNSVerificationResult, error) { + if deadline, ok := ctx.Deadline(); ok { + f.verifyDeadline = deadline + f.verifyHasDeadline = true + } if f.verifyResult.CredentialRef == "" { f.verifyResult = setup.DNSVerificationResult{ CredentialRef: "secret://dns/cloudflare/test", @@ -203,6 +209,35 @@ func TestSetupDNSProviderCatalogAndOnlineTest(t *testing.T) { } } +func TestSetupDNSTestUsesConfiguredRequestTimeout(t *testing.T) { + baseDir := t.TempDir() + cfg := config.Default(baseDir) + cfg.ACME.PropagationTimeoutSecs = 1 + cfg.ACME.PropagationPollingIntervalSecs = 1 + 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":"cloudflare", + "test_domain":"example.com", + "credentials":{"api_token":"test-token"} + }`)) + 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 !verifier.verifyHasDeadline { + t.Fatal("DNS 在线测试应收到带截止时间的上下文") + } + remaining := verifier.verifyDeadline.Sub(started) + if remaining < 30*time.Second || remaining > 32*time.Second { + t.Fatalf("DNS 在线测试请求超时未按配置计算:剩余时间=%s", remaining) + } +} func TestSetupApplyPersistsConfigurationWithoutPlainPassword(t *testing.T) { baseDir := t.TempDir() cfg := config.Default(baseDir) diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index bf6d8b7..40dfe7a 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3103,4 +3103,38 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 ### 80.3 验收边界 - `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 证书部署、Nginx 反向代理、生产 HTTPS、Windows Service、Linux systemd、Wails 打包、Windows ACL 和 MySQL 实例仍需在授权目标环境执行实机验收。 +- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。 +## 八十一、DNS 在线测试传播参数接入与全量复核(2026-08-30) + +### 81.1 本轮需求清单 + +- `[✓]` DNS 测试器支持使用配置中的 DNS 传播等待时间,不再固定使用 30 秒。 +- `[✓]` DNS 测试器支持使用配置中的 DNS 轮询检测间隔,不再固定使用 2 秒。 +- `[✓]` Web 初始化 DNS 在线测试的请求上下文超时与传播等待配置同步,并保留清理记录所需的缓冲时间。 +- `[✓]` CLI 初始化 DNS 在线测试使用与配置一致的传播等待参数和请求超时。 +- `[✓]` 保留无效配置和旧构造函数的安全默认值,避免已有调用方失去超时保护。 +- `[✓]` 增加 DNS 测试器配置生效和 Web 请求截止时间的回归测试。 + +### 81.2 代码与验证状态 + +- `[✓]` 更新 `backend/internal/dnsprovider/tester.go`,增加可配置传播等待时间和轮询间隔的构造函数。 +- `[✓]` 更新 `backend/internal/app/app.go`,应用启动时注入 ACME DNS 传播配置。 +- `[✓]` 更新 `backend/internal/cli/root.go`,CLI 初始化流程注入 DNS 传播配置并计算请求超时。 +- `[✓]` 更新 `backend/internal/httpapi/setup.go`,Web DNS 在线测试按配置计算请求上下文超时。 +- `[✓]` 增加 DNS 测试器和 Web API 超时专项回归测试。 +- `[✓]` `go test ./...` +- `[✓]` `go test -race ./...` +- `[✓]` `go vet ./...` +- `[✓]` `go mod verify` +- `[✓]` `go build ./cmd/scert` +- `[✓]` `go build ./cmd/scert-admin` +- `[✓]` 网页端 `npm.cmd run build` +- `[✓]` 桌面端前端 `npm.cmd run build` +- `[✓]` `git diff --check` + +### 81.3 验收边界 + +- `[!]` 尚未使用新的真实 DNS 服务商凭据执行公网 TXT 创建、传播和清理验收。 +- `[!]` Let’s Encrypt Production、真实 SSH 主机、公钥安装、证书部署、Nginx 反向代理和生产 HTTPS 仍需在用户授权的目标环境执行实机验收。 +- `[!]` Windows Service、Linux systemd、Wails 桌面打包、Windows ACL、MySQL 实例和真实防火墙链路仍需目标环境验收。 - `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。 \ No newline at end of file