diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a2f4e28..5ae0f88 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -14,6 +14,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/database" "git.pcux.cn/xuans/Scert/backend/internal/deployment" "git.pcux.cn/xuans/Scert/backend/internal/dnsprovider" + "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/firewall" "git.pcux.cn/xuans/Scert/backend/internal/httpapi" "git.pcux.cn/xuans/Scert/backend/internal/httpchallenge" @@ -105,6 +106,25 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string return nil, fmt.Errorf("初始化超级管理员用户失败:%w", err) } } + if cfg.DNS.CredentialRef != "" && cfg.DNS.Provider != "" { + legacyProjectID := cfg.DNS.ProjectID + if legacyProjectID == "" { + legacyProjectID = domain.DefaultProjectID + } + legacyOwnerID := cfg.DNS.OwnerUserID + if legacyOwnerID == "" { + legacyOwnerID = "primary-super-admin" + } + legacyImporter := repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver) + if err := legacyImporter.ImportLegacyDNSAccount(ctx, domain.DNSAccount{ + ID: "legacy-system-dns", Name: "旧系统 DNS 账户", ProjectID: legacyProjectID, + OwnerUserID: legacyOwnerID, Provider: cfg.DNS.Provider, CredentialRef: cfg.DNS.CredentialRef, + }); err != nil { + _ = db.Close() + _ = logger.Close() + return nil, fmt.Errorf("导入旧 DNS 账户失败:%w", err) + } + } taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver) transactionRepo := repository.NewSQLDeploymentTransactionRepository(db.DB, runtimeDatabaseConfig.Driver) deploymentService := deployment.NewServiceWithJournal(deployment.SSHFactory{Secrets: secretStore}, transactionRepo) diff --git a/backend/internal/repository/dns_account_repository.go b/backend/internal/repository/dns_account_repository.go index a80db08..41ca3dd 100644 --- a/backend/internal/repository/dns_account_repository.go +++ b/backend/internal/repository/dns_account_repository.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/domain" @@ -12,6 +13,8 @@ import ( var ErrDNSAccountConflict = errors.New("DNS 账户已变更,请刷新后重试") +var ErrLegacyDNSAccountConflict = errors.New("旧系统 DNS 账户标识已被其他账户占用") + // DNSAccountRepository 的公开读取不返回凭据引用;秘密读取只能使用独立的使用权限入口。 // 删除及运行租约将在域名绑定接入后提供,当前不提供无引用保护的删除方法。 type DNSAccountRepository interface { @@ -26,13 +29,75 @@ type DNSAccountRepository interface { RecordDNSAccountVerification(context.Context, string, string, int64, bool) (domain.DNSAccount, error) } +// DNSLegacyAccountImporter 只负责把旧配置登记为可追踪的账户元数据。 +// 它不读取或复制秘密,也不把旧验证状态冒充为新账户已在线验证。 +type DNSLegacyAccountImporter interface { + ImportLegacyDNSAccount(context.Context, domain.DNSAccount) error +} + var _ DNSAccountRepository = (*SQLProjectMemberRepository)(nil) +var _ DNSLegacyAccountImporter = (*SQLProjectMemberRepository)(nil) const dnsAccountColumns = `dns_accounts.id, dns_accounts.name, dns_accounts.project_id, dns_accounts.owner_user_id, dns_accounts.provider, dns_accounts.credential_ref, dns_accounts.status, dns_accounts.verification_state, dns_accounts.revision, dns_accounts.created_at, dns_accounts.updated_at` +// ImportLegacyDNSAccount 将旧的单账户配置幂等登记到独立账户表。 +// 已存在同凭据引用时保持现有记录不变;同一固定 ID 被其他账户占用时拒绝启动迁移,避免覆盖用户修改。 +func (r *SQLProjectMemberRepository) ImportLegacyDNSAccount(ctx context.Context, item domain.DNSAccount) error { + if err := r.ensureDB(); err != nil { + return err + } + item.Name = strings.TrimSpace(item.Name) + item.Status = domain.DNSAccountActive + item.VerificationState = domain.DNSAccountUnverified + item.Revision = 1 + if err := item.Validate(); err != nil { + return err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var existingID string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM dns_accounts WHERE credential_ref = ?`+r.resourceWriteLock()), item.CredentialRef).Scan(&existingID) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + var existingRef string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT credential_ref FROM dns_accounts WHERE id = ?`+r.resourceWriteLock()), item.ID).Scan(&existingRef) + if err == nil { + if existingRef == item.CredentialRef { + return nil + } + return ErrLegacyDNSAccountConflict + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + var projectID string + if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM projects WHERE id = ?`), item.ProjectID).Scan(&projectID); err != nil { + return fmt.Errorf("旧 DNS 账户所属项目不存在:%w", err) + } + now := time.Now().UTC() + item.CreatedAt, item.UpdatedAt = now, now + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO dns_accounts + (id, name, project_id, owner_user_id, provider, credential_ref, status, verification_state, revision, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.ProjectID, item.OwnerUserID, + item.Provider, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.CreatedAt, item.UpdatedAt); err != nil { + return errors.New("登记旧 DNS 账户失败") + } + if err := writeDNSAccountAudit(ctx, tx, r.driver, item.OwnerUserID, item, "dns_account.import_legacy"); err != nil { + return err + } + return tx.Commit() +} + func scanDNSAccount(row scanner) (domain.DNSAccount, error) { var item domain.DNSAccount err := row.Scan(&item.ID, &item.Name, &item.ProjectID, &item.OwnerUserID, &item.Provider, diff --git a/backend/internal/repository/dns_account_repository_test.go b/backend/internal/repository/dns_account_repository_test.go index af9f169..b9930be 100644 --- a/backend/internal/repository/dns_account_repository_test.go +++ b/backend/internal/repository/dns_account_repository_test.go @@ -41,6 +41,54 @@ func TestDNSAccountRepository(t *testing.T) { } } +func TestImportLegacyDNSAccountIsIdempotentAndPreservesExistingChanges(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "legacy-dns.db") + db, err := database.Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + repo := NewSQLProjectMemberRepository(db.DB, "sqlite") + item := domain.DNSAccount{ + ID: "legacy-system-dns", Name: "旧系统 DNS 账户", ProjectID: domain.DefaultProjectID, + OwnerUserID: "primary-super-admin", Provider: "aliyun", CredentialRef: "secret://dns/legacy", + } + if err := repo.ImportLegacyDNSAccount(t.Context(), item); err != nil { + t.Fatal(err) + } + var name, status, verification, reference string + var revision int + if err := db.DB.QueryRowContext(t.Context(), `SELECT name,status,verification_state,credential_ref,revision FROM dns_accounts WHERE id = ?`, item.ID). + Scan(&name, &status, &verification, &reference, &revision); err != nil { + t.Fatal(err) + } + if name != item.Name || status != domain.DNSAccountActive || verification != domain.DNSAccountUnverified || reference != item.CredentialRef || revision != 1 { + t.Fatalf("旧账户初次导入状态错误:%s %s %s %s %d", name, status, verification, reference, revision) + } + if _, err := db.DB.ExecContext(t.Context(), `UPDATE dns_accounts SET name = ?, status = ?, verification_state = ?, revision = ? WHERE id = ?`, "用户修改后的账户", domain.DNSAccountDisabled, domain.DNSAccountVerified, 8, item.ID); err != nil { + t.Fatal(err) + } + if err := repo.ImportLegacyDNSAccount(t.Context(), item); err != nil { + t.Fatal(err) + } + if err := db.DB.QueryRowContext(t.Context(), `SELECT name,status,verification_state,revision FROM dns_accounts WHERE id = ?`, item.ID). + Scan(&name, &status, &verification, &revision); err != nil { + t.Fatal(err) + } + if name != "用户修改后的账户" || status != domain.DNSAccountDisabled || verification != domain.DNSAccountVerified || revision != 8 { + t.Fatal("重复导入覆盖了现有账户修改") + } + conflict := item + conflict.CredentialRef = "secret://dns/other" + if err := repo.ImportLegacyDNSAccount(t.Context(), conflict); !errors.Is(err, ErrLegacyDNSAccountConflict) { + t.Fatalf("固定旧账户标识冲突未拒绝:%v", err) + } +} + func checkDNSAccountRepository(t *testing.T, db, admin *sql.DB, driver string) { t.Helper() repo := NewSQLProjectMemberRepository(db, driver)