完善阿里云权限与会话回归测试

This commit is contained in:
xuans 2026-09-16 04:09:07 +08:00
parent 50ece6093b
commit bbabc35cbf
25 changed files with 502 additions and 250 deletions

View File

@ -20,7 +20,7 @@ type scopedAuthorizationChecker struct {
type scopedServiceCredentials struct{} type scopedServiceCredentials struct{}
func (scopedServiceCredentials) Get(context.Context, string) (map[string]string, error) { func (scopedServiceCredentials) Get(context.Context, string) (map[string]string, error) {
return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,example.org", "allowed_domains": "example.com,other.example.org"}, nil return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, nil
} }
func (c *scopedAuthorizationChecker) VerifyDomain(_ context.Context, _ string, name string, _ map[string]string) error { func (c *scopedAuthorizationChecker) VerifyDomain(_ context.Context, _ string, name string, _ map[string]string) error {
@ -107,16 +107,72 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) {
} }
} }
func TestAliyunWholeOrderScopeValidationBeforeAnyCloudProbe(t *testing.T) { type protectedDomainCredentials struct {
getCalls int
}
func (c *protectedDomainCredentials) Get(context.Context, string) (map[string]string, error) {
c.getCalls++
return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}, nil
}
type protectedDomainProviderFactory struct {
createCalls int
}
func (f *protectedDomainProviderFactory) Create(string, map[string]string) (challenge.Provider, error) {
f.createCalls++
return serviceProvider{}, nil
}
type protectedDomainAccounts struct {
loadOrCreateCalls int
saveCalls int
}
func (a *protectedDomainAccounts) LoadOrCreate(context.Context, string, string) (*AccountUser, error) {
a.loadOrCreateCalls++
return &AccountUser{}, nil
}
func (a *protectedDomainAccounts) Save(context.Context, string, *AccountUser) error {
a.saveCalls++
return nil
}
func TestAliyunProtectedSANRejectedBeforeAnyExternalSideEffect(t *testing.T) {
cfg := certificateTestConfig(t.TempDir()) cfg := certificateTestConfig(t.TempDir())
credentials := &protectedDomainCredentials{}
providerFactory := &protectedDomainProviderFactory{}
accounts := &protectedDomainAccounts{}
checker := &scopedAuthorizationChecker{} checker := &scopedAuthorizationChecker{}
service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) service := NewService(cfg, nil, credentials, providerFactory, accounts)
service.SetDNSAuthorizationChecker(checker) service.SetDNSAuthorizationChecker(checker)
issuerFactoryCalls := 0
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
issuerFactoryCalls++
return nil
}
_, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{ _, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{
Domains: []string{"example.com", "outside.net"}, CertificateType: "san", Domains: []string{"example.com", "pcux.cn"}, CertificateType: "san",
ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "staging", ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "staging",
}) })
if err == nil || !strings.Contains(err.Error(), "outside.net") || len(checker.calls) != 0 { if err == nil || !strings.Contains(err.Error(), "pcux.cn") {
t.Fatalf("部分越界必须零云调用拒绝整单:%v %v", err, checker.calls) t.Fatalf("保护 SAN 必须被本地拒绝:%v", err)
}
if credentials.getCalls != 0 {
t.Fatalf("保护 SAN 被拒绝前不得读取 DNS 凭据,调用次数 = %d", credentials.getCalls)
}
if len(checker.calls) != 0 {
t.Fatalf("保护 SAN 被拒绝前不得执行 DNS 权限探测:%v", checker.calls)
}
if providerFactory.createCalls != 0 {
t.Fatalf("保护 SAN 被拒绝前不得创建 DNS Provider,调用次数 = %d", providerFactory.createCalls)
}
if accounts.loadOrCreateCalls != 0 || accounts.saveCalls != 0 {
t.Fatalf("保护 SAN 被拒绝前不得访问 ACME 账户:LoadOrCreate=%d Save=%d", accounts.loadOrCreateCalls, accounts.saveCalls)
}
if issuerFactoryCalls != 0 {
t.Fatalf("保护 SAN 被拒绝前不得创建 ACME 签发器,调用次数 = %d", issuerFactoryCalls)
} }
} }

View File

@ -156,7 +156,7 @@ func TestResetURLSupportsIPv6WithoutPort(t *testing.T) {
func TestDefaultACMEPropagationSettings(t *testing.T) { func TestDefaultACMEPropagationSettings(t *testing.T) {
cfg := Default(t.TempDir()) cfg := Default(t.TempDir())
if cfg.ACME.PropagationTimeoutSecs != 900 || cfg.ACME.PropagationPollingIntervalSecs != 10 { if cfg.ACME.PropagationTimeoutSecs != 300 || cfg.ACME.PropagationPollingIntervalSecs != 5 {
t.Fatalf("默认 DNS 传播参数不正确:%+v", cfg.ACME) t.Fatalf("默认 DNS 传播参数不正确:%+v", cfg.ACME)
} }
} }
@ -212,7 +212,7 @@ func TestNormalizeRestoresMissingACMEPropagationSettings(t *testing.T) {
cfg.ACME.PropagationTimeoutSecs = 0 cfg.ACME.PropagationTimeoutSecs = 0
cfg.ACME.PropagationPollingIntervalSecs = 0 cfg.ACME.PropagationPollingIntervalSecs = 0
cfg.Normalize() cfg.Normalize()
if cfg.ACME.PropagationTimeoutSecs != 900 || cfg.ACME.PropagationPollingIntervalSecs != 10 { if cfg.ACME.PropagationTimeoutSecs != 300 || cfg.ACME.PropagationPollingIntervalSecs != 5 {
t.Fatalf("旧配置的 DNS 传播参数未补齐:%+v", cfg.ACME) t.Fatalf("旧配置的 DNS 传播参数未补齐:%+v", cfg.ACME)
} }
} }

View File

