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

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{}
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 {
@ -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())
credentials := &protectedDomainCredentials{}
providerFactory := &protectedDomainProviderFactory{}
accounts := &protectedDomainAccounts{}
checker := &scopedAuthorizationChecker{}
service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
service := NewService(cfg, nil, credentials, providerFactory, accounts)
service.SetDNSAuthorizationChecker(checker)
issuerFactoryCalls := 0
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
issuerFactoryCalls++
return nil
}
_, 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",
})
if err == nil || !strings.Contains(err.Error(), "outside.net") || len(checker.calls) != 0 {
t.Fatalf("部分越界必须零云调用拒绝整单:%v %v", err, checker.calls)
if err == nil || !strings.Contains(err.Error(), "pcux.cn") {
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) {
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)
}
}
@ -212,7 +212,7 @@ func TestNormalizeRestoresMissingACMEPropagationSettings(t *testing.T) {
cfg.ACME.PropagationTimeoutSecs = 0
cfg.ACME.PropagationPollingIntervalSecs = 0
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)
}
}

View File

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

View File

@ -8,62 +8,61 @@ import (
)
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) {
scope, err := ParseAliyunScope(scopeCredentials())
if err != nil {
t.Fatal(err)
func TestResolveAliyunTargetDomainUsesOnlyRegistrableRoot(t *testing.T) {
tests := []struct {
name string
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 }{
{"www.sub.example.com", "sub.example.com"},
{"WWW.EXAMPLE.COM.", "example.com"},
{"*.example.com", "example.com"},
{"example.com", ""},
{"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 _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ResolveAliyunTargetDomain(test.name)
if err != nil || got != test.want {
t.Fatalf("ResolveAliyunTargetDomain(%q) = %q, %v; want %q", test.name, got, err, test.want)
}
})
}
for _, value := range []string{"", "com", "co.uk", "*.example.com", "example.com,", "example.com,co.uk"} {
credentials := scopeCredentials()
credentials["managed_zones"] = value
if _, err := ParseAliyunScope(credentials); err == nil {
t.Fatalf("不安全托管区未拒绝:%s", value)
}
}
credentials := scopeCredentials()
credentials["allowed_domains"] = "outside.net"
if _, err := ParseAliyunScope(credentials); err == nil {
t.Fatal("越界的允许范围必须整体拒绝")
}
func TestResolveAliyunTargetDomainRejectsProtectedAndInvalidDomains(t *testing.T) {
for _, name := range []string{"pcux.cn", "*.pcux.cn", "api.pcux.cn", "com", "*.com", ""} {
t.Run(name, func(t *testing.T) {
if _, err := ResolveAliyunTargetDomain(name); err == nil {
t.Fatalf("ResolveAliyunTargetDomain(%q) unexpectedly succeeded", name)
}
})
}
}
type fakeAliyunRecords struct {
zones, rrs, values, deleted []string
addErr, describeErr, deleteErr error
describeInfo aliyunRecordInfo
canceledCleanup bool
zones, rrs, values, described, deleted []string
addErr, describeErr, deleteErr error
describeInfo aliyunRecordInfo
canceledCleanup bool
}
func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, value string) (string, error) {
f.zones = append(f.zones, zone)
f.rrs = append(f.rrs, rr)
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 {
return aliyunRecordInfo{}, f.describeErr
}
@ -71,10 +70,10 @@ func (f *fakeAliyunRecords) Describe(_ context.Context, _ string) (aliyunRecordI
return f.describeInfo, nil
}
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{
RecordID: "created-record-id",
RecordID: id,
DomainName: f.zones[len(f.zones)-1],
RR: f.rrs[len(f.rrs)-1],
Type: "TXT",
@ -88,74 +87,108 @@ func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error {
return f.deleteErr
}
func TestAliyunProbeNeverFallsBackOrCallsCloudOutsideScope(t *testing.T) {
func TestAliyunProbeUsesTheSingleRegistrableRootWithoutFallback(t *testing.T) {
api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")}
checker := NewAliyunAuthorizationChecker()
constructed := 0
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++; return api, nil }
credentials := scopeCredentials()
for _, name := range []string{"example.com", "outside.net", "pcux.cn", "*.sub.example.com"} {
if _, err := checker.Verify(t.Context(), name, credentials); err == nil {
t.Fatal("越界请求未拒绝")
}
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
constructed++
return api, nil
}
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 {
t.Fatal("RAM 拒绝必须返回失败")
}
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 constructed != 1 || len(api.zones) != 1 || api.zones[0] != "example.com" {
t.Fatalf("probe did not use only the registrable root: constructed=%d zones=%v", constructed, api.zones)
}
if len(api.deleted) != 0 {
t.Fatal("新增失败不能猜测记录 ID 删除")
}
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("必须删除本次新增记录")
t.Fatalf("failed Add must not guess a record ID to delete: %v", api.deleted)
}
}
func TestAliyunProviderPinsZoneAndCleanupIdentity(t *testing.T) {
scope, _ := ParseAliyunScope(scopeCredentials())
func TestAliyunProtectedDomainDoesNotConstructClientOrCallCloud(t *testing.T) {
api := &fakeAliyunRecords{}
p := &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: func(_ context.Context, name, _ string) (string, string, string) {
fqdn := "_acme-challenge." + name + "."
return fqdn, fqdn, "txt-value"
}}
if err := p.Present(t.Context(), "outside.net", "token", "key"); err == nil || len(api.zones) != 0 {
t.Fatal("实际 Provider 必须零调用拒绝越界")
checker := NewAliyunAuthorizationChecker()
constructed := 0
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) {
constructed++
return api, nil
}
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" {
t.Fatal("实际写入没有锁定指定托管区")
}
if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil {
t.Fatal("重复挑战不能覆盖待清理 ID")
}
_ = p.CleanUp(t.Context(), "www.sub.example.com", "other", "key")
if len(api.deleted) != 0 {
t.Fatal("不能清理其他挑战")
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := p.CleanUp(ctx, "www.sub.example.com", "token", "key"); err != nil || api.canceledCleanup || len(api.deleted) != 1 {
t.Fatal("请求取消后仍需有界清理本次 ID")
}
p.challengeInfo = func(context.Context, string, string) (string, string, string) {
return "_acme-challenge.www.sub.example.com.", "_acme-challenge.outside.net.", "value"
}
if err := p.Present(t.Context(), "www.sub.example.com", "new", "key"); err == nil || len(api.zones) != 1 {
t.Fatal("CNAME 越界不能调用云 API")
if constructed != 0 || len(api.zones) != 0 || len(api.described) != 0 || len(api.deleted) != 0 {
t.Fatalf("protected domain touched cloud state: constructed=%d zones=%v described=%v deleted=%v", constructed, api.zones, api.described, api.deleted)
}
}
func TestAliyunProviderDefersClientCreationAndPinsCleanupIdentity(t *testing.T) {
provider, err := newScopedAliyunProvider(scopeCredentials())
if err != nil {
t.Fatal(err)
}
api := &fakeAliyunRecords{}
constructed := 0
provider.newAPI = func(map[string]string) (aliyunRecordAPI, error) {
constructed++
return api, nil
}
provider.challengeInfo = func(_ context.Context, name, _ string) (string, string, string) {
fqdn := "_acme-challenge." + name + "."
return fqdn, fqdn, "txt-value"
}
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/dnspersist01"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/setup"
)
type Factory interface {
@ -86,7 +86,7 @@ func NewTesterWithResolverAndSettings(factory Factory, resolver TXTResolver, tim
}
type VerificationError struct {
Report setup.DNSVerificationReport
Report dnsverification.Report
Err error
}
@ -104,12 +104,12 @@ func (e *VerificationError) Unwrap() error {
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)
}
func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) {
report := setup.DNSVerificationReport{}
func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain string, credentials map[string]string) (dnsverification.Report, error) {
report := dnsverification.Report{}
if t == nil {
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)
report.RecordName = challengeInfo.EffectiveFQDN
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 记录失败。",
})
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 记录。",
})
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 记录已生效。",
})
cleanupErr := cleanupChallenge(ctx, provider, testDomain, token, keyAuth)
if cleanupErr != nil {
report.Stages = append(report.Stages, setup.DNSVerificationStage{
report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "failed", Message: "测试记录清理失败,请检查 DNS API 权限。",
})
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 记录。",
})
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 记录生效。",
})
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: "测试记录已创建并确认生效,但自动清理失败。",
})
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 记录。",
})
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 仍能查询到旧记录,可能正在等待缓存刷新。",
}
}
return report, nil
}
func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credentials map[string]string) (setup.DNSVerificationReport, error) {
report := setup.DNSVerificationReport{}
func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credentials map[string]string) (dnsverification.Report, error) {
report := dnsverification.Report{}
if t.aliyunAuthorization == nil {
return report, errors.New("阿里云 DNS 权限验证服务未初始化")
}
authorization, err := t.aliyunAuthorization.Verify(ctx, testDomain, credentials)
report.RecordName = authorization.RecordName
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 记录失败。",
})
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 记录。",
})
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 记录。",
})
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
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 传播。",
})
appendAliyunCleanupStage(&report, authorization.Domains[0].Cleanup)
@ -224,14 +224,14 @@ func (t *Tester) verifyAliyun(ctx context.Context, testDomain string, credential
return report, nil
}
func appendAliyunCleanupStage(report *setup.DNSVerificationReport, cleaned bool) {
func appendAliyunCleanupStage(report *dnsverification.Report, cleaned bool) {
if cleaned {
report.Stages = append(report.Stages, setup.DNSVerificationStage{
report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "completed", Message: "已通过阿里云 DNS API 清理本次临时 TXT 记录。",
})
return
}
report.Stages = append(report.Stages, setup.DNSVerificationStage{
report.Stages = append(report.Stages, dnsverification.Stage{
Name: "清理记录", Status: "failed", Message: "临时 TXT 记录清理失败,不能确认阿里云 DNS 权限验证通过。",
})
}

View File

@ -210,8 +210,6 @@ func TestLegoFactoryCreatesConfiguredProviders(t *testing.T) {
credentials: map[string]string{
"access_key_id": "id",
"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)
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)
if err != nil {
s.writeError(c, http.StatusInternalServerError, "读取阿里云 DNS 凭据失败", err)
return
}
if s.aliyunChecker == nil {
s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil)
credentials, err = dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials)
if err != nil {
s.writeError(c, http.StatusBadRequest, "阿里云 DNS 凭据无效", err)
return
}
if err := dnsprovider.ValidateAliyunTargetDomains(provider, []string{targetDomain}); err != nil {
c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()})
if s.aliyunChecker == nil {
s.writeError(c, http.StatusNotImplemented, "阿里云权限探测器未装载", nil)
return
}
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
@ -101,6 +106,10 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
if !setupReady {
return errors.New("系统初始化尚未完成,不能验证域名授权")
}
// 在读取凭据、构造阿里云客户端或发起任何云 API 请求前检查整张订单。
if err := dnsprovider.ValidateAliyunTargetDomains(provider, domainNames); err != nil {
return err
}
if s.aliyunChecker == nil {
return errors.New("阿里云权限探测器未装载,不能确认域名授权")
}
@ -112,9 +121,6 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
}
if err := dnsprovider.ValidateAliyunTargetDomains(provider, domainNames); err != nil {
return err
}
for _, domainName := range domainNames {
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName)

View File

@ -12,15 +12,13 @@ import (
)
type fakeAliyunScopeChecker struct {
allowedZones []string
}
func (f *fakeAliyunScopeChecker) Discover(ctx context.Context, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
// 模拟 RAM 账号处于最小资源限制模式,无全局枚举权限
return dnsprovider.AuthorizationReport{}, errors.New("SDKError: Code: Forbidden.NoPermission")
allowedZones []string
verifyCalls int
verifyDomainCalls int
}
func (f *fakeAliyunScopeChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (dnsprovider.AuthorizationReport, error) {
f.verifyCalls++
report := dnsprovider.AuthorizationReport{
Provider: dnsprovider.Aliyun,
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 {
f.verifyDomainCalls++
for _, zone := range f.allowedZones {
if requestedDomain == zone || strings.HasSuffix(requestedDomain, "."+zone) {
return nil
@ -47,7 +46,8 @@ func (f *fakeAliyunScopeChecker) VerifyDomain(ctx context.Context, providerID, r
}
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 {
@ -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) {
s.getCalls++
if val, ok := s.data[ref]; ok {
return val, nil
}
return nil, errors.New("secret not found")
}
func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
func TestAliyunDomainCheckUsesOnlyCurrentTargetAndProtectsPCUX(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.SetupReady = true
cfg.DNS.Provider = "aliyun"
@ -81,8 +82,6 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
"secret://dns/aliyun/ref1": {
"access_key_id": "LTAI_test",
"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,
})
// 1. 测试 GET /api/v1/dns/aliyun/scope(受限模式报告)
// 旧的授权范围枚举接口已移除,不能重新引入账户域名发现能力。
{
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/dns/aliyun/scope", nil)
serveAdminHTTP(server, w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/v1/dns/aliyun/scope status = %d; want 200, body=%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())
if w.Code != http.StatusNotFound {
t.Fatalf("已移除的授权范围接口状态码 = %d,期望 404,响应:%s", w.Code, w.Body.String())
}
}
// 2. 测试 POST /api/v1/dns/aliyun/check 验证授权域名(如 solit.cn)
// 对用户刚输入的域名使用当前 RAM 凭据执行验证,不依赖手工范围字段。
{
w := httptest.NewRecorder()
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())
}
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()
body := strings.NewReader(`{"domain":"unauth.com"}`)
body := strings.NewReader(`{"domain":"pcux.cn"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", body)
serveAdminHTTP(server, w, req)
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`) {
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") {
t.Fatalf("应包含明确的 RAM 策略建议: %s", w.Body.String())
if store.getCalls != 1 || checker.verifyCalls != 1 || checker.verifyDomainCalls != 0 {
t.Fatalf("保护域不能触发凭据读取或云端探测:读取=%d,检查=%d,域名检查=%d", store.getCalls, checker.verifyCalls, checker.verifyDomainCalls)
}
}
// 4. 测试 POST /api/v1/domains 添加授权域名(例如 *.solit.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 err := server.validateDomainAliyunAuthorization(t.Context(), "pcux.cn"); err == nil {
t.Fatal("直接校验 pcux.cn 必须被拒绝")
}
// 5. 测试 POST /api/v1/domains 添加未授权域名(例如 forbidden.cn),应被前置阻断
{
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())
}
if store.getCalls != 1 || checker.verifyDomainCalls != 0 {
t.Fatalf("直接校验保护域不能读取凭据或调用探测:读取=%d,域名检查=%d", store.getCalls, checker.verifyDomainCalls)
}
}
@ -203,21 +184,25 @@ func TestAliyunDomainAuthorizationFailsClosed(t *testing.T) {
}
}
func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) {
func TestAliyunDomainAPIRejectsProtectedSANWithoutSideEffects(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.DNS.Provider = dnsprovider.Aliyun
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{
Config: cfg, Repository: &stubDomainRepository{},
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"}},
Config: cfg, Repository: repo, Secrets: store, AliyunChecker: checker,
})
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)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "example.org") {
t.Fatalf("部分 SAN 未授权应整单拒绝:%d %s", w.Code, w.Body.String())
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "pcux.cn") {
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)
s.authMu.Unlock()
}
s.clearAuthCookie(c)
return authSession{}, false
}
if !s.authSessionAccountIsValid(c.Request.Context(), session) {

View File

@ -83,13 +83,13 @@ func TestHTTPLoginBurstRejectsBeforeAccountLookup(t *testing.T) {
func TestSessionCannotReviveAfterSlowAccountLookup(t *testing.T) {
users := &pausedSessionRepository{
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})
now := time.Now()
s.now = func() time.Time { return now }
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),
}
done := make(chan *httptest.ResponseRecorder, 1)

