完善阿里云权限与会话回归测试
This commit is contained in:
parent
50ece6093b
commit
bbabc35cbf
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 权限验证通过。",
|
||||
})
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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: "",
|
||||
|
||||
@ -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, "");
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user