保存执行租约与原子绑定复核前检查点

This commit is contained in:
xuans 2026-10-04 07:31:40 +08:00
parent 73dba287d9
commit 7a3b78daa3
28 changed files with 1236 additions and 48 deletions

View File

@ -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()

View File

@ -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) {

View File

@ -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
}

View File

@ -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("拒绝前不应产生副作用")
}
}

View File

@ -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 {

View File

@ -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)
}
}
})
}
}

View File

@ -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"
}

View File

@ -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)
);

View File

@ -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);

View File

@ -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);

View File

@ -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 {

View File

@ -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:"-"`
}

View File

@ -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) {

View File

@ -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)

View File

@ -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)

View File

@ -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)
}
}
})
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}
})
})
}
}

View File

@ -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 = ?

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 非跳过。

View File

@ -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,
};
}

View File

@ -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);
});
}

View File

@ -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;