保存执行租约与原子绑定复核前检查点
This commit is contained in:
parent
73dba287d9
commit
7a3b78daa3
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
82
backend/internal/certificate/dns_execution.go
Normal file
82
backend/internal/certificate/dns_execution.go
Normal 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
|
||||
}
|
||||
198
backend/internal/certificate/dns_execution_test.go
Normal file
198
backend/internal/certificate/dns_execution_test.go
Normal 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("拒绝前不应产生副作用")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
45
backend/internal/database/dns_execution_migration_test.go
Normal file
45
backend/internal/database/dns_execution_migration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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 {
|
||||
|
||||
22
backend/internal/domain/dns_execution.go
Normal file
22
backend/internal/domain/dns_execution.go
Normal 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:"-"`
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
65
backend/internal/httpapi/domain_initial_dns_test.go
Normal file
65
backend/internal/httpapi/domain_initial_dns_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
185
backend/internal/repository/dns_execution_repository.go
Normal file
185
backend/internal/repository/dns_execution_repository.go
Normal 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
|
||||
}
|
||||
325
backend/internal/repository/dns_execution_repository_test.go
Normal file
325
backend/internal/repository/dns_execution_repository_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 = ?
|
||||
|
||||
40
backend/internal/repository/domain_initial_dns.go
Normal file
40
backend/internal/repository/domain_initial_dns.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 非跳过。
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user