接入独立 DNS 账户管理接口并记录三库竞态复核

This commit is contained in:
xuans 2026-10-04 05:11:37 +08:00
parent 02e039984a
commit 1dc72f2b44
14 changed files with 720 additions and 10 deletions

View File

@ -0,0 +1,259 @@
package httpapi
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
type dnsAccountVerifier interface {
Verify(context.Context, string, string, map[string]string) (dnsverification.Report, error)
}
func (s *Server) managedDNSRepository(c *gin.Context) (repository.DNSAccountRepository, bool) {
repo, ok := s.projectMembers.(repository.DNSAccountRepository)
if !ok {
s.writeError(c, http.StatusServiceUnavailable, "DNS 账户服务尚未就绪", nil)
}
return repo, ok
}
func (s *Server) writeDNSAccountError(c *gin.Context, err error) {
switch {
case errors.Is(err, repository.ErrProjectAccessDenied):
s.writeError(c, http.StatusForbidden, "无权操作此 DNS 账户", nil)
case errors.Is(err, repository.ErrDNSAccountConflict):
s.writeError(c, http.StatusConflict, "DNS 账户已变更,请刷新后重试", nil)
default:
// 驱动、秘密仓库和服务商的错误均可能携带凭据,不能回显或写入日志。
s.writeError(c, http.StatusServiceUnavailable, "DNS 账户操作失败,请稍后重试", nil)
}
}
func (s *Server) createManagedDNSAccount(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
var request struct {
Name string `json:"name"`
ProjectID string `json:"project_id"`
Provider string `json:"provider"`
Credentials map[string]string `json:"credentials"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 账户参数无效", nil)
return
}
if !s.authorizeProjectPermissions(c, request.ProjectID, domain.PermissionDNSManage) {
return
}
item := domain.DNSAccount{
ID: domain.NewID(), Name: strings.TrimSpace(request.Name), ProjectID: request.ProjectID,
OwnerUserID: c.GetString(authenticatedActorKey), Provider: request.Provider,
CredentialRef: "secret://dns/" + domain.NewID(), Status: domain.DNSAccountActive,
VerificationState: domain.DNSAccountUnverified, Revision: 1,
}
if err := item.Validate(); err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 账户名称、项目或服务商无效", nil)
return
}
if !s.storeManagedDNSCredential(c, item.Provider, item.CredentialRef, request.Credentials) {
return
}
created, err := repo.CreateDNSAccount(c.Request.Context(), item.OwnerUserID, item)
if err != nil {
// 提交错误可能有歧义,不能猜测未提交并删除新秘密;后续引用回收统一处理。
s.writeDNSAccountError(c, err)
return
}
c.JSON(http.StatusCreated, created)
}
func (s *Server) storeManagedDNSCredential(c *gin.Context, provider, reference string, credentials map[string]string) bool {
descriptor, err := dnsprovider.GetDescriptor(provider)
if err != nil || !descriptor.Available {
s.writeError(c, http.StatusBadRequest, "DNS 服务商不可用", nil)
return false
}
cleaned, err := dnsprovider.ValidateCredentials(provider, credentials)
if err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 凭据字段无效或不完整", nil)
return false
}
if s.secrets == nil {
s.writeError(c, http.StatusServiceUnavailable, "凭据存储尚未就绪", nil)
return false
}
if err := s.secrets.Put(c.Request.Context(), reference, cleaned); err != nil {
s.writeError(c, http.StatusServiceUnavailable, "保存 DNS 凭据失败", nil)
return false
}
return true
}
func (s *Server) getManagedDNSAccount(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeDNSAccountError(c, err)
return
}
c.JSON(http.StatusOK, item)
}
func (s *Server) renameManagedDNSAccount(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
var request struct {
Name string `json:"name"`
Revision int64 `json:"revision"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 {
s.writeError(c, http.StatusBadRequest, "DNS 账户名称或版本无效", nil)
return
}
// 先核对可见范围,避免借参数错误探测其他项目账户。
item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeDNSAccountError(c, err)
return
}
item.Name, item.CredentialRef = strings.TrimSpace(request.Name), "secret://dns/validation"
if err := item.Validate(); err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 账户名称无效", nil)
return
}
updated, err := repo.RenameDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name)
if err != nil {
s.writeDNSAccountError(c, err)
return
}
c.JSON(http.StatusOK, updated)
}
func (s *Server) setManagedDNSAccountStatus(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
var request struct {
Enabled *bool `json:"enabled"`
Revision int64 `json:"revision"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Enabled == nil || request.Revision < 1 {
s.writeError(c, http.StatusBadRequest, "DNS 账户状态或版本无效", nil)
return
}
item, err := repo.SetDNSAccountEnabled(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, *request.Enabled)
if err != nil {
s.writeDNSAccountError(c, err)
return
}
c.JSON(http.StatusOK, item)
}
func (s *Server) rotateManagedDNSAccountCredential(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
var request struct {
Revision int64 `json:"revision"`
Credentials map[string]string `json:"credentials"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 {
s.writeError(c, http.StatusBadRequest, "DNS 凭据或版本无效", nil)
return
}
item, err := repo.GetVisibleDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"))
if err != nil {
s.writeDNSAccountError(c, err)
return
}
if !s.authorizeProjectPermissions(c, item.ProjectID, domain.PermissionDNSManage) {
return
}
if item.Revision != request.Revision {
s.writeDNSAccountError(c, repository.ErrDNSAccountConflict)
return
}
reference := "secret://dns/" + domain.NewID()
if !s.storeManagedDNSCredential(c, item.Provider, reference, request.Credentials) {
return
}
item, err = repo.RotateDNSAccountCredential(c.Request.Context(), c.GetString(authenticatedActorKey), item.ID, request.Revision, reference)
if err != nil {
s.writeDNSAccountError(c, err)
return
}
// 不删除旧凭据,仍有运行任务或旧配置引用时须由后续租约回收流程处理。
c.JSON(http.StatusOK, item)
}
func (s *Server) verifyManagedDNSAccount(c *gin.Context) {
repo, ok := s.managedDNSRepository(c)
if !ok {
return
}
var request struct {
Revision int64 `json:"revision"`
TestDomain string `json:"test_domain"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 || !domain.IsValidName(request.TestDomain) {
s.writeError(c, http.StatusBadRequest, "DNS 验证域名或版本无效", nil)
return
}
actor := c.GetString(authenticatedActorKey)
item, err := repo.GetDNSAccountForVerification(c.Request.Context(), actor, c.Param("id"))
if err != nil {
s.writeDNSAccountError(c, err)
return
}
if item.Revision != request.Revision {
s.writeDNSAccountError(c, repository.ErrDNSAccountConflict)
return
}
credentials, err := s.readDNSCredentials(c.Request.Context(), item.CredentialRef)
if err != nil {
s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 凭据失败", nil)
return
}
verifier := s.dnsAccountVerifier
if verifier == nil {
verifier = dnsprovider.NewTester(dnsprovider.NewLegoFactory())
}
ctx, cancel := context.WithTimeout(c.Request.Context(), dnsprovider.PermissionVerificationTimeoutFor(item.Provider))
defer cancel()
_, verificationErr := verifier.Verify(ctx, item.Provider, request.TestDomain, credentials)
if verificationErr == nil {
verificationErr = ctx.Err()
}
// 在线调用可能超时或断连,短独立上下文只用于记录实际结果,仍重新核对权限与版本。
recordCtx, recordCancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 5*time.Second)
defer recordCancel()
updated, err := repo.RecordDNSAccountVerification(recordCtx, actor, item.ID, item.Revision, verificationErr == nil)
if err != nil {
s.writeDNSAccountError(c, err)
return
}
if verificationErr != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "DNS 在线验证失败,请检查凭据、测试域名和权限", "account": updated})
return
}
c.JSON(http.StatusOK, gin.H{"verified": true, "account": updated})
}

View File

@ -0,0 +1,295 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/dnsverification"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
type accountVerifierFunc func(context.Context, string, string, map[string]string) (dnsverification.Report, error)
func (f accountVerifierFunc) Verify(ctx context.Context, provider, name string, credentials map[string]string) (dnsverification.Report, error) {
return f(ctx, provider, name, credentials)
}
const managedDNSCreateBody = `{"name":"项目 DNS","project_id":"project-a","provider":"cloudflare","credentials":{"api_token":"fixture-sensitive-token"}}`
func managedDNSRequest(t *testing.T, f projectMemberAPIFixture, actor, method, path, body string, status int) *httptest.ResponseRecorder {
t.Helper()
response := serveUserRequest(f.server, f.cookies[actor], method, "/api/v1/dns/accounts"+path, body)
if response.Code != status {
t.Fatalf("DNS 请求状态错误:%s %s %s,实际 %d 期望 %d,%s", actor, method, path, response.Code, status, response.Body.String())
}
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatal("DNS 账户响应未隔离缓存")
}
for _, value := range []string{"fixture-sensitive-token", "rotated-sensitive-token", "secret://", "credential_ref", "internal-sensitive-error"} {
if strings.Contains(response.Body.String(), value) {
t.Fatalf("DNS 账户响应暴露内部敏感内容:%s", value)
}
}
return response
}
func newManagedDNSFixture(t *testing.T) (projectMemberAPIFixture, *fakeSecretReaderStore, domain.DNSAccount) {
t.Helper()
f := newProjectMemberAPIFixture(t)
f.server.config.DNS.CredentialRef = ""
secrets := &fakeSecretReaderStore{}
f.server.secrets = secrets
response := managedDNSRequest(t, f, "manager", http.MethodPost, "", managedDNSCreateBody, http.StatusCreated)
var item domain.DNSAccount
if err := json.Unmarshal(response.Body.Bytes(), &item); err != nil || item.ID == "" || item.OwnerUserID != "manager" || item.VerificationState != domain.DNSAccountUnverified || item.Revision != 1 {
t.Fatal("创建未固定真实归属和验证状态", err)
}
return f, secrets, item
}
func TestManagedDNSAccountLifecycle(t *testing.T) {
f, secrets, item := newManagedDNSFixture(t)
path := "/" + item.ID
verifications := 0
f.server.dnsAccountVerifier = accountVerifierFunc(func(ctx context.Context, provider, name string, credentials map[string]string) (dnsverification.Report, error) {
verifications++
if provider != "cloudflare" || name != "example.test" || credentials["api_token"] != "fixture-sensitive-token" {
t.Fatal("验证未使用数据库账户对应的凭据")
}
if _, exists := ctx.Deadline(); !exists {
t.Fatal("在线验证没有超时上限")
}
return dnsverification.Report{}, nil
})
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":1,"test_domain":"example.test"}`, http.StatusOK)
stored, err := f.members.GetDNSAccountForUse(t.Context(), "manager", item.ID, "project-a")
if err != nil || stored.Revision != 2 || stored.CredentialRef == "" || verifications != 1 {
t.Fatal("验证结果未写入正确版本", err)
}
oldReference := stored.CredentialRef
managedDNSRequest(t, f, "manager", http.MethodPut, path, `{"revision":2,"name":"更名后的账户"}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path, `{"revision":2,"name":"旧版本"}`, http.StatusConflict)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":3,"enabled":false}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":4,"test_domain":"example.test"}`, http.StatusForbidden)
if verifications != 1 {
t.Fatal("已禁用账户仍调用服务商")
}
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/credentials", `{"revision":4,"credentials":{"api_token":"rotated-sensitive-token"}}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":5,"enabled":true}`, http.StatusOK)
stored, err = f.members.GetDNSAccountForVerification(t.Context(), "manager", item.ID)
if err != nil || stored.CredentialRef == oldReference || stored.VerificationState != domain.DNSAccountUnverified || len(secrets.data) != 2 {
t.Fatal("轮换没有生成新引用、保留旧引用或清除验证状态", err)
}
f.server.dnsAccountVerifier = accountVerifierFunc(func(_ context.Context, _, _ string, credentials map[string]string) (dnsverification.Report, error) {
if credentials["api_token"] != "rotated-sensitive-token" {
t.Fatal("验证仍使用旧凭据")
}
return dnsverification.Report{}, errors.New("internal-sensitive-error")
})
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":6,"test_domain":"example.test"}`, http.StatusUnprocessableEntity)
stored, err = f.members.GetVisibleDNSAccount(t.Context(), "manager", item.ID)
if err != nil || stored.VerificationState != domain.DNSAccountVerificationFailed || stored.Revision != 7 {
t.Fatal("服务商失败没有记录到对应版本", err)
}
var count int
if err := f.db.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE resource_type='dns_account' AND (message LIKE '%token%' OR message LIKE '%secret://%')`).Scan(&count); err != nil || count != 0 {
t.Fatal("审计泄露凭据", err)
}
}
func TestManagedDNSAccountPermissionsAndListing(t *testing.T) {
f, secrets, item := newManagedDNSFixture(t)
for _, actor := range []string{"viewer", "global-admin", "target", "anonymous"} {
status := http.StatusForbidden
if actor == "anonymous" {
status = http.StatusUnauthorized
}
managedDNSRequest(t, f, actor, http.MethodPost, "", managedDNSCreateBody, status)
managedDNSRequest(t, f, actor, http.MethodPut, "/"+item.ID, `{"revision":1,"name":"越权更名"}`, status)
managedDNSRequest(t, f, actor, http.MethodPut, "/"+item.ID+"/status", `{"revision":1,"enabled":false}`, status)
managedDNSRequest(t, f, actor, http.MethodPut, "/"+item.ID+"/credentials", `{"revision":1,"credentials":{"api_token":"rotated-sensitive-token"}}`, status)
managedDNSRequest(t, f, actor, http.MethodPost, "/"+item.ID+"/verify", `{"revision":1,"test_domain":"example.test"}`, status)
}
if len(secrets.data) != 1 || secrets.getCalls != 0 {
t.Fatal("越权请求访问了秘密存储")
}
for actor, count := range map[string]int{"manager": 1, "viewer": 1, "global-admin": 0, "target": 0, "primary-super-admin": 1} {
response := managedDNSRequest(t, f, actor, http.MethodGet, "", "", http.StatusOK)
var page struct {
Items []dnsAccountView `json:"items"`
}
if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || len(page.Items) != count {
t.Fatal("独立账户列表未按项目过滤", actor, err)
}
if count == 1 && (page.Items[0].OwnerUserID != "manager" || page.Items[0].OwnerState != "assigned" || page.Items[0].OwnerUsername == "" || !page.Items[0].Managed) {
t.Fatal("列表缺少用户归属或管理标记")
}
}
managedDNSRequest(t, f, "viewer", http.MethodGet, "/"+item.ID, "", http.StatusOK)
managedDNSRequest(t, f, "global-admin", http.MethodGet, "/"+item.ID, "", http.StatusForbidden)
managedDNSRequest(t, f, "global-admin", http.MethodGet, "/missing", "", http.StatusForbidden)
// 系统账户属于别的项目时,不能把本项目独立账户一并隐藏。
f.server.config.DNS.CredentialRef, f.server.config.DNS.ProjectID = "secret://legacy/hidden", "project-b"
response := managedDNSRequest(t, f, "manager", http.MethodGet, "", "", http.StatusOK)
if !strings.Contains(response.Body.String(), item.ID) || strings.Contains(response.Body.String(), "system-dns") || secrets.getCalls != 0 {
t.Fatal("旧系统账户影响独立账户范围或触发秘密读取")
}
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
if _, err := f.db.Exec(`DELETE FROM users WHERE id='manager'`); err != nil {
t.Fatal(err)
}
f.server.config.DNS.CredentialRef = ""
response = managedDNSRequest(t, f, "primary-super-admin", http.MethodGet, "", "", http.StatusOK)
if !strings.Contains(response.Body.String(), `"owner_state":"missing"`) {
t.Fatal("删除用户后未保留历史归属")
}
}
func TestManagedDNSAccountRejectsForgedAndStaleVerification(t *testing.T) {
for _, change := range []string{"rotate", "disable", "revoke"} {
t.Run(change, func(t *testing.T) {
f, secrets, item := newManagedDNSFixture(t)
f.server.dnsAccountVerifier = accountVerifierFunc(func(context.Context, string, string, map[string]string) (dnsverification.Report, error) {
var err error
switch change {
case "rotate":
_, err = f.members.RotateDNSAccountCredential(t.Context(), "manager", item.ID, 1, "secret://dns/new-version")
case "disable":
_, err = f.members.SetDNSAccountEnabled(t.Context(), "manager", item.ID, 1, false)
case "revoke":
err = f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin)
}
if err != nil {
t.Fatal(err)
}
return dnsverification.Report{}, nil
})
path := "/" + item.ID + "/verify"
managedDNSRequest(t, f, "manager", http.MethodPost, path, `{"revision":1,"test_domain":"example.test","verified":true}`, http.StatusBadRequest)
managedDNSRequest(t, f, "manager", http.MethodPost, path, `{"revision":2,"test_domain":"example.test"}`, http.StatusConflict)
if secrets.getCalls != 0 {
t.Fatal("伪造或过期请求读取凭据")
}
status := http.StatusConflict
if change == "revoke" {
status = http.StatusForbidden
}
managedDNSRequest(t, f, "manager", http.MethodPost, path, `{"revision":1,"test_domain":"example.test"}`, status)
stored, err := f.members.GetVisibleDNSAccount(t.Context(), "primary-super-admin", item.ID)
if err != nil || stored.VerificationState != domain.DNSAccountUnverified {
t.Fatal("旧验证响应覆盖新状态", err)
}
})
}
}
func TestManagedDNSAccountStrictRequests(t *testing.T) {
f, secrets, item := newManagedDNSFixture(t)
for _, field := range []string{`"owner_user_id":"primary-super-admin"`, `"credential_ref":"secret://dns/forged"`, `"verification_state":"verified"`, `"status":"active"`} {
body := strings.TrimSuffix(managedDNSCreateBody, "}") + "," + field + "}"
managedDNSRequest(t, f, "manager", http.MethodPost, "", body, http.StatusBadRequest)
}
for _, body := range []string{`{"revision":1}`, `{"revision":0,"enabled":true}`, `{"revision":1,"enabled":false,"actor_id":"primary-super-admin"}`} {
managedDNSRequest(t, f, "manager", http.MethodPut, "/"+item.ID+"/status", body, http.StatusBadRequest)
}
managedDNSRequest(t, f, "manager", http.MethodPost, "", strings.Replace(managedDNSCreateBody, "project-a", "project-b", 1), http.StatusForbidden)
managedDNSRequest(t, f, "manager", http.MethodPost, "", strings.Replace(managedDNSCreateBody, "cloudflare", "unknown", 1), http.StatusBadRequest)
managedDNSRequest(t, f, "manager", http.MethodPut, "/"+item.ID, `{"revision":1,"name":""}`, http.StatusBadRequest)
managedDNSRequest(t, f, "manager", http.MethodPut, "/"+item.ID+"/credentials", `{"revision":1,"credentials":{"api_token":""}}`, http.StatusBadRequest)
if len(secrets.data) != 1 {
t.Fatal("无效请求留下秘密")
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/dns/accounts", strings.NewReader(managedDNSCreateBody))
request.Header.Set("Origin", "https://untrusted.example.test")
request.AddCookie(f.cookies["manager"])
response := httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden || len(secrets.data) != 1 {
t.Fatal("跨源请求创建了账户", response.Code)
}
if _, err := f.db.Exec(`CREATE TRIGGER reject_managed_dns_audit BEFORE INSERT ON audit_logs WHEN NEW.resource_type='dns_account' BEGIN SELECT RAISE(ABORT, 'blocked'); END`); err != nil {
t.Fatal(err)
}
managedDNSRequest(t, f, "manager", http.MethodPut, "/"+item.ID, `{"revision":1,"name":"不能保存"}`, http.StatusServiceUnavailable)
managedDNSRequest(t, f, "manager", http.MethodPost, "", managedDNSCreateBody, http.StatusServiceUnavailable)
var count int
if err := f.db.QueryRow(`SELECT COUNT(*) FROM dns_accounts`).Scan(&count); err != nil || count != 1 {
t.Fatal("审计失败留下账户", err)
}
stored, err := f.members.GetVisibleDNSAccount(t.Context(), "manager", item.ID)
if err != nil || stored.Name != item.Name || stored.Revision != item.Revision {
t.Fatal("审计失败没有回滚改名", err)
}
// 不确定提交结果时保留新秘密,避免误删可能已提交的引用;待引用回收流程处理。
if len(secrets.data) != 2 {
t.Fatalf("提交错误后的秘密保护不符:%d", len(secrets.data))
}
}
type failingDNSAccountSecretStore struct {
*fakeSecretReaderStore
putErr error
getErr error
}
func (s failingDNSAccountSecretStore) Put(ctx context.Context, reference string, credentials map[string]string) error {
if s.putErr != nil {
return s.putErr
}
return s.fakeSecretReaderStore.Put(ctx, reference, credentials)
}
func (s failingDNSAccountSecretStore) Get(ctx context.Context, reference string) (map[string]string, error) {
if s.getErr != nil {
return nil, s.getErr
}
return s.fakeSecretReaderStore.Get(ctx, reference)
}
func TestManagedDNSAccountDependencyFailures(t *testing.T) {
for _, failure := range []string{"missing_store", "write_store", "read_store", "repository", "canceled_verification"} {
t.Run(failure, func(t *testing.T) {
f, secrets, item := newManagedDNSFixture(t)
method, path, body := http.MethodPost, "", managedDNSCreateBody
status := http.StatusServiceUnavailable
calls := 0
f.server.dnsAccountVerifier = accountVerifierFunc(func(context.Context, string, string, map[string]string) (dnsverification.Report, error) {
calls++
return dnsverification.Report{}, context.Canceled
})
switch failure {
case "missing_store":
f.server.secrets = nil
case "write_store":
f.server.secrets = failingDNSAccountSecretStore{fakeSecretReaderStore: secrets, putErr: errors.New("internal-sensitive-error")}
case "read_store":
f.server.secrets = failingDNSAccountSecretStore{fakeSecretReaderStore: secrets, getErr: errors.New("internal-sensitive-error")}
path, body = "/"+item.ID+"/verify", `{"revision":1,"test_domain":"example.test"}`
case "repository":
f.server.projectMembers = nil
case "canceled_verification":
path, body = "/"+item.ID+"/verify", `{"revision":1,"test_domain":"example.test"}`
status = http.StatusUnprocessableEntity
}
managedDNSRequest(t, f, "manager", method, path, body, status)
stored, err := f.members.GetVisibleDNSAccount(t.Context(), "primary-super-admin", item.ID)
if err != nil {
t.Fatal(err)
}
if failure == "canceled_verification" {
if calls != 1 || stored.VerificationState != domain.DNSAccountVerificationFailed || stored.Revision != 2 {
t.Fatal("取消验证没有记录失败")
}
} else if calls != 0 || stored.Revision != 1 || len(secrets.data) != 1 {
t.Fatal("依赖失败仍调用服务商或修改状态")
}
})
}
}

View File

@ -25,14 +25,32 @@ type dnsAccountView struct {
OwnerState string `json:"owner_state"` OwnerState string `json:"owner_state"`
VerificationState string `json:"verification_state"` VerificationState string `json:"verification_state"`
CredentialState string `json:"credential_state"` CredentialState string `json:"credential_state"`
Status string `json:"status"`
Revision int64 `json:"revision"`
Managed bool `json:"managed"`
} }
// listDNSAccounts 仅列出当前绑定的配置,不枚举凭据仓库中的临时或历史密钥。 // listDNSAccounts 合并独立账户和旧系统配置;不枚举秘密仓库中的临时或历史密钥。
func (s *Server) listDNSAccounts(c *gin.Context) { func (s *Server) listDNSAccounts(c *gin.Context) {
s.configMu.RLock() s.configMu.RLock()
cfg := s.config cfg := s.config
s.configMu.RUnlock() s.configMu.RUnlock()
items := make([]dnsAccountView, 0, 1) items := make([]dnsAccountView, 0, 1)
if repo, ok := s.projectMembers.(repository.DNSAccountRepository); ok {
accounts, err := repo.ListVisibleDNSAccounts(c.Request.Context(), c.GetString(authenticatedActorKey))
if err != nil {
s.writeDNSAccountError(c, err)
return
}
for _, account := range accounts {
item, err := s.managedDNSAccountView(c, account)
if err != nil {
s.writeError(c, http.StatusServiceUnavailable, "读取 DNS 账户归属失败", nil)
return
}
items = append(items, item)
}
}
if strings.TrimSpace(cfg.DNS.CredentialRef) == "" { if strings.TrimSpace(cfg.DNS.CredentialRef) == "" {
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
return return
@ -66,6 +84,7 @@ func (s *Server) listDNSAccounts(c *gin.Context) {
ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识", ProviderName: "未知服务商", AccountIdentifier: "未提供账号标识",
OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配", OwnerUserID: cfg.DNS.OwnerUserID, OwnerState: "unassigned", OwnerUsername: "未分配",
VerificationState: cfg.DNS.VerificationState, CredentialState: "available", VerificationState: cfg.DNS.VerificationState, CredentialState: "available",
Status: domain.DNSAccountActive,
} }
var owner repository.User var owner repository.User
ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID) ownerID := strings.TrimSpace(cfg.DNS.OwnerUserID)
@ -110,6 +129,30 @@ func (s *Server) listDNSAccounts(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
} }
func (s *Server) managedDNSAccountView(c *gin.Context, account domain.DNSAccount) (dnsAccountView, error) {
item := dnsAccountView{
ID: account.ID, Name: account.Name, Provider: account.Provider, ProviderName: "未知服务商",
ProjectID: account.ProjectID, OwnerUserID: account.OwnerUserID,
OwnerUsername: "原用户已不存在", OwnerState: "missing",
VerificationState: account.VerificationState, CredentialState: "stored",
AccountIdentifier: "凭据已隐藏", Status: account.Status, Revision: account.Revision, Managed: true,
}
if descriptor, err := dnsprovider.GetDescriptor(account.Provider); err == nil {
item.ProviderName = descriptor.Name
}
if s.users == nil {
return dnsAccountView{}, errors.New("用户归属查询暂不可用")
}
owner, err := s.users.GetUserByID(c.Request.Context(), account.OwnerUserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return dnsAccountView{}, err
}
if err == nil {
item.OwnerUsername, item.OwnerState = owner.Username, "assigned"
}
return item, nil
}
// dnsAccountIdentifier 只允许输出公开标识的脱敏结果,绝不输出令牌或私钥片段。 // dnsAccountIdentifier 只允许输出公开标识的脱敏结果,绝不输出令牌或私钥片段。
func dnsAccountIdentifier(provider string, credentials map[string]string) string { func dnsAccountIdentifier(provider string, credentials map[string]string) string {
var value string var value string

View File

@ -14,6 +14,12 @@ func (s *Server) registerProjectResourceRoutes() {
api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated) api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
api.GET("/domains", s.listDomains) api.GET("/domains", s.listDomains)
api.GET("/dns/accounts", s.listDNSAccounts) api.GET("/dns/accounts", s.listDNSAccounts)
api.GET("/dns/accounts/:id", s.getManagedDNSAccount)
api.POST("/dns/accounts", s.requireSameOrigin, s.createManagedDNSAccount)
api.PUT("/dns/accounts/:id", s.requireSameOrigin, s.renameManagedDNSAccount)
api.PUT("/dns/accounts/:id/status", s.requireSameOrigin, s.setManagedDNSAccountStatus)
api.PUT("/dns/accounts/:id/credentials", s.requireSameOrigin, s.rotateManagedDNSAccountCredential)
api.POST("/dns/accounts/:id/verify", s.requireSameOrigin, s.dnsOperation, s.verifyManagedDNSAccount)
api.GET("/audit-logs", s.listAuditLogs) api.GET("/audit-logs", s.listAuditLogs)
api.GET("/servers", s.listServers) api.GET("/servers", s.listServers)
api.GET("/servers/:id", s.getServer) api.GET("/servers/:id", s.getServer)

View File

@ -57,6 +57,7 @@ type Dependencies struct {
DeploymentFactory deployment.ClientFactory DeploymentFactory deployment.ClientFactory
RecoveryInspection localRecoveryReader RecoveryInspection localRecoveryReader
SetupDNS setupDNSVerifier SetupDNS setupDNSVerifier
DNSAccountVerifier dnsAccountVerifier
Secrets setupSecretWriter Secrets setupSecretWriter
Firewall firewall.Manager Firewall firewall.Manager
IssueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) IssueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
@ -103,6 +104,7 @@ type Server struct {
deploymentFactory deployment.ClientFactory deploymentFactory deployment.ClientFactory
recoveryInspection localRecoveryReader recoveryInspection localRecoveryReader
setupDNS setupDNSVerifier setupDNS setupDNSVerifier
dnsAccountVerifier dnsAccountVerifier
secrets setupSecretWriter secrets setupSecretWriter
firewall firewall.Manager firewall firewall.Manager
issueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error) issueCertificate func(context.Context, string) (certificate.IssueResult, deployment.Report, error)
@ -171,6 +173,7 @@ func New(deps Dependencies) *Server {
servers: deps.Servers, servers: deps.Servers,
users: deps.Users, users: deps.Users,
projectMembers: deps.ProjectMembers, projectMembers: deps.ProjectMembers,
dnsAccountVerifier: deps.DNSAccountVerifier,
projectDirectory: deps.ProjectDirectory, projectDirectory: deps.ProjectDirectory,
projectLifecycle: deps.ProjectLifecycle, projectLifecycle: deps.ProjectLifecycle,
projectResources: deps.ProjectResources, projectResources: deps.ProjectResources,

View File

@ -19,6 +19,7 @@ type DNSAccountRepository interface {
ListVisibleDNSAccounts(context.Context, string) ([]domain.DNSAccount, error) ListVisibleDNSAccounts(context.Context, string) ([]domain.DNSAccount, error)
GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error) GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error)
GetDNSAccountForUse(context.Context, string, string, string) (domain.DNSAccount, error) GetDNSAccountForUse(context.Context, string, string, string) (domain.DNSAccount, error)
GetDNSAccountForVerification(context.Context, string, string) (domain.DNSAccount, error)
RenameDNSAccount(context.Context, string, string, int64, string) (domain.DNSAccount, error) RenameDNSAccount(context.Context, string, string, int64, string) (domain.DNSAccount, error)
SetDNSAccountEnabled(context.Context, string, string, int64, bool) (domain.DNSAccount, error) SetDNSAccountEnabled(context.Context, string, string, int64, bool) (domain.DNSAccount, error)
RotateDNSAccountCredential(context.Context, string, string, int64, string) (domain.DNSAccount, error) RotateDNSAccountCredential(context.Context, string, string, int64, string) (domain.DNSAccount, error)
@ -138,6 +139,22 @@ func (r *SQLProjectMemberRepository) RenameDNSAccount(ctx context.Context, actor
}) })
} }
// 首次验证允许读取尚未验证的账户,但必须实时拥有管理权限,且账户未被禁用。
// 结果落库仍须核对读取时版本,不能将此快照作为长时间授权或执行租约。
func (r *SQLProjectMemberRepository) GetDNSAccountForVerification(ctx context.Context, actorID, id string) (domain.DNSAccount, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return domain.DNSAccount{}, err
}
predicate, args := resourceReadPredicate("dns_accounts.project_id", actorID, domain.PermissionDNSManage)
args = append(args, id, domain.DNSAccountActive)
item, err := scanDNSAccount(r.db.QueryRowContext(ctx, Rebind(r.driver, `SELECT `+dnsAccountColumns+` FROM dns_accounts WHERE `+predicate+`
AND dns_accounts.id = ? AND dns_accounts.status = ?`), args...))
if errors.Is(err, sql.ErrNoRows) {
return domain.DNSAccount{}, ErrProjectAccessDenied
}
return item, err
}
func (r *SQLProjectMemberRepository) SetDNSAccountEnabled(ctx context.Context, actorID, id string, revision int64, enabled bool) (domain.DNSAccount, error) { func (r *SQLProjectMemberRepository) SetDNSAccountEnabled(ctx context.Context, actorID, id string, revision int64, enabled bool) (domain.DNSAccount, error) {
return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.status", func(item *domain.DNSAccount) error { return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.status", func(item *domain.DNSAccount) error {
item.Status = domain.DNSAccountDisabled item.Status = domain.DNSAccountDisabled

View File

@ -19,8 +19,11 @@ func TestDNSAccountRepository(t *testing.T) {
t.Run(driver, func(t *testing.T) { t.Run(driver, func(t *testing.T) {
cfg := config.Default(t.TempDir()).Database cfg := config.Default(t.TempDir()).Database
cfg.DSN = filepath.Join(t.TempDir(), "dns-accounts.db") cfg.DSN = filepath.Join(t.TempDir(), "dns-accounts.db")
var admin *sql.DB
if driver == "mysql" { if driver == "mysql" {
cfg = mysqltest.New(t).Database(t) server := mysqltest.New(t)
cfg = server.Database(t)
admin = server.Admin()
} }
if driver == "postgres" { if driver == "postgres" {
cfg = postgrestest.New(t).Database(t) cfg = postgrestest.New(t).Database(t)
@ -33,12 +36,12 @@ func TestDNSAccountRepository(t *testing.T) {
if err := database.Migrate(t.Context(), db.DB, driver); err != nil { if err := database.Migrate(t.Context(), db.DB, driver); err != nil {
t.Fatal(err) t.Fatal(err)
} }
checkDNSAccountRepository(t, db.DB, driver) checkDNSAccountRepository(t, db.DB, admin, driver)
}) })
} }
} }
func checkDNSAccountRepository(t *testing.T, db *sql.DB, driver string) { func checkDNSAccountRepository(t *testing.T, db, admin *sql.DB, driver string) {
t.Helper() t.Helper()
repo := NewSQLProjectMemberRepository(db, driver) repo := NewSQLProjectMemberRepository(db, driver)
for _, actor := range []struct{ id, role string }{ for _, actor := range []struct{ id, role string }{
@ -101,6 +104,17 @@ func checkDNSAccountRepository(t *testing.T, db *sql.DB, driver string) {
if _, err := repo.GetDNSAccountForUse(t.Context(), "dns", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { if _, err := repo.GetDNSAccountForUse(t.Context(), "dns", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatal("未验证账户可被使用", err) t.Fatal("未验证账户可被使用", err)
} }
for _, actor := range []string{"root", "manager", "dns"} {
item, err := repo.GetDNSAccountForVerification(t.Context(), actor, a.ID)
if err != nil || item.CredentialRef == "" || item.Revision != a.Revision {
t.Fatal("管理角色无法开始首次验证", actor, err)
}
}
for _, actor := range []string{"reader", "cert", "audit", "outsider", "missing"} {
if _, err := repo.GetDNSAccountForVerification(t.Context(), actor, a.ID); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatal("无管理权限取得待验证凭据", actor, err)
}
}
a, err = repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, true) a, err = repo.RecordDNSAccountVerification(t.Context(), "dns", a.ID, a.Revision, true)
if err != nil || a.Revision != 2 { if err != nil || a.Revision != 2 {
t.Fatal(err) t.Fatal(err)
@ -146,6 +160,9 @@ func checkDNSAccountRepository(t *testing.T, db *sql.DB, driver string) {
if _, err := repo.GetDNSAccountForUse(t.Context(), "root", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) { if _, err := repo.GetDNSAccountForUse(t.Context(), "root", a.ID, "dns-a"); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatal("禁用账户允许使用", err) t.Fatal("禁用账户允许使用", err)
} }
if _, err := repo.GetDNSAccountForVerification(t.Context(), "root", a.ID); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatal("禁用账户可开始验证", err)
}
a, err = repo.SetDNSAccountEnabled(t.Context(), "manager", a.ID, a.Revision, true) a, err = repo.SetDNSAccountEnabled(t.Context(), "manager", a.ID, a.Revision, true)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -224,17 +241,25 @@ func checkDNSAccountRepository(t *testing.T, db *sql.DB, driver string) {
t.Fatal("并发失败留下多余审计", err) t.Fatal("并发失败留下多余审计", err)
} }
var rejection []string var rejection []string
executor := db
switch driver { switch driver {
case "sqlite": case "sqlite":
rejection = []string{`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs WHEN NEW.resource_type='dns_account' BEGIN SELECT RAISE(ABORT, 'blocked'); END`} rejection = []string{`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs WHEN NEW.resource_type='dns_account' BEGIN SELECT RAISE(ABORT, 'blocked'); END`}
case "mysql": case "mysql":
rejection = []string{`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='blocked'`} // 仅隔离库故障注入使用管理连接,业务仓储仍使用受限账户,不放宽全局权限。
var schema string
if err := db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil {
t.Fatal(err)
}
schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`"
rejection = []string{`CREATE TRIGGER ` + schema + `.reject_dns_audit BEFORE INSERT ON ` + schema + `.audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='blocked'`}
executor = admin
case "postgres": case "postgres":
rejection = []string{`CREATE FUNCTION reject_dns_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'blocked'; END $$`, rejection = []string{`CREATE FUNCTION reject_dns_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'blocked'; END $$`,
`CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION reject_dns_audit()`} `CREATE TRIGGER reject_dns_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION reject_dns_audit()`}
} }
for _, statement := range rejection { for _, statement := range rejection {
if _, err := db.ExecContext(t.Context(), statement); err != nil { if _, err := executor.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }

View File

@ -33,7 +33,7 @@ const visibleDomainSelect = `SELECT domains.id, domains.name, domains.certificat
func resourceReadPredicate(projectColumn, actorID string, permission domain.ProjectPermission) (string, []any) { func resourceReadPredicate(projectColumn, actorID string, permission domain.ProjectPermission) (string, []any) {
valid := projectColumn == "domains.project_id" && permission == domain.PermissionCertificateRead || valid := projectColumn == "domains.project_id" && permission == domain.PermissionCertificateRead ||
projectColumn == "servers.project_id" && permission == domain.PermissionServerRead || projectColumn == "servers.project_id" && permission == domain.PermissionServerRead ||
projectColumn == "dns_accounts.project_id" && (permission == domain.PermissionDNSRead || permission == domain.PermissionDNSCredentialUse) projectColumn == "dns_accounts.project_id" && (permission == domain.PermissionDNSRead || permission == domain.PermissionDNSCredentialUse || permission == domain.PermissionDNSManage)
if !valid { if !valid {
return "1 = 0", nil return "1 = 0", nil
} }

View File

@ -17,11 +17,11 @@
- [✓] 修改前检查点已提交推送:`26b8c3c`。保留未跟踪文件 `=`,不提交、不删除。 - [✓] 修改前检查点已提交推送:`26b8c3c`。保留未跟踪文件 `=`,不提交、不删除。
- [x] 账户基础模型和迁移二十七:独立账户元数据、归属、状态、秘密引用和版本号;三库初轮专项通过,等待最终回归复审。 - [x] 账户基础模型和迁移二十七:独立账户元数据、归属、状态、秘密引用和版本号;三库初轮专项通过,等待最终回归复审。
- [x] 基础仓储:实时项目权限、事务审计、版本冲突、轮换后验证失效和禁止公开返回秘密引用;未提供无引用保护的删除方法。 - [x] 基础仓储:实时项目权限、事务审计、版本冲突、轮换后验证失效和禁止公开返回秘密引用;未提供无引用保护的删除方法。
- [~] 三库迁移和仓储自动化:首轮通过,正在补充三轮竞态、三库审计失败回滚及 SQLite 四种开关组合。 - [✓] 三库迁移和仓储专项三轮竞态通过;MySQL 审计故障夹具权限已修正,失败记录保留。完整执行链仍待实现验证。
- [ ] 旧系统账户导入及幂等恢复,保护配置与历史凭据引用。 - [ ] 旧系统账户导入及幂等恢复,保护配置与历史凭据引用。
- [ ] 域名绑定、引用保护、安全删除,以及运行中任务的凭据租约和清理。 - [ ] 域名绑定、引用保护、安全删除,以及运行中任务的凭据租约和清理。
- [ ] 正式签发、续期、重试的账户解析与执行前权限复核。 - [ ] 正式签发、续期、重试的账户解析与执行前权限复核。
- [ ] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离。 - [x] 账户验证、增改、启停、轮换接口及无缓存和敏感字段隔离;本地全包与专项通过,第一台 Linux 回归待终态确认。
- [ ] DNS 账户页、域名配置页及前端回归。 - [ ] DNS 账户页、域名配置页及前端回归。
- [ ] 第一台 Linux 全量、竞态、三库实测与正式产物升级。 - [ ] 第一台 Linux 全量、竞态、三库实测与正式产物升级。
- [ ] 公共域名真实浏览器和授权 DNS/公共 CA 验收。 - [ ] 公共域名真实浏览器和授权 DNS/公共 CA 验收。
@ -35,5 +35,17 @@
- 本地首轮专项发现 SQLite 默认外键开关关闭时引用保护无效;补项目插入、更新、删除、更换标识及替换触发器,不全局修改用户连接参数。首批失败真实保留,不把原代码计为通过。 - 本地首轮专项发现 SQLite 默认外键开关关闭时引用保护无效;补项目插入、更新、删除、更换标识及替换触发器,不全局修改用户连接参数。首批失败真实保留,不把原代码计为通过。
- 本地领域、数据库和仓储包全量通过;本地未配置的 MySQL/PostgreSQL 实库用例跳过,不计实库证据。新增完整用例后三轮专项和 vet 通过。 - 本地领域、数据库和仓储包全量通过;本地未配置的 MySQL/PostgreSQL 实库用例跳过,不计实库证据。新增完整用例后三轮专项和 vet 通过。
- 第一台首轮任务 `scert-dns-accounts-20261004-0425.service` 已 inactive、success、退出码零:三库迁移、账户仓储、MySQL 八种中断/冲突和 vet 通过。日志为 `/var/tmp/scert-dns-accounts-20261004-0425/dns-accounts.log`。 - 第一台首轮任务 `scert-dns-accounts-20261004-0425.service` 已 inactive、success、退出码零:三库迁移、账户仓储、MySQL 八种中断/冲突和 vet 通过。日志为 `/var/tmp/scert-dns-accounts-20261004-0425/dns-accounts.log`。
- 增量竞态任务 `scert-dns-accounts-race-20261004-0430.service` 正在运行;结果以真实终态为准,尚未标记通过。 - 增量竞态任务 `scert-dns-accounts-race-20261004-0430.service` 于 04:41 实查为 failed、退出码一;数据库三轮通过,185.274 秒,仓储 SQLite/PostgreSQL 通过,MySQL 三轮均在故障触发器创建时报 1419。修复仅将隔离库触发器注入改用测试管理连接,业务操作继续受限连接,不修改生产权限或全局信任参数。
- [~] 本轮沿用已确认需求:先复核修复后的实库竞态,再接入域名账户绑定及引用保护,随后接续执行链和页面;数据层检查点 `02e0399` 已提交推送,完整功能仍未完成。
- 当前 HTTP 路由、系统旧账户、域名签发配置和线上正式版本均未切换,生产数据及第二台未操作。 - 当前 HTTP 路由、系统旧账户、域名签发配置和线上正式版本均未切换,生产数据及第二台未操作。
## 账户管理接口接入(运行环境 UTC+8,2026年10月4日05:04)
- [x] `/api/v1/dns/accounts` 现在合并独立账户与旧系统账户;独立账户列表只读元数据、不解密凭据,显示真实归属用户,用户已删除时保留原标识并显示缺失状态。旧系统账户仍是兼容视图,未伪装成已完成导入。
- [x] 创建、读取、改名、启停、轮换、验证六条路由已接入真实数据库仓储;写操作检查同源、实时项目权限,创建不能冒领归属或伪造验证结果,修改携带版本号。读取、拒绝和错误响应均禁止缓存。
- [x] 首次验证使用独立的管理权限入口,只读取目标账户的实际凭据并调用现有 DNS 在线验证器;轮换、禁用或撤权后,旧验证结果不得覆盖新状态。不接受客户端自报成功、凭据引用或验证令牌。
- [x] 故障注入覆盖秘密写入/读取失败、服务商错误、验证取消、依赖缺失、审计失败回滚和敏感字段隔离。HTTP 测试使用隔离数据库与注入验证器,不能计为真实云 DNS 成功。
- [✓] 本地 HTTP、仓储、数据库、领域四包全量及 vet 通过;随后新增依赖错误用例后的三轮 HTTP/仓储专项及 vet 通过。本地缺少外部数据库时跳过的用例不计实库证据。
- [✓] 05:09 实查第一台 `scert-dns-accounts-api-20261004-0458.service` 已 inactive、success、退出码零;三库与 HTTP 三轮竞态及 vet 通过。领域 1.023 秒、数据库 185.441 秒、仓储 49.344 秒、HTTP 40.278 秒;日志 `/var/tmp/scert-dns-accounts-20261004-0425/dns-accounts-api-race.log`。此前 `0445` 环境变量错误退出一,失败记录保留。本次为隔离覆盖树,未包含最后追加的依赖故障用例与领域标识生成器复用,须由后续干净源码回归补齐。
- [ ] 旧系统账户导入、域名绑定、执行租约、引用回收和安全删除、签发/续期/重试、前端操作仍未完成。旧秘密及提交结果不确定时的新秘密暂时保留,不能在缺少引用保护时误删;这不是完整回收方案。
- [ ] 完成当前实现复审后,提交干净源码并在第一台做 Linux 全量;本批不重复正式升级,不操作第二台,不把接口进展当作生产验收。

View File

@ -1,5 +1,27 @@
# Linux 生产流程测试记录 # Linux 生产流程测试记录
## 2026年10月4日05:04 DNS 多账户数据层及管理接口
本节按运行环境 UTC+8 记录。第一台只执行独立数据库和源码目录测试,未升级正式服务;第二台未操作。没有清理生产数据、新增辅助验收脚本或制作远端备份。
### 首轮及失败记录
- `scert-dns-accounts-20261004-0425.service`:已成功终止,三库迁移、基础仓储、MySQL 八种中断/结构冲突及 vet 通过。源码目录 `/var/tmp/scert-dns-accounts-20261004-0425`,日志 `dns-accounts.log`。
- `scert-dns-accounts-race-20261004-0430.service`:实查 failed、退出一;数据库三轮竞态通过,185.274 秒;仓储 SQLite/PostgreSQL 通过,MySQL 故障触发器创建三轮均报 1419。修复只让隔离库的故障注入使用测试管理连接,仓储仍使用受限业务连接,没有放宽数据库全局信任参数。
- `scert-dns-accounts-race-fixed-20261004-0445.service`:实查 failed、退出一;启动命令误用隔离产物环境变量,测试正确拒绝缺失的绝对目录。修正为 `SCERT_MYSQL_TEST_ROOT` 和 `SCERT_POSTGRES_TEST_ROOT` 后启动下一任务,此次失败不能计为实库通过。
- 新增 HTTP 用户已删除场景首轮被“先撤销项目成员关系”的既有保护拒绝;夹具补充正式撤权后再删除测试用户,不移除保护或放宽断言。
### 当前回归范围
- 本地领域、数据库、仓储、HTTP 四包全量成功,分别 0.047、23.621、8.633、28.412 秒,随后 vet 成功;增加秘密读写失败、依赖缺失和验证取消场景后三轮 HTTP/仓储专项及 vet 通过。本地外部实库跳过不计通过证据。
- 05:09 实查第一台 `scert-dns-accounts-api-20261004-0458.service` 已 inactive、success、退出码零;日志 `dns-accounts-api-race.log`。三库和 HTTP 三轮竞态及 vet 通过,领域 1.023 秒、数据库 185.441 秒、仓储 49.344 秒、HTTP 40.278 秒。覆盖 DNS 账户、SQLite 四种开关、MySQL 迁移恢复和结构校验、迁移目录及 DNS 接口。隔离覆盖树未含最后追加的依赖故障用例及领域标识生成器复用,不是最新干净发行树全量。
- 使用隔离 MySQL 8.0.46、PostgreSQL 14.24;数据库仅回环监听,MySQL 严格 TLS。源码为覆盖测试树,不冒充干净发行树;最后本地追加的依赖故障用例和复用领域标识生成器将在干净提交验证中覆盖。
- HTTP 自动化覆盖真实 SQL 仓储与接口路由,但云验证器为注入实现,未调用真实云 DNS;没有声称独立账户已经用于实际签发、续期或部署。
### 尚未完成
旧系统账户幂等导入、域名账户绑定、运行租约与安全引用回收、签发/续期/重试接入、前端操作、干净源码 Linux 全量、正式升级及公共 DNS/CA 验收仍需继续。P1-03 和总体生产门禁不关闭。
## 2026年10月4日03:55 系统设置越权读取修复及正式部署 ## 2026年10月4日03:55 系统设置越权读取修复及正式部署
- 旧 `9c26db0` 工作树加入 `settings_scope_test.go`,执行 `scert-settings-scope-red-20261004-0333.service`,真实失败,退出码一。普通管理员 GET 原返回二百而非四百零三,响应未隔离缓存;角色降权后的旧会话原已返回四百零一,不误报为原认证失效。 - 旧 `9c26db0` 工作树加入 `settings_scope_test.go`,执行 `scert-settings-scope-red-20261004-0333.service`,真实失败,退出码一。普通管理员 GET 原返回二百而非四百零三,响应未隔离缓存;角色降权后的旧会话原已返回四百零一,不误报为原认证失效。

View File

@ -1,5 +1,13 @@
# Scert 开发任务清单 # Scert 开发任务清单
## 2026年10月4日05:04 DNS 多账户接口开发
- [x] 创建、详情、改名、启停、轮换与在线验证已接真实仓储;列表返回独立账户和旧系统兼容账户,显示归属用户,独立账户列表不解密秘密。
- [x] 验证读取要求实时管理权限;验证响应写回再次核对版本和权限,轮换、禁用与撤权后拒绝旧响应。客户端不能指定归属、秘密引用或验证成功状态。
- [✓] 本地四包全量及 vet 通过,追加依赖故障场景后的三轮专项通过。
- [✓] 05:09 实查第一台三库、HTTP API 三轮专项竞态及 vet 成功终止,退出码零;具体单元及隔离覆盖树边界见专项清单和 Linux 测试记录。
- [ ] 前端账户操作、旧配置导入、域名绑定、任务租约及引用回收、正式签发续期仍未完成;本批没有升级线上服务。
## 2026年10月4日04:32 DNS 多账户开发 ## 2026年10月4日04:32 DNS 多账户开发
- [x] 独立账户数据模型、三库迁移二十七、带实时项目授权的基础操作仓储已编写,初轮实库通过。 - [x] 独立账户数据模型、三库迁移二十七、带实时项目授权的基础操作仓储已编写,初轮实库通过。

View File

@ -1,5 +1,13 @@
# Scert 整改任务执行清单 # Scert 整改任务执行清单
## 2026年10月4日05:04 DNS 账户管理接口
- [✓] 修改前检查点 `02e0399` 已提交推送,包含上一批数据层和失败夹具状态;未跟踪文件 `=` 不处理。
- [x] 独立账户管理与服务端验证路由、实时项目权限、版本竞争和用户归属列表已接入。
- [✓] 本地四包全量、静态检查,以及新增故障用例后的三轮专项通过;验证器注入测试不算真实 DNS 云端验收。
- [✓] 05:09 实查第一台三库及 HTTP 三轮竞态任务 `scert-dns-accounts-api-20261004-0458.service` 已 inactive、success、退出码零,含 vet;两批失败均保留。隔离覆盖树结果不代替最新干净源码全量。
- [ ] 旧账户导入、域名绑定、执行租约、安全回收、签发续期和前端仍待开发,完整 P1-03 不关闭。
## 2026年10月4日04:32 DNS 多账户数据层 ## 2026年10月4日04:32 DNS 多账户数据层
- [✓] 基于 P1-03 已确认需求建立《DNS多账户整改任务清单》,修改前检查点 `26b8c3c` 已提交推送。 - [✓] 基于 P1-03 已确认需求建立《DNS多账户整改任务清单》,修改前检查点 `26b8c3c` 已提交推送。

View File

@ -1,5 +1,15 @@
# Scert 整改、合并与优化清单 # Scert 整改、合并与优化清单
## 2026年10月4日05:04 DNS 多账户接口接入
本节按运行环境 UTC+8 记录,优先于历史状态;范围仍为 Linux 与 Web。
- [x] 独立 DNS 账户数据层已接入创建、读取、改名、启停、轮换、服务端在线验证接口;账户列表显示项目及用户归属,旧系统账户保留兼容展示。不是只保存数据模型,也不是完整生命周期已完成。
- [✓] 本地四包全量和静态检查通过,追加依赖故障用例后的三轮专项通过;覆盖越权、旧版本、验证期间轮换/禁用/撤权、审计回滚和响应不含凭据。
- [✓] 05:09 实查第一台三库与 HTTP 三轮专项竞态、vet 已成功终止,退出码零;此前触发器权限和启动环境变量错误保留失败记录。故障注入使用隔离库管理连接,业务操作仍使用受限账户。本次为隔离覆盖树,最后两项本地增量待干净源码回归。
- [ ] 旧账户导入、域名绑定、执行租约和引用回收、签发续期、前端账户操作尚未完成。P1-03 和总体生产验收维持未完成。
- [ ] 当前在线版本未升级;第一台公共入口、系统发信、三库首次 Web 和真实云端验收仍需完成。未修改解析、清理生产数据或重复第二台主程序测试。
## 2026年10月4日04:06 DNS 多账户生命周期开发 ## 2026年10月4日04:06 DNS 多账户生命周期开发
- [~] 正在按《DNS多账户整改任务清单》补独立账户模型、三库迁移及带实时权限的生命周期仓储;修改前检查点 `26b8c3c` 已推送。 - [~] 正在按《DNS多账户整改任务清单》补独立账户模型、三库迁移及带实时权限的生命周期仓储;修改前检查点 `26b8c3c` 已推送。

View File

@ -41,6 +41,8 @@
## P1-03 完善 ACME 和 DNS 管理 ## P1-03 完善 ACME 和 DNS 管理
2026年10月4日05:09 接口增量复查:独立账户创建、详情、改名、启停、轮换和服务端在线验证路由已接入真实仓储,列表增加归属用户、状态、版本和独立账户标记。首次验证必须具有管理权限,结果落库再次检查实时授权与版本;接口不接受客户端自报验证成功或秘密引用。四包本地全量、静态检查、追加故障用例后的三轮专项通过;第一台三库与 HTTP 三轮专项竞态及 vet 已成功终止,退出码零。隔离覆盖树未含最后两项本地增量,待干净源码补齐。此前 MySQL 审计触发器权限和环境变量错误保留失败记录。旧配置导入、域名绑定、任务租约、安全回收、签发续期与前端仍未完成;注入验证器不算真实云 DNS 或生产验收。
2026年10月4日04:32 增量:独立 DNS 账户模型、三库迁移二十七和实时授权基础仓储已经实现,首轮第一台三库专项与 vet 通过;SQLite 外键关闭时的保护缺口已补触发器修复。三轮竞态、三库审计回滚等增量回归运行中。仍未接入旧系统账户导入、域名绑定、任务租约、正式签发续期、账户接口和页面,不能把本批基础数据层视为完整多账户生命周期。细项见《DNS多账户整改任务清单》。 2026年10月4日04:32 增量:独立 DNS 账户模型、三库迁移二十七和实时授权基础仓储已经实现,首轮第一台三库专项与 vet 通过;SQLite 外键关闭时的保护缺口已补触发器修复。三轮竞态、三库审计回滚等增量回归运行中。仍未接入旧系统账户导入、域名绑定、任务租约、正式签发续期、账户接口和页面,不能把本批基础数据层视为完整多账户生命周期。细项见《DNS多账户整改任务清单》。
2026年10月4日03:14 更新:HTTP-01 已接入正式签发、订单隔离路由、域名验证方式与迁移二十六;实际签发快照授权已补强。第一台 Linux 全量、vet、专项竞态、真实 Nginx 十项和三库迁移专项通过,干净 `9c26db0` 官方包已升级第一台并复核运行摘要。实际 lego 客户端完整 ACME 协议与公共域名签发仍待验收;system-dns 单账户列表不能代表独立多账户生命周期完成,完整账户及 CNAME 委派仍有开发缺口。保留六阶段未完成状态,内部就绪不替代公共入口、首次 Web 与全角色矩阵。 2026年10月4日03:14 更新:HTTP-01 已接入正式签发、订单隔离路由、域名验证方式与迁移二十六;实际签发快照授权已补强。第一台 Linux 全量、vet、专项竞态、真实 Nginx 十项和三库迁移专项通过,干净 `9c26db0` 官方包已升级第一台并复核运行摘要。实际 lego 客户端完整 ACME 协议与公共域名签发仍待验收;system-dns 单账户列表不能代表独立多账户生命周期完成,完整账户及 CNAME 委派仍有开发缺口。保留六阶段未完成状态,内部就绪不替代公共入口、首次 Web 与全角色矩阵。