@ -111,7 +111,7 @@ func TestAliyunAuthorizationVerifyClosesWriteReadCleanupLoop(t *testing.T) {
} }
} }
func TestAliyunAuthorizationVerifyDoesNotCallCloudOutsideExplicitScope(t *testing.T) { func TestAliyunAuthorizationVerifyDoesNotCallCloudForProtectedDomain(t *testing.T) {
api := &authorizationRecordAPI{} api := &authorizationRecordAPI{}
checker := newAuthorizationCheckerForTest(api) checker := newAuthorizationCheckerForTest(api)
constructed := 0 constructed := 0

View File

@ -8,62 +8,61 @@ import (
) )
func scopeCredentials() map[string]string { func scopeCredentials() map[string]string {
return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,sub.example.com", "allowed_domains": "www.sub.example.com,*.example.com"} return map[string]string{
"access_key_id": "test-id",
"access_key_secret": "test-secret",
}
} }
func TestAliyunScopeBoundaries(t *testing.T) { func TestResolveAliyunTargetDomainUsesOnlyRegistrableRoot(t *testing.T) {
scope, err := ParseAliyunScope(scopeCredentials()) tests := []struct {
if err != nil { name string
t.Fatal(err) want string
}{
{name: "www.sub.example.com", want: "example.com"},
{name: "WWW.EXAMPLE.COM.", want: "example.com"},
{name: "*.example.com", want: "example.com"},
{name: "example.co.uk", want: "example.co.uk"},
} }
for _, tc := range []struct{ name, zone string }{ for _, test := range tests {
{"www.sub.example.com", "sub.example.com"}, t.Run(test.name, func(t *testing.T) {
{"WWW.EXAMPLE.COM.", "example.com"}, got, err := ResolveAliyunTargetDomain(test.name)
{"*.example.com", "example.com"}, if err != nil || got != test.want {
{"example.com", ""}, t.Fatalf("ResolveAliyunTargetDomain(%q) = %q, %v; want %q", test.name, got, err, test.want)
{"evil-example.com", ""},
{"example.com.evil.net", ""},
{"deep.www.example.com", ""},
{"*.sub.example.com", ""},
{"pcux.cn", ""},
{"com", ""},
} {
t.Run(tc.name, func(t *testing.T) {
zone, err := scope.Resolve(tc.name)
if zone != tc.zone || (err != nil) != (tc.zone == "") {
t.Fatalf("范围校验错误:zone=%s err=%v", zone, err)
} }
}) })
} }
for _, value := range []string{"", "com", "co.uk", "*.example.com", "example.com,", "example.com,co.uk"} { }
credentials := scopeCredentials()
credentials["managed_zones"] = value func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomains(t *testing.T) {
if _, err := ParseAliyunScope(credentials); err == nil { for _, name := range []string{"pcux.cn", "*.pcux.cn", "api.pcux.cn", "com", "*.com", ""} {
t.Fatalf("不安全托管区未拒绝:%s", value) t.Run(name, func(t *testing.T) {
} if _, err := ResolveAliyunTargetDomain(name); err == nil {
} t.Fatalf("ResolveAliyunTargetDomain(%q) unexpectedly succeeded", name)
credentials := scopeCredentials() }
credentials["allowed_domains"] = "outside.net" })
if _, err := ParseAliyunScope(credentials); err == nil {
t.Fatal("越界的允许范围必须整体拒绝")
} }
} }
type fakeAliyunRecords struct { type fakeAliyunRecords struct {
zones, rrs, values, deleted []string zones, rrs, values, described, deleted []string
addErr, describeErr, deleteErr error addErr, describeErr, deleteErr error
describeInfo aliyunRecordInfo describeInfo aliyunRecordInfo
canceledCleanup bool canceledCleanup bool
} }
func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, value string) (string, error) { func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, value string) (string, error) {
f.zones = append(f.zones, zone) f.zones = append(f.zones, zone)
f.rrs = append(f.rrs, rr) f.rrs = append(f.rrs, rr)
f.values = append(f.values, value) f.values = append(f.values, value)
return "created-record-id", f.addErr if f.addErr != nil {
return "", f.addErr
}
return "created-record-id", nil
} }
func (f *fakeAliyunRecords) Describe(_ context.Context, _ string) (aliyunRecordInfo, error) { func (f *fakeAliyunRecords) Describe(_ context.Context, id string) (aliyunRecordInfo, error) {
f.described = append(f.described, id)
if f.describeErr != nil { if f.describeErr != nil {
return aliyunRecordInfo{}, f.describeErr return aliyunRecordInfo{}, f.describeErr
} }
@ -71,10 +70,10 @@ func (f *fakeAliyunRecords) Describe(_ context.Context, _ string) (aliyunRecordI
return f.describeInfo, nil return f.describeInfo, nil
} }
if len(f.zones) == 0 || len(f.rrs) == 0 || len(f.values) == 0 { if len(f.zones) == 0 || len(f.rrs) == 0 || len(f.values) == 0 {
return aliyunRecordInfo{}, errors.New("没有可供查询的临时记录") return aliyunRecordInfo{}, errors.New("no temporary record exists")
} }
return aliyunRecordInfo{ return aliyunRecordInfo{
RecordID: "created-record-id", RecordID: id,
DomainName: f.zones[len(f.zones)-1], DomainName: f.zones[len(f.zones)-1],
RR: f.rrs[len(f.rrs)-1], RR: f.rrs[len(f.rrs)-1],
Type: "TXT", Type: "TXT",
@ -88,74 +87,108 @@ func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error {
return f.deleteErr return f.deleteErr
} }
func TestAliyunProbeNeverFallsBackOrCallsCloudOutsideScope(t *testing.T) { func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) {
api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")} api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")}
checker := NewAliyunAuthorizationChecker() checker := NewAliyunAuthorizationChecker()
constructed := 0 constructed := 0
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++; return api, nil } checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
credentials := scopeCredentials() constructed++
for _, name := range []string{"example.com", "outside.net", "pcux.cn", "*.sub.example.com"} { return api, nil
if _, err := checker.Verify(t.Context(), name, credentials); err == nil {
t.Fatal("越界请求未拒绝")
}
} }
if constructed != 0 {
t.Fatal("越界请求不能创建云客户端") if _, err := checker.Verify(t.Context(), "www.sub.example.com", scopeCredentials()); err == nil {
t.Fatal("RAM denial unexpectedly succeeded")
} }
if _, err := checker.Verify(t.Context(), "www.sub.example.com", credentials); err == nil { if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" {
t.Fatal("RAM 拒绝必须返回失败") t.Fatalf("probe did not use only the registrable root: constructed=%d zones=%v", constructed, api.zones)
}
if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || !strings.HasSuffix(api.rrs[0], ".www") {
t.Fatalf("必须仅写授权子域:%v %v", api.zones, api.rrs)
} }
if len(api.deleted) != 0 { if len(api.deleted) != 0 {
t.Fatal("新增失败不能猜测记录 ID 删除") t.Fatalf("failed Add must not guess a record ID to delete: %v", api.deleted)
}
api.addErr = nil
api.deleteErr = errors.New("Forbidden.RAM")
report, err := checker.Verify(t.Context(), "www.sub.example.com", credentials)
if err == nil || report.Verified || !report.Domains[0].Writable || report.Domains[0].Cleanup {
t.Fatal("清理失败不能证明权限通过")
}
api.deleteErr = nil
report, err = checker.Verify(t.Context(), "www.sub.example.com", credentials)
if err != nil || !report.Verified || api.deleted[len(api.deleted)-1] != "created-record-id" {
t.Fatal("必须删除本次新增记录")
} }
} }
func TestAliyunProviderPinsZoneAndCleanupIdentity(t *testing.T) { func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) {
scope, _ := ParseAliyunScope(scopeCredentials())
api := &fakeAliyunRecords{} api := &fakeAliyunRecords{}
p := &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: func(_ context.Context, name, _ string) (string, string, string) { checker := NewAliyunAuthorizationChecker()
fqdn := "_acme-challenge." + name + "." constructed := 0
return fqdn, fqdn, "txt-value" checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
}} constructed++
if err := p.Present(t.Context(), "outside.net", "token", "key"); err == nil || len(api.zones) != 0 { return api, nil
t.Fatal("实际 Provider 必须零调用拒绝越界")
} }
if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil {
t.Fatal(err) for _, name := range []string{"pcux.cn", "*.pcux.cn", "api.pcux.cn"} {
if _, err := checker.Verify(t.Context(), name, scopeCredentials()); err == nil {
t.Fatalf("protected domain %q unexpectedly succeeded", name)
}
} }
if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || api.rrs[0] != "_acme-challenge.www" { if constructed != 0 || len(api.zones) != 0 || len(api.described) != 0 || len(api.deleted) != 0 {
t.Fatal("实际写入没有锁定指定托管区") t.Fatalf("protected domain touched cloud state: constructed=%d zones=%v described=%v deleted=%v", constructed, api.zones, api.described, api.deleted)
} }
if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil { }
t.Fatal("重复挑战不能覆盖待清理 ID")
} func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) {
_ = p.CleanUp(t.Context(), "www.sub.example.com", "other", "key") provider, err := newScopedAliyunProvider(scopeCredentials())
if len(api.deleted) != 0 { if err != nil {
t.Fatal("不能清理其他挑战") t.Fatal(err)
} }
ctx, cancel := context.WithCancel(t.Context()) api := &fakeAliyunRecords{}
cancel() constructed := 0
if err := p.CleanUp(ctx, "www.sub.example.com", "token", "key"); err != nil || api.canceledCleanup || len(api.deleted) != 1 { provider.newAPI = func(map[string]string) (aliyunRecordAPI, error) {
t.Fatal("请求取消后仍需有界清理本次 ID") constructed++
} return api, nil
p.challengeInfo = func(context.Context, string, string) (string, string, string) { }
return "_acme-challenge.www.sub.example.com.", "_acme-challenge.outside.net.", "value" provider.challengeInfo = func(_ context.Context, name, _ string) (string, string, string) {
} fqdn := "_acme-challenge." + name + "."
if err := p.Present(t.Context(), "www.sub.example.com", "new", "key"); err == nil || len(api.zones) != 1 { return fqdn, fqdn, "txt-value"
t.Fatal("CNAME 越界不能调用云 API") }
if constructed != 0 {
t.Fatal("provider construction must not construct a cloud client")
}
if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil {
t.Fatal(err)
}
if constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" || api.rrs[0] != "_acme-challenge.www.sub" {
t.Fatalf("provider wrote outside its single registrable root: constructed=%d zones=%v rrs=%v", constructed, api.zones, api.rrs)
}
if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil {
t.Fatal("duplicate challenge unexpectedly overwrote a pending record")
}
if err := provider.CleanUp(t.Context(), "www.sub.example.com", "other-token", "key"); err != nil {
t.Fatal(err)
}
if len(api.deleted) != 0 {
t.Fatalf("cleanup for a different challenge deleted a record: %v", api.deleted)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := provider.CleanUp(ctx, "www.sub.example.com", "token", "key"); err != nil {
t.Fatal(err)
}
if len(api.deleted) != 1 || api.deleted[0] != "created-record-id" || api.canceledCleanup {
t.Fatalf("cleanup did not use the exact created record ID: deleted=%v canceled=%v", api.deleted, api.canceledCleanup)
}
}
func TestAliyunProviderRejectsCNAMEBeforeClientConstruction(t *testing.T) {
provider, err := newScopedAliyunProvider(scopeCredentials())
if err != nil {
t.Fatal(err)
}
constructed := 0
provider.newAPI = func(map[string]string) (aliyunRecordAPI, error) {
constructed++
return &fakeAliyunRecords{}, nil
}
provider.challengeInfo = func(context.Context, string, string) (string, string, string) {
return "_acme-challenge.www.sub.example.com.", "_acme-challenge.delegated.example.net.", "txt-value"
}
if err := provider.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil || !strings.Contains(err.Error(), "CNAME") {
t.Fatalf("CNAME redirection was not rejected: %v", err)
}
if constructed != 0 {
t.Fatalf("CNAME redirection constructed a cloud client: %d", constructed)
} }
} }

