diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 56fb463..a2f4e28 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -144,6 +144,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string } accounts := certificate.NewAccountRepository(secretStore) certificates := certificate.NewService(cfg, repo, secretStore, dnsprovider.NewLegoFactory(), accounts) + certificates.SetDNSExecutionLeaseStore(repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver)) httpChallenges := httpchallenge.NewStore() certificates.SetHTTPChallenges(httpChallenges) aliyunAuthChecker := dnsprovider.NewAliyunAuthorizationChecker() diff --git a/backend/internal/app/certificate_tasks.go b/backend/internal/app/certificate_tasks.go index 99aaa22..21618f8 100644 --- a/backend/internal/app/certificate_tasks.go +++ b/backend/internal/app/certificate_tasks.go @@ -157,6 +157,10 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s issueCtx = deployment.WithTransactionContext(issueCtx, deployment.TransactionContext{ TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber, }) + issueCtx = certificate.WithDNSExecutionIdentity(issueCtx, domain.DNSExecutionIdentity{ + TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber, + Scheduled: taskType == "renewal_run" && task.OperatorID == "", + }) result, resultErr = execute(issueCtx, id) cancel() if resultErr == nil || number == attempts || result.Certificate.Saved.CertificatePath != "" || result.Deployment.Status == "partial" || result.Deployment.RecoveryStatus == "unconfirmed" || result.Deployment.RecoveryStatus == "failed" || errors.Is(resultErr, context.Canceled) || errors.Is(resultErr, context.DeadlineExceeded) { diff --git a/backend/internal/certificate/dns_execution.go b/backend/internal/certificate/dns_execution.go new file mode 100644 index 0000000..6488aef --- /dev/null +++ b/backend/internal/certificate/dns_execution.go @@ -0,0 +1,82 @@ +package certificate + +import ( + "context" + "errors" + "sync" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "github.com/go-acme/lego/v5/challenge" +) + +type DNSExecutionLeaseStore interface { + AcquireDNSExecutionLease(context.Context, domain.Domain, domain.DNSExecutionIdentity) (domain.DNSExecutionLease, error) + ValidateDNSExecutionLease(context.Context, domain.DNSExecutionLease) error + ReleaseDNSExecutionLease(context.Context, string) error +} + +type dnsExecutionContextKey struct{} + +func WithDNSExecutionIdentity(ctx context.Context, identity domain.DNSExecutionIdentity) context.Context { + return context.WithValue(ctx, dnsExecutionContextKey{}, identity) +} + +func (s *Service) SetDNSExecutionLeaseStore(store DNSExecutionLeaseStore) { + if s != nil { + s.dnsLeases = store + } +} + +// 每次写挑战前再次检查任务和权限;清理只能使用原 Provider 和固定凭据。 +// 失败的 Present 也可能已写入远端,因此必须等待匹配的 CleanUp 成功才释放引用。 +type leasedDNSProvider struct { + challenge.Provider + store DNSExecutionLeaseStore + lease domain.DNSExecutionLease + mu sync.Mutex + pending map[[3]string]bool +} + +func (p *leasedDNSProvider) Present(ctx context.Context, name, token, keyAuth string) error { + if err := ctx.Err(); err != nil { + return err + } + if err := p.store.ValidateDNSExecutionLease(ctx, p.lease); err != nil { + return errors.New("DNS 执行授权已失效,未写入挑战记录") + } + key := [3]string{name, token, keyAuth} + p.mu.Lock() + p.pending[key] = true + p.mu.Unlock() + return p.Provider.Present(ctx, name, token, keyAuth) +} + +func (p *leasedDNSProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error { + // 任务取消不取消必要的挑战清理;清理自身仍有明确的时间上限。 + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute) + defer cancel() + if err := p.Provider.CleanUp(cleanupCtx, name, token, keyAuth); err != nil { + return err + } + p.mu.Lock() + delete(p.pending, [3]string{name, token, keyAuth}) + p.mu.Unlock() + return nil +} + +func (p *leasedDNSProvider) clean() bool { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.pending) == 0 +} + +// 不覆盖服务商自己的传播等待参数。 +func (p *leasedDNSProvider) Timeout() (time.Duration, time.Duration) { + if timed, ok := p.Provider.(interface { + Timeout() (time.Duration, time.Duration) + }); ok { + return timed.Timeout() + } + return time.Minute, 2 * time.Second +} diff --git a/backend/internal/certificate/dns_execution_test.go b/backend/internal/certificate/dns_execution_test.go new file mode 100644 index 0000000..2d764d8 --- /dev/null +++ b/backend/internal/certificate/dns_execution_test.go @@ -0,0 +1,198 @@ +package certificate + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "github.com/go-acme/lego/v5/challenge" +) + +type executionLeaseStub struct { + lease domain.DNSExecutionLease + acquireErr, validateErr, releaseErr error + acquired, validated, released int +} + +func (s *executionLeaseStub) AcquireDNSExecutionLease(_ context.Context, _ domain.Domain, identity domain.DNSExecutionIdentity) (domain.DNSExecutionLease, error) { + s.acquired++ + s.lease.Identity = identity + return s.lease, s.acquireErr +} +func (s *executionLeaseStub) ValidateDNSExecutionLease(_ context.Context, lease domain.DNSExecutionLease) error { + s.validated++ + if lease.CredentialRef != s.lease.CredentialRef { + return errors.New("秘密引用被替换") + } + return s.validateErr +} +func (s *executionLeaseStub) ReleaseDNSExecutionLease(ctx context.Context, _ string) error { + s.released++ + if err := ctx.Err(); err != nil { + return err + } + return s.releaseErr +} + +type executionCredentials struct { + reference string + err error +} + +func (r *executionCredentials) Get(_ context.Context, reference string) (map[string]string, error) { + r.reference = reference + return map[string]string{"api_token": "independent-test-only"}, r.err +} + +type executionProvider struct { + presented, cleaned int + presentErr, cleanErr error +} + +func (p *executionProvider) Present(context.Context, string, string, string) error { + p.presented++ + return p.presentErr +} +func (p *executionProvider) CleanUp(ctx context.Context, _, _, _ string) error { + p.cleaned++ + if err := ctx.Err(); err != nil { + return err + } + return p.cleanErr +} +func (p *executionProvider) Timeout() (time.Duration, time.Duration) { + return 3 * time.Minute, 4 * time.Second +} + +type executionFactory struct { + provider challenge.Provider + id string +} + +func (f *executionFactory) Create(id string, _ map[string]string) (challenge.Provider, error) { + f.id = id + return f.provider, nil +} + +type executionIssuer struct { + run func(context.Context) (Resource, error) +} + +func (i executionIssuer) Obtain(ctx context.Context, _ ObtainRequest) (Resource, error) { + return i.run(ctx) +} + +func TestIndependentDNSExecutionLifecycle(t *testing.T) { + for _, scenario := range []string{"success", "acquire-failed", "validate-failed", "credential-failed", "present-failed", "cleanup-failed", "missing-cleanup", "release-failed", "cancelled", "revoked-before-present", "revoked-before-cleanup"} { + t.Run(scenario, func(t *testing.T) { + cfg := certificateTestConfig(t.TempDir()) + cfg.DNS.Provider, cfg.DNS.CredentialRef, cfg.DNS.VerificationState = "aliyun", "secret://dns/system-must-not-read", "unverified" + item, err := domain.NewDomain("lease.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.DNSAccountID, item.DNSBindingID, item.DNSProvider = "account", "binding", "cloudflare" + repo := &serviceRepository{item: item} + store := &executionLeaseStub{lease: domain.DNSExecutionLease{ID: "lease", DomainID: item.ID, AccountID: "account", BindingID: "binding", Provider: "cloudflare", CredentialRef: "secret://dns/independent", Revision: 2}} + reader := &executionCredentials{} + provider := &executionProvider{} + factory := &executionFactory{provider: provider} + service := NewService(cfg, repo, reader, factory, serviceAccounts{}) + service.SetDNSExecutionLeaseStore(store) + resource := testResourceForDomains(t, []string{item.Name}) + service.acmeRoots = testResourceRoots(t, resource) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ctx = WithDNSExecutionIdentity(ctx, domain.DNSExecutionIdentity{TaskID: "task", AttemptID: "attempt", AttemptNumber: 2}) + failure := errors.New("测试失败") + switch scenario { + case "acquire-failed": + store.acquireErr = failure + case "validate-failed": + store.validateErr = failure + case "credential-failed": + reader.err = failure + case "present-failed": + provider.presentErr = failure + case "cleanup-failed": + provider.cleanErr = failure + case "release-failed": + store.releaseErr = failure + } + service.newIssuer = func(_ *AccountUser, challengeProvider challenge.Provider, _ string) Issuer { + return executionIssuer{run: func(ctx context.Context) (Resource, error) { + if scenario == "revoked-before-present" { + store.validateErr = failure + } + if timeout, interval := challengeProvider.(challenge.ProviderTimeout).Timeout(); timeout != 3*time.Minute || interval != 4*time.Second { + t.Fatal("服务商传播参数丢失") + } + if err := challengeProvider.Present(ctx, item.Name, "token", "auth"); err != nil { + return Resource{}, err + } + if scenario == "missing-cleanup" { + return resource, nil + } + if scenario == "revoked-before-cleanup" { + store.validateErr = failure + } + if scenario == "cancelled" { + cancel() + } + if err := challengeProvider.CleanUp(ctx, item.Name, "token", "auth"); err != nil { + return Resource{}, err + } + if err := ctx.Err(); err != nil { + return Resource{}, err + } + return resource, nil + }} + } + _, err = service.Issue(ctx, item.ID) + wantSuccess := scenario == "success" || scenario == "revoked-before-cleanup" + if (err == nil) != wantSuccess { + t.Fatalf("场景结果不符:%v", err) + } + if store.acquired != 1 { + t.Fatal("未获取真实执行租约") + } + if reader.reference == cfg.DNS.CredentialRef || (factory.id != "" && factory.id != "cloudflare") { + t.Fatal("独立账户回退到系统配置") + } + if scenario != "acquire-failed" && scenario != "validate-failed" && reader.reference != "secret://dns/independent" { + t.Fatal("未读取固定凭据") + } + retained := scenario == "present-failed" || scenario == "cleanup-failed" || scenario == "missing-cleanup" || scenario == "acquire-failed" + if (store.released == 0) != retained { + t.Fatalf("不确定挑战清理时释放引用,或正常收尾未释放:%d", store.released) + } + if scenario == "revoked-before-present" && provider.presented != 0 { + t.Fatal("撤权后仍调用 DNS 服务商") + } + if scenario == "cancelled" && provider.cleaned != 1 { + t.Fatal("取消不应跳过清理") + } + if scenario == "release-failed" && (err == nil || !strings.Contains(err.Error(), "租约释放失败")) { + t.Fatal("释放失败未上报") + } + }) + } +} + +func TestIndependentDNSExecutionRequiresTaskContext(t *testing.T) { + item, _ := domain.NewDomain("lease.example.test", domain.CertificateSingle, nil) + item.DNSAccountID, item.DNSBindingID = "account", "binding" + repo := &serviceRepository{item: item} + service := NewService(certificateTestConfig(t.TempDir()), repo, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{}) + store := &executionLeaseStub{} + service.SetDNSExecutionLeaseStore(store) + if _, err := service.Issue(t.Context(), item.ID); err == nil { + t.Fatal("不能在任务之外使用独立凭据") + } + if store.acquired != 0 || repo.updateCalls != 0 { + t.Fatal("拒绝前不应产生副作用") + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index d3d6ebc..0a40ede 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -81,6 +81,7 @@ type Service struct { preferredChain string acmeRoots *x509.CertPool httpChallenges *httpchallenge.Store + dnsLeases DNSExecutionLeaseStore } func (s *Service) SetHTTPChallenges(store *httpchallenge.Store) { @@ -138,7 +139,7 @@ func NewService(cfg config.Config, repository DomainStateRepository, credentials } } -func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, error) { +func (s *Service) Issue(ctx context.Context, domainID string) (result IssueResult, returnErr error) { if s == nil || s.repository == nil || s.accounts == nil || s.newIssuer == nil { return IssueResult{}, errors.New("证书申请服务未初始化") } @@ -150,8 +151,8 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro if err != nil { return IssueResult{}, fmt.Errorf("读取域名配置失败:%w", err) } - // 独立账户执行租约接入前禁止退回系统账户,避免配置与实际用证账户不一致。 - if item.DNSAccountID != "" || item.DNSBindingID != "" { + independent := item.DNSAccountID != "" || item.DNSBindingID != "" + if independent && s.dnsLeases == nil { return IssueResult{}, errors.New("独立 DNS 账户执行租约尚未就绪,拒绝使用系统凭据替代") } domains, err := requestedDomains(item) @@ -166,8 +167,38 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro return IssueResult{}, fmt.Errorf("签发配置权限校验失败:%w", err) } providerID := "" + credentialRef := s.config.DNS.CredentialRef + var dnsLease domain.DNSExecutionLease + var guardedProvider *leasedDNSProvider var lease *httpchallenge.Lease - if challengeType == domain.ChallengeHTTP01 { + if independent { + if challengeType != domain.ChallengeDNS01 || s.credentials == nil || s.providers == nil { + return IssueResult{}, errors.New("独立 DNS 账户配置无效") + } + identity, ok := ctx.Value(dnsExecutionContextKey{}).(domain.DNSExecutionIdentity) + if !ok { + return IssueResult{}, errors.New("独立 DNS 账户缺少真实任务执行身份") + } + dnsLease, err = s.dnsLeases.AcquireDNSExecutionLease(ctx, item, identity) + if err != nil { + return IssueResult{}, errors.New("获取独立 DNS 账户执行租约失败,未使用系统凭据") + } + defer func() { + if guardedProvider != nil && !guardedProvider.clean() { + returnErr = errors.Join(returnErr, errors.New("DNS 挑战清理尚未确认,执行租约及原凭据引用已保留")) + return + } + finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if err := s.dnsLeases.ReleaseDNSExecutionLease(finishCtx, dnsLease.ID); err != nil { + returnErr = errors.Join(returnErr, errors.New("DNS 执行租约释放失败,原凭据引用已保留")) + } + }() + providerID, credentialRef = dnsLease.Provider, dnsLease.CredentialRef + if err := dnsprovider.ValidateAliyunTargetDomains(providerID, domains); err != nil { + return IssueResult{}, err + } + } else if challengeType == domain.ChallengeHTTP01 { lease, err = s.httpChallenges.NewLease(ctx, domains) if err != nil { return IssueResult{}, err @@ -197,7 +228,12 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro return fail(err) } } else { - credentials, err = s.credentials.Get(ctx, s.config.DNS.CredentialRef) + if independent { + if err := s.dnsLeases.ValidateDNSExecutionLease(ctx, dnsLease); err != nil { + return fail(errors.New("DNS 执行授权已失效,未读取凭据")) + } + } + credentials, err = s.credentials.Get(ctx, credentialRef) if err != nil { return fail(fmt.Errorf("读取 DNS 凭据失败:%w", err)) } @@ -214,6 +250,10 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro if err != nil { return fail(fmt.Errorf("创建 DNS Provider 失败:%w", err)) } + if independent { + guardedProvider = &leasedDNSProvider{Provider: provider, store: s.dnsLeases, lease: dnsLease, pending: make(map[[3]string]bool)} + provider = guardedProvider + } } email := strings.TrimSpace(s.config.ACME.AccountEmail) if email == "" && len(s.config.Notification.Recipients) > 0 { diff --git a/backend/internal/database/dns_execution_migration_test.go b/backend/internal/database/dns_execution_migration_test.go new file mode 100644 index 0000000..7e13bf8 --- /dev/null +++ b/backend/internal/database/dns_execution_migration_test.go @@ -0,0 +1,45 @@ +package database + +import "testing" + +func TestMySQLDNSExecutionInterruptedMigration(t *testing.T) { + f := startRealMySQL(t) + for _, boundary := range []string{"before-create", "after-create", "with-record", "bad-column", "bad-index", "bad-collation"} { + t.Run(boundary, func(t *testing.T) { + db := f.database(t) + mysqlPrepareVersion(t, db.DB, 28) + statements := mysqlDeploymentStatements(t, 29) + if boundary != "before-create" { + mysqlDeploymentExec(t, db.DB, statements[0]) + } + conflict := true + switch boundary { + case "bad-column": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_execution_leases MODIFY COLUMN account_revision INT NULL") + case "bad-index": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_execution_leases DROP INDEX uq_dns_leases_attempt") + case "bad-collation": + mysqlDeploymentExec(t, db.DB, "ALTER TABLE dns_execution_leases CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + default: + conflict = false + } + if boundary == "with-record" { + mysqlDeploymentExec(t, db.DB, `INSERT INTO dns_execution_leases VALUES ('kept','domain','binding','project','account',9,'cloudflare','secret://dns/test','task','attempt',1,0,'active',NOW(),NOW())`) + } + err := Migrate(t.Context(), db.DB, "mysql") + if (err != nil) != conflict { + t.Fatal("租约迁移中断恢复或冲突拒绝失败", err) + } + version, err := currentMigrationVersion(t.Context(), db.DB) + if err != nil || (conflict && version != 28) || (!conflict && version != latestMigrationVersion) { + t.Fatal("错误标记迁移版本", version, err) + } + if boundary == "with-record" { + var status string + if err := db.DB.QueryRowContext(t.Context(), `SELECT status FROM dns_execution_leases WHERE id = 'kept'`).Scan(&status); err != nil || status != "active" { + t.Fatal("重入丢失活动秘密引用", err) + } + } + }) + } +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 065f6ff..5baa9eb 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -29,6 +29,7 @@ import ( //go:embed migrations/sqlite/0026_domain_challenge.sql migrations/mysql/0026_domain_challenge.sql migrations/postgres/0026_domain_challenge.sql //go:embed migrations/sqlite/0027_dns_accounts.sql migrations/mysql/0027_dns_accounts.sql migrations/postgres/0027_dns_accounts.sql //go:embed migrations/sqlite/0028_domain_dns_accounts.sql migrations/mysql/0028_domain_dns_accounts.sql migrations/postgres/0028_domain_dns_accounts.sql +//go:embed migrations/sqlite/0029_dns_execution_leases.sql migrations/mysql/0029_dns_execution_leases.sql migrations/postgres/0029_dns_execution_leases.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -53,7 +54,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 28 +const latestMigrationVersion = 29 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -99,7 +100,7 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) if version == 27 { return applyMySQLDNSAccountMigration(ctx, db, version, statements) } - if (version >= 15 && version <= 17) || (version >= 20 && version <= 24) { + if (version >= 15 && version <= 17) || (version >= 20 && version <= 24) || version == 29 { return applyMySQLDeploymentMigration(ctx, db, version, statements) } if version == 14 { @@ -298,6 +299,8 @@ func migrationName(version int) string { return "dns_accounts" case 28: return "domain_dns_accounts" + case 29: + return "dns_execution_leases" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0029_dns_execution_leases.sql b/backend/internal/database/migrations/mysql/0029_dns_execution_leases.sql new file mode 100644 index 0000000..24e52f8 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0029_dns_execution_leases.sql @@ -0,0 +1,21 @@ +CREATE TABLE dns_execution_leases ( + id VARCHAR(64) NOT NULL, + domain_id VARCHAR(64) NOT NULL, + binding_id VARCHAR(64) NOT NULL, + project_id VARCHAR(64) NOT NULL, + account_id VARCHAR(64) NOT NULL, + account_revision INT NOT NULL, + provider VARCHAR(64) NOT NULL, + credential_ref VARCHAR(255) NOT NULL, + task_id VARCHAR(64) NOT NULL, + attempt_id VARCHAR(64) NOT NULL, + attempt_number INT NOT NULL, + scheduled INT NOT NULL, + status VARCHAR(32) NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_dns_leases_attempt (task_id, attempt_id), + INDEX idx_dns_leases_account (account_id, status), + INDEX idx_dns_leases_domain (domain_id, status) +); diff --git a/backend/internal/database/migrations/postgres/0029_dns_execution_leases.sql b/backend/internal/database/migrations/postgres/0029_dns_execution_leases.sql new file mode 100644 index 0000000..2257bfc --- /dev/null +++ b/backend/internal/database/migrations/postgres/0029_dns_execution_leases.sql @@ -0,0 +1,20 @@ +CREATE TABLE dns_execution_leases ( + id TEXT NOT NULL PRIMARY KEY, + domain_id TEXT NOT NULL, + binding_id TEXT NOT NULL, + project_id TEXT NOT NULL, + account_id TEXT NOT NULL, + account_revision INTEGER NOT NULL, + provider TEXT NOT NULL, + credential_ref TEXT NOT NULL, + task_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + scheduled INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (task_id, attempt_id) +); +CREATE INDEX idx_dns_leases_account ON dns_execution_leases (account_id, status); +CREATE INDEX idx_dns_leases_domain ON dns_execution_leases (domain_id, status); diff --git a/backend/internal/database/migrations/sqlite/0029_dns_execution_leases.sql b/backend/internal/database/migrations/sqlite/0029_dns_execution_leases.sql new file mode 100644 index 0000000..1ff2fc4 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0029_dns_execution_leases.sql @@ -0,0 +1,20 @@ +CREATE TABLE dns_execution_leases ( + id TEXT NOT NULL PRIMARY KEY, + domain_id TEXT NOT NULL, + binding_id TEXT NOT NULL, + project_id TEXT NOT NULL, + account_id TEXT NOT NULL, + account_revision INTEGER NOT NULL, + provider TEXT NOT NULL, + credential_ref TEXT NOT NULL, + task_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + scheduled INTEGER NOT NULL, + status TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE (task_id, attempt_id) +); +CREATE INDEX idx_dns_leases_account ON dns_execution_leases (account_id, status); +CREATE INDEX idx_dns_leases_domain ON dns_execution_leases (domain_id, status); diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go index f60b9e4..30c652e 100644 --- a/backend/internal/database/mysql_deployment_migration_test.go +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -8,7 +8,7 @@ import ( ) func TestMySQLDeploymentShapeDefinitions(t *testing.T) { - for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24, 27, 28} { + for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24, 27, 28, 29} { statements := mysqlDeploymentStatements(t, version) shape, err := parseMySQLDeploymentShape(statements[0]) if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 { diff --git a/backend/internal/domain/dns_execution.go b/backend/internal/domain/dns_execution.go new file mode 100644 index 0000000..c69a150 --- /dev/null +++ b/backend/internal/domain/dns_execution.go @@ -0,0 +1,22 @@ +package domain + +// DNSExecutionIdentity 仅由服务端任务执行器创建,不从 HTTP 请求解码。 +type DNSExecutionIdentity struct { + TaskID string + AttemptID string + AttemptNumber int + Scheduled bool +} + +// DNSExecutionLease 是秘密使用能力,不允许出现在 API 响应或日志中。 +type DNSExecutionLease struct { + ID string `json:"-"` + DomainID string `json:"-"` + BindingID string `json:"-"` + ProjectID string `json:"-"` + AccountID string `json:"-"` + Revision int64 `json:"-"` + Provider string `json:"-"` + CredentialRef string `json:"-"` + Identity DNSExecutionIdentity `json:"-"` +} diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index 8af3f71..7c7791a 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -25,20 +25,21 @@ const ( type Domain struct { ResourceScope - ID string `json:"id"` - Name string `json:"name"` - CertificateType CertificateType `json:"certificate_type"` - SANs []string `json:"sans"` - AutoRenew bool `json:"auto_renew"` - ACMEEnvironment string `json:"acme_environment"` - DNSProvider string `json:"dns_provider,omitempty"` - DNSAccountID string `json:"dns_account_id,omitempty"` - DNSBindingID string `json:"dns_binding_id,omitempty"` - ChallengeType string `json:"challenge_type"` - Status string `json:"status"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + CertificateType CertificateType `json:"certificate_type"` + SANs []string `json:"sans"` + AutoRenew bool `json:"auto_renew"` + ACMEEnvironment string `json:"acme_environment"` + DNSProvider string `json:"dns_provider,omitempty"` + DNSAccountID string `json:"dns_account_id,omitempty"` + DNSBindingID string `json:"dns_binding_id,omitempty"` + InitialDNSRevision int64 `json:"-"` + ChallengeType string `json:"challenge_type"` + Status string `json:"status"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func NewDomain(name string, certificateType CertificateType, sans []string) (Domain, error) { diff --git a/backend/internal/httpapi/dns_account_mutations.go b/backend/internal/httpapi/dns_account_mutations.go index 42b262a..53261c3 100644 --- a/backend/internal/httpapi/dns_account_mutations.go +++ b/backend/internal/httpapi/dns_account_mutations.go @@ -33,6 +33,8 @@ func (s *Server) writeDNSAccountError(c *gin.Context, err error) { s.writeError(c, http.StatusForbidden, "无权操作此 DNS 账户", nil) case errors.Is(err, repository.ErrDNSAccountConflict): s.writeError(c, http.StatusConflict, "DNS 账户已变更,请刷新后重试", nil) + case errors.Is(err, repository.ErrDNSAccountBusy): + s.writeError(c, http.StatusConflict, "DNS 账户仍有执行租约,请等待任务及挑战清理完成", nil) default: // 驱动、秘密仓库和服务商的错误均可能携带凭据,不能回显或写入日志。 s.writeError(c, http.StatusServiceUnavailable, "DNS 账户操作失败,请稍后重试", nil) diff --git a/backend/internal/httpapi/domain_dns_account_api.go b/backend/internal/httpapi/domain_dns_account_api.go index 7442092..5966353 100644 --- a/backend/internal/httpapi/domain_dns_account_api.go +++ b/backend/internal/httpapi/domain_dns_account_api.go @@ -29,7 +29,7 @@ func (s *Server) writeDomainDNSAccountError(c *gin.Context, err error) { s.writeError(c, http.StatusForbidden, "无权操作此域名的 DNS 账户绑定", nil) case errors.Is(err, repository.ErrDNSBindingConflict), errors.Is(err, repository.ErrDNSAccountConflict): s.writeError(c, http.StatusConflict, "域名绑定或 DNS 账户已变更,请刷新后重试", nil) - case errors.Is(err, repository.ErrDNSBindingBusy): + case errors.Is(err, repository.ErrDNSBindingBusy), errors.Is(err, repository.ErrDNSAccountBusy): s.writeError(c, http.StatusConflict, "域名存在未结束的任务,暂不能变更 DNS 账户", nil) case errors.Is(err, repository.ErrDNSBindingInvalid): s.writeError(c, http.StatusUnprocessableEntity, "域名验证方式或 DNS 账户状态不允许绑定", nil) diff --git a/backend/internal/httpapi/domain_initial_dns_test.go b/backend/internal/httpapi/domain_initial_dns_test.go new file mode 100644 index 0000000..e4c1296 --- /dev/null +++ b/backend/internal/httpapi/domain_initial_dns_test.go @@ -0,0 +1,65 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestDomainInitialDNSAccountAtomicAPI(t *testing.T) { + f := newProjectMemberAPIFixture(t) + f.server.projectDomains = f.members + f.server.repository = repository.NewSQLDomainRepository(f.db) + account, err := f.members.CreateDNSAccount(t.Context(), "primary-super-admin", domain.DNSAccount{ID: "initial-account", Name: "初始账户", ProjectID: "default", Provider: "cloudflare", CredentialRef: "secret://dns/initial"}) + if err != nil { + t.Fatal(err) + } + account, err = f.members.RecordDNSAccountVerification(t.Context(), "primary-super-admin", account.ID, account.Revision, true) + if err != nil { + t.Fatal(err) + } + for _, scenario := range []struct { + name, actor, provider, challenge, accountID string + revision int64 + status int + }{ + {"success", "primary-super-admin", "cloudflare", "dns-01", account.ID, account.Revision, http.StatusCreated}, + {"old-version", "primary-super-admin", "cloudflare", "dns-01", account.ID, 1, http.StatusConflict}, + {"wrong-provider", "primary-super-admin", "route53", "dns-01", account.ID, account.Revision, http.StatusUnprocessableEntity}, + {"wrong-challenge", "primary-super-admin", "cloudflare", "http-01", account.ID, account.Revision, http.StatusBadRequest}, + {"no-account", "primary-super-admin", "cloudflare", "dns-01", "", 2, http.StatusBadRequest}, + {"no-permission", "viewer", "cloudflare", "dns-01", account.ID, account.Revision, http.StatusForbidden}, + } { + t.Run(scenario.name, func(t *testing.T) { + name := scenario.name + ".example.test" + body := fmt.Sprintf(`{"name":%q,"certificate_type":"single","challenge_type":%q,"dns_provider":%q,"dns_account_id":%q,"dns_account_revision":%d}`, name, scenario.challenge, scenario.provider, scenario.accountID, scenario.revision) + response := serveUserRequest(f.server, f.cookies[scenario.actor], http.MethodPost, "/api/v1/domains", body) + if response.Code != scenario.status { + t.Fatal("创建接口状态错误", response.Code, response.Body.String()) + } + if response.Code == http.StatusCreated { + var item domain.Domain + if err := json.Unmarshal(response.Body.Bytes(), &item); err != nil { + t.Fatal(err) + } + loaded, err := repository.NewSQLDomainRepository(f.db).GetByID(t.Context(), item.ID) + if err != nil || item.DNSBindingID == "" || item.DNSBindingID != loaded.DNSBindingID || loaded.DNSAccountID != account.ID { + t.Fatal("创建响应与原子绑定不符", err) + } + response = serveUserRequest(f.server, f.cookies[scenario.actor], http.MethodPut, "/api/v1/domains/"+item.ID, body) + if response.Code != http.StatusBadRequest { + t.Fatal("普通更新不能绕过绑定令牌", response.Code) + } + } else { + var count int + if err := f.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM domains WHERE name = ?`, name).Scan(&count); err != nil || count != 0 { + t.Fatal("失败后遗留未绑定域名", err) + } + } + }) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index ae4e1b7..b483d0d 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -419,13 +419,15 @@ func (s *Server) Shutdown(ctx context.Context) error { } type domainRequest struct { - Name string `json:"name" binding:"required"` - CertificateType string `json:"certificate_type" binding:"required"` - SANs []string `json:"sans"` - AutoRenew *bool `json:"auto_renew"` - ACMEEnvironment string `json:"acme_environment"` - DNSProvider string `json:"dns_provider"` - ChallengeType *string `json:"challenge_type"` + Name string `json:"name" binding:"required"` + CertificateType string `json:"certificate_type" binding:"required"` + SANs []string `json:"sans"` + AutoRenew *bool `json:"auto_renew"` + ACMEEnvironment string `json:"acme_environment"` + DNSProvider string `json:"dns_provider"` + DNSAccountID string `json:"dns_account_id"` + DNSAccountRevision int64 `json:"dns_account_revision"` + ChallengeType *string `json:"challenge_type"` } type serverRequest struct { @@ -645,6 +647,16 @@ func (s *Server) createDomain(c *gin.Context) { return } item.ResourceScope = s.newResourceScope(c) + if request.DNSAccountID != "" { + if !domain.ValidProjectID(request.DNSAccountID) || request.DNSAccountRevision < 1 || item.ChallengeType != domain.ChallengeDNS01 { + s.writeError(c, http.StatusBadRequest, "初始 DNS 账户参数无效", nil) + return + } + item.DNSAccountID, item.DNSBindingID, item.InitialDNSRevision = request.DNSAccountID, domain.NewID(), request.DNSAccountRevision + } else if request.DNSAccountRevision != 0 { + s.writeError(c, http.StatusBadRequest, "初始 DNS 账户参数不完整", nil) + return + } if !s.authorizeDomainMutation(c, item.ProjectID) { return } @@ -652,6 +664,10 @@ func (s *Server) createDomain(c *gin.Context) { return } if err := s.projectDomains.CreateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil { + if errors.Is(err, repository.ErrDNSAccountConflict) || errors.Is(err, repository.ErrDNSBindingInvalid) { + s.writeDomainDNSAccountError(c, err) + return + } if errors.Is(err, repository.ErrProjectAccessDenied) { s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil) return @@ -675,6 +691,10 @@ func (s *Server) updateDomain(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "请求参数无效", err) return } + if request.DNSAccountID != "" || request.DNSAccountRevision != 0 { + s.writeError(c, http.StatusBadRequest, "编辑 DNS 账户须使用独立绑定接口", nil) + return + } existing, err := s.projectResources.GetVisibleDomain(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) if err != nil { s.writeProjectResourceError(c, err) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 8e16304..468533a 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -274,6 +274,9 @@ func (s *Server) runCertificateOperation(ctx context.Context, task repository.Ta return } if task.TaskType == "certificate_issue" { + executionCtx = certificate.WithDNSExecutionIdentity(executionCtx, domain.DNSExecutionIdentity{ + TaskID: task.ID, AttemptID: attempt.ID, AttemptNumber: attempt.AttemptNumber, + }) executionCtx = s.withCertificateIssueAuthorization(executionCtx, task.OperatorID, task.DomainID, task.ProjectID, attempt.AttemptNumber > 1) } result, issueErr := operation(executionCtx, task.DomainID) diff --git a/backend/internal/repository/dns_account_repository.go b/backend/internal/repository/dns_account_repository.go index 6640ee1..a80db08 100644 --- a/backend/internal/repository/dns_account_repository.go +++ b/backend/internal/repository/dns_account_repository.go @@ -211,6 +211,9 @@ func (r *SQLProjectMemberRepository) changeDNSAccount(ctx context.Context, actor if item.Revision != revision { return domain.DNSAccount{}, ErrDNSAccountConflict } + if err := r.rejectActiveDNSLease(ctx, tx, "account_id", id); err != nil { + return domain.DNSAccount{}, err + } if err := change(&item); err != nil { return domain.DNSAccount{}, err } diff --git a/backend/internal/repository/dns_execution_repository.go b/backend/internal/repository/dns_execution_repository.go new file mode 100644 index 0000000..87272fb --- /dev/null +++ b/backend/internal/repository/dns_execution_repository.go @@ -0,0 +1,185 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ( + ErrDNSExecutionInvalid = errors.New("DNS 执行租约或任务授权已失效") + ErrDNSAccountBusy = errors.New("DNS 账户仍有执行租约,需等待任务及挑战清理完成") +) + +// 租约不设置自动到期:外部调用或清理结果不确定时必须保留秘密引用。 +func (r *SQLProjectMemberRepository) AcquireDNSExecutionLease(ctx context.Context, item domain.Domain, identity domain.DNSExecutionIdentity) (domain.DNSExecutionLease, error) { + if err := r.ensureDB(); err != nil { + return domain.DNSExecutionLease{}, err + } + if item.DNSAccountID == "" || item.DNSBindingID == "" || !domain.ValidProjectID(identity.TaskID) || !domain.ValidProjectID(identity.AttemptID) || identity.AttemptNumber < 1 { + return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.DNSExecutionLease{}, err + } + defer tx.Rollback() + lease := domain.DNSExecutionLease{ID: domain.NewID(), DomainID: item.ID, ProjectID: item.ResourceScope.Normalize().ProjectID, + BindingID: item.DNSBindingID, AccountID: item.DNSAccountID, Identity: identity} + account, err := r.authorizeDNSExecution(ctx, tx, lease) + if err != nil { + return domain.DNSExecutionLease{}, err + } + if err := r.rejectActiveDNSLease(ctx, tx, "domain_id", item.ID); err != nil { + return domain.DNSExecutionLease{}, err + } + // 域名行锁与资料更新共用,拒绝授权前后被换掉的签发快照。 + var updated time.Time + if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT updated_at FROM domains WHERE id = ?`), item.ID).Scan(&updated); err != nil { + return domain.DNSExecutionLease{}, err + } + if !updated.Equal(item.UpdatedAt) { + return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid + } + lease.Revision, lease.Provider, lease.CredentialRef = account.Revision, account.Provider, account.CredentialRef + now := time.Now().UTC() + scheduled := 0 + if identity.Scheduled { + scheduled = 1 + } + _, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO dns_execution_leases + (id, domain_id, binding_id, project_id, account_id, account_revision, provider, credential_ref, + task_id, attempt_id, attempt_number, scheduled, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`), lease.ID, lease.DomainID, lease.BindingID, + lease.ProjectID, lease.AccountID, lease.Revision, lease.Provider, lease.CredentialRef, + identity.TaskID, identity.AttemptID, identity.AttemptNumber, scheduled, now, now) + if err != nil { + return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid + } + if err := tx.Commit(); err != nil { + return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid + } + return lease, nil +} + +// 所有校验在短事务内完成;网络调用期间不占用数据库事务。 +// 加锁顺序为域名、任务、尝试、账户,账户变更在同一账户行上串行化。 +func (r *SQLProjectMemberRepository) authorizeDNSExecution(ctx context.Context, tx *sql.Tx, lease domain.DNSExecutionLease) (domain.DNSAccount, error) { + var projectID, challengeType string + var autoRenew bool + err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT project_id, challenge_type, auto_renew FROM domains WHERE id = ?`+r.resourceWriteLock()), lease.DomainID).Scan(&projectID, &challengeType, &autoRenew) + if err != nil || projectID != lease.ProjectID || challengeType != domain.ChallengeDNS01 { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + var bindingID, accountID string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id, dns_account_id FROM domain_dns_accounts WHERE domain_id = ? AND project_id = ?`+r.resourceWriteLock()), lease.DomainID, projectID).Scan(&bindingID, &accountID) + if err != nil || bindingID != lease.BindingID || accountID != lease.AccountID { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + var taskType, actorID, taskDomain, taskProject, taskStatus, taskScope string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT task_type, operator_id, domain_id, project_id, status, scope_type FROM tasks WHERE id = ?`+r.resourceWriteLock()), lease.Identity.TaskID). + Scan(&taskType, &actorID, &taskDomain, &taskProject, &taskStatus, &taskScope) + if err != nil || taskDomain != lease.DomainID || taskProject != projectID || taskStatus != "running" || taskScope != ScopeProject { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + var number int + var attemptTask, attemptStatus string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT task_id, attempt_number, status FROM task_attempts WHERE id = ?`+r.resourceWriteLock()), lease.Identity.AttemptID). + Scan(&attemptTask, &number, &attemptStatus) + if err != nil || attemptTask != lease.Identity.TaskID || number != lease.Identity.AttemptNumber || attemptStatus != "running" { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + if lease.Identity.Scheduled { + if taskType != "renewal_run" || actorID != "" || !autoRenew { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + } else { + if actorID == "" || (taskType != "certificate_issue" && taskType != "renewal_run") { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + permissions := []domain.ProjectPermission{domain.PermissionCertificateManage, domain.PermissionDeploymentExecute, domain.PermissionDNSCredentialUse} + if number > 1 { + permissions = append(permissions, domain.PermissionTaskRetry) + } + if err := r.authorizeProjectTransaction(ctx, tx, actorID, projectID, permissions...); err != nil { + return domain.DNSAccount{}, err + } + } + account, err := scanDNSAccount(tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), accountID)) + if err != nil || account.ProjectID != projectID || account.Status != domain.DNSAccountActive || account.VerificationState != domain.DNSAccountVerified { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + if err := account.Validate(); err != nil { + return domain.DNSAccount{}, ErrDNSExecutionInvalid + } + return account, nil +} + +func (r *SQLProjectMemberRepository) ValidateDNSExecutionLease(ctx context.Context, lease domain.DNSExecutionLease) error { + if err := r.ensureDB(); err != nil { + return err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + account, err := r.authorizeDNSExecution(ctx, tx, lease) + if err != nil { + return err + } + if account.Revision != lease.Revision || account.Provider != lease.Provider || account.CredentialRef != lease.CredentialRef { + return ErrDNSExecutionInvalid + } + var id string + scheduled := 0 + if lease.Identity.Scheduled { + scheduled = 1 + } + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM dns_execution_leases WHERE id = ? AND domain_id = ? + AND binding_id = ? AND project_id = ? AND account_id = ? AND account_revision = ? AND provider = ? AND credential_ref = ? + AND task_id = ? AND attempt_id = ? AND attempt_number = ? AND scheduled = ? AND status = 'active'`+r.resourceWriteLock()), + lease.ID, lease.DomainID, lease.BindingID, lease.ProjectID, lease.AccountID, lease.Revision, lease.Provider, lease.CredentialRef, + lease.Identity.TaskID, lease.Identity.AttemptID, lease.Identity.AttemptNumber, scheduled).Scan(&id) + if err != nil { + return ErrDNSExecutionInvalid + } + return tx.Commit() +} + +// 释放只接受持有方随机能力标识,取消和撤权不能妨碍原凭据的清理收尾。 +func (r *SQLProjectMemberRepository) ReleaseDNSExecutionLease(ctx context.Context, id string) error { + if err := r.ensureDB(); err != nil { + return err + } + if !domain.ValidProjectID(id) { + return ErrDNSExecutionInvalid + } + _, err := r.db.ExecContext(ctx, Rebind(r.driver, `UPDATE dns_execution_leases SET status = 'released', updated_at = ? WHERE id = ? AND status = 'active'`), time.Now().UTC(), id) + if err != nil { + return ErrDNSExecutionInvalid + } + var status string + if err := r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT status FROM dns_execution_leases WHERE id = ?`), id).Scan(&status); err != nil || status != "released" { + return ErrDNSExecutionInvalid + } + return nil +} + +func (r *SQLProjectMemberRepository) rejectActiveDNSLease(ctx context.Context, tx *sql.Tx, column, id string) error { + if column != "account_id" && column != "domain_id" { + return ErrDNSExecutionInvalid + } + var active string + err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM dns_execution_leases WHERE `+column+` = ? AND status = 'active' LIMIT 1`+r.resourceWriteLock()), id).Scan(&active) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + return ErrDNSAccountBusy +} diff --git a/backend/internal/repository/dns_execution_repository_test.go b/backend/internal/repository/dns_execution_repository_test.go new file mode 100644 index 0000000..20089ac --- /dev/null +++ b/backend/internal/repository/dns_execution_repository_test.go @@ -0,0 +1,325 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/postgrestest" +) + +type dnsLeaseFixture struct { + repo *SQLProjectMemberRepository + db *sql.DB + driver string + item domain.Domain + account domain.DNSAccount + identity domain.DNSExecutionIdentity +} + +func (f dnsLeaseFixture) exec(t *testing.T, query string, args ...any) { + t.Helper() + if _, err := f.db.ExecContext(t.Context(), Rebind(f.driver, query), args...); err != nil { + t.Fatal(err) + } +} + +func newDNSLeaseFixture(t *testing.T, db *sql.DB, driver string) dnsLeaseFixture { + t.Helper() + repo := NewSQLProjectMemberRepository(db, driver) + item, err := domain.NewDomain(domain.NewID()+".example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ProjectID = domain.NewID() + if err := NewSQLProjectRepository(db, driver).CreateProject(t.Context(), domain.Project{ID: item.ProjectID, Name: "租约测试"}); err != nil { + t.Fatal(err) + } + if err := repo.GrantProjectRole(t.Context(), "lease-root", item.ProjectID, "lease-manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + if err := repo.CreateProjectDomain(t.Context(), "lease-root", item); err != nil { + t.Fatal(err) + } + account, err := repo.CreateDNSAccount(t.Context(), "lease-root", domain.DNSAccount{ID: domain.NewID(), Name: "租约账户", ProjectID: item.ProjectID, Provider: "cloudflare", CredentialRef: "secret://dns/" + domain.NewID()}) + if err != nil { + t.Fatal(err) + } + account, err = repo.RecordDNSAccountVerification(t.Context(), "lease-root", account.ID, account.Revision, true) + if err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "lease-root", item.ID, account.ID, "", account.Revision); err != nil { + t.Fatal(err) + } + item, err = NewSQLDomainRepository(db, driver).GetByID(t.Context(), item.ID) + if err != nil { + t.Fatal(err) + } + identity := domain.DNSExecutionIdentity{TaskID: domain.NewID(), AttemptID: domain.NewID(), AttemptNumber: 1} + tasks := NewSQLTaskRepository(db, driver) + if err := tasks.CreateTask(t.Context(), Task{ID: identity.TaskID, DomainID: item.ID, TaskType: "certificate_issue", Status: "running", Stage: "certificate_issue", OperatorID: "lease-manager"}); err != nil { + t.Fatal(err) + } + if err := tasks.CreateAttempt(t.Context(), TaskAttempt{ID: identity.AttemptID, TaskID: identity.TaskID, AttemptNumber: 1, Status: "running", Stage: "certificate_issue"}); err != nil { + t.Fatal(err) + } + return dnsLeaseFixture{repo: repo, db: db, driver: driver, item: item, account: account, identity: identity} +} + +func TestDNSExecutionLeaseRepository(t *testing.T) { + for _, driver := range []string{"sqlite", "mysql", "postgres"} { + t.Run(driver, func(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "dns-leases.db") + if driver == "mysql" { + cfg = mysqltest.New(t).Database(t) + } + if driver == "postgres" { + cfg = postgrestest.New(t).Database(t) + } + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := database.Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + for id, role := range map[string]string{"lease-root": "super_admin", "lease-manager": "user"} { + if _, err := db.DB.ExecContext(t.Context(), Rebind(driver, `INSERT INTO users (id, username, password_hash, role, status) VALUES (?, ?, 'test-only', ?, 'active')`), id, id, role); err != nil { + t.Fatal(err) + } + } + t.Run("lifecycle", func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + lease, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity) + if err != nil { + t.Fatal(err) + } + if lease.Provider != "cloudflare" || lease.AccountID != f.account.ID || lease.CredentialRef == "" || lease.Revision != f.account.Revision { + t.Fatal("租约未固定实际账户") + } + if err := f.repo.ValidateDNSExecutionLease(t.Context(), lease); err != nil { + t.Fatal(err) + } + if _, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity); err == nil { + t.Fatal("重复获取未拒绝") + } + for _, mutate := range []func() error{ + func() error { + _, err := f.repo.RotateDNSAccountCredential(t.Context(), "lease-root", f.account.ID, f.account.Revision, "secret://dns/rotated") + return err + }, + func() error { + _, err := f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false) + return err + }, + func() error { + _, err := f.repo.SetDomainDNSAccountBinding(t.Context(), "lease-root", f.item.ID, "", f.item.DNSBindingID, 0) + return err + }, + func() error { return f.repo.UpdateProjectDomain(t.Context(), "lease-root", f.item) }, + } { + if err := mutate(); !errors.Is(err, ErrDNSAccountBusy) { + t.Fatal("活动租约未阻止变更", err) + } + } + f.exec(t, `UPDATE tasks SET status = 'failed' WHERE id = ?`, f.identity.TaskID) + if err := f.repo.DeleteProjectDomain(t.Context(), "lease-root", f.item.ID); !errors.Is(err, ErrDNSAccountBusy) { + t.Fatal("终态任务仍有租约,不能删除域名", err) + } + if err := f.repo.ValidateDNSExecutionLease(t.Context(), lease); err == nil { + t.Fatal("终态任务仍允许发起 DNS 操作") + } + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + if err := f.repo.ReleaseDNSExecutionLease(cancelled, lease.ID); err == nil { + t.Fatal("取消的释放应失败") + } + if _, err := f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false); !errors.Is(err, ErrDNSAccountBusy) { + t.Fatal("释放失败不能丢失引用", err) + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal(err) + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal("释放应幂等", err) + } + if _, err := f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false); err != nil { + t.Fatal("正常释放后应允许禁用", err) + } + }) + t.Run("atomic-initial-binding", func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + for _, scenario := range []string{"success", "old-revision", "wrong-provider", "duplicate-binding", "foreign-project", "http-challenge", "missing-account"} { + t.Run(scenario, func(t *testing.T) { + item, err := domain.NewDomain(domain.NewID()+".example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ProjectID, item.DNSProvider = f.item.ProjectID, "cloudflare" + item.DNSAccountID, item.DNSBindingID, item.InitialDNSRevision = f.account.ID, domain.NewID(), f.account.Revision + switch scenario { + case "old-revision": + item.InitialDNSRevision-- + case "wrong-provider": + item.DNSProvider = "aliyun" + case "duplicate-binding": + item.DNSBindingID = f.item.DNSBindingID + case "foreign-project": + item.ProjectID = domain.DefaultProjectID + case "http-challenge": + item.ChallengeType = domain.ChallengeHTTP01 + case "missing-account": + item.DNSAccountID = "missing" + } + err = f.repo.CreateProjectDomain(t.Context(), "lease-root", item) + if (err == nil) != (scenario == "success") { + t.Fatal("原子创建结果不符", err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM domains WHERE id = ?`), item.ID).Scan(&count); err != nil { + t.Fatal(err) + } + if scenario != "success" && count != 0 { + t.Fatal("绑定失败遗留未绑定域名") + } + if scenario == "success" { + loaded, err := NewSQLDomainRepository(db.DB, driver).GetByID(t.Context(), item.ID) + if err != nil || loaded.DNSAccountID != item.DNSAccountID || loaded.DNSBindingID != item.DNSBindingID { + t.Fatal("创建后绑定不可见", err) + } + } else { + if err := db.DB.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs WHERE resource_id = ?`), item.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("失败创建遗留成功审计", err) + } + } + }) + } + }) + for _, scenario := range []string{"missing-task", "wrong-attempt", "wrong-number", "cancelled-task", "finished-attempt", "wrong-project", "revoked", "anonymous", "fake-scheduler", "disabled", "unverified", "stale-binding", "stale-snapshot", "retry-without-permission"} { + t.Run(scenario, func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + switch scenario { + case "missing-task": + f.identity.TaskID = "missing" + case "wrong-attempt": + f.identity.AttemptID = "missing" + case "wrong-number": + f.identity.AttemptNumber = 2 + case "cancelled-task": + f.exec(t, `UPDATE tasks SET status = 'cancelling' WHERE id = ?`, f.identity.TaskID) + case "finished-attempt": + f.exec(t, `UPDATE task_attempts SET status = 'failed' WHERE id = ?`, f.identity.AttemptID) + case "wrong-project": + f.item.ProjectID = domain.DefaultProjectID + case "revoked": + f.exec(t, `DELETE FROM project_members WHERE project_id = ?`, f.item.ProjectID) + case "anonymous": + f.exec(t, `UPDATE tasks SET operator_id = '' WHERE id = ?`, f.identity.TaskID) + case "fake-scheduler": + f.identity.Scheduled = true + case "disabled": + _, err = f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false) + case "unverified": + _, err = f.repo.RecordDNSAccountVerification(t.Context(), "lease-root", f.account.ID, f.account.Revision, false) + case "stale-binding": + f.item.DNSBindingID = "old-binding" + case "stale-snapshot": + f.item.UpdatedAt = f.item.UpdatedAt.Add(-time.Hour) + case "retry-without-permission": + f.identity.AttemptNumber = 2 + f.exec(t, `UPDATE task_attempts SET attempt_number = 2 WHERE id = ?`, f.identity.AttemptID) + f.exec(t, `DELETE FROM project_members WHERE project_id = ?`, f.item.ProjectID) + } + if err != nil { + t.Fatal(err) + } + if _, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity); err == nil { + t.Fatal("无效身份或状态获得租约") + } + }) + } + t.Run("scheduled-and-revalidation", func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + f.exec(t, `UPDATE tasks SET operator_id = '', task_type = 'renewal_run' WHERE id = ?`, f.identity.TaskID) + if _, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity); err == nil { + t.Fatal("空操作者不能隐式成为调度器") + } + f.identity.Scheduled = true + lease, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity) + if err != nil { + t.Fatal(err) + } + forged := lease + forged.CredentialRef = "secret://dns/other" + if err := f.repo.ValidateDNSExecutionLease(t.Context(), forged); err == nil { + t.Fatal("篡改租约未拒绝") + } + f.exec(t, `UPDATE domains SET auto_renew = ? WHERE id = ?`, false, f.item.ID) + if err := f.repo.ValidateDNSExecutionLease(t.Context(), lease); err == nil { + t.Fatal("关闭续期后仍允许 DNS 写入") + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal(err) + } + }) + t.Run("revocation-after-acquire", func(t *testing.T) { + f := newDNSLeaseFixture(t, db.DB, driver) + lease, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity) + if err != nil { + t.Fatal(err) + } + f.exec(t, `DELETE FROM project_members WHERE project_id = ?`, f.item.ProjectID) + if err := f.repo.ValidateDNSExecutionLease(t.Context(), lease); err == nil { + t.Fatal("撤权后仍允许 DNS 写入") + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal("撤权不应阻止清理收尾", err) + } + }) + t.Run("rotation-race", func(t *testing.T) { + for round := 0; round < 5; round++ { + f := newDNSLeaseFixture(t, db.DB, driver) + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + var lease domain.DNSExecutionLease + var acquireErr, rotateErr error + go func() { + defer wg.Done() + <-start + lease, acquireErr = f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity) + }() + go func() { + defer wg.Done() + <-start + _, rotateErr = f.repo.RotateDNSAccountCredential(t.Context(), "lease-root", f.account.ID, f.account.Revision, "secret://dns/"+domain.NewID()) + }() + close(start) + wg.Wait() + if acquireErr == nil && rotateErr == nil { + t.Fatal("获取租约与轮换不能同时成功") + } + if acquireErr == nil { + if err := f.repo.ValidateDNSExecutionLease(t.Context(), lease); err != nil { + t.Fatal("获胜租约失效", err) + } + if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil { + t.Fatal(err) + } + } + } + }) + }) + } +} diff --git a/backend/internal/repository/domain_dns_account_repository.go b/backend/internal/repository/domain_dns_account_repository.go index bb1a7d5..cc46c5c 100644 --- a/backend/internal/repository/domain_dns_account_repository.go +++ b/backend/internal/repository/domain_dns_account_repository.go @@ -79,6 +79,9 @@ func (r *SQLProjectMemberRepository) SetDomainDNSAccountBinding(ctx context.Cont if currentID != expectedID { return domain.DNSAccountBinding{}, ErrDNSBindingConflict } + if err := r.rejectActiveDNSLease(ctx, tx, "domain_id", domainID); err != nil { + return domain.DNSAccountBinding{}, err + } // 与任务创建持有同一域名行锁,避免检查后插入新任务并使用旧绑定。 var activeID string err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM tasks WHERE domain_id = ? diff --git a/backend/internal/repository/domain_initial_dns.go b/backend/internal/repository/domain_initial_dns.go new file mode 100644 index 0000000..fe81ac2 --- /dev/null +++ b/backend/internal/repository/domain_initial_dns.go @@ -0,0 +1,40 @@ +package repository + +import ( + "context" + "database/sql" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// 创建域名与初始 DNS 绑定共用一个事务,自动续期看不到未绑定的中间状态。 +func (r *SQLProjectMemberRepository) validateInitialDNSBinding(ctx context.Context, tx *sql.Tx, actorID string, item domain.Domain, challengeType string) error { + if item.DNSAccountID == "" { + if item.DNSBindingID != "" || item.InitialDNSRevision != 0 { + return ErrDNSBindingInvalid + } + return nil + } + if !domain.ValidProjectID(item.DNSAccountID) || !domain.ValidProjectID(item.DNSBindingID) || item.InitialDNSRevision < 1 || challengeType != domain.ChallengeDNS01 { + return ErrDNSBindingInvalid + } + if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionDNSCredentialUse); err != nil { + return err + } + var projectID, provider, status, verification string + var revision int64 + if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT project_id, provider, status, verification_state, revision FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), item.DNSAccountID). + Scan(&projectID, &provider, &status, &verification, &revision); err != nil { + return ErrDNSBindingInvalid + } + if projectID != item.ProjectID { + return ErrProjectAccessDenied + } + if revision != item.InitialDNSRevision { + return ErrDNSAccountConflict + } + if provider != item.DNSProvider || status != domain.DNSAccountActive || verification != domain.DNSAccountVerified { + return ErrDNSBindingInvalid + } + return nil +} diff --git a/backend/internal/repository/project_domain_repository.go b/backend/internal/repository/project_domain_repository.go index 744cc6d..e2c7e19 100644 --- a/backend/internal/repository/project_domain_repository.go +++ b/backend/internal/repository/project_domain_repository.go @@ -36,6 +36,9 @@ func (r *SQLProjectMemberRepository) CreateProjectDomain(ctx context.Context, ac if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionCertificateManage); err != nil { return err } + if err := r.validateInitialDNSBinding(ctx, tx, actorID, item, challengeType); err != nil { + return err + } _, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO domains (id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by, challenge_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew, @@ -47,6 +50,15 @@ func (r *SQLProjectMemberRepository) CreateProjectDomain(ctx context.Context, ac if err := writeDomainAudit(ctx, tx, r.driver, "domain.create", item.ID, item.ProjectID, actorID, "创建域名"); err != nil { return err } + if item.DNSAccountID != "" { + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO domain_dns_accounts (domain_id, id, project_id, dns_account_id, updated_at) VALUES (?, ?, ?, ?, ?)`), + item.ID, item.DNSBindingID, item.ProjectID, item.DNSAccountID, item.UpdatedAt); err != nil { + return err + } + if err := writeDomainAudit(ctx, tx, r.driver, "domain.dns_account", item.ID, item.ProjectID, actorID, "创建域名时绑定 DNS 账户"); err != nil { + return err + } + } return commitProjectDomain(tx, "提交域名创建事务失败") } @@ -77,6 +89,9 @@ func (r *SQLProjectMemberRepository) UpdateProjectDomain(ctx context.Context, ac return err } item.ProjectID = stored.ProjectID + if err := r.rejectActiveDNSLease(ctx, tx, "domain_id", item.ID); err != nil { + return err + } if err := r.validateDomainPathsInTransaction(ctx, tx, item); err != nil { return err } @@ -133,6 +148,9 @@ func (r *SQLProjectMemberRepository) DeleteProjectDomain(ctx context.Context, ac if busy { return ErrProjectDomainBusy } + if err := r.rejectActiveDNSLease(ctx, tx, "domain_id", domainID); err != nil { + return err + } if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil { return fmt.Errorf("删除域名服务器关联失败:%w", err) } diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index 0ee6256..ccfaa7e 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -1,5 +1,17 @@ # DNS 多账户整改任务清单 +## 2026年10月4日07:01 执行租约接入任务 + +沿用已确认的独立账户完整生命周期需求,本批不处理 Windows、不变更正式配置。检查点 `73dba28` 已提交推送。 + +- [~] 增加持久化执行租约,固定域名、绑定、账户版本、秘密引用及真实任务尝试;手工任务实时校验权限,自动续期必须核验真实调度身份。 +- [ ] 接通签发、续期和重试;独立账户不读取系统凭据,DNS 操作使用固定账户,取消后仍允许原凭据清理挑战记录。 +- [ ] 在活动租约期间拒绝账户变更和绑定切换;释放失败或挑战清理不确定时保留租约,不按时间到期猜测可以回收秘密。 +- [ ] 检查新建域名与初始账户绑定之间的执行窗口,避免自动任务使用历史系统账户。 +- [ ] 补齐迁移、权限、并发、取消和清理失败测试,再在第一台进行 Linux 与三库验证;更新相关清单和真实测试结果。 + +租约记录是不可变执行快照,不随任务历史清理级联删除。崩溃遗留租约的外部 TXT 核对及安全回收仍属完整 P1-03 门禁,不能仅凭任务终态或租约年龄释放。 + ## 2026年10月4日06:51 域名账户选择与 Linux 复审结果 - [✓] 第一台干净 `412c51f` 的 `0630` 单元 06:45:57 结束,inactive/success、退出码零;Linux 全量、vet 通过。单轮竞态仓储 17.076 秒、HTTP 23.331 秒;SQLite、MySQL、PostgreSQL 项目目录均实际运行通过,PostgreSQL 非跳过。 diff --git a/frontend/src/views/Dashboard/DomainDNSAccount.js b/frontend/src/views/Dashboard/DomainDNSAccount.js index ef66e16..709649a 100644 --- a/frontend/src/views/Dashboard/DomainDNSAccount.js +++ b/frontend/src/views/Dashboard/DomainDNSAccount.js @@ -141,6 +141,25 @@ export function useDomainDNSAccount({ fetchAdmin, form, saving }) { }); } + function acceptCreated(snapshot, result) { + if (snapshot.generation !== generation || snapshot.domainID || !snapshot.accountID || + !validID(result?.id) || !validID(result?.dns_binding_id) || + result.project_id !== snapshot.projectID || result.dns_account_id !== snapshot.accountID || + result.dns_provider !== snapshot.provider) { + throw new Error("域名初始 DNS 绑定响应不完整,请关闭表单并刷新列表核对"); + } + binding = { + id: result.dns_binding_id, domain_id: result.id, + project_id: result.project_id, dns_account_id: result.dns_account_id, + }; + target.domainID = result.id; + dnsAccountID.value = result.dns_account_id; + dnsBound.value = true; + boundProvider = snapshot.provider; + form.value.dns_provider = boundProvider; + return Object.freeze({ ...snapshot, domainID: result.id, expectedID: result.dns_binding_id, changed: false }); + } + async function persist(snapshot, domainID) { if (snapshot.generation !== generation) return null; if (!validID(domainID) || (snapshot.domainID && domainID !== snapshot.domainID)) { @@ -182,6 +201,6 @@ export function useDomainDNSAccount({ fetchAdmin, form, saving }) { return { dnsAccountID, dnsBindingLoading, dnsBindingReady, dnsBindingError, dnsBound, domainDNSAccountOptions, dnsUnavailableSelection, reloadDomainDNS, - open, cancel, prepare, persist, + open, cancel, prepare, acceptCreated, persist, }; } diff --git a/frontend/src/views/Dashboard/DomainDNSAccount.test.js b/frontend/src/views/Dashboard/DomainDNSAccount.test.js index 17d3b8c..c0b6841 100644 --- a/frontend/src/views/Dashboard/DomainDNSAccount.test.js +++ b/frontend/src/views/Dashboard/DomainDNSAccount.test.js @@ -39,7 +39,12 @@ async function mountForm(t, { accounts = [account], binding = emptyBinding, inte return Response.json(currentBinding); } if (path.endsWith("/servers")) return Response.json({ items: [] }); - return Response.json({ ...item, ...JSON.parse(options.body) }); + const body = JSON.parse(options.body); + if (options.method === "POST" && body.dns_account_id) { + currentBinding = { ...emptyBinding, id: "binding-new", dns_account_id: body.dns_account_id }; + return Response.json({ ...item, ...body, dns_binding_id: currentBinding.id }); + } + return Response.json({ ...item, ...body }); }); return { ...mounted, deps, requests, writes: () => requests.filter((request) => request.method) }; } @@ -50,17 +55,16 @@ async function create(state) { state.dnsAccountID.value = account.id; } -test("新域名先保存资料再显式绑定账户,提交真实版本和空绑定令牌", async (t) => { +test("新域名与初始 DNS 账户原子保存,提交真实账户版本且不重复绑定", async (t) => { const { state, deps, writes } = await mountForm(t); await create(state); await state.saveDomain(); assert.deepEqual(writes().map((request) => request.path), [ - "/api/v1/domains", `/api/v1/domains/${item.id}/dns-account`, `/api/v1/domains/${item.id}/servers`, + "/api/v1/domains", `/api/v1/domains/${item.id}/servers`, ]); assert.equal(JSON.parse(writes()[0].body).dns_provider, "aliyun"); - assert.deepEqual(JSON.parse(writes()[1].body), { - dns_account_id: account.id, expected_binding_id: "", account_revision: 3, - }); + assert.equal(JSON.parse(writes()[0].body).dns_account_id, account.id); + assert.equal(JSON.parse(writes()[0].body).dns_account_revision, 3); assert.equal(deps.domains.value[0].dns_binding_id, "binding-new"); assert.equal(state.showForm.value, false); }); @@ -161,7 +165,8 @@ for (const failure of ["network", "conflict", "forbidden", "server", "invalid-js ...(failure === "wrong-domain" ? { domain_id: "other-domain" } : { dns_account_id: "other-account" }), }); } }); - await create(state); + await state.openEdit(item); + state.dnsAccountID.value = account.id; await state.saveDomain(); assert.equal(writes().length, 2); assert.equal(state.dnsBindingReady.value, false); @@ -181,33 +186,35 @@ test("绑定已成功而服务器关联失败时只重试服务器,不重新 } }); await create(state); await state.saveDomain(); - assert.equal(writes().length, 3); + assert.equal(writes().length, 2); assert.equal(state.showForm.value, true); await state.saveDomain(); - assert.equal(writes().length, 4); - assert.ok(writes()[3].path.endsWith("/servers")); + assert.equal(writes().length, 3); + assert.ok(writes()[2].path.endsWith("/servers")); assert.equal(state.showForm.value, false); }); test("冲突后重新读取实际绑定,确认状态而不是自动重放旧选择", async (t) => { let conflict = true; + let reads = 0; const { state, writes } = await mountForm(t, { intercept: (path, options) => { if (!path.endsWith("/dns-account")) return null; if (options.method && conflict) { conflict = false; return Response.json({}, { status: 409 }); } - if (!options.method) return Response.json(originalBinding); + if (!options.method) return Response.json(++reads === 1 ? emptyBinding : originalBinding); return null; } }); - await create(state); + await state.openEdit(item); + state.dnsAccountID.value = account.id; await state.saveDomain(); await state.reloadDomainDNS(); assert.equal(state.dnsBindingReady.value, true); assert.equal(state.dnsAccountID.value, account.id); await state.saveDomain(); assert.equal(writes().filter((request) => request.path.endsWith("/dns-account")).length, 1); - assert.equal(writes().filter((request) => request.method === "POST").length, 1); + assert.equal(writes().filter((request) => request.method === "POST").length, 0); }); test("切换或卸载后迟到的账户响应不能恢复旧表单状态", async (t) => { @@ -243,10 +250,11 @@ test("保存中固定目标账户和版本,拒绝双击、关闭、重读和 await state.reloadDomainDNS(); await state.saveDomain(); assert.equal(writes().length, 1); - pending.resolve(Response.json(item)); + pending.resolve(Response.json({ ...item, dns_account_id: account.id, dns_binding_id: "binding-new" })); await saving; - assert.equal(writes()[1].path, `/api/v1/domains/${item.id}/dns-account`); - assert.equal(JSON.parse(writes()[1].body).dns_account_id, account.id); + assert.equal(writes()[1].path, `/api/v1/domains/${item.id}/servers`); + assert.equal(JSON.parse(writes()[0].body).dns_account_id, account.id); + assert.equal(JSON.parse(writes()[0].body).dns_account_revision, 3); }); test("卸载后返回的资料响应不继续写 DNS 绑定或服务器", async (t) => { @@ -259,3 +267,21 @@ test("卸载后返回的资料响应不继续写 DNS 绑定或服务器", async await saving; assert.equal(writes().length, 1); }); + +for (const mismatch of ["missing-binding", "wrong-account", "wrong-provider"]) { + test(`初始绑定响应异常时禁止继续执行或重复创建:${mismatch}`, async (t) => { + const result = { ...item, dns_account_id: account.id, dns_binding_id: "binding-new" }; + if (mismatch === "missing-binding") delete result.dns_binding_id; + if (mismatch === "wrong-account") result.dns_account_id = "other"; + if (mismatch === "wrong-provider") result.dns_provider = "cloudflare"; + const { state, writes } = await mountForm(t, { + intercept: (path) => path === "/api/v1/domains" ? Response.json(result) : null, + }); + await create(state); + await state.saveDomain(); + assert.equal(state.domainSaveUncertain.value, true); + assert.match(state.formError.value, /初始 DNS 绑定/); + await state.saveDomain(); + assert.equal(writes().length, 1); + }); +} diff --git a/frontend/src/views/Dashboard/DomainForm.js b/frontend/src/views/Dashboard/DomainForm.js index 895a13d..49fd2c9 100644 --- a/frontend/src/views/Dashboard/DomainForm.js +++ b/frontend/src/views/Dashboard/DomainForm.js @@ -179,7 +179,9 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not response = await fetchAdmin(existingId ? `/api/v1/domains/${existingId}` : "/api/v1/domains", { method: existingId ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, - body: serialized, + body: !existingId && dnsSnapshot.accountID ? JSON.stringify({ + ...payload, dns_account_id: dnsSnapshot.accountID, dns_account_revision: dnsSnapshot.revision, + }) : serialized, }); } catch { if (currentVersion !== version) return; @@ -200,6 +202,14 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not domainSaveUncertain.value = true; throw new Error("域名保存响应不完整,请关闭表单并刷新列表确认,暂不重复提交"); } + if (!existingId && dnsSnapshot.accountID) { + try { + dnsSnapshot = dnsAccount.acceptCreated(dnsSnapshot, result); + } catch (err) { + domainSaveUncertain.value = true; + throw err; + } + } editingId.value = result.id; targetID = result.id; form.value.project_id = result.project_id;