补齐执行租约配置快照与底层域名保护并更新进度
This commit is contained in:
parent
7a3b78daa3
commit
f17ea6c99e
54
backend/internal/httpapi/domain_dns_execution_test.go
Normal file
54
backend/internal/httpapi/domain_dns_execution_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestDomainDNSExecutionLeaseMutationConflict(t *testing.T) {
|
||||
f, account := newDomainDNSAPIFixture(t)
|
||||
f.server.projectDomains = f.members
|
||||
if _, err := f.members.SetDomainDNSAccountBinding(t.Context(), "manager", "domain-a", account.ID, "", account.Revision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := repository.NewSQLDomainRepository(f.db).GetByID(t.Context(), "domain-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := repository.NewSQLTaskRepository(f.db)
|
||||
identity := domain.DNSExecutionIdentity{TaskID: domain.NewID(), AttemptID: domain.NewID(), AttemptNumber: 1}
|
||||
if err := tasks.CreateTask(t.Context(), repository.Task{ID: identity.TaskID, DomainID: item.ID, TaskType: "certificate_issue", Status: "running", Stage: "certificate_issue", OperatorID: "manager"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tasks.CreateAttempt(t.Context(), repository.TaskAttempt{ID: identity.AttemptID, TaskID: identity.TaskID, AttemptNumber: 1, Status: "running", Stage: "certificate_issue"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lease, err := f.members.AcquireDNSExecutionLease(t.Context(), item, identity)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 任务已终态也不能越过尚未清理的租约。
|
||||
if _, err := f.db.ExecContext(t.Context(), `UPDATE tasks SET status = 'failed' WHERE id = ?`, identity.TaskID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := fmt.Sprintf(`{"name":%q,"certificate_type":%q,"dns_provider":%q,"challenge_type":"dns-01"}`, item.Name, item.CertificateType, item.DNSProvider)
|
||||
for _, method := range []string{http.MethodPut, http.MethodDelete} {
|
||||
response := serveUserRequest(f.server, f.cookies["manager"], method, "/api/v1/domains/"+item.ID, body)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatal("活动租约应返回冲突而非内部错误", method, response.Code, response.Body.String())
|
||||
}
|
||||
for _, secret := range []string{lease.ID, account.CredentialRef, "credential_ref"} {
|
||||
if secret != "" && strings.Contains(response.Body.String(), secret) {
|
||||
t.Fatal("冲突响应泄露租约或凭据引用")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := repository.NewSQLDomainRepository(f.db).GetByID(t.Context(), item.ID); err != nil {
|
||||
t.Fatal("冲突请求不能删除域名", err)
|
||||
}
|
||||
}
|
||||
@ -721,6 +721,10 @@ func (s *Server) updateDomain(c *gin.Context) {
|
||||
item.Status = "pending"
|
||||
item.ExpiresAt = nil
|
||||
if err := s.projectDomains.UpdateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
|
||||
if errors.Is(err, repository.ErrDNSAccountBusy) {
|
||||
s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
|
||||
return
|
||||
@ -773,6 +777,10 @@ func (s *Server) deleteDomain(c *gin.Context) {
|
||||
}
|
||||
domainID := strings.TrimSpace(c.Param("id"))
|
||||
if err := s.projectDomains.DeleteProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), domainID); err != nil {
|
||||
if errors.Is(err, repository.ErrDNSAccountBusy) {
|
||||
s.writeError(c, http.StatusConflict, repository.ErrDNSAccountBusy.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrProjectAccessDenied) {
|
||||
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
|
||||
return
|
||||
|
||||
@ -3,7 +3,9 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
@ -37,11 +39,21 @@ func (r *SQLProjectMemberRepository) AcquireDNSExecutionLease(ctx context.Contex
|
||||
return domain.DNSExecutionLease{}, err
|
||||
}
|
||||
// 域名行锁与资料更新共用,拒绝授权前后被换掉的签发快照。
|
||||
var updated time.Time
|
||||
if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT updated_at FROM domains WHERE id = ?`), item.ID).Scan(&updated); err != nil {
|
||||
var stored domain.Domain
|
||||
var sansJSON string
|
||||
if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT name, certificate_type, sans_json, auto_renew,
|
||||
acme_environment, dns_provider, challenge_type, updated_at FROM domains WHERE id = ?`), item.ID).
|
||||
Scan(&stored.Name, &stored.CertificateType, &sansJSON, &stored.AutoRenew, &stored.ACMEEnvironment,
|
||||
&stored.DNSProvider, &stored.ChallengeType, &stored.UpdatedAt); err != nil {
|
||||
return domain.DNSExecutionLease{}, err
|
||||
}
|
||||
if !updated.Equal(item.UpdatedAt) {
|
||||
// 时间戳可能只有秒精度,必须核对实际配置;SAN 按解码后的数组比较。
|
||||
if json.Unmarshal([]byte(sansJSON), &stored.SANs) != nil ||
|
||||
!stored.UpdatedAt.Equal(item.UpdatedAt) || stored.Name != item.Name ||
|
||||
stored.CertificateType != item.CertificateType || !slices.Equal(stored.SANs, item.SANs) ||
|
||||
stored.AutoRenew != item.AutoRenew || stored.ACMEEnvironment != item.ACMEEnvironment ||
|
||||
stored.DNSProvider != item.DNSProvider || stored.ChallengeType != item.ChallengeType ||
|
||||
stored.DNSProvider != account.Provider {
|
||||
return domain.DNSExecutionLease{}, ErrDNSExecutionInvalid
|
||||
}
|
||||
lease.Revision, lease.Provider, lease.CredentialRef = account.Revision, account.Provider, account.CredentialRef
|
||||
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@ -128,12 +129,20 @@ func TestDNSExecutionLeaseRepository(t *testing.T) {
|
||||
return err
|
||||
},
|
||||
func() error { return f.repo.UpdateProjectDomain(t.Context(), "lease-root", f.item) },
|
||||
func() error { return NewSQLDomainRepository(db.DB, driver).Update(t.Context(), f.item) },
|
||||
func() error { return NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID) },
|
||||
} {
|
||||
if err := mutate(); !errors.Is(err, ErrDNSAccountBusy) {
|
||||
t.Fatal("活动租约未阻止变更", err)
|
||||
}
|
||||
}
|
||||
f.exec(t, `UPDATE tasks SET status = 'failed' WHERE id = ?`, f.identity.TaskID)
|
||||
if err := NewSQLDomainRepository(db.DB, driver).UpdateCertificateState(t.Context(), f.item.ID, "failed", nil); err != nil {
|
||||
t.Fatal("活动租约不能阻止证书状态更新", err)
|
||||
}
|
||||
if err := NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID); !errors.Is(err, ErrDNSAccountBusy) {
|
||||
t.Fatal("底层删除不能绕过终态任务的遗留租约", err)
|
||||
}
|
||||
if err := f.repo.DeleteProjectDomain(t.Context(), "lease-root", f.item.ID); !errors.Is(err, ErrDNSAccountBusy) {
|
||||
t.Fatal("终态任务仍有租约,不能删除域名", err)
|
||||
}
|
||||
@ -157,6 +166,51 @@ func TestDNSExecutionLeaseRepository(t *testing.T) {
|
||||
if _, err := f.repo.SetDNSAccountEnabled(t.Context(), "lease-root", f.account.ID, f.account.Revision, false); err != nil {
|
||||
t.Fatal("正常释放后应允许禁用", err)
|
||||
}
|
||||
if err := NewSQLDomainRepository(db.DB, driver).Update(t.Context(), f.item); err != nil {
|
||||
t.Fatal("正常释放后应允许资料更新", err)
|
||||
}
|
||||
if err := NewSQLDomainRepository(db.DB, driver).Delete(t.Context(), f.item.ID); err != nil {
|
||||
t.Fatal("正常释放后应允许底层删除", err)
|
||||
}
|
||||
})
|
||||
for _, scenario := range []struct {
|
||||
name, column string
|
||||
value any
|
||||
}{
|
||||
{"name", "name", "changed.example.test"},
|
||||
{"type", "certificate_type", "san"},
|
||||
{"sans", "sans_json", `["other.example.test"]`},
|
||||
{"renewal", "auto_renew", false},
|
||||
{"environment", "acme_environment", "production"},
|
||||
{"provider", "dns_provider", "aliyun"},
|
||||
{"challenge", "challenge_type", "http-01"},
|
||||
} {
|
||||
t.Run("same-timestamp-"+scenario.name, func(t *testing.T) {
|
||||
f := newDNSLeaseFixture(t, db.DB, driver)
|
||||
f.exec(t, `UPDATE domains SET `+scenario.column+` = ? WHERE id = ?`, scenario.value, f.item.ID)
|
||||
if _, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity); !errors.Is(err, ErrDNSExecutionInvalid) {
|
||||
t.Fatal("同时间戳的旧配置快照未拒绝", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.DB.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM dns_execution_leases WHERE domain_id = ?`), f.item.ID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatal("无效快照遗留执行租约", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("equivalent-sans-encoding", func(t *testing.T) {
|
||||
f := newDNSLeaseFixture(t, db.DB, driver)
|
||||
encoded, err := json.MarshalIndent(f.item.SANs, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.exec(t, `UPDATE domains SET sans_json = ? WHERE id = ?`, string(encoded), f.item.ID)
|
||||
lease, err := f.repo.AcquireDNSExecutionLease(t.Context(), f.item, f.identity)
|
||||
if err != nil {
|
||||
t.Fatal("相同 SAN 不应因 JSON 格式而被拒绝", err)
|
||||
}
|
||||
if err := f.repo.ReleaseDNSExecutionLease(t.Context(), lease.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
t.Run("atomic-initial-binding", func(t *testing.T) {
|
||||
f := newDNSLeaseFixture(t, db.DB, driver)
|
||||
|
||||
@ -72,6 +72,9 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er
|
||||
if err := validateBoundDomainDNS(ctx, tx, r.driver, item, challengeType); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := NewSQLProjectMemberRepository(r.db, r.driver).rejectActiveDNSLease(ctx, tx, "domain_id", item.ID); 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,
|
||||
@ -125,6 +128,13 @@ func (r *SQLDomainRepository) Delete(ctx context.Context, id string) error {
|
||||
_ = tx.Rollback()
|
||||
return cause
|
||||
}
|
||||
var storedID string
|
||||
if err := tx.QueryRowContext(ctx, r.query(`SELECT id FROM domains WHERE id = ?`+taskResourceWriteLock(r.driver)), id).Scan(&storedID); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if err := NewSQLProjectMemberRepository(r.db, r.driver).rejectActiveDNSLease(ctx, tx, "domain_id", id); err != nil {
|
||||
return rollback(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), id); err != nil {
|
||||
return rollback(fmt.Errorf("删除域名服务器关联失败:%w", err))
|
||||
}
|
||||
|
||||
@ -1,14 +1,26 @@
|
||||
# DNS 多账户整改任务清单
|
||||
|
||||
## 2026年10月4日07:30 执行租约边界复核
|
||||
|
||||
沿用已确认的 Linux 与 Web 整改范围。修改前检查点 `7a3b78d` 已提交推送,未跟踪文件 `=` 保留。本批不操作第二台业务、不干扰浏览器发信配置。
|
||||
|
||||
- [x] 对比完整签发配置,拒绝时间戳相同但域名、SAN、证书类型、环境等资料已经变化的旧快照;SAN 按解码数组比较。
|
||||
- [x] 底层域名更新和删除已补齐同一域名锁及活动租约保护,任务终态也不能绕过;证书状态更新仍正常进行,释放后可更新、删除。
|
||||
- [x] 域名更新、删除遇到活动租约返回 409,接口回归验证不泄露租约标识或凭据引用。
|
||||
- [✓] 本地五包全量及 vet 通过;仓储租约专项十轮、原子创建和租约冲突接口五轮通过。前端重新执行全量 339 项及构建通过。MySQL、PostgreSQL 本地跳过,不算实库证据。
|
||||
- [~] 待提交干净源码并在第一台执行 Linux 全量与三库专项;本轮新迁移及租约尚未获得实库证据。
|
||||
|
||||
首轮新增 SAN 等价编码测试失败,原因是夹具单域名 SAN 实际包含主域名,不能改为空数组后声称等价;现已使用原 SAN 的缩进 JSON,保留实际语义。未据测试错误放宽产品校验。
|
||||
|
||||
## 2026年10月4日07:01 执行租约接入任务
|
||||
|
||||
沿用已确认的独立账户完整生命周期需求,本批不处理 Windows、不变更正式配置。检查点 `73dba28` 已提交推送。
|
||||
|
||||
- [~] 增加持久化执行租约,固定域名、绑定、账户版本、秘密引用及真实任务尝试;手工任务实时校验权限,自动续期必须核验真实调度身份。
|
||||
- [ ] 接通签发、续期和重试;独立账户不读取系统凭据,DNS 操作使用固定账户,取消后仍允许原凭据清理挑战记录。
|
||||
- [ ] 在活动租约期间拒绝账户变更和绑定切换;释放失败或挑战清理不确定时保留租约,不按时间到期猜测可以回收秘密。
|
||||
- [ ] 检查新建域名与初始账户绑定之间的执行窗口,避免自动任务使用历史系统账户。
|
||||
- [ ] 补齐迁移、权限、并发、取消和清理失败测试,再在第一台进行 Linux 与三库验证;更新相关清单和真实测试结果。
|
||||
- [x] 增加持久化执行租约,固定域名、绑定、账户版本、秘密引用及真实任务尝试;手工任务实时校验权限,自动续期必须核验真实调度身份。
|
||||
- [x] 签发、续期和重试执行器已接通租约;独立账户不读取系统凭据,DNS 操作使用固定账户,取消后仍允许原凭据清理挑战记录。真实 DNS/ACME 全流程待验收。
|
||||
- [x] 活动租约期间拒绝账户变更和绑定切换;释放失败或挑战清理不确定时保留租约,不按时间到期猜测可以回收秘密。
|
||||
- [x] 新建域名与初始账户绑定、审计已改为同一事务,前端一次提交,不再出现先创建后绑定窗口。
|
||||
- [~] 已补迁移、权限、并发、取消和清理失败测试,本地五包与前端通过;第一台 Linux 与三库复审待执行。
|
||||
|
||||
租约记录是不可变执行快照,不随任务历史清理级联删除。崩溃遗留租约的外部 TXT 核对及安全回收仍属完整 P1-03 门禁,不能仅凭任务终态或租约年龄释放。
|
||||
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# Linux 生产流程测试记录
|
||||
|
||||
## 2026年10月4日07:40 执行租约复审准备
|
||||
|
||||
- 修改前检查点 `7a3b78d` 已提交推送,之后补齐完整配置快照、底层域名租约保护及接口冲突回归。
|
||||
- 本地 `go test ./internal/repository ./internal/certificate ./internal/database ./internal/httpapi ./internal/app` 和同范围 vet 通过;仓储 8.790 秒、HTTP 26.759 秒、app 11.943 秒,certificate/database 为缓存结果。
|
||||
- 租约专项十轮通过,原子创建及租约冲突接口专项五轮通过;前端重新执行 339 项,零失败零跳过,构建 103 模块通过。
|
||||
- 新增 SAN 等价编码用例首轮失败,已纠正夹具保持实际 SAN 后通过;这是测试数据错误,产品未放宽校验。本地 MySQL、PostgreSQL 未启用而跳过,不计三库证据。
|
||||
- 第一台 SSH 于 07:37 实时验证可用;前轮 `scert-dns-ui-20261004-0630.service` 为 inactive/dead、success、退出零。本轮尚未运行远端测试,不复用前轮结果冒充新代码通过。
|
||||
- 本批未清理正式数据库、未升级正式服务、未改域名或发信配置、未操作第二台。后续用第一台隔离源码及数据库验证,不创建辅助验收脚本。
|
||||
|
||||
## 2026年10月4日06:51 DNS 页面权限干净源码复审
|
||||
|
||||
- 第一台 `8.137.23.95`,源码 `/var/tmp/scert-dns-ui-20261004-0630/source`,真实 Git 干净签出 `412c51f5f0cf466f9d1ff01a09e23dd02e116385`;运行后工作区仍干净。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 开发任务清单
|
||||
|
||||
## 2026年10月4日07:40 DNS 执行链进展
|
||||
|
||||
- [x] 独立账户执行租约已接通证书任务与自动续期执行器,重试依真实尝试序号重新授权,不允许系统凭据回退。
|
||||
- [x] 新建域名首次绑定原子保存;完整配置快照、底层写入租约保护及 409 响应完成编码。
|
||||
- [✓] 本地五包全量与 vet、仓储十轮和接口五轮专项、前端 339 项及构建通过。MySQL/PostgreSQL 本地跳过,不能作为实库结果。
|
||||
- [~] 第一台干净源码复审待执行;历史导入、遗留清理核对、安全引用回收、真实签发续期和生产流程仍未完成,P1-03 保持进行中。
|
||||
|
||||
## 2026年10月4日06:51 开发与复审进展
|
||||
|
||||
- [x] 独立 DNS 账户选择已接入域名新建和编辑,显示服务商及归属;显式解除、实时绑定读取、版本冲突、跨项目过滤和部分成功恢复完成编码。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月4日07:40 执行租约边界复核
|
||||
|
||||
- [x] 完成租约迁移二十九、真实任务授权、固定凭据引用、取消清理及活动引用保护;新建域名与首次绑定改为原子事务。
|
||||
- [x] 完整签发配置比较避免同秒修改漏检,底层更新/删除加锁并拒绝活动租约;接口忙状态映射 409。
|
||||
- [✓] 本地五包全量与 vet、租约十轮及接口五轮专项、前端 339 项和构建通过;首轮 SAN 测试夹具错误已修正并保留失败说明。
|
||||
- [~] 第一台 Linux 与三库本轮实测待执行;真实签发、续期、重试、遗留租约核对、安全回收、旧账户导入和最终生产验收未完成。
|
||||
|
||||
## 2026年10月4日06:51 DNS 域名配置接入
|
||||
|
||||
- [✓] 第一台 `412c51f` 干净源码全量、vet 和三库目录及接口单轮竞态通过,`0630` 单元退出零;不是三轮结果。
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月4日07:40 执行租约与原子创建进度
|
||||
|
||||
- [x] P1-03 已接入真实任务尝试的 DNS 执行租约,固定账户版本及秘密引用;取消后使用原凭据清理,清理或释放不确定则保留引用。未实现的崩溃遗留核对与安全回收仍是门禁。
|
||||
- [x] 创建域名、初始 DNS 绑定及审计原子保存;前端一次创建并核对返回绑定,不再先创建再绑定。完整配置快照比较及底层更新、删除租约保护已补齐,忙状态接口返回 409。
|
||||
- [✓] 本地五包全量、vet、租约十轮专项、接口五轮专项通过;前端再次全量 339 项与构建通过。本地实库未启用,跳过不计三库结果。
|
||||
- [~] 待第一台干净源码 Linux 全量及三库租约、迁移、竞态实测;旧账户导入、安全回收、真实签发续期、浏览器、邮件及生产门禁尚未关闭。
|
||||
|
||||
第一台主程序测试、第二台仅证书推送的分工不变。本批未升级正式服务、清生产库或改 DNS;不处理 Windows,域名登录页保留给用户配置发信。
|
||||
|
||||
## 2026年10月4日06:51 最新执行进度
|
||||
|
||||
- [✓] 第一台干净 `412c51f` Linux 全量、vet 和单轮三库项目目录及 HTTP 权限竞态全部结束通过;`0630` 单元 success、退出零,PostgreSQL 新增目录用例实际执行通过。
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
# 核心能力任务规格
|
||||
|
||||
## 2026年10月4日07:40 DNS 执行与原子绑定契约
|
||||
|
||||
- 新建域名可携带 `dns_account_id`、`dns_account_revision`;绑定标识由服务端生成,域名、绑定、审计共用事务。普通资料更新拒绝这些初始绑定字段,编辑仍使用独立绑定接口及真实旧令牌。
|
||||
- 租约绑定真实运行中的任务、尝试、序号、项目、域名、账户版本及秘密引用。自动续期必须同时满足服务端调度标记、真实无操作者续期任务和域名启用续期;手工重试额外要求重试权限。
|
||||
- 获取租约在域名行锁内比较完整签发配置,不依赖秒级时间戳;SAN 按实际数组比较。活动租约阻止账户变更、绑定切换及两层域名资料更新/删除,证书状态更新不受影响。
|
||||
- DNS 写入前重新授权,清理使用原 Provider 和固定凭据、独立超时;任务取消不得跳过清理。不按 TTL 或任务终态释放未知外部状态的引用,释放失败保持租约。
|
||||
- 接口租约忙返回 409,不暴露随机租约能力或秘密引用。遗留租约外部核对与安全回收尚未实现;当前契约的本地测试不能替代真实 DNS/ACME 及三库生产验收。
|
||||
|
||||
## 2026年10月4日06:51 域名 DNS 账户交互契约
|
||||
|
||||
- 新建及编辑域名读取 `/api/v1/dns/accounts`;编辑还读取 `/api/v1/domains/:id/dns-account`,列表缓存不能替代真实绑定令牌。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user