View File

@ -15,7 +15,7 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
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) {
s := New(Dependencies{Config: cfg})
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
@ -25,6 +25,8 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
session.UserID = ""
case "username":
session.Username = ""
case "email":
session.Email = ""
case "hash":
session.PasswordHash = ""
case "role":
@ -35,12 +37,17 @@ func TestIncompleteSessionsFailClosed(t *testing.T) {
session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second)
}
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("不完整会话必须拒绝")
}
if _, ok := s.authSessions[cookie.Value]; ok {
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) {
cfg := readyTestConfig(t.TempDir())
cfg.Admin.Username = "admin"
cfg.Admin.Email = "admin@example.test"
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
s := New(Dependencies{Config: cfg})
now := time.Now()

View File

@ -48,7 +48,7 @@ func TestSessionCapacityDoesNotEvictActiveSessions(t *testing.T) {
server.now = func() time.Time { return now }
for index := 0; index < authStateCapacity; index++ {
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),
}
}

View File

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

View File

@ -14,7 +14,7 @@ import (
"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) {
cfg := config.Default(t.TempDir())
@ -155,6 +155,7 @@ func readinessConfig(t *testing.T) config.Config {
directory := t.TempDir()
cfg := config.Default(directory)
cfg.SetupReady = true
cfg.DNS.Provider = "aliyun"
cfg.DNS.CredentialRef = readinessDNSReference
cfg.Server.TLSCert = filepath.Join(directory, "server.crt")
cfg.Server.TLSKey = filepath.Join(directory, "server.key")
@ -173,7 +174,10 @@ func readinessStore(t *testing.T) *secretstore.Store {
if err != nil {
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)
}
return store

View File

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

View File

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

View File

@ -140,7 +140,14 @@ func httpUserSessionCookie(server *Server, userID, role string) *http.Cookie {
if err != nil {
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" {
user.Username = server.config.Admin.Username
user.Email = server.config.Admin.Email

View File

@ -6,10 +6,13 @@ import (
"encoding/base64"
"errors"
"fmt"
"log"
"strings"
"sync"
"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"
)
@ -24,16 +27,9 @@ type DNSCredentialTester interface {
Verify(ctx context.Context, providerID, testDomain string, credentials map[string]string) (DNSVerificationReport, error)
}
type DNSVerificationStage struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message"`
}
type DNSVerificationStage = dnsverification.Stage
type DNSVerificationReport struct {
RecordName string `json:"record_name"`
Stages []DNSVerificationStage `json:"stages"`
}
type DNSVerificationReport = dnsverification.Report
type DNSVerificationError struct {
Report DNSVerificationReport
@ -114,6 +110,10 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
return DNSVerificationResult{}, fmt.Errorf("DNS 测试域名格式无效:%s", testDomain)
}
normalizedCredentials := normalizeCredentials(input.Credentials)
cleanedCredentials, err := dnsprovider.ValidateCredentials(providerID, normalizedCredentials)
if err != nil {
return DNSVerificationResult{}, err
}
if err := m.reserveVerificationSlot(); err != nil {
return DNSVerificationResult{}, err
}
@ -123,7 +123,7 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
m.releaseVerificationSlot()
}
}()
testReport, err := m.tester.Verify(ctx, providerID, testDomain, normalizedCredentials)
testReport, err := m.tester.Verify(ctx, providerID, testDomain, cleanedCredentials)
if err != nil {
return DNSVerificationResult{TestReport: testReport}, &DNSVerificationError{
Report: testReport,
@ -139,10 +139,11 @@ func (m *DNSVerificationManager) Verify(ctx context.Context, input DNSVerificati
return DNSVerificationResult{}, fmt.Errorf("生成 DNS 凭据引用失败:%w", err)
}
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)
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)
}
@ -250,10 +251,16 @@ func (m *DNSVerificationManager) clearExpiredLocked(now time.Time) []string {
func (m *DNSVerificationManager) deleteCredentials(references []string) {
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 {
if m == nil || m.deleter == nil || strings.TrimSpace(reference) == "" {
return nil

View File

@ -3,6 +3,7 @@ package setup
import (
"context"
"errors"
"sync"
"testing"
"time"
)
@ -11,24 +12,33 @@ type fakeDNSTester struct {
err error
}
func TestDNSVerificationBindsScopeToNewCredentialReference(t *testing.T) {
func TestDNSVerificationStoresOnlyCurrentAliyunCredentials(t *testing.T) {
store := &memorySecretWriter{}
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)
if err != nil {
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)
if err != nil {
t.Fatal(err)
}
if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["allowed_domains"] != "www.example.com" {
t.Fatal("范围变更不能覆盖已验证引用")
if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["access_key_secret"] != "test-secret" {
t.Fatal("credential changes must not overwrite an already verified reference")
}
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 {
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 {
m.mu.Lock()
defer m.mu.Unlock()
if m.values == nil {
m.values = map[string]map[string]string{}
}
m.values[reference] = value
return nil
copy := make(map[string]string, len(value))
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) {
@ -96,7 +124,8 @@ func TestDNSVerificationManagerRejectsMismatchedConfiguration(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)
manager.now = func() time.Time { return now }
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 {
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) {

View File

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

View File

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

View File

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

View File

@ -88,9 +88,9 @@ export function useSetupWizard(props, emit) {
email_retry_intervals_secs: "30,120,600",
recipients: "",
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:
props.status.propagation_polling_interval_secs || 10,
props.status.propagation_polling_interval_secs || 5,
certificate_root: props.status.certificate_root || "",
web_hostname: props.status.web_hostname || "",
web_public_ip: props.status.web_public_ip || "",

View File

@ -8,6 +8,8 @@ test("全新初始化默认选择阿里云", async (t) => {
const { state } = await mountComposable(t, () => useSetupWizard({ status: {} }, () => {}),
async () => Response.json({ items: [aliyunProvider] }));
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) => {
@ -111,13 +113,11 @@ test("初始化修改 DNS 输入后会停止等待计时器", async (t) => {
await task;
});
test("初始化修改显式授权范围立即作废旧验证", async (t) => {
test("初始化修改阿里云凭据立即作废旧验证", async (t) => {
const { state } = await setup(t, () => verifiedResponse());
state.dnsCredentials.value.managed_zones = "example.test";
state.dnsCredentials.value.allowed_domains = "example.test";
await state.testDNS();
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.dnsVerification.value, null);
});

View File

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