diff --git a/.claude/docs/ai/project-members/api-handoff-v3.md b/.claude/docs/ai/project-members/api-handoff-v3.md new file mode 100644 index 0000000..413f86f --- /dev/null +++ b/.claude/docs/ai/project-members/api-handoff-v3.md @@ -0,0 +1,86 @@ +# 项目生命周期、资源读取及多对多绑定接口交接 + +日期:2026-09-28。本文对应已编写但尚未统一测试的源码;不表示接口、浏览器或生产部署验收通过。前两版保留历史范围,不修改作为 Smail 参考的《后端接口说明文档》。 + +## 业务范围与认证 + +域名与服务器是同项目内的多对多关系,一个域名可部署多台服务器,一台服务器也能承载多个域名。取消关联只改变当前域名,不删除服务器或远端文件、不影响其他域名。保存关联不执行 SSH、证书签发或推送。 + +使用原有会话 Cookie,操作者只能取服务端身份。全局普通管理员和用户分组不隐式授予项目权限;账户停用、成员撤权必须实时拒绝。成功和失败响应均使用 `Cache-Control: private, no-store`、`Vary: Cookie`。跨来源写入由现有保护拒绝。 + +## 项目生命周期 + +| 接口 | 请求 | 成功响应 | +|---|---|---| +| `POST /api/v1/projects` | `{"name":"生产证书","description":"生产环境"}` | 201,项目对象 | +| `PUT /api/v1/projects/:projectID` | `{"name":"生产证书","description":"更新说明"}` | 200,`{"message":"项目资料已更新"}` | +| `DELETE /api/v1/projects/:projectID` | `{"confirmation":"目标项目标识"}` | 204,无正文 | + +- 创建只允许活跃超级管理员,编辑/删除依实际项目管理权限;前端能力字段仅用于显示,不作为授权凭证。 +- 标识由服务端创建,格式 `^[a-z0-9][a-z0-9_-]{0,35}$`;请求不得伪造标识、操作者或其他未知字段,不接受查询参数。 +- 名称去除首尾空白后为 1~128 个 Unicode 字符,不含控制字符;说明最多 4096 个字符,允许换行、回车和制表符。 +- 默认项目不可删除,资源或成员尚有关联也不能删除。删除确认必须与目标 ID 一致,不代替用户自动清理资源。 +- 参数错误 400、未认证 401、无权或未知项目 403、默认项/引用冲突 409、依赖未就绪 503、内部错误 500。错误为 `{"error":"中文提示"}`。 +- 成功变更和审计同事务。前端生命周期与成员操作共用忙状态,禁止相互冲突的重复提交;切换项目或失去权限后丢弃旧响应。 + +## 资源读取入口 + +| 接口 | 权限 | 返回 | +|---|---|---| +| `GET /api/v1/domains` | `certificate.read` | 当前可见域名 `items` | +| `GET /api/v1/servers` | `server.read` | 当前可见服务器 `items` | +| `GET /api/v1/servers/:id` | `server.read` | 服务器对象 | +| `GET /api/v1/domains/:id/servers` | 双方读取权限,同项目 | 关联服务器 `items` | +| `GET /api/v1/certificates` | `certificate.read` | 当前可见证书元数据 `items` | +| `GET /api/v1/certificates/:id` | 域名的 `certificate.read` | 证书元数据 | +| `GET /api/v1/domains/:id/certificate/versions` | `certificate.read` | 版本 `items` | + +沿用原有响应字段;本批资源列表没有新增游标参数,不与项目目录分页混淆。列表在 SQL 内过滤,多角色不重复返回;空列表为 `[]` 而不是 `null`。未知与越权详情均为 403,缺少授权依赖为 503,不回退全量基础仓储。证书文件读取前先检查实际域名权限。 + +## 多对多关联写入 + +### `PUT /api/v1/domains/:id/servers` + +请求必须明确包含数组: + +```json +{"server_ids":["server-one","server-two"]} +``` + +清空当前域名: + +```json +{"server_ids":[]} +``` + +- 成功 200,`{"items":[服务器对象]}`;清空返回 `{"items":[]}`。按服务器名称、ID 排序,不能依赖请求顺序。 +- 有效超级管理员可操作;其他活跃账户需同时具备当前项目的 `certificate.read`、`server.read`、`deployment.execute`。当前角色矩阵中项目管理员和部署管理员符合,多个角色能力可合并。 +- 所有服务器必须与实际域名同项目,包括超级管理员;不接受客户端 `project_id`、`actor_id` 或任何查询参数。 +- ID 为 1~128 位 ASCII 字母、数字、连字符或下划线。最多输入 100 个服务器 ID,上限按去重前计算;重复 ID 去重后仅保存一条关联。 +- 缺字段、`null`、错误类型、未知字段、非法 ID、超限为 400;无权、跨项目或未知资源统一 403;已知共享路径冲突为 409;缺依赖为 503;内部失败为 500。401 表示未认证。 +- 成功审计 `domain.servers.replace` 与关联同事务,失败不清空旧绑定。失败审计根据实际域名归属写入,不保存请求体、密钥或原始数据库错误。 + +## 路径隔离与错误处理 + +默认目标为 `/ssl/{域名}/{证书类型}/` 下的 `cert.pem`、`fullchain.pem`、`key.pem`,也接受英文占位符 `{domain}`、`{certificate_type}`。 + +绑定保存、服务器配置修改及部署预检会比较实际展开并规范化后的各文件路径。多个域名共用固定路径、占位符被 `..` 抵消、不同文件类型相互占用或文件目录重叠时拒绝;单个域名的固定路径仍可使用。409 只返回中文修正提示,不暴露其他域名或私钥路径。 + +前端保留多选复选框,不因服务器已被其他域名使用而禁用或复制服务器。说明放入 `HelpTip`。409 时保留用户选择,引导修改服务器模板后重试;无权限或未知资源不显示区别提示。不要乐观清空列表,500/网络失败需刷新确认真实状态。 + +域名资料保存与绑定保存仍是两个请求,不应显示为跨请求原子提交。当前域名创建后绑定失败的重复提交体验及全资源写入授权仍需后续前端/权限整改,不能误称已闭环。 + +## 当前未覆盖的边界 + +- 本批路径预检只针对同一服务器记录的当前绑定,不识别多个记录指向同一主机、远端别名或解除绑定后的遗留文件;配置并发变化与在途任务的完整路径占用协调仍待部署整改。 +- 其他资源 CRUD、签发/导入/部署、DNS/SSH、任务读取/执行/取消/重试和审计读取的项目权限仍待补齐。 +- 本批只编写回归源码,尚未执行测试、构建或浏览器验收,旧批次通过结果不可复用为本批证据。 + +## 待统一执行的关键场景 + +1. A、B 两个域名均关联 S1、S2;分别读取均得到两台服务器,重复输入不产生重复关联。 +2. 修改或清空 A,B 的两条关联和服务器本身保持不变;只有显式空数组才能清空。 +3. 跨项目、越权、停用/撤权、未知资源、缺依赖或审计故障均不改变旧关联。 +4. 两个域名在同一服务器解析为不同路径;固定私钥/证书/链路径、规范化和跨文件冲突拒绝。 +5. 修改服务器不能引入共享冲突;历史危险配置必须在部署前被拒绝,不能调用远端写入。 +6. Vue 模板同时渲染两个已选复选框及悬浮提示;SSR 回归不代替真实浏览器、SSH 或多服务器生产推送验收。 diff --git a/backend/internal/app/certificate_workflow.go b/backend/internal/app/certificate_workflow.go index cbafb64..9a8d3cc 100644 --- a/backend/internal/app/certificate_workflow.go +++ b/backend/internal/app/certificate_workflow.go @@ -38,7 +38,7 @@ type certificateService interface { } type serverLister interface { - ListServersByDomainID(context.Context, string) ([]deployment.Server, error) + ListDeploymentServersByDomainID(context.Context, string) ([]deployment.Server, error) } type savedDeployer interface { @@ -191,7 +191,7 @@ func (w *CertificateWorkflow) deploy(ctx context.Context, result certificate.Iss if err != nil { return deployment.Report{}, fmt.Errorf("查询域名配置失败:%w", err) } - servers, err := w.Servers.ListServersByDomainID(ctx, result.DomainID) + servers, err := w.Servers.ListDeploymentServersByDomainID(ctx, result.DomainID) if err != nil { return deployment.Report{}, fmt.Errorf("查询域名部署服务器失败:%w", err) } diff --git a/backend/internal/app/certificate_workflow_test.go b/backend/internal/app/certificate_workflow_test.go index 047f0fa..e5fcf82 100644 --- a/backend/internal/app/certificate_workflow_test.go +++ b/backend/internal/app/certificate_workflow_test.go @@ -29,10 +29,24 @@ func (s *workflowCertificateService) Import(context.Context, certificate.ImportR type workflowServerLister struct { servers []deployment.Server + err error } -func (s workflowServerLister) ListServersByDomainID(context.Context, string) ([]deployment.Server, error) { - return s.servers, nil +func (s workflowServerLister) ListDeploymentServersByDomainID(context.Context, string) ([]deployment.Server, error) { + return s.servers, s.err +} + +func TestCertificateWorkflowStopsBeforeRemoteWriteOnSharedPathConflict(t *testing.T) { + deployer := &workflowDeployer{} + workflow := &CertificateWorkflow{ + Domains: &workflowDomainState{}, + Servers: workflowServerLister{err: deployment.ErrSharedPathConflict}, + Deployment: deployer, + } + _, err := workflow.deploy(t.Context(), certificate.IssueResult{DomainID: "shared-domain"}) + if !errors.Is(err, deployment.ErrSharedPathConflict) || deployer.targets != nil { + t.Fatalf("路径冲突后仍尝试远端部署:%v,%+v", err, deployer.targets) + } } type workflowDeployer struct { diff --git a/backend/internal/deployment/shared_paths.go b/backend/internal/deployment/shared_paths.go new file mode 100644 index 0000000..db3eec0 --- /dev/null +++ b/backend/internal/deployment/shared_paths.go @@ -0,0 +1,33 @@ +package deployment + +import ( + "errors" + "fmt" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ErrSharedPathConflict = errors.New("多个域名的远程证书路径冲突,请使用按域名区分的证书、证书链和私钥路径") + +// 比较实际展开并规范化后的全部文件路径,不仅检查模板是否含有域名占位符。 +// 错误不携带其他域名名称、资源标识或远程私钥路径。 +func ValidateServerDomainPaths(server Server, domains []domain.Domain) error { + paths := make([]string, 0, len(domains)*3) + for _, item := range domains { + resolved, err := ResolveServerPaths(server, item.Name, string(item.CertificateType)) + if err != nil { + return fmt.Errorf("关联域名的远程证书路径无效:%w", err) + } + current := []string{resolved.CertificatePath, resolved.CertificateChainPath, resolved.PrivateKeyPath} + for _, candidate := range current { + for _, existing := range paths { + if candidate == existing || strings.HasPrefix(candidate, existing+"/") || strings.HasPrefix(existing, candidate+"/") { + return ErrSharedPathConflict + } + } + } + paths = append(paths, current...) + } + return nil +} diff --git a/backend/internal/deployment/shared_paths_test.go b/backend/internal/deployment/shared_paths_test.go new file mode 100644 index 0000000..44ea9d8 --- /dev/null +++ b/backend/internal/deployment/shared_paths_test.go @@ -0,0 +1,51 @@ +package deployment + +import ( + "errors" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func TestSharedServerDomainPaths(t *testing.T) { + domains := []domain.Domain{ + {ID: "a", Name: "a.example.test", CertificateType: domain.CertificateSingle}, + {ID: "b", Name: "b.example.test", CertificateType: domain.CertificateSingle}, + } + for _, tc := range []struct { + name string + server Server + conflict bool + }{ + {name: "default_templates"}, + {name: "english_templates", server: Server{CertificatePath: "/ssl/{domain}/cert.pem", CertificateChainPath: "/ssl/{domain}/chain.pem", PrivateKeyPath: "/ssl/{domain}/key.pem"}}, + {name: "fixed_certificate", server: Server{CertificatePath: "/ssl/cert.pem"}, conflict: true}, + {name: "fixed_chain", server: Server{CertificateChainPath: "/ssl/chain.pem"}, conflict: true}, + {name: "fixed_key", server: Server{PrivateKeyPath: "/ssl/key.pem"}, conflict: true}, + {name: "collapsed_placeholder", server: Server{CertificatePath: "/ssl/{domain}/../cert.pem"}, conflict: true}, + {name: "cross_file_collision", server: Server{CertificatePath: "/ssl/{domain}/b.example.test", PrivateKeyPath: "/ssl/a.example.test/{domain}"}, conflict: true}, + {name: "file_directory_collision", server: Server{CertificatePath: "/ssl/{domain}/b.example.test", PrivateKeyPath: "/ssl/a.example.test/{domain}/key.pem"}, conflict: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateServerDomainPaths(tc.server, domains) + if tc.conflict { + if !errors.Is(err, ErrSharedPathConflict) { + t.Fatalf("共享路径冲突未拒绝:%v", err) + } + } else if err != nil { + t.Fatal(err) + } + }) + } + if err := ValidateServerDomainPaths(Server{CertificatePath: "/ssl/cert.pem"}, domains[:1]); err != nil { + t.Fatalf("单域名固定路径应保持兼容:%v", err) + } + for _, item := range []domain.Domain{ + {ID: "other", Name: "a.example.test", CertificateType: domain.CertificateSingle}, + {ID: "wildcard", Name: "*.a.example.test", CertificateType: domain.CertificateSingle}, + } { + if err := ValidateServerDomainPaths(Server{}, []domain.Domain{domains[0], item}); !errors.Is(err, ErrSharedPathConflict) { + t.Fatalf("规范化后同路径的资源未拒绝:%v", err) + } + } +} diff --git a/backend/internal/httpapi/project_bindings_api.go b/backend/internal/httpapi/project_bindings_api.go index 808b846..8b4c8ff 100644 --- a/backend/internal/httpapi/project_bindings_api.go +++ b/backend/internal/httpapi/project_bindings_api.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) @@ -41,6 +42,8 @@ func (s *Server) replaceProjectDomainServers(c *gin.Context) { s.writeError(c, http.StatusForbidden, "无权修改此项目的服务器绑定", nil) case errors.Is(err, repository.ErrProjectBindingInput): s.writeError(c, http.StatusBadRequest, err.Error(), nil) + case errors.Is(err, deployment.ErrSharedPathConflict): + s.writeError(c, http.StatusConflict, deployment.ErrSharedPathConflict.Error(), nil) case err != nil: s.writeError(c, http.StatusInternalServerError, "保存项目服务器绑定失败,请刷新后核对", nil) default: diff --git a/backend/internal/httpapi/project_bindings_many_test.go b/backend/internal/httpapi/project_bindings_many_test.go new file mode 100644 index 0000000..24bbef2 --- /dev/null +++ b/backend/internal/httpapi/project_bindings_many_test.go @@ -0,0 +1,90 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestProjectBindingAPIManyToManyAndPathConflict(t *testing.T) { + f, _ := newProjectResourceAPIFixture(t) + server, err := f.server.servers.GetServerByID(t.Context(), "server-a") + if err != nil { + t.Fatal(err) + } + server.CertificatePath, server.CertificateChainPath, server.PrivateKeyPath = "", "", "" + if err := f.server.servers.UpdateServer(t.Context(), server); err != nil { + t.Fatal(err) + } + server.ID, server.Name = "server-c", "第二台共享服务器" + if err := f.server.servers.CreateServer(t.Context(), server); err != nil { + t.Fatal(err) + } + item, err := domain.NewDomain("second.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ID, item.ProjectID = "domain-c", "project-a" + if err := repository.NewSQLDomainRepository(f.db).Create(t.Context(), item); err != nil { + t.Fatal(err) + } + put := func(id, body string, expected int) { + t.Helper() + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/domains/"+id+"/servers", body) + if response.Code != expected { + t.Fatalf("多对多保存响应错误:%s,%d,%s", id, response.Code, response.Body.String()) + } + if expected == http.StatusConflict && (!strings.Contains(response.Body.String(), "路径冲突") || strings.Contains(response.Body.String(), "second.example.test")) { + t.Fatalf("冲突提示不明确或泄露其他域名:%s", response.Body.String()) + } + } + assertBindings := func(id string, expected []string) { + t.Helper() + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/domains/"+id+"/servers", "") + var result struct { + Items []deployment.Server `json:"items"` + } + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Items == nil { + t.Fatalf("多对多读取响应错误:%d,%s", response.Code, response.Body.String()) + } + ids := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + ids = append(ids, item.ID) + } + slices.Sort(ids) + if !slices.Equal(ids, expected) { + t.Fatalf("改变一个域名后关联不正确:%s,%v,期望 %v", id, ids, expected) + } + } + both := []string{"server-a", "server-c"} + put("domain-a", `{"server_ids":["server-c","server-a","server-a"]}`, http.StatusOK) + put("domain-c", `{"server_ids":["server-a","server-c"]}`, http.StatusOK) + assertBindings("domain-a", both) + assertBindings("domain-c", both) + put("domain-a", `{"server_ids":["server-c"]}`, http.StatusOK) + assertBindings("domain-a", both[1:]) + assertBindings("domain-c", both) + put("domain-a", `{"server_ids":[]}`, http.StatusOK) + assertBindings("domain-a", []string{}) + assertBindings("domain-c", both) + put("domain-a", `{"server_ids":["server-a","server-c"]}`, http.StatusOK) + put("domain-a", `{"server_ids":["server-b"]}`, http.StatusForbidden) + assertBindings("domain-a", both) + assertBindings("domain-c", both) + if _, err := f.db.ExecContext(t.Context(), `UPDATE servers SET certificate_path='/ssl/fixed/cert.pem' WHERE id='server-a'`); err != nil { + t.Fatal(err) + } + put("domain-a", `{"server_ids":["server-a","server-c"]}`, http.StatusConflict) + assertBindings("domain-a", both) + assertBindings("domain-c", both) + entry := f.lastAudit(t) + if entry.Action != "domain.servers.replace" || entry.ProjectID != "project-a" || entry.Status != "failed" { + t.Fatalf("路径冲突缺少项目失败审计:%+v", entry) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index e43a183..5563242 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -768,6 +768,10 @@ func (s *Server) updateServer(c *gin.Context) { } if err := s.servers.UpdateServer(c.Request.Context(), item); err != nil { cleanup() + if errors.Is(err, deployment.ErrSharedPathConflict) { + s.writeError(c, http.StatusConflict, deployment.ErrSharedPathConflict.Error(), nil) + return + } s.writeError(c, serverErrorStatus(err), "更新服务器失败", err) return } @@ -974,6 +978,9 @@ func serverFromRequest(request serverRequest) deployment.Server { } func serverErrorStatus(err error) int { + if errors.Is(err, deployment.ErrSharedPathConflict) { + return http.StatusConflict + } if repository.IsConflict(err) { return http.StatusConflict } diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index 54585d9..16a7760 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -312,10 +312,15 @@ func TestServerAndDomainAssociationAPI(t *testing.T) { server := New(Dependencies{ Config: cfg, Repository: repository.NewSQLDomainRepository(db.DB), Servers: repository.NewSQLServerRepository(db.DB), SSHKeys: sshKeys, - ProjectResources: projectResourceReaderStub{ - domains: repository.NewSQLDomainRepository(db.DB), servers: repository.NewSQLServerRepository(db.DB), - }, + Users: repository.NewSQLUserRepository(db.DB), + ProjectResources: repository.NewSQLProjectMemberRepository(db.DB), + ProjectBindings: repository.NewSQLProjectMemberRepository(db.DB), }) + // 此旧格式用例同样使用真实账户与项目成员,不能依赖全局管理员绕过绑定权限。 + _ = httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin) + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'test-admin', 'project_admin')`); err != nil { + t.Fatal(err) + } domainRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`)) domainRequest.Header.Set("Content-Type", "application/json") diff --git a/backend/internal/repository/project_bindings.go b/backend/internal/repository/project_bindings.go index be1b0a0..d2ff6c7 100644 --- a/backend/internal/repository/project_bindings.go +++ b/backend/internal/repository/project_bindings.go @@ -72,11 +72,12 @@ func (r *SQLProjectMemberRepository) ReplaceProjectDomainServers(ctx context.Con return nil, errors.New("项目绑定及授权审计相关表必须使用 InnoDB") } } - var projectID string + var candidate domain.Domain if err := tx.QueryRowContext(ctx, Rebind(r.driver, - `SELECT project_id FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&projectID); err != nil { + `SELECT id, project_id, name, certificate_type FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&candidate.ID, &candidate.ProjectID, &candidate.Name, &candidate.CertificateType); err != nil { return nil, projectBindingReadError(err) } + projectID := candidate.ProjectID if err := r.authorizeBindingTransaction(ctx, tx, actorID, projectID); err != nil { return nil, err } @@ -92,6 +93,11 @@ func (r *SQLProjectMemberRepository) ReplaceProjectDomainServers(ctx context.Con } items = append(items, item) } + for _, item := range items { + if err := validateServerDomainPaths(ctx, tx, r.driver, item, &candidate); err != nil { + return nil, err + } + } if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil { return nil, fmt.Errorf("清理项目绑定失败:%w", err) } @@ -166,8 +172,8 @@ func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Con } defer rows.Close() permissions := map[domain.ProjectPermission]bool{ - domain.PermissionCertificateRead: false, - domain.PermissionServerRead: false, + domain.PermissionCertificateRead: false, + domain.PermissionServerRead: false, domain.PermissionDeploymentExecute: false, } for rows.Next() { diff --git a/backend/internal/repository/project_resources_test.go b/backend/internal/repository/project_resources_test.go index ca686cf..5b03b30 100644 --- a/backend/internal/repository/project_resources_test.go +++ b/backend/internal/repository/project_resources_test.go @@ -137,6 +137,9 @@ func checkProjectResources(t *testing.T, cfg config.DatabaseConfig, admin *sql.D } }) checkProjectBindings(t, db.DB, admin, repo, cfg.Driver) + t.Run("many_to_many_bindings", func(t *testing.T) { + checkManyToManyBindings(t, db.DB, admin, repo, cfg.Driver) + }) t.Run("revocation_and_disabled_account_are_not_cached", func(t *testing.T) { if err := repo.RevokeProjectRole(t.Context(), "root", "project-a", "reader", domain.ProjectRoleReadOnly); err != nil { t.Fatal(err) diff --git a/backend/internal/repository/server_domain_paths.go b/backend/internal/repository/server_domain_paths.go new file mode 100644 index 0000000..1a36ae1 --- /dev/null +++ b/backend/internal/repository/server_domain_paths.go @@ -0,0 +1,98 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +func serverResourceLock(driver string) string { + if driver == "mysql" || driver == "postgres" { + return " FOR UPDATE" + } + return "" +} + +// 调用者须在同一事务先锁定服务器行;候选域名用于校验尚未落库的新关联。 +func validateServerDomainPaths(ctx context.Context, tx *sql.Tx, driver string, server deployment.Server, candidate *domain.Domain) error { + excludeID := "" + items := make([]domain.Domain, 0) + if candidate != nil { + excludeID = candidate.ID + items = append(items, *candidate) + } + rows, err := tx.QueryContext(ctx, Rebind(driver, `SELECT d.id, d.name, d.certificate_type + FROM domains d INNER JOIN domain_servers ds ON ds.domain_id = d.id + WHERE ds.server_id = ? AND d.id <> ? ORDER BY d.id`+serverResourceLock(driver)), server.ID, excludeID) + if err != nil { + return fmt.Errorf("查询共享服务器的域名路径失败:%w", err) + } + defer rows.Close() + for rows.Next() { + var item domain.Domain + if err := rows.Scan(&item.ID, &item.Name, &item.CertificateType); err != nil { + return err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return err + } + return deployment.ValidateServerDomainPaths(server, items) +} + +// 部署入口重新读取关联及路径;不能把保存关联时的校验当成永久有效。 +// 返回的服务器配置与路径检查使用同一事务快照,事务完成后才允许进行远端操作。 +func (r *SQLServerRepository) ListDeploymentServersByDomainID(ctx context.Context, domainID string) ([]deployment.Server, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + var candidate domain.Domain + if err := tx.QueryRowContext(ctx, r.query(`SELECT id, project_id, name, certificate_type FROM domains WHERE id = ?`+serverResourceLock(r.driver)), domainID).Scan(&candidate.ID, &candidate.ProjectID, &candidate.Name, &candidate.CertificateType); err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, r.query(serverSelectQualified+` INNER JOIN domain_servers ds + ON ds.server_id = servers.id WHERE ds.domain_id = ? ORDER BY servers.id`+serverResourceLock(r.driver)), domainID) + if err != nil { + return nil, err + } + items := make([]deployment.Server, 0) + for rows.Next() { + item, err := scanServer(rows) + if err != nil { + rows.Close() + return nil, err + } + items = append(items, item) + } + readErr := rows.Err() + closeErr := rows.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + for index, item := range items { + if item.ProjectID != candidate.ProjectID { + return nil, ErrProjectAccessDenied + } + if err := validateServerDomainPaths(ctx, tx, r.driver, item, nil); err != nil { + return nil, err + } + resolved, err := deployment.ResolveServerPaths(item, candidate.Name, string(candidate.CertificateType)) + if err != nil { + return nil, err + } + items[index] = resolved + } + if err := tx.Commit(); err != nil { + return nil, err + } + return items, nil +} diff --git a/backend/internal/repository/server_repository.go b/backend/internal/repository/server_repository.go index 8c210e7..b1f3902 100644 --- a/backend/internal/repository/server_repository.go +++ b/backend/internal/repository/server_repository.go @@ -6,10 +6,12 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" "time" "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" ) type ServerRepository interface { @@ -85,25 +87,27 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen if updatedAt.IsZero() { updatedAt = time.Now().UTC() } - result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ?, updated_by = ? WHERE id = ? AND project_id = ?`), + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var projectID string + if err := tx.QueryRowContext(ctx, r.query(`SELECT project_id FROM servers WHERE id = ?`+serverResourceLock(r.driver)), server.ID).Scan(&projectID); err != nil { + return err + } + if projectID != server.ProjectID { + return sql.ErrNoRows + } + if err := validateServerDomainPaths(ctx, tx, r.driver, server, nil); err != nil { + return err + } + _, err = tx.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ?, updated_by = ? WHERE id = ? AND project_id = ?`), server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.UpdatedBy, server.ID, server.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.GetServerByID(ctx, server.ID) - if err != nil { - return err - } - if stored.ProjectID != server.ProjectID { - return sql.ErrNoRows - } - } - return nil + return tx.Commit() } func (r *SQLServerRepository) GetServerByID(ctx context.Context, id string) (deployment.Server, error) { @@ -214,28 +218,31 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID seen[id] = struct{}{} cleaned = append(cleaned, id) } + sort.Strings(cleaned) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("开始更新域名服务器关联失败:%w", err) } + defer tx.Rollback() rollback := func(cause error) error { _ = tx.Rollback() return cause } - var projectID string - if err := tx.QueryRowContext(ctx, r.query("SELECT project_id FROM domains WHERE id = ?"), domainID).Scan(&projectID); err != nil { + var candidate domain.Domain + if err := tx.QueryRowContext(ctx, r.query(`SELECT id, project_id, name, certificate_type FROM domains WHERE id = ?`+serverResourceLock(r.driver)), domainID).Scan(&candidate.ID, &candidate.ProjectID, &candidate.Name, &candidate.CertificateType); err != nil { return rollback(fmt.Errorf("读取绑定域名项目失败:%w", err)) } + projectID := candidate.ProjectID if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), domainID); err != nil { return rollback(fmt.Errorf("清理域名服务器关联失败:%w", err)) } for _, serverID := range cleaned { - var exists int - if err := tx.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE id = ? AND project_id = ?"), serverID, projectID).Scan(&exists); err != nil { + server, err := scanServer(tx.QueryRowContext(ctx, r.query(serverSelectQualified+` WHERE servers.id = ? AND servers.project_id = ?`+serverResourceLock(r.driver)), serverID, projectID)) + if err != nil { return rollback(fmt.Errorf("校验部署服务器失败:%w", err)) } - if exists == 0 { - return rollback(fmt.Errorf("部署服务器不存在或不属于域名项目:%s", serverID)) + if err := validateServerDomainPaths(ctx, tx, r.driver, server, &candidate); err != nil { + return rollback(err) } if _, err := tx.ExecContext(ctx, r.query("INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, ?)"), domainID, serverID, projectID); err != nil { return rollback(fmt.Errorf("保存域名服务器关联失败:%w", err)) diff --git a/backend/internal/repository/shared_server_bindings_test.go b/backend/internal/repository/shared_server_bindings_test.go new file mode 100644 index 0000000..954c9c7 --- /dev/null +++ b/backend/internal/repository/shared_server_bindings_test.go @@ -0,0 +1,148 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "slices" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// 由已有 SQLite 和隔离 MySQL 真实仓储入口调用,不使用业务库或额外验收脚本。 +func checkManyToManyBindings(t *testing.T, db, admin *sql.DB, members *SQLProjectMemberRepository, driver string) { + t.Helper() + domains := NewSQLDomainRepository(db, driver) + servers := NewSQLServerRepository(db, driver) + for _, suffix := range []string{"one", "two"} { + item, err := domain.NewDomain(suffix+".shared.example.test", domain.CertificateSingle, nil) + if err != nil { + t.Fatal(err) + } + item.ID, item.ProjectID = "shared-domain-"+suffix, "project-a" + if err := domains.Create(t.Context(), item); err != nil { + t.Fatal(err) + } + server := deployment.Server{ + ResourceScope: domain.ResourceScope{ProjectID: "project-a"}, + ID: "shared-server-" + suffix, Name: suffix, Address: "192.0.2.20", Port: 22, Username: "deploy", + PrivateKeyRef: "secret://ssh/fixture", HostKeyFingerprint: "SHA256:test", WebServer: deployment.WebServerNginx, + } + if err := servers.CreateServer(t.Context(), server); err != nil { + t.Fatal(err) + } + } + both := []string{"shared-server-one", "shared-server-two"} + assertBindings := func(id string, expected []string) { + t.Helper() + items, err := members.ListVisibleDomainServers(t.Context(), "manager", id) + if err != nil { + t.Fatal(err) + } + actual := make([]string, 0, len(items)) + for _, item := range items { + actual = append(actual, item.ID) + } + slices.Sort(actual) + if !slices.Equal(actual, expected) { + t.Fatalf("域名关联错误:%s,实际 %v,期望 %v", id, actual, expected) + } + } + bind := func(id string, ids []string) { + t.Helper() + if _, err := members.ReplaceProjectDomainServers(t.Context(), "manager", id, ids); err != nil { + t.Fatal(err) + } + } + bind("shared-domain-one", append(slices.Clone(both), both[0])) + bind("shared-domain-two", both) + assertBindings("shared-domain-one", both) + assertBindings("shared-domain-two", both) + bind("shared-domain-one", both[:1]) + assertBindings("shared-domain-one", both[:1]) + assertBindings("shared-domain-two", both) + bind("shared-domain-one", []string{}) + assertBindings("shared-domain-one", []string{}) + assertBindings("shared-domain-two", both) + bind("shared-domain-one", both) + if _, err := members.ReplaceProjectDomainServers(t.Context(), "manager", "shared-domain-one", []string{"server-b"}); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("跨项目绑定未拒绝:%v", err) + } + assertBindings("shared-domain-one", both) + assertBindings("shared-domain-two", both) + first, err := servers.ListDeploymentServersByDomainID(t.Context(), "shared-domain-one") + if err != nil || len(first) != 2 { + t.Fatalf("一个域名未取得两台部署服务器:%v,%v", first, err) + } + second, err := servers.ListDeploymentServersByDomainID(t.Context(), "shared-domain-two") + if err != nil || len(second) != 2 { + t.Fatalf("共享服务器读取失败:%v,%v", second, err) + } + for i := range first { + if first[i].ID != second[i].ID || first[i].CertificatePath == second[i].CertificatePath || first[i].PrivateKeyPath == second[i].PrivateKeyPath { + t.Fatal("同一服务器的不同域名未分离文件路径") + } + } + server, err := servers.GetServerByID(t.Context(), both[0]) + if err != nil { + t.Fatal(err) + } + server.PrivateKeyPath = "/ssl/shared/key.pem" + if err := servers.UpdateServer(t.Context(), server); !errors.Is(err, deployment.ErrSharedPathConflict) { + t.Fatalf("共享服务器改为固定私钥路径未拒绝:%v", err) + } + stored, err := servers.GetServerByID(t.Context(), both[0]) + if err != nil || stored.PrivateKeyPath != deployment.DefaultPrivateKeyPath { + t.Fatalf("被拒绝的配置仍写入:%+v,%v", stored, err) + } + // 模拟旧数据库已有的危险配置,部署前和新关联写入必须再次拒绝。 + if _, err := db.ExecContext(t.Context(), Rebind(driver, `UPDATE servers SET private_key_path='/ssl/shared/key.pem' WHERE id=?`), both[0]); err != nil { + t.Fatal(err) + } + for _, id := range []string{"shared-domain-one", "shared-domain-two"} { + if _, err := servers.ListDeploymentServersByDomainID(t.Context(), id); !errors.Is(err, deployment.ErrSharedPathConflict) { + t.Fatalf("历史冲突仍允许部署:%s,%v", id, err) + } + } + if _, err := members.ReplaceProjectDomainServers(t.Context(), "manager", "shared-domain-one", both); !errors.Is(err, deployment.ErrSharedPathConflict) { + t.Fatalf("危险路径仍可写入关联:%v", err) + } + if err := servers.ReplaceDomainServers(t.Context(), "shared-domain-one", both); !errors.Is(err, deployment.ErrSharedPathConflict) { + t.Fatalf("旧仓储入口绕过路径保护:%v", err) + } + assertBindings("shared-domain-one", both) + assertBindings("shared-domain-two", both) + if err := servers.UpdateServer(t.Context(), stored); err != nil { + t.Fatal(err) + } + // 两个域名均保留绑定时,审计故障不得仅回滚其中一部分。 + executor := db + triggerName := "reject_shared_binding_audit" + trigger := `CREATE TRIGGER reject_shared_binding_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); 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, "`", "``") + "`" + triggerName = schema + ".reject_shared_binding_audit" + trigger = "CREATE TRIGGER " + triggerName + " BEFORE INSERT ON " + schema + ".audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'" + executor = admin + } + if _, err := executor.ExecContext(t.Context(), trigger); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := executor.ExecContext(context.Background(), "DROP TRIGGER "+triggerName); err != nil { + t.Error(err) + } + }) + if _, err := members.ReplaceProjectDomainServers(t.Context(), "manager", "shared-domain-one", []string{}); err == nil { + t.Fatal("审计失败仍清空了共享关联") + } + assertBindings("shared-domain-one", both) + assertBindings("shared-domain-two", both) +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 309d9e0..ee24ebe 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,14 @@ # Scert 开发任务清单 +## 当前开发批次(2026-09-28,先开发后统一测试) + +- [x] 生命周期前后端收尾、七个资源读取入口隔离及绑定写入授权已有源码,等待统一测试。 +- [x] 按多对多补齐共享服务器、局部清空、去重、审计失败保留和路径冲突保护及回归源码;页面使用悬浮提示,未执行测试或构建。 +- [~] 继续其他业务资源写入与任务授权整改;服务器别名与在途部署路径占用仍待补齐,不算本批已完成。 +- [!] 历史正式包与回归不能用于新代码验收;准确范围见任务执行清单首节、模型第十一/十二节和支持矩阵第二十六节。 + +以下为历史正式包和浏览器批次。 + ## 当前正式包与浏览器批次(2026-09-28 20:15) - [x] 干净提交 `5f9af32` 的 Windows 正式发布包构建成功;发布清单、压缩包内容和实际运行的前后端文件核对一致,主程序已实际启动。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 82e7962..5a135a6 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,17 @@ # Scert 整改任务执行清单 +## 2026 年 9 月 28 日:域名与服务器多对多(21:32 接续) + +需求确认:一个域名允许关联多台服务器,一台服务器允许关联多个域名;修改、清空某个域名的关联不改变其他域名的关联。沿用同项目授权边界,关联保存不触发 SSH 或证书推送。先开发,完成后再统一测试。 + +- [x] 核对现有联合主键及页面多选;保留多对多模型,不增加服务器或域名单边唯一约束。修改前检查点 `f38e290` 已提交推送。 +- [x] 已补齐两个域名共享两台服务器、重复去重、局部修改、清空及失败保留的仓储与 HTTP 回归源码;旧关联格式用例改为真实账户、项目成员和授权仓储,不靠全局管理员绕过权限。 +- [x] 已补齐同一服务器记录当前绑定的远程路径冲突保护:默认按域名与证书类型分目录,关联保存、服务器修改和部署预检均检查实际规范化路径;固定路径冲突拒绝,单域名固定路径兼容。新增路径及工作流回归源码,待统一测试。 +- [x] 页面增加多选复用悬浮提示及 Vue 模板用例;已同步模型、第三版接口交接、总清单对应条目、开发清单、基础规格、审查记录、发布记录与支持矩阵第二十六节。 +- [x] 已进行源码检查、Go 格式化和差异检查;不将格式正确视为编译或行为验证。源码与文档精确提交,状态以 Git 记录为准,未提交运行产物或凭据。 +- [ ] 同一主机的多条服务器记录、配置变化与在途任务、远端符号链接和解绑遗留文件的路径占用仍待整体部署整改;本批只完成当前绑定的多对多与路径预检,不宣称覆盖这些边界。 +- [!] 本批仅开发,不运行测试、构建、浏览器、SSH 或部署;已有通过记录不得用于本批验收。 + ## 2026 年 9 月 28 日:先开发、后统一测试(20:57 接续) 需求确认:按照用户最新指令先完成开发,再集中运行测试。沿用 P0-06/P0-07 的既定需求,不缩减原始 44 项,不将未测试的源码记为复审通过。本阶段不清库、不部署、不创建辅助验收脚本。 @@ -8,8 +20,8 @@ - [x] 生命周期收尾源码已完成,待统一测试:领域、接口及失败审计统一项目标识校验;项目与成员操作共享忙状态,补充控制字符和表单渲染用例。 - [x] 资源读取隔离源码已完成,待统一测试:域名、服务器、绑定列表及证书元数据入口根据真实资源归属判权,列表在 SQL 内过滤。 - [x] 正式应用依赖已接入;缺少授权仓储拒绝读取,不退回旧全量列表;无权限与不存在统一拒绝,内部错误不向客户端透传。 -- [~] 补齐模型、接口交接及实际进度文档;SQLite/MySQL、HTTP 和前端回归用例仅编写,不执行。 -- [~] 下一子项:将域名绑定服务器的写入改为真实项目授权与事务审计;权限须覆盖双方资源,失败保留旧绑定,不触发 SSH 或部署。 +- [x] 模型、第三版接口交接及实际进度文档已同步;SQLite/MySQL、HTTP 和前端新增回归用例仅编写,不执行。 +- [x] 绑定写入已接入真实项目授权与事务审计,候选检查点为 `f38e290`;随后补齐多对多及路径预检,详见首节,均待统一测试。保存关联不触发 SSH 或部署。 - [!] 统一测试推迟至开发阶段完成之后;构建、浏览器和生产验收均未执行,旧证据不能证明新代码。 历史补记:上一批完整前端 210 项通过且生产构建成功;MySQL 5.7 组合通过,MySQL 8.0 的三条审计故障注入用例因业务账户无建触发器权限失败。夹具改由隔离实例管理员建触发器,未提高业务权限,修正后尚未运行。新增非法项目 ID 回归已复现失败,详见 `artifacts/project-lifecycle-20260928/invalid-id-before.jsonl`。以下 20:47 及更早记录仅为历史快照。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index c5f56fa..cb562ae 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,6 +1,6 @@ # Scert 整改、合并与优化清单 -## 当前进度(2026 年 9 月 28 日 20:47,项目生命周期已通过首轮局部测试,完整回归未完成) +## 当前进度(2026 年 9 月 28 日,生命周期、读取隔离与多对多绑定源码推进中,先开发后统一测试) ### 总项状态 @@ -14,6 +14,14 @@ ### 本轮新增进展与下一步 +- [x] 生命周期收尾及七个资源读取入口已接入真实项目授权,候选已在 `709c1ef`;绑定写入授权与事务审计检查点 `f38e290` 已提交推送。后续源码仍需统一测试,不能套用此前通过记录。 +- [x] 本轮多对多子项源码已补齐:两个域名可共享两台服务器,修改或清空一方不删除服务器或另一方关联;保存需双方同项目及实时权限。共享路径冲突在绑定、服务器修改及部署前检查,页面以悬浮提示说明复用。 +- [x] 已新增多对多、路径冲突、失败保留、审计回滚和页面多选回归源码;仅进行源码检查、格式化与差异检查,不执行测试、构建、浏览器或远端部署。详细边界见执行清单首节及支持矩阵第二十六节。 +- [ ] 其他资源增删改、签发/导入/部署、DNS/SSH、任务读取和执行/取消/重试、审计读取仍需继续接入项目授权。跨服务器记录指向同一主机、配置并发变更与在途任务的路径占用仍待整体部署整改,不以本批静态路径检查替代完整安全边界。 +- [!] 按用户指令先开发再集中测试,原始 44 项大项状态不变;没有新增复审通过或生产验收证据。 + +#### 20:47 阶段记录(历史) + - [~] 20:47 补充:首轮仓储/接口/真实应用接线组合测试退出 0,136 个叶子用例通过、5 条实库条件跳过;覆盖创建、更新、删除、权限拒绝、引用保护、非法输入及审计失败回滚。随后新增的会话验证后停用/降权、取消请求审计和跨数据库回滚测试尚未运行,不能套用首轮结果。 - [~] 前端已接入创建、编辑、二次确认删除及权限能力提示;项目页面与生命周期专项 35 项通过、0 失败、0 跳过,退出 0。覆盖重复提交、切换/卸载隔离、冲突刷新、末页删除回首页等;这是组合式逻辑和模板测试,不是真实浏览器验收。 - [ ] 当前候选的完整后端、完整前端、MySQL 双版本、竞态检测和构建尚待执行;本批源码尚未提交。下方 20:36 记录保留为历史状态,不代表当前仍未开始页面开发或从未测试。 @@ -26,7 +34,7 @@ - [ ] 前端创建、编辑及二次确认删除尚未修改;后端与前端回归、数据库验证及本批正式构建尚未执行。 - [!] 上批成员页面真实浏览器验收仍未完成,具体工具限制见下方 20:15 历史快照;该限制不阻止本批源码整改。 -本次仅核对并同步进度文档,不执行产品测试、不修改产品源码、不清理业务库、不调用 RAM、不修改 DNS 或进行远端部署。当前工作区另有 7 个未提交的后端源码/测试文件,不纳入本次文档提交。上批测试与正式包证据不能证明这些新增代码通过;原始 44 项总状态不变,生产验收仍未通过。 +20:36 历史记录:当时仅同步文档,另有 7 个未提交的后端源码/测试文件;这是当时的工作区快照,不是当前待提交数量。当前已继续开发,仍未清理业务库、调用 RAM、修改 DNS 或远端部署。上批测试与正式包证据不能证明新增代码通过,生产验收仍未通过。 ### 历史批次进展(2026 年 9 月 28 日 20:15,正式包启动验证) @@ -379,7 +387,7 @@ backend/internal/database/migrations/sqlite/ **状态:** `[~] 进行中` -**本轮补充(2026-09-28 20:36):** 项目生命周期仓储、成功变更与审计同事务、失败审计及回滚测试源码正在编写;新增代码尚未执行测试。项目默认项和资源/成员引用保护仍需通过回归验证,不以已有成员事务测试代替本批生命周期验证。 +**本轮补充(2026-09-28):** 生命周期仓储、成功事务审计、默认项目与引用保护及前端操作已实现,收尾候选已在 `709c1ef`。模式仍为 14;域名与服务器沿用联合主键的多对多关系,新增共享绑定、去重、局部清空和路径冲突保护源码及回归用例。先开发后统一测试,不能以历史局部结果关闭本项。 **最新进度(2026-09-28):** 已实现域名/服务器项目归属、同项目绑定、任务/审计范围快照与项目成员关系;成员成功变更和审计共用事务。并非下列全部核心实体均已交付;剩余资源关联与业务调用链仍需补齐,具体模型见《项目资源关联模型》第七至九节。 @@ -418,11 +426,11 @@ backup_records 备份记录 **状态:** `[~] 进行中` -**本轮补充(2026-09-28 20:36):** 项目创建/编辑/删除 API、应用接线及目录能力字段已有未提交实现;服务端实时判权、非法字段、删除确认、撤权及审计失败边界尚待测试。前端生命周期操作尚未修改,整体仍为进行中;下方测试数字仅适用于上批代码。 +**本轮补充(2026-09-28):** 项目生命周期前后端、七个资源 GET 的读取隔离,以及域名绑定 PUT 的实时授权、失败审计和成功事务审计已有源码实现;缺依赖拒绝服务,不回退全量或未授权仓储。多对多仍要求同项目,不因共用服务器扩大授权。生命周期收尾、读取隔离及本轮绑定代码尚未统一测试,其他资源写入和任务权限链未闭环,P0-07 保持进行中。 **本轮补充(2026-09-28 20:15):** 正式包已进入浏览器的首次初始化入口,未认证及无效令牌拒绝路径已验证;工具文件访问范围阻止传入本机一次性地址,尚未取得初始化会话,因此成员页面、角色操作和布局都未进行真实浏览器验收。详见本文件顶部与支持矩阵第二十五节,不以启动页面代替项目权限验收。 -**最新进度(2026-09-28 19:54):** 成员列表、授予/撤销 API、事务及失败审计已实现;新增可见项目分页入口、管理员和普通项目管理员的成员页面、账户 ID 展示及禁止缓存保护。前端 198 项通过,最后源码后端专项 83 个叶子通过、4 条条件跳过,完整后端 1700 个叶子通过、54 条条件跳过;双版本 MySQL 三轮组合各 195 个叶子通过,静态检查、模块校验及双平台构建通过。本批三轮专项竞态 249 个叶子通过、12 条外部 MySQL 条件跳过,退出码 0;证据见支持矩阵第二十四节。真实浏览器、项目创建编辑删除、全部资源入口和排队任务执行/取消/重试重判仍未完成,P0-07 保持进行中;页面实现与本地回归不等于完整项目隔离或生产验收。 +**历史进度(2026-09-28 19:54):** 成员及可见项目入口当时的前端 198 项通过,后端专项 83 个叶子通过、4 条条件跳过,完整后端 1700 个叶子通过、54 条条件跳过;双版本 MySQL 三轮组合各 195 个叶子通过,三轮专项竞态 249 个叶子通过、12 条条件跳过,静态检查与双平台构建通过。证据见第二十四节,仅适用于该批候选,不覆盖此后的生命周期收尾、读取隔离和多对多修改。 建议角色: diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index 8e23654..cec3216 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -1,5 +1,11 @@ # 全面审查、测试与优化记录 +## 当前仅开发记录(2026-09-28,21:32 接续) + +按用户要求先开发后统一测试。新增多对多仓储/HTTP 用例及共享服务器证书路径校验,覆盖一域名多服务器、一服务器多域名、局部修改/清空、去重、失败保留和审计回滚;服务器编辑与部署预检也接入路径冲突检查。新增工作流停止远端调用及 Vue 多选渲染用例,但全部未执行。本阶段只做源码检查、格式化和差异检查,没有测试、构建、浏览器、SSH、清库或部署。 + +生命周期收尾及读取隔离已在 `709c1ef`,绑定检查点为 `f38e290`;后续源码以 Git 为准。未把旧通过数字转移至新候选,全资源权限和完整部署安全边界仍待开发。证据分层及历史补记见支持矩阵第二十六节,下方保留各批历史范围。 + ## 当前正式包与浏览器验证(2026-09-28 20:15) - 正式发布来源为干净提交 `5f9af32`;Windows 包构建成功,清单中 10 个产物的大小和 SHA-256、压缩包内 9 个文件及运行目录主程序与 4 个 Web 文件的 SHA-256 均匹配。实际主程序提供前后端,不是 Vite 或模拟服务。 diff --git a/doc/发布加固与实机验收记录.md b/doc/发布加固与实机验收记录.md index 6ab9fef..739fbae 100644 --- a/doc/发布加固与实机验收记录.md +++ b/doc/发布加固与实机验收记录.md @@ -1,5 +1,12 @@ # 发布加固与实机验收记录 +## 当前开发边界(2026-09-28) + +- [x] 生命周期、资源读取隔离及域名服务器多对多已有新增源码,关联保存、服务器编辑和部署预检增加共享路径冲突保护,等待统一测试。 +- [!] 本阶段未构建、未重新部署、未使用 SSH 或浏览器,不清理业务数据。正式运行包不能据此标记为已升级,历史包 `5f9af32` 不代表当前源码。 +- [ ] 开发完成后按正式产物身份重新进行数据库、接口、桌面页面与实机推送验收;需验证两个域名共享服务器、每域名多服务器、失败保留及路径隔离,不使用辅助脚本作为验收条件。 +- [ ] 同主机别名、配置并发变化与在途任务、真实远端路径占用及全资源权限继续保留待办,不以本批预检或旧通过记录关闭生产门禁。详见支持矩阵第二十六节。 + ## 当前发布验证补充(2026-09-28 20:15) - [x] 干净提交 `5f9af32` 的 Windows 正式发布包构建成功,发布清单中 10 个产物、压缩包内 9 个交付文件及运行副本中的主程序与 4 个 Web 文件核对一致;主程序与正式 Web 已在独立目录实际启动。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index f1d529c..c07e1d4 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -1,5 +1,7 @@ # 基础整改任务规格 +> 当前开发补充(2026-09-28):已按用户确认补齐域名与服务器多对多源码,同项目内可双向复用,修改或清空一方不改变另一方;共享路径冲突在绑定保存、服务器修改及部署前检查。生命周期收尾、七个资源读取入口与绑定写入授权均待统一测试,其他业务资源及任务授权仍未闭环,六阶段及原始 44 项不缩减。服务器别名、在途任务与远端文件占用不由本批静态路径检查替代。详见执行清单首节、模型第十一/十二节和支持矩阵第二十六节;下方为历史证据。 + > 当前补充(2026-09-28 20:15):P0-04 已新增干净提交 Windows 正式发布构建、包内容与运行副本核验及实际启动证据;P0-07 的浏览器验证仅到达首次初始化的未认证入口,配置和无效令牌均被拒绝。浏览器文件工具工作区配置阻止传入一次性地址,尚未兑换合法会话,不能称成员页面、项目隔离或生产验收通过。初始化专项 128 个叶子通过、4 条平台条件跳过,完整范围见支持矩阵第二十五节。各总项及六阶段验收标准不变,下方为历史进度。 > 当前进度(2026-09-28 19:54):P0-07 的成员 API、成功事务审计、失败请求审计、可见项目分页入口和成员 Web 页面均已实现;普通项目管理员可从自己的工作台进入,项目和成员响应禁止缓存,账户建议不扩大原有用户可见性。最后源码专项、完整后端、前端、双版本 MySQL 组合及三轮专项竞态已通过,条件跳过和构建证据见支持矩阵第二十四节。真实浏览器、项目增删改、全业务资源及排队任务授权仍未交付或验收;P0-07 保持进行中,原始六阶段与生产门禁不变。下方未接入成员 Web/API 的表述仅对应历史批次。 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index 6aa3b6f..63b36fe 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -1,6 +1,6 @@ # 构建版本与数据库支持矩阵 -首次核查日期:2026 年 9 月 27 日;项目入口与成员页面的最新结果见第二十四节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。 +首次核查日期:2026 年 9 月 27 日;当前仅开发批次见第二十六节,历史成员页面和正式包证据见第二十四、二十五节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。 运行时候选选型依据见第二十二节,成员接口验证见第二十三节,项目入口与页面验证见第二十四节;此前各节保留各自批次的源码、依赖和环境证据,不据此推断新代码已完成相同范围的验证。 @@ -1266,3 +1266,21 @@ go test -p 1 -count=1 -json -timeout=6m ./internal/bootstrap - [ ] 项目生命周期与事务审计、所有资源和排队任务权限、PostgreSQL 当前实库、Linux 原生及完整生产门禁继续实施。 总清单和执行清单已同步到本批证据。原始 44 项范围、六阶段验收要求及大项状态不变;本批工具阻塞不替代其他可继续推进的整改,生产部署验收仍未通过。 + +## 二十六、生命周期收尾、资源隔离与多对多(2026-09-28,仅开发) + +按用户最新要求先完成开发,再集中测试。本阶段未运行测试、构建、浏览器、SSH 或部署,未清理业务数据库。修改前检查点 `709c1ef`、`f38e290` 已提交推送,后续候选以 Git 记录为准。 + +| 范围 | 本批源码变化 | 当前证据等级 | +|---|---|---| +| 生命周期 | 统一项目 ID、控制字符、前端忙状态和表单渲染用例 | 已开发,未统一测试 | +| 读取隔离 | 七个 GET 按真实项目与能力过滤,无缺依赖回退 | 已开发,未统一测试 | +| 多对多绑定 | 同项目双向复用、去重、明确清空、事务审计及失败保留 | 已开发,未统一测试 | +| 共享路径 | 保存关联、修改服务器、部署前重新校验规范化路径 | 已开发,未统一测试 | +| SQLite/MySQL | 新增真实仓储多对多、权限及审计故障回滚用例 | 仅编写;双版本 MySQL 均未重跑 | +| HTTP/工作流/前端 | 新增多对多、409 冲突、停止远端调用及 Vue 多选渲染用例 | 仅编写,未运行 | +| PostgreSQL/Linux/生产 | 不增加任何通过声明 | 仍待独立验证 | + +历史补记:生命周期首轮后端组合 136 个叶子通过、5 条条件跳过;完整前端曾为 210 项通过且构建成功。MySQL 5.7 组合曾通过;MySQL 8.0 三条故障注入因创建触发器权限失败,夹具已改为隔离实例管理员创建,尚未重跑。非法项目 ID 曾复现失败,修复亦未重跑。这些均是旧候选记录,不覆盖本节源码;历史日志位于 `artifacts/project-lifecycle-20260928/`。 + +路径预检只针对同一服务器记录的当前绑定,不把数据库事务延长到 SSH 阶段;同一主机多条服务器记录、在途任务与配置变更协调、远端实际路径及遗留文件占用仍待后续部署闭环。全资源写入和任务授权也未完成,不能将本批多对多开发记作 P0-06/P0-07 整项通过。 diff --git a/doc/项目资源关联模型.md b/doc/项目资源关联模型.md index d48ee19..47734ea 100644 --- a/doc/项目资源关联模型.md +++ b/doc/项目资源关联模型.md @@ -1,5 +1,24 @@ # 项目资源关联模型 +## 十二、资源读取与域名服务器多对多(2026-09-28,源码完成待统一测试) + +- `domain_servers` 继续使用 `(domain_id, server_id)` 联合主键,不增加任何单边唯一约束,模式仍为 14,无新增迁移。一个域名关联多台服务器,同一服务器复用于多个域名,双方必须同项目。 +- 新绑定接口只替换目标域名的关联。重复服务器标识去重,明确空数组表示清空;不删除服务器、不清理远端证书,不改变其他域名的关联,也不触发 SSH 或推送。 +- 活跃超级管理员或同时持有 `certificate.read`、`server.read`、`deployment.execute` 的项目成员可修改绑定。操作者来自会话,项目来自实际域名,既有用户分组和全局管理员身份不自动授权。 +- 七个资源 GET 在数据库内按真实资源及账户状态过滤;绑定读取要求双方读取权限。无权和未知详情统一 403;缺少授权依赖为 503,不回退基础仓储,响应禁止缓存。 +- 绑定成功审计与关联共用事务,审计或保存失败回滚;失败审计另行按实际资源归属记录,不保存请求正文或敏感凭据。输入最多 100 个服务器标识,限制在去重之前计算。 +- 共享服务器默认按域名与证书类型分目录。绑定保存、服务器修改和部署预检比较实际展开、规范化后的证书、证书链、私钥路径;固定路径、被 `..` 抵消的占位符、跨文件或文件目录冲突均拒绝。单域名固定路径保持兼容。 +- 部署预检通过事务读取当前绑定及配置并返回已解析的目标路径;不在数据库事务内连接 SSH。它不等于跨主机别名的文件系统占用管理,也不覆盖配置变更与在途任务的完整协调、远端符号链接或解除绑定后的遗留文件识别,这些仍属部署整改待办。 +- 真实 SQLite/隔离 MySQL、HTTP、工作流及 Vue 模板回归源码已补齐,尚未执行;不能宣称远端多服务器推送已通过。 + +## 十一、项目生命周期(2026-09-28,收尾待统一测试) + +- 已接入项目创建、编辑、明确确认删除及前端共享忙状态;默认项目不可删除,有资源或成员引用时拒绝删除。 +- 项目标识统一为 `^[a-z0-9][a-z0-9_-]{0,35}$`;名称拒绝控制字符,说明允许换行、回车和制表符。非法 ID、停用或撤权、事务审计与失败回滚已补用例。 +- 生命周期收尾与读取隔离候选 `709c1ef`、绑定检查点 `f38e290` 已提交推送;历史局部测试不覆盖最新源码,后续集中验证。其他业务资源及排队任务权限尚未完成。 + +以下第十节及早期记录保留历史实现与验证范围,不代表当前候选仍未开发生命周期,也不表示新代码继承历史通过状态。 + > 当前入口批次(2026-09-28 19:54):已新增服务端实时过滤的可见项目分页接口和独立成员页面,管理员导航与普通用户工作台均有入口;账户建议沿用既有用户可见性,不开放全站搜索。见本文件第十节。最后源码后端专项、完整回归、双版本 MySQL 组合、前端测试、三轮专项竞态及双平台构建已通过;条件跳过和验证范围见任务执行清单首节和支持矩阵第二十四节。真实浏览器、项目增删改和全资源权限链仍待验收,下方早期批次未接入界面的描述仅为历史。 ## 十、可见项目入口与成员页面 diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 7b149a7..fcec640 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import "./DeploymentResult.test.js"; import "./DashboardLogout.test.js"; +import "./DomainModal.test.js"; import test from "node:test"; import { nextTick, ref } from "vue"; import { useDashboard } from "./Dashboard.js"; diff --git a/frontend/src/views/Dashboard/DomainModal.test.js b/frontend/src/views/Dashboard/DomainModal.test.js new file mode 100644 index 0000000..6d33c01 --- /dev/null +++ b/frontend/src/views/Dashboard/DomainModal.test.js @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import * as Vue from "vue"; +import { parse, compileTemplate } from "@vue/compiler-sfc"; +import { renderToString } from "@vue/server-renderer"; + +test("域名表单使用复选框同时显示两台已关联服务器并提供复用提示", async () => { + const source = await readFile(new URL("./DomainModal.vue", import.meta.url), "utf8"); + const { descriptor, errors } = parse(source); + assert.deepEqual(errors, []); + const compiled = compileTemplate({ + source: descriptor.template.content, + filename: "DomainModal.vue", + id: "domain-many-to-many", + compilerOptions: { mode: "function" }, + }); + assert.deepEqual(compiled.errors, []); + const render = new Function("Vue", compiled.code)(Vue); + const warnings = []; + const app = Vue.createSSRApp({ + render, + setup: () => ({ + showForm: true, + formTitle: "编辑域名", + closeForm() {}, + saveDomain() {}, + form: { + name: "example.test", + certificate_type: "single", + sans: "", + auto_renew: false, + acme_environment: "staging", + dns_provider: "", + server_ids: ["server-one", "server-two"], + }, + dnsProviders: [], + servers: [ + { id: "server-one", name: "共享服务器一", address: "192.0.2.1", web_server: "nginx" }, + { id: "server-two", name: "共享服务器二", address: "192.0.2.2", web_server: "nginx" }, + ], + displayLabel: (_, value) => value, + webServerLabels: {}, + formError: "", + saving: false, + }), + }); + app.component("HelpTip", { + setup: (_, { slots }) => () => Vue.h("span", { class: "help-tip" }, slots.default?.()), + }); + app.config.warnHandler = (message) => warnings.push(message); + const html = await renderToString(app); + assert.deepEqual(warnings, []); + const inputs = html.match(/]*>/g) || []; + for (const id of ["server-one", "server-two"]) { + const input = inputs.find((value) => value.includes(`value="${id}"`)); + assert.ok(input); + assert.match(input, /type="checkbox"/); + assert.match(input, /\bchecked(?:[\s=>])/); + } + assert.match(html, /同一服务器可关联多个域名/); + assert.match(html, /不影响其他域名/); +}); diff --git a/frontend/src/views/Dashboard/DomainModal.vue b/frontend/src/views/Dashboard/DomainModal.vue index 0fabfed..3c9a04d 100644 --- a/frontend/src/views/Dashboard/DomainModal.vue +++ b/frontend/src/views/Dashboard/DomainModal.vue @@ -117,7 +117,10 @@ const { >