接入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()
|
_ = logger.Close()
|
||||||
return nil, fmt.Errorf("初始化敏感信息存储失败:%w", err)
|
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)
|
dnsVerification := setup.NewDNSVerificationManager(dnsTester, secretStore)
|
||||||
repo := repository.NewSQLDomainRepository(db.DB)
|
repo := repository.NewSQLDomainRepository(db.DB)
|
||||||
serverRepo := repository.NewSQLServerRepository(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)
|
return func() {}, fmt.Errorf("初始化 DNS 凭据存储失败:%w", err)
|
||||||
}
|
}
|
||||||
manager := setup.NewDNSVerificationManager(
|
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,
|
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) {
|
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 {
|
if input == nil {
|
||||||
return func() {}, errors.New("初始化输入不能为空")
|
return func() {}, errors.New("初始化输入不能为空")
|
||||||
}
|
}
|
||||||
@ -478,7 +494,7 @@ func prepareDNSCredentialsWithManager(ctx context.Context, input *setup.Input, o
|
|||||||
if strings.TrimSpace(input.DNSTestDomain) == "" {
|
if strings.TrimSpace(input.DNSTestDomain) == "" {
|
||||||
return func() {}, errors.New("DNS 权限测试域名不能为空")
|
return func() {}, errors.New("DNS 权限测试域名不能为空")
|
||||||
}
|
}
|
||||||
testContext, cancel := context.WithTimeout(ctx, 45*time.Second)
|
testContext, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := manager.Verify(testContext, setup.DNSVerificationInput{
|
result, err := manager.Verify(testContext, setup.DNSVerificationInput{
|
||||||
ProviderID: input.DNSProvider,
|
ProviderID: input.DNSProvider,
|
||||||
|
|||||||
@ -26,18 +26,44 @@ type TXTResolver interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Tester struct {
|
type Tester struct {
|
||||||
factory Factory
|
factory Factory
|
||||||
resolver TXTResolver
|
resolver TXTResolver
|
||||||
|
propagationTimeout time.Duration
|
||||||
|
propagationInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
const dnsCleanupTimeout = 30 * time.Second
|
const dnsCleanupTimeout = 30 * time.Second
|
||||||
|
const defaultDNSPropagationTimeout = 30 * time.Second
|
||||||
|
const defaultDNSPropagationInterval = 2 * time.Second
|
||||||
|
|
||||||
func NewTester(factory Factory) *Tester {
|
func NewTester(factory Factory) *Tester {
|
||||||
return NewTesterWithResolver(factory, legoTXTResolver{})
|
return NewTesterWithResolverAndSettings(factory, legoTXTResolver{}, defaultDNSPropagationTimeout, defaultDNSPropagationInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTesterWithResolver(factory Factory, resolver TXTResolver) *Tester {
|
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 {
|
type VerificationError struct {
|
||||||
@ -100,7 +126,7 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
|
|||||||
Name: "创建记录", Status: "completed", Message: "已调用 DNS API 创建临时 TXT 记录。",
|
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{
|
report.Stages = append(report.Stages, setup.DNSVerificationStage{
|
||||||
Name: "查询记录", Status: "failed", Message: "公网 DNS 查询未确认 TXT 记录已生效。",
|
Name: "查询记录", Status: "failed", Message: "公网 DNS 查询未确认 TXT 记录已生效。",
|
||||||
})
|
})
|
||||||
@ -158,13 +184,22 @@ func (legoTXTResolver) LookupTXT(ctx context.Context, fqdn string) ([]string, er
|
|||||||
return values, nil
|
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 {
|
if resolver == nil {
|
||||||
return errors.New("TXT 查询服务未初始化")
|
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()
|
defer deadline.Stop()
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
values, err := resolver.LookupTXT(ctx, fqdn)
|
values, err := resolver.LookupTXT(ctx, fqdn)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-acme/lego/v5/challenge"
|
"github.com/go-acme/lego/v5/challenge"
|
||||||
"github.com/go-acme/lego/v5/challenge/dns01"
|
"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) {
|
func TestTesterDoesNotCleanupWhenPresentFails(t *testing.T) {
|
||||||
provider := &fakeProvider{presentErr: errors.New("拒绝访问")}
|
provider := &fakeProvider{presentErr: errors.New("拒绝访问")}
|
||||||
tester := NewTester(fakeFactory{provider: provider})
|
tester := NewTester(fakeFactory{provider: provider})
|
||||||
|
|||||||
@ -157,7 +157,7 @@ func (s *Server) testDNS(c *gin.Context) {
|
|||||||
s.writeError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err)
|
s.writeError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), s.dnsVerificationTimeout())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
result, err := s.setupDNS.Verify(ctx, setup.DNSVerificationInput{
|
result, err := s.setupDNS.Verify(ctx, setup.DNSVerificationInput{
|
||||||
ProviderID: request.ProviderID, TestDomain: request.TestDomain, Credentials: request.Credentials,
|
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) {
|
func (s *Server) applySetup(c *gin.Context) {
|
||||||
s.configMu.Lock()
|
s.configMu.Lock()
|
||||||
defer s.configMu.Unlock()
|
defer s.configMu.Unlock()
|
||||||
|
|||||||
@ -20,12 +20,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeSetupDNSVerifier struct {
|
type fakeSetupDNSVerifier struct {
|
||||||
verifyResult setup.DNSVerificationResult
|
verifyResult setup.DNSVerificationResult
|
||||||
verifyErr error
|
verifyErr error
|
||||||
checkErr error
|
checkErr error
|
||||||
consumeErr error
|
consumeErr error
|
||||||
checked bool
|
checked bool
|
||||||
consumed bool
|
consumed bool
|
||||||
|
verifyDeadline time.Time
|
||||||
|
verifyHasDeadline bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeSetupSecretWriter struct {
|
type fakeSetupSecretWriter struct {
|
||||||
@ -106,7 +108,11 @@ func (f *fakeFirewallManager) Rollback(_ context.Context, result firewall.Result
|
|||||||
return f.rollbackErr
|
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 == "" {
|
if f.verifyResult.CredentialRef == "" {
|
||||||
f.verifyResult = setup.DNSVerificationResult{
|
f.verifyResult = setup.DNSVerificationResult{
|
||||||
CredentialRef: "secret://dns/cloudflare/test",
|
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) {
|
func TestSetupApplyPersistsConfigurationWithoutPlainPassword(t *testing.T) {
|
||||||
baseDir := t.TempDir()
|
baseDir := t.TempDir()
|
||||||
cfg := config.Default(baseDir)
|
cfg := config.Default(baseDir)
|
||||||
|
|||||||
@ -3104,3 +3104,37 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
|
|||||||
|
|
||||||
- `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 证书部署、Nginx 反向代理、生产 HTTPS、Windows Service、Linux systemd、Wails 打包、Windows ACL 和 MySQL 实例仍需在授权目标环境执行实机验收。
|
- `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 证书部署、Nginx 反向代理、生产 HTTPS、Windows Service、Linux systemd、Wails 打包、Windows ACL 和 MySQL 实例仍需在授权目标环境执行实机验收。
|
||||||
- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。
|
- `[!]` 工作区中已有的 `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