接入DNS在线测试传播参数
This commit is contained in:
parent
e982f54607
commit
8c1471e8fe
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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*` 未删除、未纳入本轮提交,等待用户单独授权清理。
|
||||
Loading…
Reference in New Issue
Block a user