diff --git a/backend/internal/certificate/authorization_test.go b/backend/internal/certificate/authorization_test.go index a48c0ff..8706a8b 100644 --- a/backend/internal/certificate/authorization_test.go +++ b/backend/internal/certificate/authorization_test.go @@ -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) } } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 8de25a6..60dfda8 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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) } } diff --git a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go index 1012498..8176819 100644 --- a/backend/internal/dnsprovider/aliyun_authorization_verify_test.go +++ b/backend/internal/dnsprovider/aliyun_authorization_verify_test.go @@ -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 diff --git a/backend/internal/dnsprovider/aliyun_scope_test.go b/backend/internal/dnsprovider/aliyun_scope_test.go index 1e51ace..0d83bf6 100644 --- a/backend/internal/dnsprovider/aliyun_scope_test.go +++ b/backend/internal/dnsprovider/aliyun_scope_test.go @@ -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) } } diff --git a/backend/internal/dnsprovider/tester.go b/backend/internal/dnsprovider/tester.go index 690639e..30c6c5d 100644 --- a/backend/internal/dnsprovider/tester.go +++ b/backend/internal/dnsprovider/tester.go @@ -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 权限验证通过。", }) } diff --git a/backend/internal/dnsprovider/tester_test.go b/backend/internal/dnsprovider/tester_test.go index 3e7562c..44fd98b 100644 --- a/backend/internal/dnsprovider/tester_test.go +++ b/backend/internal/dnsprovider/tester_test.go @@ -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", }, }, { diff --git a/backend/internal/httpapi/aliyun_scope_api.go b/backend/internal/httpapi/aliyun_scope_api.go index 45d8b31..b6196b1 100644 --- a/backend/internal/httpapi/aliyun_scope_api.go +++ b/backend/internal/httpapi/aliyun_scope_api.go @@ -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) diff --git a/backend/internal/httpapi/aliyun_scope_api_test.go b/backend/internal/httpapi/aliyun_scope_api_test.go index 4343aa5..da3a935 100644 --- a/backend/internal/httpapi/aliyun_scope_api_test.go +++ b/backend/internal/httpapi/aliyun_scope_api_test.go @@ -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) } } diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index 30e06e7..0f349bf 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -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) { diff --git a/backend/internal/httpapi/auth_http_load_test.go b/backend/internal/httpapi/auth_http_load_test.go index e58fd71..93392d0 100644 --- a/backend/internal/httpapi/auth_http_load_test.go +++ b/backend/internal/httpapi/auth_http_load_test.go @@ -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) diff --git a/backend/internal/httpapi/auth_load_test.go b/backend/internal/httpapi/auth_load_test.go index 80692f0..9a2366a 100644 --- a/backend/internal/httpapi/auth_load_test.go +++ b/backend/internal/httpapi/auth_load_test.go @@ -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() diff --git a/backend/internal/httpapi/auth_retention_test.go b/backend/internal/httpapi/auth_retention_test.go index 4cd9180..9cf3a0b 100644 --- a/backend/internal/httpapi/auth_retention_test.go +++ b/backend/internal/httpapi/auth_retention_test.go @@ -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), } } diff --git a/backend/internal/httpapi/certificate_restore_api_test.go b/backend/internal/httpapi/certificate_restore_api_test.go index b0e5b2e..91cfa31 100644 --- a/backend/internal/httpapi/certificate_restore_api_test.go +++ b/backend/internal/httpapi/certificate_restore_api_test.go @@ -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, diff --git a/backend/internal/httpapi/readiness_test.go b/backend/internal/httpapi/readiness_test.go index 262c736..ccc3ad0 100644 --- a/backend/internal/httpapi/readiness_test.go +++ b/backend/internal/httpapi/readiness_test.go @@ -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 diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index fb8a615..82dca04 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -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 { diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 9472905..6290a1d 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -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, diff --git a/backend/internal/httpapi/users_api_test.go b/backend/internal/httpapi/users_api_test.go index 967b716..2d385d3 100644 --- a/backend/internal/httpapi/users_api_test.go +++ b/backend/internal/httpapi/users_api_test.go @@ -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 diff --git a/backend/internal/setup/dns_verification.go b/backend/internal/setup/dns_verification.go index ee8996a..922ca4c 100644 --- a/backend/internal/setup/dns_verification.go +++ b/backend/internal/setup/dns_verification.go @@ -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 diff --git a/backend/internal/setup/dns_verification_test.go b/backend/internal/setup/dns_verification_test.go index 8899a9a..5cc28b3 100644 --- a/backend/internal/setup/dns_verification_test.go +++ b/backend/internal/setup/dns_verification_test.go @@ -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) { diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 01f3975..eacce34 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -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: "", diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index e24b73a..ef40254 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -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, ""); }); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 4e61cbc..24e96cb 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -473,12 +473,31 @@ const {
剩余有效期低于自动续期阈值时触发续期;低于紧急阈值时按配置重试。
- - + +
+

+ 阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT + 记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01 + 传播等待与轮询。 +

DNS API 验证

- 证书申请前会使用 DNS API 创建临时验证记录。 + 输入域名后,系统将使用当前阿里云账号立即创建、查询并清理临时 TXT 记录,以验证该域名的实际 DNS 操作权限。 凭据只会在本次在线测试请求中提交,系统仅保存加密引用。
@@ -540,14 +559,6 @@ const { />
-

- {{ settingsForm.dns.scope_message }} -

-
-

已保存托管区:{{ settingsForm.dns.user_scope.managed_zones.join(",") }}

-

已保存允许域名:{{ settingsForm.dns.user_scope.allowed_domains.join(",") }}

-

修改范围需重新填写凭据并在线验证,不会自动扩大已有授权。

-
+

+ 阿里云 RAM 权限验证最多等待 6 秒:系统会创建、查询并清理临时 TXT + 记录。以上 300 秒和 5 秒用于实际申请证书时的公网 DNS-01 + 传播等待与轮询。 +

{{ dnsCatalogError }}