From 7a6da6b2c96dd280ea8ff2e734c6a02b50db6cc2 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 05:35:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=9F=9F=E5=90=8D=20DNS=20?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E7=BB=91=E5=AE=9A=E4=B8=8E=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificate/dns_account_binding_test.go | 24 ++ backend/internal/certificate/service.go | 4 + .../domain_dns_account_migration_test.go | 200 ++++++++++++++ backend/internal/database/migrate.go | 14 +- .../mysql/0028_domain_dns_accounts.sql | 13 + .../postgres/0028_domain_dns_accounts.sql | 11 + .../sqlite/0028_domain_dns_accounts.sql | 11 + .../mysql_deployment_migration_test.go | 2 +- .../database/mysql_domain_dns_migration.go | 115 ++++++++ .../database/sqlite_domain_dns_constraints.go | 43 +++ .../internal/domain/dns_account_binding.go | 12 + backend/internal/domain/domain.go | 2 + .../repository/dns_account_repository.go | 13 + .../domain_dns_account_repository.go | 152 +++++++++++ .../domain_dns_account_repository_test.go | 252 ++++++++++++++++++ .../repository/project_domain_repository.go | 6 + .../internal/repository/project_resources.go | 4 +- .../repository/sql_domain_repository.go | 40 +-- .../repository/sqlite_write_conflict_test.go | 25 ++ doc/DNS多账户整改任务清单.md | 12 +- doc/Linux生产流程测试记录.md | 8 + doc/Scert开发任务清单.md | 7 + doc/Scert整改任务执行清单.md | 7 + doc/Scert整改合并优化清单.md | 7 + doc/核心能力任务规格.md | 6 + 25 files changed, 966 insertions(+), 24 deletions(-) create mode 100644 backend/internal/certificate/dns_account_binding_test.go create mode 100644 backend/internal/database/domain_dns_account_migration_test.go create mode 100644 backend/internal/database/migrations/mysql/0028_domain_dns_accounts.sql create mode 100644 backend/internal/database/migrations/postgres/0028_domain_dns_accounts.sql create mode 100644 backend/internal/database/migrations/sqlite/0028_domain_dns_accounts.sql create mode 100644 backend/internal/database/mysql_domain_dns_migration.go create mode 100644 backend/internal/database/sqlite_domain_dns_constraints.go create mode 100644 backend/internal/domain/dns_account_binding.go create mode 100644 backend/internal/repository/domain_dns_account_repository.go create mode 100644 backend/internal/repository/domain_dns_account_repository_test.go create mode 100644 backend/internal/repository/sqlite_write_conflict_test.go diff --git a/backend/internal/certificate/dns_account_binding_test.go b/backend/internal/certificate/dns_account_binding_test.go new file mode 100644 index 0000000..b69be18 --- /dev/null +++ b/backend/internal/certificate/dns_account_binding_test.go @@ -0,0 +1,24 @@ +package certificate + +import ( + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func TestIndependentDNSAccountNeverFallsBackToSystemCredentials(t *testing.T) { + item, err := domain.NewDomain("bound.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.DNSAccountID, item.DNSBindingID = "independent-account", "binding" + repo := &serviceRepository{item: item} + service := NewService(certificateTestConfig(t.TempDir()), repo, nil, nil, serviceAccounts{}) + if _, err := service.Issue(t.Context(), item.ID); err == nil || !strings.Contains(err.Error(), "执行租约") { + t.Fatal("未接入执行租约时不能回退系统凭据", err) + } + if repo.updateCalls != 0 { + t.Fatal("拒绝执行后不应变更域名状态") + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 3766d03..d3d6ebc 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -150,6 +150,10 @@ 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 != "" { + return IssueResult{}, errors.New("独立 DNS 账户执行租约尚未就绪,拒绝使用系统凭据替代") + } domains, err := requestedDomains(item) if err != nil { return IssueResult{}, err diff --git a/backend/internal/database/domain_dns_account_migration_test.go b/backend/internal/database/domain_dns_account_migration_test.go new file mode 100644 index 0000000..8e2ce76 --- /dev/null +++ b/backend/internal/database/domain_dns_account_migration_test.go @@ -0,0 +1,200 @@ +package database + +import ( + "database/sql" + "fmt" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/postgrestest" +) + +func TestDomainDNSAccountMigration(t *testing.T) { + for _, driver := range []string{"sqlite", "mysql", "postgres"} { + t.Run(driver, func(t *testing.T) { + var cfg config.DatabaseConfig + switch driver { + case "sqlite": + cfg = sqliteBusinessUpgradeConfig(t) + case "mysql": + cfg = mysqltest.New(t).Database(t) + case "postgres": + cfg = postgrestest.New(t).Database(t) + } + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL(driver)); err != nil { + t.Fatal(err) + } + for version := 1; version <= 27; version++ { + if err := applyMigration(t.Context(), db.DB, driver, version); err != nil { + t.Fatal(err) + } + } + seedDomainDNSMigration(t, db.DB) + if err := Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + checkDomainDNSConstraints(t, db.DB, driver) + if err := Migrate(t.Context(), db.DB, driver); err != nil { + t.Fatal(err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM domain_dns_accounts WHERE id='binding' AND dns_account_id='account-a'`).Scan(&count); err != nil || count != 1 { + t.Fatal("重入迁移改变已有绑定", err) + } + }) + } +} + +func TestSQLiteDomainDNSAccountGuards(t *testing.T) { + for _, foreignKeys := range []int{0, 1} { + for _, recursive := range []int{0, 1} { + t.Run(fmt.Sprintf("foreign_keys_%d_recursive_%d", foreignKeys, recursive), func(t *testing.T) { + cfg := sqliteBusinessUpgradeConfig(t) + cfg.MaxOpenConns, cfg.MaxIdleConns = 1, 1 + db, err := Open(t.Context(), cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, statement := range []string{fmt.Sprintf("PRAGMA foreign_keys=%d", foreignKeys), fmt.Sprintf("PRAGMA recursive_triggers=%d", recursive)} { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + seedDomainDNSMigration(t, db.DB) + checkDomainDNSConstraints(t, db.DB, "sqlite") + for name, want := range map[string]int{"foreign_keys": foreignKeys, "recursive_triggers": recursive} { + var value int + if err := db.DB.QueryRowContext(t.Context(), "PRAGMA "+name).Scan(&value); err != nil || value != want { + t.Fatal("迁移不应修改连接设置", name, err) + } + } + }) + } + } +} + +func seedDomainDNSMigration(t *testing.T, db *sql.DB) { + t.Helper() + for _, statement := range []string{ + `INSERT INTO projects (id,name,description) VALUES ('binding-other','另一个项目','')`, + `INSERT INTO domains (id,name,certificate_type,sans_json,status,project_id) VALUES ('domain-a','bound.example.test','single','[]','valid','default')`, + `INSERT INTO domains (id,name,certificate_type,sans_json,status,project_id) VALUES ('domain-b','other.example.test','single','[]','valid','binding-other')`, + `INSERT INTO dns_accounts VALUES ('account-a','账户','default','old-owner','cloudflare','secret://dns/account-a','active','verified',8,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, + `INSERT INTO dns_accounts VALUES ('account-b','其他账户','binding-other','old-owner','cloudflare','secret://dns/account-b','active','verified',2,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, + } { + if _, err := db.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } +} + +func checkDomainDNSConstraints(t *testing.T, db *sql.DB, driver string) { + t.Helper() + if _, err := db.ExecContext(t.Context(), `INSERT INTO domain_dns_accounts VALUES ('domain-a','binding','default','account-a',CURRENT_TIMESTAMP)`); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `INSERT INTO domain_dns_accounts VALUES ('domain-b','cross-domain','default','account-a',CURRENT_TIMESTAMP)`, + `INSERT INTO domain_dns_accounts VALUES ('domain-b','cross-account','binding-other','account-a',CURRENT_TIMESTAMP)`, + `INSERT INTO domain_dns_accounts VALUES ('missing','orphan','default','account-a',CURRENT_TIMESTAMP)`, + `UPDATE domain_dns_accounts SET dns_account_id='account-b' WHERE id='binding'`, + `UPDATE domain_dns_accounts SET domain_id='domain-b' WHERE id='binding'`, + `UPDATE domain_dns_accounts SET project_id='binding-other' WHERE id='binding'`, + `DELETE FROM dns_accounts WHERE id='account-a'`, + `DELETE FROM domains WHERE id='domain-a'`, + `UPDATE dns_accounts SET id='moved' WHERE id='account-a'`, + `UPDATE dns_accounts SET project_id='binding-other' WHERE id='account-a'`, + `UPDATE domains SET id='moved' WHERE id='domain-a'`, + `UPDATE domains SET project_id='binding-other' WHERE id='domain-a'`, + } { + if _, err := db.ExecContext(t.Context(), statement); err == nil { + t.Fatal("数据库未阻止破坏引用:", statement) + } + } + if driver == "sqlite" { + for _, statement := range []string{ + `INSERT OR REPLACE INTO domains (id,name,certificate_type,sans_json,status) VALUES ('domain-a','new.example.test','single','[]','pending')`, + `INSERT OR REPLACE INTO domains (id,name,certificate_type,sans_json,status) VALUES ('new-domain','bound.example.test','single','[]','pending')`, + `INSERT OR REPLACE INTO dns_accounts VALUES ('account-a','替换','default','owner','cloudflare','secret://dns/new','active','verified',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, + `INSERT OR REPLACE INTO dns_accounts VALUES ('new-account','替换','default','owner','cloudflare','secret://dns/account-a','active','verified',1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, + } { + if _, err := db.ExecContext(t.Context(), statement); err == nil { + t.Fatal("替换操作破坏绑定父记录", statement) + } + } + } + var revision int + var status string + if err := db.QueryRowContext(t.Context(), `SELECT revision FROM dns_accounts WHERE id='account-a'`).Scan(&revision); err != nil || revision != 8 { + t.Fatal("约束拒绝改变账户数据", err) + } + if err := db.QueryRowContext(t.Context(), `SELECT status FROM domains WHERE id='domain-a'`).Scan(&status); err != nil || status != "valid" { + t.Fatal("升级或拒绝改变域名状态", err) + } +} + +func TestMySQLDomainDNSAccountInterruptedMigration(t *testing.T) { + f := startRealMySQL(t) + for _, boundary := range []string{"before_create", "after_create", "after_index", "after_domain_fk", "after_account_fk", "with_binding", "bad_column", "bad_index", "bad_foreign_key", "extra_foreign_key"} { + t.Run(boundary, func(t *testing.T) { + db := f.database(t) + mysqlPrepareVersion(t, db.DB, 27) + statements := mysqlDeploymentStatements(t, 28) + if boundary != "before_create" { + mysqlDeploymentExec(t, db.DB, statements[0]) + } + if boundary == "after_index" || boundary == "after_domain_fk" || boundary == "after_account_fk" || boundary == "with_binding" { + mysqlDeploymentExec(t, db.DB, statements[1]) + } + if boundary == "after_domain_fk" || boundary == "after_account_fk" || boundary == "with_binding" { + mysqlDeploymentExec(t, db.DB, statements[2]) + } + if boundary == "after_account_fk" || boundary == "with_binding" { + mysqlDeploymentExec(t, db.DB, statements[3]) + } + conflict := true + switch boundary { + case "bad_column": + mysqlDeploymentExec(t, db.DB, `ALTER TABLE domain_dns_accounts MODIFY COLUMN id VARCHAR(36) NULL`) + case "bad_index": + mysqlDeploymentExec(t, db.DB, `CREATE INDEX uq_dns_accounts_project_id ON dns_accounts (project_id, id)`) + case "bad_foreign_key": + mysqlDeploymentExec(t, db.DB, `ALTER TABLE domain_dns_accounts ADD CONSTRAINT fk_domain_dns_domain FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id,id) ON DELETE CASCADE`) + case "extra_foreign_key": + mysqlDeploymentExec(t, db.DB, `ALTER TABLE domain_dns_accounts ADD CONSTRAINT unexpected_domain_dns FOREIGN KEY (project_id) REFERENCES projects(id)`) + default: + conflict = false + } + if boundary == "with_binding" { + seedDomainDNSMigration(t, db.DB) + mysqlDeploymentExec(t, db.DB, `INSERT INTO domain_dns_accounts VALUES ('domain-a','binding','default','account-a',CURRENT_TIMESTAMP)`) + } + for range 2 { + 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 != 27) || (!conflict && version != latestMigrationVersion) { + t.Fatal("迁移版本提前登记", version, err) + } + } + if boundary == "with_binding" { + var account string + if err := db.DB.QueryRowContext(t.Context(), `SELECT dns_account_id FROM domain_dns_accounts WHERE id='binding'`).Scan(&account); err != nil || account != "account-a" { + t.Fatal("中断恢复改变原有绑定", err) + } + } + }) + } +} diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 598f8e5..065f6ff 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -28,6 +28,7 @@ import ( //go:embed migrations/sqlite/0025_recovery_reconciliation.sql migrations/mysql/0025_recovery_reconciliation.sql migrations/postgres/0025_recovery_reconciliation.sql //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 var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -52,7 +53,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 27 +const latestMigrationVersion = 28 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -92,6 +93,9 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { + if version == 28 { + return applyMySQLDomainDNSMigration(ctx, db, version, statements) + } if version == 27 { return applyMySQLDNSAccountMigration(ctx, db, version, statements) } @@ -181,6 +185,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return err } } + if driver == "sqlite" && version == 28 { + if err := installSQLiteDomainDNSConstraints(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" @@ -286,6 +296,8 @@ func migrationName(version int) string { return "domain_challenge" case 27: return "dns_accounts" + case 28: + return "domain_dns_accounts" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0028_domain_dns_accounts.sql b/backend/internal/database/migrations/mysql/0028_domain_dns_accounts.sql new file mode 100644 index 0000000..a4cbc53 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0028_domain_dns_accounts.sql @@ -0,0 +1,13 @@ +CREATE TABLE domain_dns_accounts ( + domain_id VARCHAR(36) NOT NULL PRIMARY KEY, + id VARCHAR(36) NOT NULL, + project_id VARCHAR(36) NOT NULL, + dns_account_id VARCHAR(36) NOT NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uq_domain_dns_accounts_id (id), + INDEX idx_domain_dns_accounts_domain (project_id, domain_id), + INDEX idx_domain_dns_accounts_account (project_id, dns_account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE UNIQUE INDEX uq_dns_accounts_project_id ON dns_accounts (project_id, id); +ALTER TABLE domain_dns_accounts ADD CONSTRAINT fk_domain_dns_domain FOREIGN KEY (project_id, domain_id) REFERENCES domains (project_id, id) ON DELETE RESTRICT; +ALTER TABLE domain_dns_accounts ADD CONSTRAINT fk_domain_dns_account FOREIGN KEY (project_id, dns_account_id) REFERENCES dns_accounts (project_id, id) ON DELETE RESTRICT; diff --git a/backend/internal/database/migrations/postgres/0028_domain_dns_accounts.sql b/backend/internal/database/migrations/postgres/0028_domain_dns_accounts.sql new file mode 100644 index 0000000..e044b81 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0028_domain_dns_accounts.sql @@ -0,0 +1,11 @@ +CREATE UNIQUE INDEX uq_dns_accounts_project_id ON dns_accounts (project_id, id); +CREATE TABLE domain_dns_accounts ( + domain_id TEXT NOT NULL PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + project_id TEXT NOT NULL, + dns_account_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id) ON DELETE RESTRICT, + FOREIGN KEY (project_id, dns_account_id) REFERENCES dns_accounts(project_id, id) ON DELETE RESTRICT +); +CREATE INDEX idx_domain_dns_accounts_account ON domain_dns_accounts (project_id, dns_account_id); diff --git a/backend/internal/database/migrations/sqlite/0028_domain_dns_accounts.sql b/backend/internal/database/migrations/sqlite/0028_domain_dns_accounts.sql new file mode 100644 index 0000000..cb5ff78 --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0028_domain_dns_accounts.sql @@ -0,0 +1,11 @@ +CREATE UNIQUE INDEX uq_dns_accounts_project_id ON dns_accounts (project_id, id); +CREATE TABLE domain_dns_accounts ( + domain_id TEXT NOT NULL PRIMARY KEY, + id TEXT NOT NULL UNIQUE, + project_id TEXT NOT NULL, + dns_account_id TEXT NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id) ON DELETE RESTRICT, + FOREIGN KEY (project_id, dns_account_id) REFERENCES dns_accounts(project_id, id) ON DELETE RESTRICT +); +CREATE INDEX idx_domain_dns_accounts_account ON domain_dns_accounts (project_id, dns_account_id); diff --git a/backend/internal/database/mysql_deployment_migration_test.go b/backend/internal/database/mysql_deployment_migration_test.go index 3e6e4de..f60b9e4 100644 --- a/backend/internal/database/mysql_deployment_migration_test.go +++ b/backend/internal/database/mysql_deployment_migration_test.go @@ -8,7 +8,7 @@ import ( ) func TestMySQLDeploymentShapeDefinitions(t *testing.T) { - for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24, 27} { + for _, version := range []int{15, 16, 17, 20, 21, 22, 23, 24, 27, 28} { statements := mysqlDeploymentStatements(t, version) shape, err := parseMySQLDeploymentShape(statements[0]) if err != nil || len(shape.Columns) == 0 || len(shape.Indexes) == 0 { diff --git a/backend/internal/database/mysql_domain_dns_migration.go b/backend/internal/database/mysql_domain_dns_migration.go new file mode 100644 index 0000000..fee9b6d --- /dev/null +++ b/backend/internal/database/mysql_domain_dns_migration.go @@ -0,0 +1,115 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// MySQL DDL 不支持事务回滚,每一步重入均检查真实结构;不覆盖冲突表或外键。 +func applyMySQLDomainDNSMigration(ctx context.Context, db *sql.DB, version int, statements []string) error { + if len(statements) != 4 { + return fmt.Errorf("域名 DNS 绑定迁移不完整") + } + shape, err := parseMySQLDeploymentShape(statements[0]) + if err != nil { + return err + } + if _, err := db.ExecContext(ctx, strings.Replace(statements[0], "CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)); err != nil { + return err + } + if err := validateMySQLDeploymentShape(ctx, db, shape); err != nil { + return err + } + var indexCount int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'dns_accounts' AND index_name = 'uq_dns_accounts_project_id'`).Scan(&indexCount); err != nil { + return err + } + if indexCount == 0 { + if _, err := db.ExecContext(ctx, statements[1]); err != nil { + return err + } + indexCount = 2 + } + var validIndex int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'dns_accounts' AND index_name = 'uq_dns_accounts_project_id' + AND non_unique = 0 AND sub_part IS NULL AND index_type = 'BTREE' AND is_visible = 'YES' + AND ((seq_in_index = 1 AND column_name = 'project_id') OR (seq_in_index = 2 AND column_name = 'id'))`).Scan(&validIndex); err != nil { + return err + } + if indexCount != 2 || validIndex != 2 { + return fmt.Errorf("DNS 账户项目唯一索引不符合预期") + } + existing, err := validateMySQLDomainDNSForeignKeys(ctx, db) + if err != nil { + return err + } + for i, name := range []string{"fk_domain_dns_domain", "fk_domain_dns_account"} { + if !existing[name] { + if _, err := db.ExecContext(ctx, statements[i+2]); err != nil { + return err + } + } + } + existing, err = validateMySQLDomainDNSForeignKeys(ctx, db) + if err != nil { + return err + } + if len(existing) != 2 { + return fmt.Errorf("域名 DNS 绑定外键不完整") + } + _, err = db.ExecContext(ctx, "INSERT INTO schema_migrations (version) VALUES (?)", version) + return err +} + +func validateMySQLDomainDNSForeignKeys(ctx context.Context, db *sql.DB) (map[string]bool, error) { + rows, err := db.QueryContext(ctx, `SELECT r.constraint_name, r.delete_rule, r.update_rule, + k.column_name, k.referenced_table_schema, k.referenced_table_name, k.referenced_column_name, + k.ordinal_position, DATABASE() + FROM information_schema.referential_constraints r JOIN information_schema.key_column_usage k + ON k.constraint_schema = r.constraint_schema AND k.table_name = r.table_name AND k.constraint_name = r.constraint_name + WHERE r.constraint_schema = DATABASE() AND r.table_name = 'domain_dns_accounts'`) + if err != nil { + return nil, err + } + defer rows.Close() + positions := make(map[string]map[int]bool) + for rows.Next() { + var name, onDelete, onUpdate, column, schema, table, target, currentSchema string + var position int + if err := rows.Scan(&name, &onDelete, &onUpdate, &column, &schema, &table, &target, &position, ¤tSchema); err != nil { + return nil, err + } + expectedTable, expectedColumn := "domains", "domain_id" + if name == "fk_domain_dns_account" { + expectedTable, expectedColumn = "dns_accounts", "dns_account_id" + } else if name != "fk_domain_dns_domain" { + return nil, fmt.Errorf("域名 DNS 绑定包含未知外键") + } + validColumn := position == 1 && column == "project_id" && target == "project_id" || position == 2 && column == expectedColumn && target == "id" + if !validColumn || table != expectedTable || schema != currentSchema || onDelete != "RESTRICT" || (onUpdate != "RESTRICT" && onUpdate != "NO ACTION") { + return nil, fmt.Errorf("域名 DNS 绑定外键与预期不一致") + } + if positions[name] == nil { + positions[name] = make(map[int]bool) + } + if positions[name][position] { + return nil, fmt.Errorf("域名 DNS 绑定外键列重复") + } + positions[name][position] = true + } + if err := rows.Err(); err != nil { + return nil, err + } + result := make(map[string]bool) + for name, columns := range positions { + if len(columns) != 2 { + return nil, fmt.Errorf("域名 DNS 绑定外键列不完整") + } + result[name] = true + } + return result, nil +} diff --git a/backend/internal/database/sqlite_domain_dns_constraints.go b/backend/internal/database/sqlite_domain_dns_constraints.go new file mode 100644 index 0000000..b44dc08 --- /dev/null +++ b/backend/internal/database/sqlite_domain_dns_constraints.go @@ -0,0 +1,43 @@ +package database + +import ( + "context" + "database/sql" + "fmt" +) + +// 即使用户关闭外键或递归触发器,仍阻止跨项目绑定和父记录替换造成悬空引用。 +func installSQLiteDomainDNSConstraints(ctx context.Context, tx *sql.Tx) error { + for _, operation := range []string{"INSERT", "UPDATE"} { + statement := fmt.Sprintf(`CREATE TRIGGER scert_domain_dns_%s BEFORE %s ON domain_dns_accounts + WHEN NOT EXISTS (SELECT 1 FROM domains WHERE id = NEW.domain_id AND project_id = NEW.project_id) + OR NOT EXISTS (SELECT 1 FROM dns_accounts WHERE id = NEW.dns_account_id AND project_id = NEW.project_id) + BEGIN SELECT RAISE(ABORT, 'domain DNS project mismatch'); END`, operation, operation) + if _, err := tx.ExecContext(ctx, statement); err != nil { + return err + } + } + for _, parent := range []struct{ table, column, unique string }{ + {"domains", "domain_id", "name"}, {"dns_accounts", "dns_account_id", "credential_ref"}, + } { + statements := []string{ + fmt.Sprintf(`CREATE TRIGGER scert_domain_dns_%s_delete BEFORE DELETE ON %s + WHEN EXISTS (SELECT 1 FROM domain_dns_accounts WHERE %s = OLD.id) + BEGIN SELECT RAISE(ABORT, 'domain DNS reference exists'); END`, parent.table, parent.table, parent.column), + fmt.Sprintf(`CREATE TRIGGER scert_domain_dns_%s_update BEFORE UPDATE OF id, project_id ON %s + WHEN (NEW.id <> OLD.id OR NEW.project_id <> OLD.project_id) + AND EXISTS (SELECT 1 FROM domain_dns_accounts WHERE %s = OLD.id) + BEGIN SELECT RAISE(ABORT, 'domain DNS reference exists'); END`, parent.table, parent.table, parent.column), + fmt.Sprintf(`CREATE TRIGGER scert_domain_dns_%s_replace BEFORE INSERT ON %s + WHEN EXISTS (SELECT 1 FROM domain_dns_accounts binding JOIN %s parent ON parent.id = binding.%s + WHERE parent.id = NEW.id OR parent.%s = NEW.%s) + BEGIN SELECT RAISE(ABORT, 'domain DNS replacement denied'); END`, parent.table, parent.table, parent.table, parent.column, parent.unique, parent.unique), + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return err + } + } + } + return nil +} diff --git a/backend/internal/domain/dns_account_binding.go b/backend/internal/domain/dns_account_binding.go new file mode 100644 index 0000000..cad655f --- /dev/null +++ b/backend/internal/domain/dns_account_binding.go @@ -0,0 +1,12 @@ +package domain + +import "time" + +// 每次绑定生成新标识;解除后重新绑定也不能让旧页面的写入条件再次成立。 +type DNSAccountBinding struct { + ID string `json:"id"` + DomainID string `json:"domain_id"` + ProjectID string `json:"project_id"` + DNSAccountID string `json:"dns_account_id"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/domain/domain.go b/backend/internal/domain/domain.go index c854de0..8af3f71 100644 --- a/backend/internal/domain/domain.go +++ b/backend/internal/domain/domain.go @@ -32,6 +32,8 @@ type Domain struct { 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"` diff --git a/backend/internal/repository/dns_account_repository.go b/backend/internal/repository/dns_account_repository.go index 08d95f4..6640ee1 100644 --- a/backend/internal/repository/dns_account_repository.go +++ b/backend/internal/repository/dns_account_repository.go @@ -222,6 +222,9 @@ func (r *SQLProjectMemberRepository) changeDNSAccount(ctx context.Context, actor result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE dns_accounts SET name = ?, credential_ref = ?, status = ?, verification_state = ?, revision = ?, updated_at = ? WHERE id = ? AND revision = ?`), item.Name, item.CredentialRef, item.Status, item.VerificationState, item.Revision, item.UpdatedAt, item.ID, revision) if err != nil { + if r.driver == "sqlite" && isSQLiteWriteConflict(err) { + return domain.DNSAccount{}, ErrDNSAccountConflict + } return domain.DNSAccount{}, errors.New("更新 DNS 账户失败,请检查凭据引用是否重复") } if count, err := result.RowsAffected(); err != nil || count != 1 { @@ -236,6 +239,16 @@ func (r *SQLProjectMemberRepository) changeDNSAccount(ctx context.Context, actor return publicDNSAccount(item), nil } +// 使用驱动错误码识别忙与锁冲突,不依赖可能包含秘密或本地化文本的错误消息。 +func isSQLiteWriteConflict(err error) bool { + var coded interface{ Code() int } + if !errors.As(err, &coded) { + return false + } + code := coded.Code() & 0xff + return code == 5 || code == 6 +} + func writeDNSAccountAudit(ctx context.Context, tx *sql.Tx, driver, actorID string, item domain.DNSAccount, action string) error { result, err := tx.ExecContext(ctx, Rebind(driver, `INSERT INTO audit_logs (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) diff --git a/backend/internal/repository/domain_dns_account_repository.go b/backend/internal/repository/domain_dns_account_repository.go new file mode 100644 index 0000000..bb1a7d5 --- /dev/null +++ b/backend/internal/repository/domain_dns_account_repository.go @@ -0,0 +1,152 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ( + ErrDNSBindingConflict = errors.New("域名 DNS 账户绑定已变更,请刷新后重试") + ErrDNSBindingBusy = errors.New("域名存在未结束的任务,暂不能变更 DNS 账户") + ErrDNSBindingInvalid = errors.New("域名验证方式或 DNS 账户状态不允许绑定") +) + +type DomainDNSAccountRepository interface { + GetDomainDNSAccountBinding(context.Context, string, string) (domain.DNSAccountBinding, error) + SetDomainDNSAccountBinding(context.Context, string, string, string, string, int64) (domain.DNSAccountBinding, error) +} + +var _ DomainDNSAccountRepository = (*SQLProjectMemberRepository)(nil) + +func (r *SQLProjectMemberRepository) GetDomainDNSAccountBinding(ctx context.Context, actorID, domainID string) (domain.DNSAccountBinding, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return domain.DNSAccountBinding{}, err + } + predicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead) + args = append(args, domainID) + var result domain.DNSAccountBinding + var id, accountID sql.NullString + var updated sql.NullTime + err := r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT domains.id, domains.project_id, binding.id, binding.dns_account_id, binding.updated_at + FROM domains LEFT JOIN domain_dns_accounts binding ON binding.domain_id = domains.id AND binding.project_id = domains.project_id + WHERE `+predicate+` AND domains.id = ?`), args...).Scan(&result.DomainID, &result.ProjectID, &id, &accountID, &updated) + if errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccountBinding{}, ErrProjectAccessDenied + } + if err != nil { + return domain.DNSAccountBinding{}, err + } + result.ID, result.DNSAccountID, result.UpdatedAt = id.String, accountID.String, updated.Time + return result, nil +} + +// 空账户表示显式解除;expectedID 为空只允许首次绑定。凭据版本和旧绑定同时参与校验。 +// 这里只变更配置,不授予签发权限;执行阶段必须再次获得实际凭据租约。 +func (r *SQLProjectMemberRepository) SetDomainDNSAccountBinding(ctx context.Context, actorID, domainID, accountID, expectedID string, accountRevision int64) (domain.DNSAccountBinding, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return domain.DNSAccountBinding{}, err + } + if !domain.ValidProjectID(domainID) || (accountID != "" && (!domain.ValidProjectID(accountID) || accountRevision < 1)) || (expectedID != "" && !domain.ValidProjectID(expectedID)) { + return domain.DNSAccountBinding{}, ErrDNSBindingInvalid + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return domain.DNSAccountBinding{}, err + } + defer tx.Rollback() + var result domain.DNSAccountBinding + result.DomainID = domainID + var challengeType string + if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT project_id, challenge_type FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&result.ProjectID, &challengeType); err != nil { + return domain.DNSAccountBinding{}, projectBindingReadError(err) + } + permissions := []domain.ProjectPermission{domain.PermissionCertificateManage} + if accountID != "" { + permissions = append(permissions, domain.PermissionDNSCredentialUse) + } + if err := r.authorizeProjectTransaction(ctx, tx, actorID, result.ProjectID, permissions...); err != nil { + return domain.DNSAccountBinding{}, err + } + var currentID string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM domain_dns_accounts WHERE domain_id = ?`+r.resourceWriteLock()), domainID).Scan(¤tID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccountBinding{}, err + } + if currentID != expectedID { + return domain.DNSAccountBinding{}, ErrDNSBindingConflict + } + // 与任务创建持有同一域名行锁,避免检查后插入新任务并使用旧绑定。 + var activeID string + err = tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id FROM tasks WHERE domain_id = ? + AND status IN ('queued', 'running', 'retry_wait', 'cancelling') LIMIT 1`+r.resourceWriteLock()), domainID).Scan(&activeID) + if err == nil { + return domain.DNSAccountBinding{}, ErrDNSBindingBusy + } + if !errors.Is(err, sql.ErrNoRows) { + return domain.DNSAccountBinding{}, err + } + provider := "" + if accountID != "" { + if challengeType != domain.ChallengeDNS01 { + return domain.DNSAccountBinding{}, ErrDNSBindingInvalid + } + var projectID, 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()), accountID).Scan(&projectID, &provider, &status, &verification, &revision); err != nil { + return domain.DNSAccountBinding{}, projectBindingReadError(err) + } + if projectID != result.ProjectID { + return domain.DNSAccountBinding{}, ErrProjectAccessDenied + } + if revision != accountRevision { + return domain.DNSAccountBinding{}, ErrDNSAccountConflict + } + if status != domain.DNSAccountActive || verification != domain.DNSAccountVerified { + return domain.DNSAccountBinding{}, ErrDNSBindingInvalid + } + result.ID, result.DNSAccountID = domain.NewID(), accountID + } + result.UpdatedAt = time.Now().UTC() + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_dns_accounts WHERE domain_id = ?`), domainID); err != nil { + return domain.DNSAccountBinding{}, err + } + if accountID != "" { + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO domain_dns_accounts (domain_id, id, project_id, dns_account_id, updated_at) + VALUES (?, ?, ?, ?, ?)`), domainID, result.ID, result.ProjectID, accountID, result.UpdatedAt); err != nil { + return domain.DNSAccountBinding{}, err + } + } + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE domains SET dns_provider = ?, updated_at = ?, updated_by = ? WHERE id = ?`), provider, result.UpdatedAt, actorID, domainID); err != nil { + return domain.DNSAccountBinding{}, err + } + if err := writeDomainAudit(ctx, tx, r.driver, "domain.dns_account", domainID, result.ProjectID, actorID, "更新域名 DNS 账户绑定"); err != nil { + return domain.DNSAccountBinding{}, err + } + if err := tx.Commit(); err != nil { + return domain.DNSAccountBinding{}, err + } + return result, nil +} + +// 普通域名编辑不能绕过独立绑定修改服务商或切换验证方式;须先显式解除绑定。 +func validateBoundDomainDNS(ctx context.Context, tx *sql.Tx, driver string, item domain.Domain, challengeType string) error { + var provider string + err := tx.QueryRowContext(ctx, Rebind(driver, `SELECT account.provider FROM domain_dns_accounts binding + JOIN dns_accounts account ON account.id = binding.dns_account_id AND account.project_id = binding.project_id + WHERE binding.domain_id = ?`), item.ID).Scan(&provider) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if challengeType != domain.ChallengeDNS01 || item.DNSProvider != provider { + return ErrDNSBindingInvalid + } + return nil +} diff --git a/backend/internal/repository/domain_dns_account_repository_test.go b/backend/internal/repository/domain_dns_account_repository_test.go new file mode 100644 index 0000000..920a84a --- /dev/null +++ b/backend/internal/repository/domain_dns_account_repository_test.go @@ -0,0 +1,252 @@ +package repository + +import ( + "database/sql" + "errors" + "path/filepath" + "strings" + "sync" + "testing" + + "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" +) + +func TestDomainDNSAccountRepository(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(), "domain-dns.db") + var admin *sql.DB + if driver == "mysql" { + server := mysqltest.New(t) + cfg, admin = server.Database(t), server.Admin() + } + 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) + } + checkDomainDNSAccountRepository(t, db.DB, admin, driver) + }) + } +} + +func checkDomainDNSAccountRepository(t *testing.T, db, admin *sql.DB, driver string) { + t.Helper() + repo := NewSQLProjectMemberRepository(db, driver) + domains := NewSQLDomainRepository(db, driver) + for _, actor := range []struct{ id, role string }{{"root", "super_admin"}, {"manager", "user"}, {"cert", "user"}, {"dns", "user"}, {"reader", "user"}, {"outsider", "admin"}} { + if _, err := db.ExecContext(t.Context(), Rebind(driver, `INSERT INTO users (id, username, password_hash, role, status) VALUES (?, ?, 'test-only', ?, 'active')`), actor.id, actor.id, actor.role); err != nil { + t.Fatal(err) + } + } + for _, id := range []string{"binding-a", "binding-b"} { + if err := NewSQLProjectRepository(db, driver).CreateProject(t.Context(), domain.Project{ID: id, Name: "绑定测试"}); err != nil { + t.Fatal(err) + } + } + for actor, role := range map[string]domain.ProjectRole{"manager": domain.ProjectRoleAdmin, "cert": domain.ProjectRoleCertificateAdmin, "dns": domain.ProjectRoleDNSAdmin, "reader": domain.ProjectRoleReadOnly} { + if err := repo.GrantProjectRole(t.Context(), "root", "binding-a", actor, role); err != nil { + t.Fatal(err) + } + } + makeDomain := func(id string) domain.Domain { + t.Helper() + item, err := domain.NewDomain(id+".example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ID, item.ProjectID = id, "binding-a" + if err := repo.CreateProjectDomain(t.Context(), "root", item); err != nil { + t.Fatal(err) + } + return item + } + item := makeDomain("bound-domain") + second := makeDomain("second-domain") + accounts := make(map[string]domain.DNSAccount) + for _, id := range []string{"account-a", "account-b", "foreign-account"} { + projectID := "binding-a" + if id == "foreign-account" { + projectID = "binding-b" + } + account, err := repo.CreateDNSAccount(t.Context(), "root", domain.DNSAccount{ID: id, Name: "测试账户", ProjectID: projectID, Provider: "cloudflare", CredentialRef: "secret://dns/" + id}) + if err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, id, "", account.Revision); err == nil { + t.Fatal("未验证账户不应允许绑定") + } + account, err = repo.RecordDNSAccountVerification(t.Context(), "root", id, account.Revision, true) + if err != nil { + t.Fatal(err) + } + accounts[id] = account + } + a := accounts["account-a"] + httpItem := makeDomain("http-domain") + httpItem.ChallengeType = domain.ChallengeHTTP01 + if err := repo.UpdateProjectDomain(t.Context(), "root", httpItem); err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", httpItem.ID, a.ID, "", a.Revision); !errors.Is(err, ErrDNSBindingInvalid) { + t.Fatal("HTTP 验证域名不应绑定 DNS 账户", err) + } + disabled, err := repo.SetDNSAccountEnabled(t.Context(), "root", "account-b", accounts["account-b"].Revision, false) + if err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, disabled.ID, "", disabled.Revision); !errors.Is(err, ErrDNSBindingInvalid) { + t.Fatal("禁用账户不应允许新绑定", err) + } + for _, actor := range []string{"", "missing", "cert", "dns", "reader", "outsider"} { + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), actor, item.ID, a.ID, "", a.Revision); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("角色 %s 越权绑定未拒绝:%v", actor, err) + } + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, "foreign-account", "", 2); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("超级管理员也不能跨项目绑定", err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "manager", item.ID, a.ID, "", a.Revision-1); !errors.Is(err, ErrDNSAccountConflict) { + t.Fatal("旧凭据版本未拒绝", err) + } + binding, err := repo.SetDomainDNSAccountBinding(t.Context(), "manager", item.ID, a.ID, "", a.Revision) + if err != nil || binding.ID == "" || binding.ProjectID != item.ProjectID || binding.DNSAccountID != a.ID { + t.Fatal("绑定失败", binding, err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", second.ID, a.ID, "", a.Revision); err != nil { + t.Fatal("同一账户应允许绑定多个域名", err) + } + for _, read := range []func() (domain.Domain, error){ + func() (domain.Domain, error) { return domains.GetByID(t.Context(), item.ID) }, + func() (domain.Domain, error) { return repo.GetVisibleDomain(t.Context(), "cert", item.ID) }, + } { + stored, err := read() + if err != nil || stored.DNSAccountID != a.ID || stored.DNSBindingID != binding.ID || stored.DNSProvider != a.Provider { + t.Fatal("域名查询遗漏真实绑定", stored, err) + } + } + if read, err := repo.GetDomainDNSAccountBinding(t.Context(), "reader", item.ID); err != nil || read.ID != binding.ID { + t.Fatal("同项目只读账户应能查看绑定元数据", err) + } + if _, err := repo.GetDomainDNSAccountBinding(t.Context(), "outsider", item.ID); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("外项目不应查看绑定", err) + } + item.DNSProvider = a.Provider + item.ChallengeType = domain.ChallengeHTTP01 + if err := repo.UpdateProjectDomain(t.Context(), "manager", item); !errors.Is(err, ErrDNSBindingInvalid) { + t.Fatal("普通编辑绕过绑定切换验证方式", err) + } + if err := domains.Update(t.Context(), item); !errors.Is(err, ErrDNSBindingInvalid) { + t.Fatal("基础仓储绕过绑定切换验证方式", err) + } + item.ChallengeType = domain.ChallengeDNS01 + item.DNSProvider = "aliyun" + if err := repo.UpdateProjectDomain(t.Context(), "manager", item); !errors.Is(err, ErrDNSBindingInvalid) { + t.Fatal("普通编辑绕过绑定切换服务商", err) + } + item.DNSProvider = a.Provider + if err := repo.UpdateProjectDomain(t.Context(), "manager", item); err != nil { + t.Fatal("合法编辑不能破坏绑定", err) + } + for _, status := range []string{"queued", "running", "retry_wait", "cancelling"} { + if _, err := db.ExecContext(t.Context(), Rebind(driver, `INSERT INTO tasks (id,domain_id,task_type,status,message) VALUES ('binding-task',?,'certificate_issue',?,'')`), item.ID, status); err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, "", binding.ID, 0); !errors.Is(err, ErrDNSBindingBusy) { + t.Fatal("活动任务未阻止解除绑定", status, err) + } + if _, err := db.ExecContext(t.Context(), `DELETE FROM tasks WHERE id='binding-task'`); err != nil { + t.Fatal(err) + } + } + // 同一个旧绑定令牌只能有一个并发写入成功;数据库忙或冲突均不能绕过条件重试。 + var wg sync.WaitGroup + results := make(chan error, 2) + for range 2 { + wg.Add(1) + go func() { + defer wg.Done() + _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, a.ID, binding.ID, a.Revision) + results <- err + }() + } + wg.Wait() + close(results) + winners := 0 + for err := range results { + if err == nil { + winners++ + } + } + if winners != 1 { + t.Fatal("同一绑定条件应只有一个成功者", winners) + } + current, err := repo.GetDomainDNSAccountBinding(t.Context(), "root", item.ID) + if err != nil || current.ID == binding.ID { + t.Fatal("绑定令牌未变化", err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, "", binding.ID, 0); !errors.Is(err, ErrDNSBindingConflict) { + t.Fatal("旧绑定仍可解除", err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "cert", item.ID, "", current.ID, 0); err != nil { + t.Fatal("证书管理员无法显式解除绑定", err) + } + rebound, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, a.ID, "", a.Revision) + if err != nil || rebound.ID == current.ID || rebound.ID == binding.ID { + t.Fatal("解除后重绑重复使用旧令牌", err) + } + if _, err := db.ExecContext(t.Context(), Rebind(driver, `DELETE FROM dns_accounts WHERE id=?`), a.ID); err == nil { + t.Fatal("引用账户被直接删除") + } + if err := repo.DeleteProjectDomain(t.Context(), "root", second.ID); err != nil { + t.Fatal("删除域名没有同时解除引用", err) + } + if _, err := repo.GetVisibleDNSAccount(t.Context(), "root", a.ID); err != nil { + t.Fatal("删除域名误删账户", err) + } + if err := repo.RevokeProjectRole(t.Context(), "root", "binding-a", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "manager", item.ID, "", rebound.ID, 0); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatal("撤权后仍可修改绑定", err) + } + // 故障注入只对隔离库使用管理连接,不扩大应用账户权限。 + executor := db + statements := []string{`CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'blocked'); END`} + if driver == "mysql" { + var schema string + if err := db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil { + t.Fatal(err) + } + schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`" + executor = admin + statements = []string{`CREATE TRIGGER ` + schema + `.reject_binding_audit BEFORE INSERT ON ` + schema + `.audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='blocked'`} + } else if driver == "postgres" { + statements = []string{`CREATE FUNCTION reject_binding_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'blocked'; END $$`, `CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION reject_binding_audit()`} + } + for _, statement := range statements { + if _, err := executor.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + if _, err := repo.SetDomainDNSAccountBinding(t.Context(), "root", item.ID, "", rebound.ID, 0); err == nil { + t.Fatal("审计失败仍修改绑定") + } + if err := repo.DeleteProjectDomain(t.Context(), "root", item.ID); err == nil { + t.Fatal("审计失败仍删除域名") + } + if stored, err := repo.GetDomainDNSAccountBinding(t.Context(), "root", item.ID); err != nil || stored.ID != rebound.ID { + t.Fatal("审计失败未恢复绑定", err) + } +} diff --git a/backend/internal/repository/project_domain_repository.go b/backend/internal/repository/project_domain_repository.go index 8d04645..744cc6d 100644 --- a/backend/internal/repository/project_domain_repository.go +++ b/backend/internal/repository/project_domain_repository.go @@ -84,6 +84,9 @@ func (r *SQLProjectMemberRepository) UpdateProjectDomain(ctx context.Context, ac if err != nil { return err } + if err := validateBoundDomainDNS(ctx, tx, r.driver, item, challengeType); err != nil { + return err + } result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE domains SET name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?, challenge_type = ? WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment, @@ -133,6 +136,9 @@ func (r *SQLProjectMemberRepository) DeleteProjectDomain(ctx context.Context, ac if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil { return fmt.Errorf("删除域名服务器关联失败:%w", err) } + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_dns_accounts WHERE domain_id = ?`), domainID); err != nil { + return err + } result, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domains WHERE id = ? AND project_id = ?`), domainID, projectID) if err != nil { return fmt.Errorf("删除域名失败:%w", err) diff --git a/backend/internal/repository/project_resources.go b/backend/internal/repository/project_resources.go index 1a33f95..3d679e8 100644 --- a/backend/internal/repository/project_resources.go +++ b/backend/internal/repository/project_resources.go @@ -26,7 +26,9 @@ var _ ProjectResourceReader = (*SQLProjectMemberRepository)(nil) const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificate_type, domains.sans_json, domains.auto_renew, domains.acme_environment, domains.dns_provider, domains.status, domains.expires_at, domains.created_at, domains.updated_at, - domains.project_id, domains.owner_id, domains.created_by, domains.updated_by, domains.challenge_type FROM domains` + domains.project_id, domains.owner_id, domains.created_by, domains.updated_by, domains.challenge_type, + COALESCE((SELECT dns_account_id FROM domain_dns_accounts WHERE domain_id = domains.id AND project_id = domains.project_id), ''), + COALESCE((SELECT id FROM domain_dns_accounts WHERE domain_id = domains.id AND project_id = domains.project_id), '') FROM domains` // 列名只接受内部固定值,角色能力复用领域模型;所有身份和角色值均使用绑定参数。 // EXISTS 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。 diff --git a/backend/internal/repository/sql_domain_repository.go b/backend/internal/repository/sql_domain_repository.go index 1e4a995..7b9b974 100644 --- a/backend/internal/repository/sql_domain_repository.go +++ b/backend/internal/repository/sql_domain_repository.go @@ -60,35 +60,33 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er if err != nil { return err } - result, err := r.db.ExecContext(ctx, r.query(`UPDATE domains SET + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var storedID string + if err := tx.QueryRowContext(ctx, r.query(`SELECT id FROM domains WHERE id = ? AND project_id = ?`+taskResourceWriteLock(r.driver)), item.ID, item.ResourceScope.Normalize().ProjectID).Scan(&storedID); err != nil { + return err + } + if err := validateBoundDomainDNS(ctx, tx, r.driver, item, challengeType); err != nil { + return err + } + _, err = tx.ExecContext(ctx, r.query(`UPDATE domains SET name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?, challenge_type = ? WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment, item.DNSProvider, item.UpdatedAt, item.UpdatedBy, challengeType, item.ID, item.ResourceScope.Normalize().ProjectID) if err != nil { return fmt.Errorf("更新域名失败:%w", err) } - count, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("读取更新结果失败:%w", err) - } - if count == 0 { - stored, err := r.GetByID(ctx, item.ID) - if err != nil { - return err - } - if stored.ProjectID != item.ResourceScope.Normalize().ProjectID { - return sql.ErrNoRows - } - } - return nil + return tx.Commit() } func (r *SQLDomainRepository) GetByID(ctx context.Context, id string) (domain.Domain, error) { if r == nil || r.db == nil { return domain.Domain{}, errors.New("数据库连接不能为空") } - row := r.db.QueryRowContext(ctx, r.query(`SELECT 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 - FROM domains WHERE id = ?`), id) + row := r.db.QueryRowContext(ctx, r.query(visibleDomainSelect+` WHERE domains.id = ?`), id) return scanDomain(row) } @@ -96,8 +94,7 @@ func (r *SQLDomainRepository) List(ctx context.Context) ([]domain.Domain, error) if r == nil || r.db == nil { return nil, errors.New("数据库连接不能为空") } - rows, err := r.db.QueryContext(ctx, r.query(`SELECT 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 - FROM domains ORDER BY name`)) + rows, err := r.db.QueryContext(ctx, r.query(visibleDomainSelect+` ORDER BY domains.name`)) if err != nil { return nil, fmt.Errorf("查询域名失败:%w", err) } @@ -131,6 +128,9 @@ func (r *SQLDomainRepository) Delete(ctx context.Context, id string) error { if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), id); err != nil { return rollback(fmt.Errorf("删除域名服务器关联失败:%w", err)) } + if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_dns_accounts WHERE domain_id = ?"), id); err != nil { + return rollback(err) + } result, err := tx.ExecContext(ctx, r.query("DELETE FROM domains WHERE id = ?"), id) if err != nil { return rollback(fmt.Errorf("删除域名失败:%w", err)) @@ -183,7 +183,7 @@ func scanDomain(row scanner) (domain.Domain, error) { var acmeEnvironment string var dnsProvider string var expiresAt sql.NullTime - if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy, &item.ChallengeType); err != nil { + if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy, &item.ChallengeType, &item.DNSAccountID, &item.DNSBindingID); err != nil { if errors.Is(err, sql.ErrNoRows) { return domain.Domain{}, sql.ErrNoRows } diff --git a/backend/internal/repository/sqlite_write_conflict_test.go b/backend/internal/repository/sqlite_write_conflict_test.go new file mode 100644 index 0000000..5bb8009 --- /dev/null +++ b/backend/internal/repository/sqlite_write_conflict_test.go @@ -0,0 +1,25 @@ +package repository + +import ( + "errors" + "fmt" + "testing" +) + +type sqliteCodeFixture int + +func (e sqliteCodeFixture) Error() string { return "测试驱动错误,不依赖文本识别" } +func (e sqliteCodeFixture) Code() int { return int(e) } + +func TestSQLiteWriteConflictClassification(t *testing.T) { + for _, code := range []int{5, 6, 261, 517, 773, 262} { + if !isSQLiteWriteConflict(fmt.Errorf("外层错误:%w", sqliteCodeFixture(code))) { + t.Fatal("忙或锁扩展错误码未被识别", code) + } + } + for _, err := range []error{nil, errors.New("database is locked"), sqliteCodeFixture(19), sqliteCodeFixture(2067), sqliteCodeFixture(10)} { + if isSQLiteWriteConflict(err) { + t.Fatal("非锁冲突不能伪装成可重试版本冲突", err) + } + } +} diff --git a/doc/DNS多账户整改任务清单.md b/doc/DNS多账户整改任务清单.md index 18c4fd1..dd2c581 100644 --- a/doc/DNS多账户整改任务清单.md +++ b/doc/DNS多账户整改任务清单.md @@ -19,7 +19,7 @@ - [x] 基础仓储:实时项目权限、事务审计、版本冲突、轮换后验证失效和禁止公开返回秘密引用;未提供无引用保护的删除方法。 - [✓] 三库迁移和仓储专项三轮竞态通过;MySQL 审计故障夹具权限已修正,失败记录保留。完整执行链仍待实现验证。 - [ ] 旧系统账户导入及幂等恢复,保护配置与历史凭据引用。 -- [ ] 域名绑定、引用保护、安全删除,以及运行中任务的凭据租约和清理。 +- [~] 域名绑定及数据库引用保护已实现,等待第一台实库复审;安全删除、执行租约和凭据清理尚未完成。 - [ ] 正式签发、续期、重试的账户解析与执行前权限复核。 - [x] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离;本地全包与专项通过,第一台 Linux 回归待终态确认。 - [ ] DNS 账户页、域名配置页及前端回归。 @@ -28,6 +28,16 @@ ## 完成标准 +### 2026年10月4日05:34 域名绑定增量 + +- [x] 迁移二十八新增域名与账户绑定;复合外键约束同项目,允许同一账户绑定多个域名。SQLite 外键关闭时仍有插入、更新、删除、替换保护;MySQL 按建表、索引、两个外键的中断边界校验并恢复,不覆盖冲突结构。 +- [x] 绑定仓储要求实时证书管理与凭据使用权限,核对账户版本、启用及验证状态;解除要求证书管理权限。每次绑定生成新令牌,拒绝旧页面覆盖和解除后重绑的旧令牌复用。 +- [x] 绑定、域名服务商元数据和成功审计同事务;排队、运行、等待重试及取消中的任务阻止变更。域名删除原子解除引用,不删除账户;普通编辑不能绕过绑定改变验证方式或服务商。 +- [x] 域名读取返回账户与绑定标识。执行租约尚未接入,因此签发入口对独立绑定明确拒绝,不能静默使用系统凭据替代;尚未开放绑定接口和页面,不部署此过渡状态作为完整功能。 +- [✓] 本地数据库、仓储、证书、HTTP 四包全量通过;追加状态与错误分类用例后,仓储二十轮专项及四包 vet 通过。本地 MySQL/PostgreSQL 未配置而跳过的用例不计实库证据。 +- [!] 干净提交 `1dc72f2` 第一台全量任务 `scert-dns-clean-20261004-0514.service` 失败:SQLite 并发锁错误被已有账户更新入口包装成凭据重复错误。已按驱动主错误码及扩展码分类为版本冲突,保留非锁错误拒绝;未放宽原并发断言,待修复版本第一台复跑。 +- [~] 本轮先核验干净源码三库迁移、绑定仓储和 Linux 全量,再接旧账户导入、绑定接口、执行租约及签发续期链和前端。完整 P1-03 仍未完成。 + 只完成数据模型或管理接口不能关闭 P1-03。每阶段记录真实测试结果;外部服务未验证、跳过或仅隔离验证的项目维持未完成。CNAME 委派保留在总清单,不因本项账户开发自动关闭。 ## 首轮验证记录(运行环境 UTC+8,2026年10月4日) diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index b116335..3fd6268 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,13 @@ # Linux 生产流程测试记录 +## 2026年10月4日05:34 域名账户绑定与干净源码回归 + +- `scert-dns-clean-20261004-0514.service` 使用 `1dc72f2` 的真实 Git 干净签出,源码 `/var/tmp/scert-dns-clean-20261004-0514/source`,日志 `full.log`;已 failed、退出一。仓储并发用例发现 SQLite 锁错误被更新入口包装成凭据重复错误;后续 vet、竞态因前置失败未执行,不计为通过。 +- 修复只按 SQLite 驱动码识别忙及锁冲突并返回版本冲突;保留真实结构与凭据错误的失败,不泄露原始驱动文本。补主错误码、扩展码和非锁拒绝单测,原并发成功数量与审计数量断言不变。 +- 绑定测试首轮夹具漏填 `tasks.message` 被非空约束拒绝;补齐测试字段,不修改生产约束。随后本地数据库 25.410 秒、仓储 9.620 秒、证书 4.847 秒、HTTP 29.070 秒全包通过;追加状态用例及分类测试后二十轮仓储专项 3.914 秒、四包 vet 通过。 +- 新增三库迁移二十八、同项目绑定与引用保护、SQLite 四种外键/递归开关、MySQL 十种中断及结构冲突用例;本地外部数据库跳过不能作为实库验收。待当前代码提交后在第一台运行干净源码专项及全量。 +- 本批未升级正式服务、未修改公共解析、未清理业务数据、未操作第二台;旧账户导入、绑定接口、执行租约和真实云端签发仍未验收。 + ## 2026年10月4日05:04 DNS 多账户数据层及管理接口 本节按运行环境 UTC+8 记录。第一台只执行独立数据库和源码目录测试,未升级正式服务;第二台未操作。没有清理生产数据、新增辅助验收脚本或制作远端备份。 diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index ce8b197..5c74d46 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,12 @@ # Scert 开发任务清单 +## 2026年10月4日05:34 DNS 域名绑定开发 + +- [x] 独立绑定数据结构与迁移、实时项目双权限、同项目及状态约束、乐观绑定令牌和审计事务已实现;域名查询包含账户和绑定标识。 +- [✓] 本地四包全量、追加用例后的二十轮仓储专项、四包 vet 通过;第一台干净源码和三库专项待本轮复审。 +- [!] 第一台原干净提交全量暴露 SQLite 锁冲突错误分类缺陷,已修复分类并补主码/扩展码测试,失败记录保留。 +- [ ] 旧系统账户导入、绑定接口、凭据执行租约、清理与删除、签发续期和前端仍未完成。签发过渡保护只防止错误凭据回退,不是完整多账户签发实现。 + ## 2026年10月4日05:04 DNS 多账户接口开发 - [x] 创建、详情、改名、启停、轮换与在线验证已接真实仓储;列表返回独立账户和旧系统兼容账户,显示归属用户,独立账户列表不解密秘密。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 2651b09..287a55e 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,12 @@ # Scert 整改任务执行清单 +## 2026年10月4日05:34 域名 DNS 账户绑定 + +- [x] 三库迁移二十八、同项目绑定、账户版本与状态检查、旧绑定令牌拒绝、在途任务保护及原子审计已实现;删除域名释放绑定但保留账户。 +- [✓] 本地四包全量、二十轮仓储专项及 vet 通过;完整实库与 Linux 回归待修复版本终态。 +- [!] 干净 `1dc72f2` Linux 全量发现 SQLite 锁错误被包装为凭据重复;已按驱动码修正,不隐藏失败。 +- [ ] 绑定接口、旧账户导入、执行租约、安全回收、实际签发续期及前端仍需接通。当前独立绑定禁止回退系统凭据,不计为功能验收。 + ## 2026年10月4日05:04 DNS 账户管理接口 - [✓] 修改前检查点 `02e0399` 已提交推送,包含上一批数据层和失败夹具状态;未跟踪文件 `=` 不处理。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 6eced0b..5b4e45a 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,12 @@ # Scert 整改、合并与优化清单 +## 2026年10月4日05:34 DNS 域名绑定与引用保护 + +- [x] 迁移二十八、同项目账户绑定仓储、变更令牌、实时双权限、在途任务保护及事务审计已实现;域名读取返回绑定标识,普通编辑不能绕过绑定切换服务商或验证方式。 +- [✓] 本地四包全量、追加用例后的仓储二十轮专项和四包 vet 通过;三库实测与最新 Linux 干净源码回归待完成。 +- [!] 第一台干净 `1dc72f2` 全量失败,暴露 SQLite 锁冲突被包装为凭据重复错误;已修正分类,首批失败保留,不以先前专项通过代替全量通过。 +- [ ] 独立账户实际签发租约、旧账户导入、安全删除和凭据回收、绑定接口及前端仍未完成。执行租约接通前,独立绑定明确拒绝签发,不回退系统账户。本批未升级线上、未操作第二台。P1-03 和生产验收维持未完成。 + ## 2026年10月4日05:04 DNS 多账户接口接入 本节按运行环境 UTC+8 记录,优先于历史状态;范围仍为 Linux 与 Web。 diff --git a/doc/核心能力任务规格.md b/doc/核心能力任务规格.md index ffac485..12dc2c9 100644 --- a/doc/核心能力任务规格.md +++ b/doc/核心能力任务规格.md @@ -1,5 +1,11 @@ # 核心能力任务规格 +## 2026年10月4日05:34 DNS 独立绑定阶段 + +迁移二十八通过同项目复合外键与 SQLite 补充触发器保存域名账户绑定。仓储必须同时核验证书管理、DNS 凭据使用权限,锁定域名并拒绝在途任务,核对账户版本、启用和验证状态;变更绑定令牌、域名服务商及审计同事务。普通域名编辑不能绕过绑定,删除域名只解除引用。域名读取显式返回账户和绑定标识。 + +本地四包全量、二十轮仓储专项和静态检查已通过。第一台原干净提交全量暴露 SQLite 并发错误分类缺陷,已修复并等待新版本实库复核。执行租约、旧账户导入、绑定接口、引用回收和前端仍未接通;独立绑定暂时明确拒绝签发而不回退系统凭据,不能把这一过渡保护算作签发能力完成。 + ## 2026年10月4日03:55 元数据读取边界补强 - [✓] 全局系统设置仅允许超级管理员读取和修改;普通管理员不再保留全局 SMTP、ACME、DNS 项目及路径元数据读取权限。项目 DNS 账户独立授权不变。