View File

@ -13,8 +13,8 @@ import (
"github.com/go-acme/lego/v5/challenge/dns01" "github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/challenge/dnspersist01" "github.com/go-acme/lego/v5/challenge/dnspersist01"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
) )
type Factory interface { type Factory interface {
@ -86,7 +86,7 @@ func NewTesterWithResolverAndSettings(factory Factory, resolver TXTResolver, tim
} }
type VerificationError struct { type VerificationError struct {
Report setup.DNSVerificationReport Report dnsverification.Report
Err error Err error
} }
@ -104,12 +104,12 @@ func (e *VerificationError) Unwrap() error {
return e.Err return e.Err
} }
func (t *Tester) Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) { func (t *Tester) Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (dnsverification.Report, error) {
return t.VerifyWithReport(ctx, providerID, testDomain, credentials) return t.VerifyWithReport(ctx, providerID, testDomain, credentials)
} }
func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) { func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain string, credentials map[string]string) (dnsverification.Report, error) {
report := setup.DNSVerificationReport{} report := dnsverification.Report{}
if t == nil { if t == nil {
return report, errors.New("DNS 验证服务未初始化") return report, errors.New("DNS 验证服务未初始化")
} }
@ -145,76 +145,76 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
challengeInfo := dns01.GetChallengeInfo(ctx, testDomain, keyAuth) challengeInfo := dns01.GetChallengeInfo(ctx, testDomain, keyAuth)
report.RecordName = challengeInfo.EffectiveFQDN report.RecordName = challengeInfo.EffectiveFQDN
if err := provider.Present(ctx, testDomain, token, keyAuth); err != nil { if err := provider.Present(ctx, testDomain, token, keyAuth); err != nil {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "创建记录", Status: "failed", Message: "调用 DNS API 创建临时 TXT 记录失败。", Name: "创建记录", Status: "failed", Message: "调用 DNS API 创建临时 TXT 记录失败。",
}) })
return report, &VerificationError{Report: report, Err: fmt.Errorf("创建 DNS 测试记录失败:%w", err)} return report, &VerificationError{Report: report, Err: fmt.Errorf("创建 DNS 测试记录失败:%w", err)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "创建记录", Status: "completed", Message: "已调用 DNS API 创建临时 TXT 记录。", Name: "创建记录", Status: "completed", Message: "已调用 DNS API 创建临时 TXT 记录。",
}) })
if err := waitForTXT(ctx, t.resolver, report.RecordName, challengeInfo.Value, t.propagationTimeout, t.propagationInterval); 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, dnsverification.Stage{
Name: "查询记录", Status: "failed", Message: "公网 DNS 查询未确认 TXT 记录已生效。", Name: "查询记录", Status: "failed", Message: "公网 DNS 查询未确认 TXT 记录已生效。",
}) })
cleanupErr := cleanupChallenge(ctx, provider, testDomain, token, keyAuth) cleanupErr := cleanupChallenge(ctx, provider, testDomain, token, keyAuth)
if cleanupErr != nil { if cleanupErr != nil {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "failed", Message: "测试记录清理失败,请检查 DNS API 权限。", Name: "清理记录", Status: "failed", Message: "测试记录清理失败,请检查 DNS API 权限。",
}) })
return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录未通过公网查询,且自动清理失败:%w", cleanupErr)} return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录未通过公网查询,且自动清理失败:%w", cleanupErr)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "completed", Message: "已清理未通过查询确认的临时 TXT 记录。", Name: "清理记录", Status: "completed", Message: "已清理未通过查询确认的临时 TXT 记录。",
}) })
return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录未通过公网查询:%w", err)} return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录未通过公网查询:%w", err)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "查询记录", Status: "completed", Message: "公网 DNS 查询已确认 TXT 记录生效。", Name: "查询记录", Status: "completed", Message: "公网 DNS 查询已确认 TXT 记录生效。",
}) })
if err := cleanupChallenge(ctx, provider, testDomain, token, keyAuth); err != nil { if err := cleanupChallenge(ctx, provider, testDomain, token, keyAuth); err != nil {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "failed", Message: "测试记录已创建并确认生效,但自动清理失败。", Name: "清理记录", Status: "failed", Message: "测试记录已创建并确认生效,但自动清理失败。",
}) })
return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录已创建并确认生效,但自动清理失败:%w", err)} return report, &VerificationError{Report: report, Err: fmt.Errorf("DNS 测试记录已创建并确认生效,但自动清理失败:%w", err)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "completed", Message: "已调用 DNS API 清理临时 TXT 记录。", Name: "清理记录", Status: "completed", Message: "已调用 DNS API 清理临时 TXT 记录。",
}) })
if values, err := t.resolver.LookupTXT(ctx, report.RecordName); err == nil && containsTXT(values, challengeInfo.Value) { if values, err := t.resolver.LookupTXT(ctx, report.RecordName); err == nil && containsTXT(values, challengeInfo.Value) {
report.Stages[len(report.Stages)-1] = setup.DNSVerificationStage{ report.Stages[len(report.Stages)-1] = dnsverification.Stage{
Name: "清理记录", Status: "warning", Message: "清理请求已成功,但公网 DNS 仍能查询到旧记录,可能正在等待缓存刷新。", Name: "清理记录", Status: "warning", Message: "清理请求已成功,但公网 DNS 仍能查询到旧记录,可能正在等待缓存刷新。",
} }
} }
return report, nil return report, nil
} }
func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) { func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credentials map[string]string) (dnsverification.Report, error) {
report := setup.DNSVerificationReport{} report := dnsverification.Report{}
if t.aliyunAuthorization == nil { if t.aliyunAuthorization == nil {
return report, errors.New("阿里云 DNS 权限验证服务未初始化") return report, errors.New("阿里云 DNS 权限验证服务未初始化")
} }
authorization, err := t.aliyunAuthorization.Verify(ctx, testDomain, credentials) authorization, err := t.aliyunAuthorization.Verify(ctx, testDomain, credentials)
report.RecordName = authorization.RecordName report.RecordName = authorization.RecordName
if len(authorization.Domains) == 0 || !authorization.Domains[0].Writable { if len(authorization.Domains) == 0 || !authorization.Domains[0].Writable {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "创建记录", Status: "failed", Message: "调用阿里云 DNS API 创建临时 TXT 记录失败。", Name: "创建记录", Status: "failed", Message: "调用阿里云 DNS API 创建临时 TXT 记录失败。",
}) })
return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)} return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "创建记录", Status: "completed", Message: "已通过阿里云 DNS API 创建临时 TXT 记录。", Name: "创建记录", Status: "completed", Message: "已通过阿里云 DNS API 创建临时 TXT 记录。",
}) })
if !authorization.Domains[0].Readable { if !authorization.Domains[0].Readable {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "查询记录", Status: "failed", Message: "阿里云 DNS API 未能精确查询并比对本次临时 TXT 记录。", Name: "查询记录", Status: "failed", Message: "阿里云 DNS API 未能精确查询并比对本次临时 TXT 记录。",
}) })
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup) appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)} return report, &VerificationError{Report: report, Err: aliyunVerificationError(err)}
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "查询记录", Status: "completed", Message: "已通过阿里云 DNS API 精确查询并比对临时 TXT 记录,未等待公网 DNS 传播。", Name: "查询记录", Status: "completed", Message: "已通过阿里云 DNS API 精确查询并比对临时 TXT 记录,未等待公网 DNS 传播。",
}) })
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup) appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
@ -224,14 +224,14 @@ func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credential
return report, nil return report, nil
} }
func appendAliyunCleanupStage(report *setup.DNSVerificationReport, cleaned bool) { func appendAliyunCleanupStage(report *dnsverification.Report, cleaned bool) {
if cleaned { if cleaned {
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "completed", Message: "已通过阿里云 DNS API 清理本次临时 TXT 记录。", Name: "清理记录", Status: "completed", Message: "已通过阿里云 DNS API 清理本次临时 TXT 记录。",
}) })
return return
} }
report.Stages = append(report.Stages, setup.DNSVerificationStage{ report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "failed", Message: "临时 TXT 记录清理失败,不能确认阿里云 DNS 权限验证通过。", Name: "清理记录", Status: "failed", Message: "临时 TXT 记录清理失败,不能确认阿里云 DNS 权限验证通过。",
}) })
} }

