增加域名 DNS 账户绑定与引用保护并修复并发冲突分类
This commit is contained in:
parent
1dc72f2b44
commit
7a6da6b2c9
24
backend/internal/certificate/dns_account_binding_test.go
Normal file
24
backend/internal/certificate/dns_account_binding_test.go
Normal file
@ -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("拒绝执行后不应变更域名状态")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
200
backend/internal/database/domain_dns_account_migration_test.go
Normal file
200
backend/internal/database/domain_dns_account_migration_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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 {
|
||||
|
||||
115
backend/internal/database/mysql_domain_dns_migration.go
Normal file
115
backend/internal/database/mysql_domain_dns_migration.go
Normal file
@ -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
|
||||
}
|
||||
43
backend/internal/database/sqlite_domain_dns_constraints.go
Normal file
43
backend/internal/database/sqlite_domain_dns_constraints.go
Normal file
@ -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
|
||||
}
|
||||
12
backend/internal/domain/dns_account_binding.go
Normal file
12
backend/internal/domain/dns_account_binding.go
Normal file
@ -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"`
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
152
backend/internal/repository/domain_dns_account_repository.go
Normal file
152
backend/internal/repository/domain_dns_account_repository.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 避免多角色账户重复返回资源,不按用户分组或全局普通管理员身份放行。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
25
backend/internal/repository/sqlite_write_conflict_test.go
Normal file
25
backend/internal/repository/sqlite_write_conflict_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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日)
|
||||
|
||||
@ -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 记录。第一台只执行独立数据库和源码目录测试,未升级正式服务;第二台未操作。没有清理生产数据、新增辅助验收脚本或制作远端备份。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 开发任务清单
|
||||
|
||||
## 2026年10月4日05:34 DNS 域名绑定开发
|
||||
|
||||
- [x] 独立绑定数据结构与迁移、实时项目双权限、同项目及状态约束、乐观绑定令牌和审计事务已实现;域名查询包含账户和绑定标识。
|
||||
- [✓] 本地四包全量、追加用例后的二十轮仓储专项、四包 vet 通过;第一台干净源码和三库专项待本轮复审。
|
||||
- [!] 第一台原干净提交全量暴露 SQLite 锁冲突错误分类缺陷,已修复分类并补主码/扩展码测试,失败记录保留。
|
||||
- [ ] 旧系统账户导入、绑定接口、凭据执行租约、清理与删除、签发续期和前端仍未完成。签发过渡保护只防止错误凭据回退,不是完整多账户签发实现。
|
||||
|
||||
## 2026年10月4日05:04 DNS 多账户接口开发
|
||||
|
||||
- [x] 创建、详情、改名、启停、轮换与在线验证已接真实仓储;列表返回独立账户和旧系统兼容账户,显示归属用户,独立账户列表不解密秘密。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月4日05:34 域名 DNS 账户绑定
|
||||
|
||||
- [x] 三库迁移二十八、同项目绑定、账户版本与状态检查、旧绑定令牌拒绝、在途任务保护及原子审计已实现;删除域名释放绑定但保留账户。
|
||||
- [✓] 本地四包全量、二十轮仓储专项及 vet 通过;完整实库与 Linux 回归待修复版本终态。
|
||||
- [!] 干净 `1dc72f2` Linux 全量发现 SQLite 锁错误被包装为凭据重复;已按驱动码修正,不隐藏失败。
|
||||
- [ ] 绑定接口、旧账户导入、执行租约、安全回收、实际签发续期及前端仍需接通。当前独立绑定禁止回退系统凭据,不计为功能验收。
|
||||
|
||||
## 2026年10月4日05:04 DNS 账户管理接口
|
||||
|
||||
- [✓] 修改前检查点 `02e0399` 已提交推送,包含上一批数据层和失败夹具状态;未跟踪文件 `=` 不处理。
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# 核心能力任务规格
|
||||
|
||||
## 2026年10月4日05:34 DNS 独立绑定阶段
|
||||
|
||||
迁移二十八通过同项目复合外键与 SQLite 补充触发器保存域名账户绑定。仓储必须同时核验证书管理、DNS 凭据使用权限,锁定域名并拒绝在途任务,核对账户版本、启用和验证状态;变更绑定令牌、域名服务商及审计同事务。普通域名编辑不能绕过绑定,删除域名只解除引用。域名读取显式返回账户和绑定标识。
|
||||
|
||||
本地四包全量、二十轮仓储专项和静态检查已通过。第一台原干净提交全量暴露 SQLite 并发错误分类缺陷,已修复并等待新版本实库复核。执行租约、旧账户导入、绑定接口、引用回收和前端仍未接通;独立绑定暂时明确拒绝签发而不回退系统凭据,不能把这一过渡保护算作签发能力完成。
|
||||
|
||||
## 2026年10月4日03:55 元数据读取边界补强
|
||||
|
||||
- [✓] 全局系统设置仅允许超级管理员读取和修改;普通管理员不再保留全局 SMTP、ACME、DNS 项目及路径元数据读取权限。项目 DNS 账户独立授权不变。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user