接入DNS在线测试传播参数

This commit is contained in:
xuans 2026-08-31 03:01:32 +08:00
parent e982f54607
commit 8c1471e8fe
7 changed files with 175 additions and 20 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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)

View File

@ -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})

View File

@ -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()

View File

@ -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)

View File

@ -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*` 未删除、未纳入本轮提交,等待用户单独授权清理。