View File

@ -210,8 +210,6 @@ func TestLegoFactoryCreatesConfiguredProviders(t *testing.T) {
credentials: map[string]string{ credentials: map[string]string{
"access_key_id": "id", "access_key_id": "id",
"access_key_secret": "secret", "access_key_secret": "secret",
"managed_zones": "example.com",
"allowed_domains": "example.com",
}, },
}, },
{ {

View File

@ -43,20 +43,25 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "当前系统 DNS 服务商不是阿里云,无法进行阿里云 RAM 权限考证", nil) s.writeError(c, http.StatusBadRequest, "当前系统 DNS 服务商不是阿里云,无法进行阿里云 RAM 权限考证", nil)
return return
} }
// 必须先在本地拒绝受保护域,不能为无效请求读取凭据或触发云端探测。
if err := dnsprovider.ValidateAliyunTargetDomains(provider, []string{targetDomain}); err != nil {
c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()})
return
}
credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef) credentials, err := s.readDNSCredentials(c.Request.Context(), credentialRef)
if err != nil { if err != nil {
s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err) s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err)
return return
} }
credentials, err = dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials)
if s.aliyunChecker == nil { if err != nil {
s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil) s.writeError(c, http.StatusBadRequest, "阿里云 DNS 凭据无效", err)
return return
} }
if err := dnsprovider.ValidateAliyunTargetDomains(provider, []string{targetDomain}); err != nil { if s.aliyunChecker == nil {
c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()}) s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil)
return return
} }
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials) report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
@ -101,6 +106,10 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
if !setupReady { if !setupReady {
return errors.New("系统初始化尚未完成,不能验证域名授权") return errors.New("系统初始化尚未完成,不能验证域名授权")
} }
// 在读取凭据、构造阿里云客户端或发起任何云 API 请求前检查整张订单。
if err := dnsprovider.ValidateAliyunTargetDomains(provider, domainNames); err != nil {
return err
}
if s.aliyunChecker == nil { if s.aliyunChecker == nil {
return errors.New("阿里云权限探测器未装载,不能确认域名授权") return errors.New("阿里云权限探测器未装载,不能确认域名授权")
} }
@ -112,9 +121,6 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil { if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err) return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
} }
if err := dnsprovider.ValidateAliyunTargetDomains(provider, domainNames); err != nil {
return err
}
for _, domainName := range domainNames { for _, domainName := range domainNames {
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil { if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName) diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName)

View File

@ -12,15 +12,13 @@ import (
) )
type fakeAliyunScopeChecker struct { type fakeAliyunScopeChecker struct {
allowedZones []string allowedZones []string
} verifyCalls int
verifyDomainCalls int
func (f *fakeAliyunScopeChecker) Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
// 模拟 RAM 账号处于最小资源限制模式,无全局枚举权限
return dnsprovider.AuthorizationReport{}, errors.New("SDKError: Code: Forbidden.NoPermission")
} }
func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) { func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
f.verifyCalls++
report := dnsprovider.AuthorizationReport{ report := dnsprovider.AuthorizationReport{
Provider: dnsprovider.Aliyun, Provider: dnsprovider.Aliyun,
RequestedDomain: requestedDomain, RequestedDomain: requestedDomain,
@ -38,6 +36,7 @@ func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain str
} }
func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error { func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, requestedDomain string, credentials map[string]string) error {
f.verifyDomainCalls++
for _, zone := range f.allowedZones { for _, zone := range f.allowedZones {
if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) { if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) {
return nil return nil
@ -47,7 +46,8 @@ func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, r
} }
type fakeSecretReaderStore struct { type fakeSecretReaderStore struct {
data map[string]map[string]string data map[string]map[string]string
getCalls int
} }
func (s *fakeSecretReaderStore) Put(ctx context.Context, ref string, val map[string]string) error { func (s *fakeSecretReaderStore) Put(ctx context.Context, ref string, val map[string]string) error {
@ -64,13 +64,14 @@ func (s *fakeSecretReaderStore) Delete(ctx context.Context, ref string) error {
} }
func (s *fakeSecretReaderStore) Get(ctx context.Context, ref string) (map[string]string, error) { func (s *fakeSecretReaderStore) Get(ctx context.Context, ref string) (map[string]string, error) {
s.getCalls++
if val, ok := s.data[ref]; ok { if val, ok := s.data[ref]; ok {
return val, nil return val, nil
} }
return nil, errors.New("secret not found") return nil, errors.New("secret not found")
} }
func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) { func TestAliyunDomainCheckUsesOnlyCurrentTargetAndProtectsPCUX(t *testing.T) {
cfg := readyTestConfig(t.TempDir()) cfg := readyTestConfig(t.TempDir())
cfg.SetupReady = true cfg.SetupReady = true
cfg.DNS.Provider = "aliyun" cfg.DNS.Provider = "aliyun"
@ -81,8 +82,6 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
"secret://dns/aliyun/ref1": { "secret://dns/aliyun/ref1": {
"access_key_id": "LTAI_test", "access_key_id": "LTAI_test",
"access_key_secret": "test_secret", "access_key_secret": "test_secret",
"managed_zones": "solit.cn,unauth.com,forbidden.cn",
"allowed_domains": "solit.cn,*.solit.cn,unauth.com,forbidden.cn",
}, },
}, },
} }
@ -99,20 +98,17 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
AliyunChecker: checker, AliyunChecker: checker,
}) })
// 1. 测试 GET /api/v1/dns/aliyun/scope(受限模式报告) // 旧的授权范围枚举接口已移除,不能重新引入账户域名发现能力。
{ {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/aliyun/scope", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/aliyun/scope", nil)
serveAdminHTTP(server, w, req) serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusNotFound {
t.Fatalf("GET /api/v1/dns/aliyun/scope status = %d; want 200, body=%s", w.Code, w.Body.String()) t.Fatalf("已移除的授权范围接口状态码 = %d,期望 404,响应:%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "resource_scoped") {
t.Fatalf("expected resource_scoped policy mode, got: %s", w.Body.String())
} }
} }
// 2. 测试 POST /api/v1/dns/aliyun/check 验证授权域名(如 solit.cn) // 对用户刚输入的域名使用当前 RAM 凭据执行验证,不依赖手工范围字段。
{ {
w := httptest.NewRecorder() w := httptest.NewRecorder()
body := strings.NewReader(`{"domain":"solit.cn"}`) body := strings.NewReader(`{"domain":"solit.cn"}`)
@ -122,50 +118,35 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
t.Fatalf("POST check solit.cn status = %d; body=%s", w.Code, w.Body.String()) t.Fatalf("POST check solit.cn status = %d; body=%s", w.Code, w.Body.String())
} }
if !strings.Contains(w.Body.String(), `"verified":true`) { if !strings.Contains(w.Body.String(), `"verified":true`) {
t.Fatalf("solit.cn 应验证通过: %s", w.Body.String()) t.Fatalf("solit.cn 应验证通过:%s", w.Body.String())
}
if store.getCalls != 1 || checker.verifyCalls != 1 {
t.Fatalf("正常域名必须仅读取一次凭据并验证一次:读取=%d,验证=%d", store.getCalls, checker.verifyCalls)
} }
} }
// 3. 测试 POST /api/v1/dns/aliyun/check 验证未授权域名(如 unauth.com) // pcux.cn 必须先本地拒绝,不能读取凭据或调用云端探测器。
{ {
w := httptest.NewRecorder() w := httptest.NewRecorder()
body := strings.NewReader(`{"domain":"unauth.com"}`) body := strings.NewReader(`{"domain":"pcux.cn"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body) req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body)
serveAdminHTTP(server, w, req) serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("POST check unauth.com status = %d; body=%s", w.Code, w.Body.String()) t.Fatalf("POST check pcux.cn 状态码 = %d,响应:%s", w.Code, w.Body.String())
} }
if !strings.Contains(w.Body.String(), `"verified":false`) { if !strings.Contains(w.Body.String(), `"verified":false`) {
t.Fatalf("unauth.com 应验证失败: %s", w.Body.String()) t.Fatalf("pcux.cn 应被本地拒绝:%s", w.Body.String())
} }
if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/unauth.com") { if store.getCalls != 1 || checker.verifyCalls != 1 || checker.verifyDomainCalls != 0 {
t.Fatalf("应包含明确的 RAM 策略建议: %s", w.Body.String()) t.Fatalf("保护域不能触发凭据读取或云端探测:读取=%d,检查=%d,域名检查=%d", store.getCalls, checker.verifyCalls, checker.verifyDomainCalls)
} }
} }
// 4. 测试 POST /api/v1/domains 添加授权域名(例如 *.solit.cn) if err := server.validateDomainAliyunAuthorization(t.Context(), "pcux.cn"); err == nil {
{ t.Fatal("直接校验 pcux.cn 必须被拒绝")
w := httptest.NewRecorder()
body := strings.NewReader(`{"name":"test.solit.cn","certificate_type":"single"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusCreated {
t.Fatalf("创建授权域名应成功,got status = %d; body=%s", w.Code, w.Body.String())
}
} }
if store.getCalls != 1 || checker.verifyDomainCalls != 0 {
// 5. 测试 POST /api/v1/domains 添加未授权域名(例如 forbidden.cn),应被前置阻断 t.Fatalf("直接校验保护域不能读取凭据或调用探测:读取=%d,域名检查=%d", store.getCalls, checker.verifyDomainCalls)
{
w := httptest.NewRecorder()
body := strings.NewReader(`{"name":"forbidden.cn","certificate_type":"single"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", body)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("添加未授权域名应被 400 阻断,got status = %d; body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "acs:alidns:*:*:domain/forbidden.cn") {
t.Fatalf("阻断提示应包含针对该域名的 RAM 授权指导,got: %s", w.Body.String())
}
} }
} }
@ -203,21 +184,25 @@ func TestAliyunDomainAuthorizationFailsClosed(t *testing.T) {
} }
} }
func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) { func TestAliyunDomainAPIRejectsProtectedSANWithoutSideEffects(t *testing.T) {
cfg := readyTestConfig(t.TempDir()) cfg := readyTestConfig(t.TempDir())
cfg.DNS.Provider = dnsprovider.Aliyun cfg.DNS.Provider = dnsprovider.Aliyun
cfg.DNS.CredentialRef = "secret://dns/test" cfg.DNS.CredentialRef = "secret://dns/test"
store := &fakeSecretReaderStore{data: map[string]map[string]string{
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"},
}}
checker := &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}
repo := &stubDomainRepository{}
server := New(Dependencies{ server := New(Dependencies{
Config: cfg, Repository: &stubDomainRepository{}, Config: cfg, Repository: repo, Secrets: store, AliyunChecker: checker,
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com", "allowed_domains": "example.com"},
}},
AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["example.org"]}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["pcux.cn"]}`))
serveAdminHTTP(server, w, req) serveAdminHTTP(server, w, req)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "example.org") { if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "pcux.cn") {
t.Fatalf("部分 SAN 未授权应整单拒绝:%d %s", w.Code, w.Body.String()) t.Fatalf("包含保护 SAN 的整单应被拒绝:%d %s", w.Code, w.Body.String())
}
if repo.created.ID != "" || store.getCalls != 0 || checker.verifyDomainCalls != 0 {
t.Fatalf("保护 SAN 被拒绝时不得持久化、读取凭据或探测:已创建=%t,读取=%d,检查=%d", repo.created.ID != "", store.getCalls, checker.verifyDomainCalls)
} }
} }

