package repository import ( "context" "database/sql" "encoding/json" "errors" "slices" "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 stored domain.Domain var sansJSON string if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, challenge_type, updated_at FROM domains WHERE id = ?`), item.ID). Scan(&stored.Name, &stored.CertificateType, &sansJSON, &stored.AutoRenew, &stored.ACMEEnvironment, &stored.DNSProvider, &stored.ChallengeType, &stored.UpdatedAt); err != nil { return domain.DNSExecutionLease{}, err } // 时间戳可能只有秒精度,必须核对实际配置;SAN 按解码后的数组比较。 if json.Unmarshal([]byte(sansJSON), &stored.SANs) != nil || !stored.UpdatedAt.Equal(item.UpdatedAt) || stored.Name != item.Name || stored.CertificateType != item.CertificateType || !slices.Equal(stored.SANs, item.SANs) || stored.AutoRenew != item.AutoRenew || stored.ACMEEnvironment != item.ACMEEnvironment || stored.DNSProvider != item.DNSProvider || stored.ChallengeType != item.ChallengeType || stored.DNSProvider != account.Provider { 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 }