View File

@ -198,6 +198,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
delete(s.authSessions, token) delete(s.authSessions, token)
s.authMu.Unlock() s.authMu.Unlock()
} }
s.clearAuthCookie(c)
return authSession{}, false return authSession{}, false
} }
if !s.authSessionAccountIsValid(c.Request.Context(), session) { if !s.authSessionAccountIsValid(c.Request.Context(), session) {

View File

@ -83,13 +83,13 @@ func TestHTTPLoginBurstRejectsBeforeAccountLookup(t *testing.T) {
func TestSessionCannotReviveAfterSlowAccountLookup(t *testing.T) { func TestSessionCannotReviveAfterSlowAccountLookup(t *testing.T) {
users := &pausedSessionRepository{ users := &pausedSessionRepository{
started: make(chan struct{}), release: make(chan struct{}), started: make(chan struct{}), release: make(chan struct{}),
user: repository.User{ID: "operator", Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive}, user: repository.User{ID: "operator", Username: "operator", Email: "operator@example.test", PasswordHash: "test-hash", Role: repository.UserRoleUser, Status: repository.UserStatusActive},
} }
s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users}) s := New(Dependencies{Config: readyTestConfig(t.TempDir()), Users: users})
now := time.Now() now := time.Now()
s.now = func() time.Time { return now } s.now = func() time.Time { return now }
s.authSessions["slow"] = authSession{ s.authSessions["slow"] = authSession{
UserID: users.user.ID, Username: users.user.Username, PasswordHash: users.user.PasswordHash, Role: users.user.Role, UserID: users.user.ID, Username: users.user.Username, Email: users.user.Email, PasswordHash: users.user.PasswordHash, Role: users.user.Role,
ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour), ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
} }
done := make(chan *httptest.ResponseRecorder, 1) done := make(chan *httptest.ResponseRecorder, 1)

View File

@ -15,7 +15,7 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
cfg := readyTestConfig(t.TempDir()) cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin" cfg.Admin.Username = "admin"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
for _, field := range []string{"user_id", "username", "hash", "role", "absolute", "inverted"} { for _, field := range []string{"user_id", "username", "email", "hash", "role", "absolute", "inverted"} {
t.Run(field, func(t *testing.T) { t.Run(field, func(t *testing.T) {
s := New(Dependencies{Config: cfg}) s := New(Dependencies{Config: cfg})
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0] cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
@ -25,6 +25,8 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
session.UserID = "" session.UserID = ""
case "username": case "username":
session.Username = "" session.Username = ""
case "email":
session.Email = ""
case "hash": case "hash":
session.PasswordHash = "" session.PasswordHash = ""
case "role": case "role":
@ -35,12 +37,17 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second) session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second)
} }
s.authSessions[cookie.Value] = session s.authSessions[cookie.Value] = session
if response := authSessionRequest(s, cookie); response.Code != http.StatusUnauthorized { response := authSessionRequest(s, cookie)
if response.Code != http.StatusUnauthorized {
t.Fatal("不完整会话必须拒绝") t.Fatal("不完整会话必须拒绝")
} }
if _, ok := s.authSessions[cookie.Value]; ok { if _, ok := s.authSessions[cookie.Value]; ok {
t.Fatal("失效会话必须移除") t.Fatal("失效会话必须移除")
} }
cookies := response.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != authCookieName || cookies[0].MaxAge != -1 {
t.Fatalf("失效会话必须清理认证 Cookie:%+v", cookies)
}
}) })
} }
} }
@ -146,6 +153,7 @@ func TestLoginAttemptTableSaturationRecoversAfterWindow(t *testing.T) {
func TestLoginBlockExpiresAndUserSessionQuotaRecovers(t *testing.T) { func TestLoginBlockExpiresAndUserSessionQuotaRecovers(t *testing.T) {
cfg := readyTestConfig(t.TempDir()) cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin" cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123") cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
s := New(Dependencies{Config: cfg}) s := New(Dependencies{Config: cfg})
now := time.Now() now := time.Now()

View File

@ -48,7 +48,7 @@ func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) {
server.now = func() time.Time { return now } server.now = func() time.Time { return now }
for index := 0; index < authStateCapacity; index++ { for index := 0; index < authStateCapacity; index++ {
server.authSessions[fmt.Sprint(index)] = authSession{ server.authSessions[fmt.Sprint(index)] = authSession{
UserID: fmt.Sprint(index), Username: "operator", PasswordHash: "test-hash", Role: repository.UserRoleUser, UserID: fmt.Sprint(index), Username: "operator", Email: "operator@example.test", PasswordHash: "test-hash", Role: repository.UserRoleUser,
ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour), ExpiresAt: now.Add(time.Minute), AbsoluteExpiresAt: now.Add(time.Hour),
} }
} }

View File

@ -42,6 +42,7 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string,
cfg := config.Default(t.TempDir()) cfg := config.Default(t.TempDir())
cfg.SetupReady = true cfg.SetupReady = true
cfg.Admin.Username = "admin" cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "RestoreTest123") cfg.Admin.PasswordHash = httpUserPasswordHash(t, "RestoreTest123")
server := New(Dependencies{ server := New(Dependencies{
Config: cfg, Config: cfg,

View File

@ -14,7 +14,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/secretstore" "git.pcux.cn/xuans/Scert/backend/internal/secretstore"
) )
const readinessDNSReference = "secret://dns/cloudflare/readiness-test" const readinessDNSReference = "secret://dns/aliyun/readiness-test"
func TestReadinessRejectsIncompleteSetup(t *testing.T) { func TestReadinessRejectsIncompleteSetup(t *testing.T) {
cfg := config.Default(t.TempDir()) cfg := config.Default(t.TempDir())
@ -155,6 +155,7 @@ func readinessConfig(t *testing.T) config.Config {
directory := t.TempDir() directory := t.TempDir()
cfg := config.Default(directory) cfg := config.Default(directory)
cfg.SetupReady = true cfg.SetupReady = true
cfg.DNS.Provider = "aliyun"
cfg.DNS.CredentialRef = readinessDNSReference cfg.DNS.CredentialRef = readinessDNSReference
cfg.Server.TLSCert = filepath.Join(directory, "server.crt") cfg.Server.TLSCert = filepath.Join(directory, "server.crt")
cfg.Server.TLSKey = filepath.Join(directory, "server.key") cfg.Server.TLSKey = filepath.Join(directory, "server.key")
@ -173,7 +174,10 @@ func readinessStore(t *testing.T) *secretstore.Store {
if err != nil { if err != nil {
t.Fatalf("打开测试敏感信息存储失败:%v", err) t.Fatalf("打开测试敏感信息存储失败:%v", err)
} }
if err := store.Put(t.Context(), readinessDNSReference, map[string]string{"api_token": "readiness-test-token"}); err != nil { if err := store.Put(t.Context(), readinessDNSReference, map[string]string{
"access_key_id": "readiness-test-id",
"access_key_secret": "readiness-test-secret",
}); err != nil {
t.Fatalf("写入测试 DNS 凭据失败:%v", err) t.Fatalf("写入测试 DNS 凭据失败:%v", err)
} }
return store return store

View File

@ -12,7 +12,6 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/config" "git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
) )
type settingsView struct { type settingsView struct {

View File

@ -369,6 +369,7 @@ func TestRenewalAPIHidesWorkflowErrors(t *testing.T) {
cfg := config.Default(t.TempDir()) cfg := config.Default(t.TempDir())
cfg.SetupReady = true cfg.SetupReady = true
cfg.Admin.Username = "admin" cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
server := New(Dependencies{ server := New(Dependencies{
Config: cfg, Config: cfg,

View File

@ -140,7 +140,14 @@ func httpUserSessionCookie(server *Server, userID, role string) *http.Cookie {
if err != nil { if err != nil {
panic(err) panic(err)
} }
user := repository.User{ID: userID, Username: userID, Role: role, Status: repository.UserStatusActive, PasswordHash: string(hash)} user := repository.User{
ID: userID,
Username: userID,
Email: userID + "@example.test",
Role: role,
Status: repository.UserStatusActive,
PasswordHash: string(hash),
}
if userID == "primary-super-admin" { if userID == "primary-super-admin" {
user.Username = server.config.Admin.Username user.Username = server.config.Admin.Username
user.Email = server.config.Admin.Email user.Email = server.config.Admin.Email

View File

@ -6,10 +6,13 @@ import (
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"log"
"strings" "strings"
"sync" "sync"
"time" "time"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/domain"
) )
@ -24,16 +27,9 @@ type DNSCredentialTester interface {
Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (DNSVerificationReport, error) Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (DNSVerificationReport, error)
} }
type DNSVerificationStage struct { type DNSVerificationStage = dnsverification.Stage
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
type DNSVerificationReport struct { type DNSVerificationReport = dnsverification.Report
RecordName string `json:"record_name"`
Stages []DNSVerificationStage `json:"stages"`
}
type DNSVerificationError struct { type DNSVerificationError struct {
Report DNSVerificationReport Report DNSVerificationReport
@ -114,6 +110,10 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
return DNSVerificationResult{}, fmt.Errorf("DNS 测试域名格式无效:%s", testDomain) return DNSVerificationResult{}, fmt.Errorf("DNS 测试域名格式无效:%s", testDomain)
} }
normalizedCredentials := normalizeCredentials(input.Credentials) normalizedCredentials := normalizeCredentials(input.Credentials)
cleanedCredentials, err := dnsprovider.ValidateCredentials(providerID, normalizedCredentials)
if err != nil {
return DNSVerificationResult{}, err
}
if err := m.reserveVerificationSlot(); err != nil { if err := m.reserveVerificationSlot(); err != nil {
return DNSVerificationResult{}, err return DNSVerificationResult{}, err
} }
@ -123,7 +123,7 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
m.releaseVerificationSlot() m.releaseVerificationSlot()
} }
}() }()
testReport, err := m.tester.Verify(ctx, providerID, testDomain, normalizedCredentials) testReport, err := m.tester.Verify(ctx, providerID, testDomain, cleanedCredentials)
if err != nil { if err != nil {
return DNSVerificationResult{TestReport: testReport}, &DNSVerificationError{ return DNSVerificationResult{TestReport: testReport}, &DNSVerificationError{
Report: testReport, Report: testReport,
@ -139,10 +139,11 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
return DNSVerificationResult{}, fmt.Errorf("生成 DNS 凭据引用失败:%w", err) return DNSVerificationResult{}, fmt.Errorf("生成 DNS 凭据引用失败:%w", err)
} }
credentialRef := fmt.Sprintf("secret://dns/%s/%s", providerID, credentialID) credentialRef := fmt.Sprintf("secret://dns/%s/%s", providerID, credentialID)
if err := m.store.Put(ctx, credentialRef, normalizedCredentials); err != nil { if err := m.store.Put(ctx, credentialRef, cleanedCredentials); err != nil {
cleanupErr := m.deleteCredential(credentialRef) cleanupErr := m.deleteCredential(credentialRef)
if cleanupErr != nil { if cleanupErr != nil {
return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败,且无法确认临时凭据已回收:%w", errors.Join(err, cleanupErr)) m.logCleanupFailure()
return DNSVerificationResult{}, errors.New("加密保存 DNS 凭据失败,临时凭据回收未确认")
} }
return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败:%w", err) return DNSVerificationResult{}, fmt.Errorf("加密保存 DNS 凭据失败:%w", err)
} }
@ -250,10 +251,16 @@ func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) []string {
func (m *DNSVerificationManager) deleteCredentials(references []string) { func (m *DNSVerificationManager) deleteCredentials(references []string) {
for _, reference := range references { for _, reference := range references {
_ = m.deleteCredential(reference) if err := m.deleteCredential(reference); err != nil {
m.logCleanupFailure()
}
} }
} }
func (m *DNSVerificationManager) logCleanupFailure() {
log.Print("DNS 临时凭据回收失败")
}
func (m *DNSVerificationManager) deleteCredential(reference string) error { func (m *DNSVerificationManager) deleteCredential(reference string) error {
if m == nil || m.deleter == nil || strings.TrimSpace(reference) == "" { if m == nil || m.deleter == nil || strings.TrimSpace(reference) == "" {
return nil return nil

View File

@ -3,6 +3,7 @@ package setup
import ( import (
"context" "context"
"errors" "errors"
"sync"
"testing" "testing"
"time" "time"
) )
@ -11,24 +12,33 @@ type fakeDNSTester struct {
err error err error
} }
func TestDNSVerificationBindsScopeToNewCredentialReference(t *testing.T) { func TestDNSVerificationStoresOnlyCurrentAliyunCredentials(t *testing.T) {
store := &memorySecretWriter{} store := &memorySecretWriter{}
manager := NewDNSVerificationManager(fakeDNSTester{}, store) manager := NewDNSVerificationManager(fakeDNSTester{}, store)
input := DNSVerificationInput{ProviderID: "aliyun", TestDomain: "www.example.com", Credentials: map[string]string{"managed_zones": "example.com", "allowed_domains": "www.example.com"}} input := DNSVerificationInput{ProviderID: "aliyun", TestDomain: "www.example.com", Credentials: map[string]string{
"access_key_id": "test-id", "access_key_secret": "test-secret",
"managed_zones": "example.com", "allowed_domains": "www.example.com",
}}
first, err := manager.Verify(t.Context(), input) first, err := manager.Verify(t.Context(), input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
input.Credentials["allowed_domains"] = "*.example.com" if _, ok := store.values[first.CredentialRef]["managed_zones"]; ok {
t.Fatal("legacy managed zone must not be persisted as a credential")
}
if _, ok := store.values[first.CredentialRef]["allowed_domains"]; ok {
t.Fatal("legacy allowed domain must not be persisted as a credential")
}
input.Credentials["access_key_secret"] = "new-secret"
second, err := manager.Verify(t.Context(), input) second, err := manager.Verify(t.Context(), input)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["allowed_domains"] != "www.example.com" { if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["access_key_secret"] != "test-secret" {
t.Fatal("范围变更不能覆盖已验证引用") t.Fatal("credential changes must not overwrite an already verified reference")
} }
if err := manager.Check(first.VerificationToken, "aliyun", second.CredentialRef, input.TestDomain); err == nil { if err := manager.Check(first.VerificationToken, "aliyun", second.CredentialRef, input.TestDomain); err == nil {
t.Fatal("旧令牌不能授权新范围") t.Fatal("old token must not authorize a new credential reference")
} }
} }
@ -37,15 +47,33 @@ func (f fakeDNSTester) Verify(context.Context, string, string, map[string]string
} }
type memorySecretWriter struct { type memorySecretWriter struct {
values map[string]map[string]string mu sync.Mutex
values map[string]map[string]string
deleted []string
putErr error
deleteErr error
} }
func (m *memorySecretWriter) Put(_ context.Context, reference string, value map[string]string) error { func (m *memorySecretWriter) Put(_ context.Context, reference string, value map[string]string) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.values == nil { if m.values == nil {
m.values = map[string]map[string]string{} m.values = map[string]map[string]string{}
} }
m.values[reference] = value copy := make(map[string]string, len(value))
return nil for key, item := range value {
copy[key] = item
}
m.values[reference] = copy
return m.putErr
}
func (m *memorySecretWriter) Delete(_ context.Context, reference string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.deleted = append(m.deleted, reference)
delete(m.values, reference)
return m.deleteErr
} }
func TestDNSVerificationManagerIssuesOneTimeToken(t *testing.T) { func TestDNSVerificationManagerIssuesOneTimeToken(t *testing.T) {
@ -96,7 +124,8 @@ func TestDNSVerificationManagerRejectsMismatchedConfiguration(t *testing.T) {
} }
func TestDNSVerificationManagerRejectsExpiredToken(t *testing.T) { func TestDNSVerificationManagerRejectsExpiredToken(t *testing.T) {
manager := NewDNSVerificationManager(fakeDNSTester{}, &memorySecretWriter{}) store := &memorySecretWriter{}
manager := NewDNSVerificationManager(fakeDNSTester{}, store)
now := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC) now := time.Date(2026, 8, 26, 8, 0, 0, 0, time.UTC)
manager.now = func() time.Time { return now } manager.now = func() time.Time { return now }
result, err := manager.Verify(t.Context(), DNSVerificationInput{ result, err := manager.Verify(t.Context(), DNSVerificationInput{
@ -110,6 +139,107 @@ func TestDNSVerificationManagerRejectsExpiredToken(t *testing.T) {
if err := manager.Consume(result.VerificationToken, "cloudflare", result.CredentialRef, "example.com"); err == nil { if err := manager.Consume(result.VerificationToken, "cloudflare", result.CredentialRef, "example.com"); err == nil {
t.Fatal("过期 DNS 验证令牌应被拒绝") t.Fatal("过期 DNS 验证令牌应被拒绝")
} }
if len(store.deleted) != 1 || store.deleted[0] != result.CredentialRef {
t.Fatalf("expired session did not delete temporary credential: %v", store.deleted)
}
}
func TestDNSVerificationManagerDeletesCredentialWhenPutReportsFailure(t *testing.T) {
store := &memorySecretWriter{putErr: errors.New("write failed")}
manager := NewDNSVerificationManager(fakeDNSTester{}, store)
_, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com",
Credentials: map[string]string{"api_token": "secret"},
})
if err == nil {
t.Fatal("Put failure unexpectedly succeeded")
}
if len(store.deleted) != 1 {
t.Fatalf("Put failure did not trigger credential cleanup: %v", store.deleted)
}
if len(store.values) != 0 {
t.Fatalf("Put failure left temporary credentials stored: %v", store.values)
}
}
type countingDNSTester struct {
mu sync.Mutex
calls int
block <-chan struct{}
}
func (f *countingDNSTester) Verify(_ context.Context, _ string, _ string, _ map[string]string) (DNSVerificationReport, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
if f.block != nil {
<-f.block
}
return DNSVerificationReport{}, nil
}
func (f *countingDNSTester) CallCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls
}
func TestDNSVerificationManagerRejectsCapacityBeforeTestingOrStoring(t *testing.T) {
tester := &countingDNSTester{}
store := &memorySecretWriter{}
manager := NewDNSVerificationManager(tester, store)
manager.sessions = make(map[string]dnsVerificationSession, dnsVerificationSessionCapacity)
expiresAt := time.Now().UTC().Add(time.Hour)
for index := 0; index < dnsVerificationSessionCapacity; index++ {
manager.sessions[string(rune(index))] = dnsVerificationSession{CredentialRef: "secret://existing", ExpiresAt: expiresAt}
}
_, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com", Credentials: map[string]string{"api_token": "secret"},
})
if err == nil || tester.CallCount() != 0 || len(store.values) != 0 {
t.Fatalf("capacity rejection must precede testing and storage: err=%v calls=%d values=%v", err, tester.CallCount(), store.values)
}
}
func TestDNSVerificationManagerRejectsInFlightCapacityBeforeTestingOrStoring(t *testing.T) {
tester := &countingDNSTester{}
store := &memorySecretWriter{}
manager := NewDNSVerificationManager(tester, store)
manager.inFlight = dnsVerificationInFlightCapacity
_, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com", Credentials: map[string]string{"api_token": "secret"},
})
if err == nil || tester.CallCount() != 0 || len(store.values) != 0 {
t.Fatalf("in-flight rejection must precede testing and storage: err=%v calls=%d values=%v", err, tester.CallCount(), store.values)
}
}
func TestDNSVerificationManagerConsumesTokenOnlyOnceUnderConcurrency(t *testing.T) {
manager := NewDNSVerificationManager(fakeDNSTester{}, &memorySecretWriter{})
result, err := manager.Verify(t.Context(), DNSVerificationInput{
ProviderID: "cloudflare", TestDomain: "example.com", Credentials: map[string]string{"api_token": "secret"},
})
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
results <- manager.Consume(result.VerificationToken, "cloudflare", result.CredentialRef, "example.com")
}()
}
close(start)
successes := 0
for range 2 {
if <-results == nil {
successes++
}
}
if successes != 1 {
t.Fatalf("concurrent Consume successes = %d; want 1", successes)
}
} }
func TestDNSVerificationManagerDoesNotStoreFailedCredential(t *testing.T) { func TestDNSVerificationManagerDoesNotStoreFailedCredential(t *testing.T) {

View File

@ -378,8 +378,8 @@ export function useDashboard() {
renewal_urgent_threshold_days: 3, renewal_urgent_threshold_days: 3,
renewal_max_retries: 3, renewal_max_retries: 3,
renewal_retry_interval_mins: 30, renewal_retry_interval_mins: 30,
propagation_timeout_secs: 900, propagation_timeout_secs: 300,
propagation_polling_interval_secs: 10, propagation_polling_interval_secs: 5,
}, },
dns: { dns: {
provider: "", provider: "",

View File

@ -26,6 +26,8 @@ async function fill(state) {
test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => { test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => {
const { state } = await setup(t, () => verifiedResponse()); const { state } = await setup(t, () => verifiedResponse());
assert.equal(state.dnsReadyToSave.value, true); assert.equal(state.dnsReadyToSave.value, true);
assert.equal(state.settingsForm.value.acme.propagation_timeout_secs, 300);
assert.equal(state.settingsForm.value.acme.propagation_polling_interval_secs, 5);
await fill(state); await fill(state);
assert.equal(state.dnsReadyToSave.value, false); assert.equal(state.dnsReadyToSave.value, false);
await state.testDNS(); await state.testDNS();
@ -54,14 +56,12 @@ test("管理设置拒绝过期验证令牌", async (t) => {
assert.equal(state.dnsReadyToSave.value, false); assert.equal(state.dnsReadyToSave.value, false);
}); });
test("管理设置变更托管区不能复用验证令牌", async (t) => { test("管理设置变更阿里云凭据不能复用验证令牌", async (t) => {
const { state } = await setup(t, () => verifiedResponse()); const { state } = await setup(t, () => verifiedResponse());
await fill(state); await fill(state);
state.settingsForm.value.dns.credentials.managed_zones = "example.test";
state.settingsForm.value.dns.credentials.allowed_domains = "example.test";
await state.testDNS(); await state.testDNS();
assert.equal(state.dnsReadyToSave.value, true); assert.equal(state.dnsReadyToSave.value, true);
state.settingsForm.value.dns.credentials.managed_zones = "other.test"; state.settingsForm.value.dns.credentials.access_key_id = "changed-key";
assert.equal(state.dnsReadyToSave.value, false); assert.equal(state.dnsReadyToSave.value, false);
assert.equal(state.settingsForm.value.dns.verification_token, ""); assert.equal(state.settingsForm.value.dns.verification_token, "");
}); });

View File

@ -473,12 +473,31 @@ const {
<div class="field-hint">剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。</div> <div class="field-hint">剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。</div>
</div> </div>
<div class="form-grid"> <div class="form-grid">
<label class="field"><span>DNS 传播等待(秒)</span><input v-model.number="settingsForm.acme.propagation_timeout_secs" type="number" min="1" /></label> <label class="field">
<label class="field"><span>DNS 检测间隔(秒)</span><input v-model.number="settingsForm.acme.propagation_polling_interval_secs" type="number" min="1" /></label> <span>DNS 传播等待(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_timeout_secs"
type="number"
min="1"
/>
</label>
<label class="field">
<span>DNS 检测间隔(秒)</span>
<input
v-model.number="settingsForm.acme.propagation_polling_interval_secs"
type="number"
min="1"
/>
</label>
</div> </div>
<p class="field-hint">
阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT
记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01
传播等待与轮询。
</p>
<h3>DNS API 验证</h3> <h3>DNS API 验证</h3>
<div class="settings-section-note"> <div class="settings-section-note">
<span>证书申请前会使用 DNS API 创建临时验证记录。</span> <span>输入域名后,系统将使用当前阿里云账号立即创建、查询并清理临时 TXT 记录,以验证该域名的实际 DNS 操作权限。</span>
<span>凭据只会在本次在线测试请求中提交,系统仅保存加密引用。</span> <span>凭据只会在本次在线测试请求中提交,系统仅保存加密引用。</span>
</div> </div>
<div class="form-grid"> <div class="form-grid">
@ -540,14 +559,6 @@ const {
/> />
</label> </label>
</div> </div>
<p v-if="settingsForm.dns.scope_message" class="form-error">
{{ settingsForm.dns.scope_message }}
</p>
<div v-if="settingsForm.dns.user_scope" class="field-hint">
<p>已保存托管区:{{ settingsForm.dns.user_scope.managed_zones.join(",") }}</p>
<p>已保存允许域名:{{ settingsForm.dns.user_scope.allowed_domains.join(",") }}</p>
<p>修改范围需重新填写凭据并在线验证,不会自动扩大已有授权。</p>
</div>
<div v-if="selectedDNSProvider" class="dns-test-actions"> <div v-if="selectedDNSProvider" class="dns-test-actions">
<button <button
class="secondary-button" class="secondary-button"

View File

@ -88,9 +88,9 @@ export function useSetupWizard(props, emit) {
email_retry_intervals_secs: "30,120,600", email_retry_intervals_secs: "30,120,600",
recipients: "", recipients: "",
dns_provider: props.status.dns_provider || "aliyun", dns_provider: props.status.dns_provider || "aliyun",
propagation_timeout_secs: props.status.propagation_timeout_secs || 900, propagation_timeout_secs: props.status.propagation_timeout_secs || 300,
propagation_polling_interval_secs: propagation_polling_interval_secs:
props.status.propagation_polling_interval_secs || 10, props.status.propagation_polling_interval_secs || 5,
certificate_root: props.status.certificate_root || "", certificate_root: props.status.certificate_root || "",
web_hostname: props.status.web_hostname || "", web_hostname: props.status.web_hostname || "",
web_public_ip: props.status.web_public_ip || "", web_public_ip: props.status.web_public_ip || "",

View File

@ -8,6 +8,8 @@ test("全新初始化默认选择阿里云", async (t) => {
const { state } = await mountComposable(t, () => useSetupWizard({ status: {} }, () => {}), const { state } = await mountComposable(t, () => useSetupWizard({ status: {} }, () => {}),
async () => Response.json({ items: [aliyunProvider] })); async () => Response.json({ items: [aliyunProvider] }));
assert.equal(state.form.value.dns_provider, "aliyun"); assert.equal(state.form.value.dns_provider, "aliyun");
assert.equal(state.form.value.propagation_timeout_secs, 300);
assert.equal(state.form.value.propagation_polling_interval_secs, 5);
}); });
test("已有初始化服务商选择保持兼容", async (t) => { test("已有初始化服务商选择保持兼容", async (t) => {
@ -111,13 +113,11 @@ test("初始化修改 DNS 输入后会停止等待计时器", async (t) => {
await task; await task;
}); });
test("初始化修改显式授权范围立即作废旧验证", async (t) => { test("初始化修改阿里云凭据立即作废旧验证", async (t) => {
const { state } = await setup(t, () => verifiedResponse()); const { state } = await setup(t, () => verifiedResponse());
state.dnsCredentials.value.managed_zones = "example.test";
state.dnsCredentials.value.allowed_domains = "example.test";
await state.testDNS(); await state.testDNS();
assert.equal(state.dnsVerificationValid.value, true); assert.equal(state.dnsVerificationValid.value, true);
state.dnsCredentials.value.allowed_domains = "*.example.test"; state.dnsCredentials.value.access_key_id = "changed-key";
assert.equal(state.dnsVerificationValid.value, false); assert.equal(state.dnsVerificationValid.value, false);
assert.equal(state.dnsVerification.value, null); assert.equal(state.dnsVerification.value, null);
}); });

View File

@ -693,7 +693,7 @@ const {
max="86400" max="86400"
required required
/> />
<small>默认 900 秒,最长允许等待 86400 秒。</small> <small>默认 300 秒,最长允许等待 86400 秒。</small>
</label> </label>
<label> <label>
<span>DNS 传播检测间隔(秒)</span> <span>DNS 传播检测间隔(秒)</span>
@ -706,9 +706,14 @@ const {
:max="form.propagation_timeout_secs" :max="form.propagation_timeout_secs"
required required
/> />
<small>默认 6 秒,不能大于最大等待时间。</small> <small>默认 5 秒,不能大于最大等待时间。</small>
</label> </label>
</div> </div>
<p class="field-hint">
阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT
记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01
传播等待与轮询。
</p>
<p v-if="dnsCatalogError" class="inline-error"> <p v-if="dnsCatalogError" class="inline-error">
{{ dnsCatalogError }} {{ dnsCatalogError }}
</p> </p>