diff --git a/README.md b/README.md index 240a5cf..eb954da 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Scert 是一个统一管理 SSL 证书、自动续期并部署到多台服务器 正式产品仅包含 Web 管理界面和 Windows/Linux 服务端。CLI 作为服务端运维入口,用于初始化、服务启动、运维诊断、数据库迁移、备份恢复、服务安装和卸载,不作为另一套业务管理界面。 +旧业务 CLI 已停用并返回中文迁移提示,范围和迁移方法见 `doc/命令行运维范围说明.md`。自动续期仍由服务端调度,手动业务操作通过 Web 执行,不再提供绕过 Web 流程的第二套 CLI 入口。 + `desktop/` 保留 Wails 历史源码,供参考和独立开发;桌面窗口、托盘、自动更新、桌面配置管理及桌面 UI 测试均不属于正式交付或验收范围。正式发布脚本不调用 Wails,也不需要 WebView2 或 GTK 开发依赖。 ## 项目目录 diff --git a/backend/README.md b/backend/README.md index 8b9914a..4621e55 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ Scert 是面向 Windows 和 Linux 的 SSL 证书管理服务。当前代码已覆盖核心证书生命周期、DNS-01、自动续期、SSH 部署、通知、认证和 Web 管理流程。 -正式交付仅包含 Web 和服务端。CLI 限定为初始化、服务启动、运维诊断、数据库迁移、备份恢复、服务安装和卸载入口;历史 `domain` 命令保留兼容,不再扩展成另一套业务管理界面。Wails 历史实现不参与正式发布及测试门禁。 +正式交付仅包含 Web 和服务端。CLI 限定为初始化、服务启动、运维诊断、数据库维护、备份恢复、服务安装和卸载入口;历史 `domain`、`server`、`ssh-key`、`renew`、`deploy` 命令已停用,仅返回中文迁移提示,不执行原业务操作。命令边界见 `../doc/命令行运维范围说明.md`。Wails 历史实现不参与正式发布及测试门禁。 ## 当前已实现 @@ -13,7 +13,7 @@ Scert 是面向 Windows 和 Linux 的 SSL 证书管理服务。当前代码已 - 三种数据库的迁移脚本、嵌入检查和迁移历史保护;实际版本及验证边界见 `../doc/构建版本与数据库支持矩阵.md`。 - 域名基础模型与 Repository。 - 健康检查、状态和域名基础 API。 -- 中文 CLI:`version`、`help`、`init`、`status`、`serve`、`domain list`、`domain add`。 +- 中文运维 CLI:`version`、`help`、`init`、`status`、`serve`、证书诊断、日志、配置、备份恢复和系统服务管理。 - Vue 前端管理页面。 ## 首轮运行 @@ -23,13 +23,13 @@ Scert 是面向 Windows 和 Linux 的 SSL 证书管理服务。当前代码已 ```powershell go run ./cmd/scert init --base-dir C:\ScertData go run ./cmd/scert status --base-dir C:\ScertData -go run ./cmd/scert domain add --base-dir C:\ScertData --name example.com --type single -go run ./cmd/scert domain list --base-dir C:\ScertData go run ./cmd/scert serve --base-dir C:\ScertData ``` 服务启动后可访问: +业务操作应在 Web 登录后进行。下列接口沿用服务端认证及授权规则,不能因列在运行示例中就视为允许匿名调用: + - `GET /healthz` - `GET /api/v1/status` - `GET /api/v1/domains` diff --git a/backend/internal/cli/operations_test.go b/backend/internal/cli/operations_test.go index 90bba81..7bcf7f5 100644 --- a/backend/internal/cli/operations_test.go +++ b/backend/internal/cli/operations_test.go @@ -66,7 +66,7 @@ func TestSetConfigValueUpdatesAndValidatesAllowedFields(t *testing.T) { func TestRootIncludesOperationalCommands(t *testing.T) { root := newRootCommand() - for _, name := range []string{"certificate", "renew", "deploy", "logs", "config", "backup", "restore"} { + for _, name := range []string{"certificate", "logs", "config", "backup", "restore", "service", "dns"} { if root.CommandPath() == name || root.Commands() == nil { t.Fatalf("根命令状态异常:%s", name) } diff --git a/backend/internal/cli/root.go b/backend/internal/cli/root.go index 6e3bb14..b157462 100644 --- a/backend/internal/cli/root.go +++ b/backend/internal/cli/root.go @@ -64,7 +64,7 @@ func Execute() { } func newRootCommand() *cobra.Command { - root := &cobra.Command{Use: "scert", Short: "SSL 证书管理服务", SilenceUsage: true} + root := &cobra.Command{Use: "scert", Short: "SSL 证书服务与运维工具,业务管理请使用 Web", SilenceUsage: true} root.CompletionOptions.DisableDefaultCmd = true root.SetHelpCommand(newHelpCommand()) root.SetHelpTemplate(helpTemplate) @@ -72,14 +72,30 @@ func newRootCommand() *cobra.Command { root.PersistentFlags().StringVar(&baseDir, "base-dir", "", "Windows 默认安装根目录") root.AddCommand( &cobra.Command{Use: "version", Short: "显示版本信息", Run: func(cmd *cobra.Command, args []string) { cmd.Printf("Scert %s\n", version) }}, - newInitCommand(), newStatusCommand(), newServeCommand(), newDomainCommand(), newCertificateCommand(), - newRenewCommand(), newDeployCommand(), newLogsCommand(), newConfigCommand(), newBackupCommand(), newRestoreCommand(), - newServerCommand(), newSSHKeyCommand(), newServiceCommand(), newDNSCommand(), + newInitCommand(), newStatusCommand(), newServeCommand(), newCertificateCommand(), + newLogsCommand(), newConfigCommand(), newBackupCommand(), newRestoreCommand(), + newServiceCommand(), newDNSCommand(), ) + for _, name := range []string{"domain", "server", "ssh-key", "renew", "deploy"} { + root.AddCommand(newRetiredBusinessCommand(name)) + } localizeHelpFlags(root) return root } +// 历史业务命令只保留迁移提示,不读取配置、凭据或数据库,也不执行远程操作。 +func newRetiredBusinessCommand(name string) *cobra.Command { + return &cobra.Command{ + Use: name, + Short: "业务命令已迁移至 Web 管理页面", + Hidden: true, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return errors.New("此业务命令已停用,请使用 Web 管理页面;CLI 仅保留初始化、服务运行和运维诊断、数据库维护、备份恢复及系统服务管理") + }, + } +} + func newHelpCommand() *cobra.Command { return &cobra.Command{ Use: "help [命令]", diff --git a/backend/internal/cli/root_test.go b/backend/internal/cli/root_test.go index f243149..d6b23a6 100644 --- a/backend/internal/cli/root_test.go +++ b/backend/internal/cli/root_test.go @@ -72,29 +72,29 @@ func TestHelpSupportsSubcommand(t *testing.T) { var output bytes.Buffer root.SetOut(&output) root.SetErr(&output) - root.SetArgs([]string{"help", "domain"}) + root.SetArgs([]string{"help", "certificate"}) if err := root.Execute(); err != nil { t.Fatalf("查看子命令帮助失败:%v", err) } text := output.String() - if !strings.Contains(text, "管理托管域名") || !strings.Contains(text, "添加托管域名") { + if !strings.Contains(text, "查询和校验证书") || !strings.Contains(text, "校验当前证书") { t.Fatalf("子命令帮助内容不正确:%s", text) } } -func TestHelpIncludesServerManagementCommands(t *testing.T) { +func TestHelpIncludesSystemServiceCommands(t *testing.T) { root := newRootCommand() var output bytes.Buffer root.SetOut(&output) root.SetErr(&output) - root.SetArgs([]string{"help", "server"}) + root.SetArgs([]string{"help", "service"}) if err := root.Execute(); err != nil { - t.Fatalf("查看服务器管理帮助失败:%v", err) + t.Fatalf("查看系统服务帮助失败:%v", err) } text := output.String() - for _, expected := range []string{"管理部署服务器", "添加部署服务器", "编辑部署服务器", "删除部署服务器"} { + for _, expected := range []string{"管理系统服务", "注册 Scert 系统服务", "取消注册 Scert 系统服务"} { if !strings.Contains(text, expected) { - t.Fatalf("服务器管理帮助缺少 %q:%s", expected, text) + t.Fatalf("系统服务帮助缺少 %q:%s", expected, text) } } } @@ -117,19 +117,19 @@ func TestServerCommandUsesDefaultCertificatePathTemplates(t *testing.T) { } } -func TestHelpIncludesSSHKeyManagementCommands(t *testing.T) { +func TestHelpIncludesDNSDiagnosticCommand(t *testing.T) { root := newRootCommand() var output bytes.Buffer root.SetOut(&output) root.SetErr(&output) - root.SetArgs([]string{"help", "ssh-key"}) + root.SetArgs([]string{"help", "dns"}) if err := root.Execute(); err != nil { - t.Fatalf("查看 SSH 私钥管理帮助失败:%v", err) + t.Fatalf("查看 DNS 诊断帮助失败:%v", err) } text := output.String() - for _, expected := range []string{"管理 SSH 部署私钥", "安全录入 SSH 私钥", "删除未被服务器使用的 SSH 私钥"} { + for _, expected := range []string{"检查指定域名的阿里云 RAM 权限", "验证当前权限"} { if !strings.Contains(text, expected) { - t.Fatalf("SSH 私钥管理帮助缺少 %q:%s", expected, text) + t.Fatalf("DNS 诊断帮助缺少 %q:%s", expected, text) } } } diff --git a/backend/internal/cli/scope_test.go b/backend/internal/cli/scope_test.go new file mode 100644 index 0000000..592f0f0 --- /dev/null +++ b/backend/internal/cli/scope_test.go @@ -0,0 +1,87 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRootExposesOnlyOperationalCommands(t *testing.T) { + allowed := map[string]bool{ + "version": true, "init": true, "status": true, "serve": true, + "certificate": true, "logs": true, "config": true, + "backup": true, "restore": true, "service": true, "dns": true, + } + root := newRootCommand() + for _, command := range root.Commands() { + if command.Hidden { + continue + } + if !allowed[command.Name()] { + t.Errorf("正式 CLI 暴露了范围外命令:%s", command.Name()) + } + delete(allowed, command.Name()) + } + if len(allowed) != 0 { + t.Fatalf("正式 CLI 缺少运维命令:%v", allowed) + } + var output bytes.Buffer + root.SetOut(&output) + root.SetArgs([]string{"help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"domain", "server", "ssh-key", "renew", "deploy"} { + if strings.Contains(output.String(), "\n "+name+" ") { + t.Errorf("正式帮助仍展示历史业务命令:%s", name) + } + } +} + +func TestRetiredBusinessCommandsRejectBeforeAccessingData(t *testing.T) { + cases := [][]string{ + {"domain", "list"}, + {"domain", "add", "--name", "example.com"}, + {"domain", "issue", "--id", "example"}, + {"domain", "import", "--private-key", "not-a-real-key.pem"}, + {"server", "add", "--password", "test-secret-not-real"}, + {"server", "delete", "--id", "example"}, + {"ssh-key", "add", "--name", "example"}, + {"ssh-key", "delete", "--id", "example"}, + {"renew"}, + {"renew", "--id", "example"}, + {"deploy", "--id", "example"}, + } + for _, args := range cases { + t.Run(strings.Join(args[:min(2, len(args))], "_"), func(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "invalid.json") + original := []byte("invalid-config-sentinel") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + root := newRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(append([]string{"--config", path, "--base-dir", directory}, args...)) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "此业务命令已停用,请使用 Web 管理页面") { + t.Fatalf("旧入口未在读取无效配置前拒绝:%v", err) + } + if strings.Contains(output.String()+err.Error(), "test-secret-not-real") { + t.Fatal("迁移提示泄露了旧命令中的凭据参数") + } + content, readErr := os.ReadFile(path) + if readErr != nil || !bytes.Equal(content, original) { + t.Fatalf("被停用命令修改了配置:%v", readErr) + } + entries, readErr := os.ReadDir(directory) + if readErr != nil || len(entries) != 1 { + t.Fatalf("被停用命令创建了运行数据:%v", readErr) + } + }) + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 8f69685..43a18a6 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -2,6 +2,8 @@ > 2026-09-27 范围调整:本文件保留早期开发历史,当前执行以《Scert整改合并优化清单》和《Scert整改任务执行清单》为准。正式交付仅为 Web 和 Windows/Linux 服务端;本文件中的 Wails 桌面窗口、托盘、桌面更新、桌面配置和桌面 UI 测试均退出正式开发及验收范围,不再作为未完成交付项。CLI 限定为初始化及服务运维,不另建业务管理界面。 +> 同日 CLI 调整:下文 `domain`、`server`、`ssh-key`、`renew`、`deploy` 等业务命令及其测试结果仅为历史记录,当前正式入口已停用,请使用 Web;详见《命令行运维范围说明》。 + > 2026-09-15 最新进展:见 [发布加固与实机验收记录](发布加固与实机验收记录.md)。初始化事务、远程部署锁、回滚与 SSH 安全依赖已加固;Linux 候选已构建并通过隔离实机推送验收。管理端仍未完成初始化,正式 ACME、业务 reload、TLS/SNI 和浏览器全流程继续标记 `[!]`,不是生产放行。 > 文档用途:作为 Scert 项目后续开发、进度跟踪、阶段复审和验收的执行清单。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 37d5ae2..d1fbf08 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -33,9 +33,9 @@ | 编号 | 状态 | 任务 | 当前问题 | 下一步交付 | 验证证据 | |---|---:|---|---|---|---| -| P0-01 | [~] | 明确产品范围 | 已移除正式发布的 Wails 路径,历史源码保留;双平台实际包和 CLI 历史业务入口仍待复核 | 核查正式归档、兼容参数和运维 CLI 边界 | 范围审查、发布包文件清单 | +| P0-01 | [~] | 明确产品范围 | 双平台和独立克隆发布范围验证通过;CLI 业务入口已停用,最终可执行文件待核查 | 核查收敛后的 CLI 发布产物,完成范围复审 | 范围审查、发布包文件清单、CLI 回归测试 | | P0-02 | [~] | 建立完整任务清单 | 已覆盖 44 项,逐项依赖、涉及文件和具体实机步骤仍需补充 | 持续维护状态、证据和复审结果 | 本文件提交记录 | -| P0-03 | [~] | 整理源码、构建产物和部署产物 | 已取消跟踪桌面二进制和 dist,补强忽略规则,本地文件保留 | 独立克隆验证正式发布和生成物边界,不再要求桌面构建 | `git ls-files`、`git check-ignore`、工作区复核 | +| P0-03 | [~] | 整理源码、构建产物和部署产物 | 独立克隆无桌面二进制和 dist,正式构建通过;根级归档及通用临时文件忽略规则仍需补齐 | 补齐全仓库忽略边界、发布及证据目录说明,保留本地历史文件 | `git ls-files`、`git check-ignore`、独立克隆发布验证 | | P0-04 | [~] | 统一开发和构建版本 | 模块实际使用 Go `1.26.6`,不存在此前误报的版本阻塞;历史文档仍需统一 | 统一模块、构建说明、锁文件和验收机版本记录 | 版本矩阵、构建日志 | | P0-05 | [!] | 确认数据库正式支持矩阵 | 三种数据库均有代码,但 README、文档和实机证据口径不完全一致 | 明确 SQLite、MySQL、PostgreSQL 的正式级别、版本和迁移回滚要求 | 三数据库迁移、备份恢复和连接测试 | | P0-06 | [~] | 建立统一领域模型 | 已有域名、证书、任务、服务器等实体,项目和部署关联仍需审查 | 补齐资源关系图、外键/归属和任务关联 | 模型文档、迁移检查、接口测试 | @@ -147,5 +147,24 @@ - 旧参数 `SkipDesktop` 仅警告并兼容,显式设假也不能恢复桌面构建;传入 `DesktopDirectory` 在构建前报错。 - 保留路径扫描、Go `-trimpath`、文件及归档 SHA256、拒绝覆盖旧输出、源码提交和脏状态记录。 - 已通过 PowerShell 语法解析、`go test -count=1 -timeout=3m ./internal/releasearchive ./cmd/scert-release`、`git diff --check`。 -- 待执行:从干净提交构建 Windows/Linux 正式范围候选包,核对清单、归档内容和校验和;验证旧参数拒绝路径。此为构建层验证,不代表 Windows/Linux 实机安装验收。 -- 待复核:历史 `domain` CLI 兼容入口仍存在,需确认收敛方式;因此 P0-01 继续保持 `[~]`,不提前标记整个任务完成。 +- 发布范围验证已完成,证据见下节;这仍是构建层验证,不代表 Windows/Linux 实机安装验收。 +- 历史业务 CLI 的执行入口已在后续批次停用,最终可执行文件仍待检查;P0-01 暂保持 `[~]`。 + +### 2026-09-27:双平台和独立克隆发布验证 + +- 源码提交:`25fd4cf`,已推送。默认双平台构建成功,`manifest.json` 的 `source_dirty=false`、`delivery_scope=[web,server]`、`desktop_requested=false`、路径检查通过。 +- 输出:`build/release-scope-20260927-25fd4cf/`。清单共 33 项(31 个包内文件、2 个归档),逐项文件长度和 SHA256 匹配。 +- 真实解包核对:Windows 9 个文件、8 条包内校验和;Linux 22 个文件、21 条包内校验和。解包内容与清单一致,无桌面产物。 +- `DesktopDirectory` 反例返回退出码 1,且没有创建输出目录。 +- 独立克隆:`build/checkout-scope-20260927-25fd4cf/`。执行 `npm.cmd ci --no-audit --no-fund`、`npm.cmd test`,150 项通过;显式 `-SkipDesktop:$false` 构建双平台通过,33 项清单的长度和 SHA256 再次匹配。 +- 独立克隆保留 `desktop/main.go`,不包含 `desktop/desktop.exe` 或桌面 dist。跟踪清单没有 `.runtime`、`node_modules`、dist、可执行文件、数据库或私钥后缀文件;克隆工作区仍干净。 +- `npm ci` 提示 esbuild 的安装脚本尚未列入批准清单,但命令、前端测试及构建均退出成功;未自动批准额外脚本。可复现安装策略仍归 P0-04 后续审查。 + +### 2026-09-27:CLI 运维范围收敛 + +- 依赖 P0-01 正式范围;涉及 `backend/internal/cli/root.go`、根命令/范围/运维测试、README 和《命令行运维范围说明》。 +- `domain`、`server`、`ssh-key`、`renew`、`deploy` 不再注册业务实现;仅保留隐藏的中文迁移提示,返回错误,不解析或回显旧凭据参数。 +- 保留初始化、服务启动、状态、证书诊断、DNS 诊断、配置、日志、备份恢复及系统服务运维;服务端自动续期未更改。 +- 新增命令树白名单和 11 类旧入口拒绝测试;用无效配置和模拟凭据证明拒绝先于配置读取,配置内容不变且没有新增运行数据。 +- CLI 定向测试、后端全量 `go test -p 1 -count=1 -timeout=3m ./...` 和 `go vet -p=1 ./...` 已通过。使用测试临时目录,没有清空或连接生产数据库。 +- 下一步:从本批干净提交构建发布包,运行实际 Windows 可执行文件的帮助和旧入口退出码检查;Linux 安装及服务运行仍需独立实机验证。 diff --git a/doc/命令行运维范围说明.md b/doc/命令行运维范围说明.md new file mode 100644 index 0000000..43039b5 --- /dev/null +++ b/doc/命令行运维范围说明.md @@ -0,0 +1,51 @@ +# 命令行运维范围说明 + +## 一、正式范围 + +依据《Scert整改合并优化清单》P0-01,正式产品为 Web 管理页面和 Windows/Linux 服务端。CLI 是本机运维入口,不再维护另一套域名、服务器或凭据业务管理界面。 + +| 入口 | 用途 | 边界 | +|---|---|---| +| `version`、`help` | 版本和中文帮助 | 不启动业务服务 | +| `init` | 首次配置初始化 | 仍执行初始化参数、凭据和防火墙检查 | +| `serve` | 启动正式服务及首次初始化入口 | 定时续期由服务端生命周期管理 | +| `status` | 本机状态诊断 | 不替代公网 HTTPS 或生产验收 | +| `certificate list/show/validate` | 查询现有证书及校验 | 不提供申请、导入、续期或部署操作 | +| `logs` | 本机日志诊断 | 不替代完整审计中心 | +| `config` | 本机配置诊断及受限配置调整 | 保留现有字段白名单,不允许直接写入密码摘要 | +| `dns check` | 指定域名的 DNS 权限诊断 | 保留写入确认、保护域和精确清理限制,不能擅自执行真实 DNS 测试 | +| `backup`、`restore` | 停服备份恢复及数据库维护 | 仍须校验维护锁、备份完整性、凭据、数据库版本及失败回滚 | +| `service` | 注册、卸载、启停系统服务 | 必须遵循目标平台权限及安装要求 | +| `scert-admin` | 超级管理员密码恢复 | 继续使用受保护本机 IPC,不开放公网重置接口 | + +数据库迁移仍由现有初始化/应用加载路径执行,本次没有新增独立 `migrate` 命令;该说明不意味着三种数据库的实机迁移已经验收。 + +## 二、停用的业务入口 + +以下一级命令不再出现在默认帮助中,也不会执行原有操作: + +- `domain`:域名增删改查、证书申请或导入、服务器关联等。 +- `server`:部署服务器管理。 +- `ssh-key`:部署私钥管理。 +- `renew`:手动续期或触发续期检查。 +- `deploy`:手动证书部署。 + +调用旧命令时返回非零退出状态及中文提示,要求使用 Web 管理页面。拒绝发生在读取配置、凭据或数据库之前,不尝试建立 SSH、DNS 或 ACME 连接,不输出旧参数中的密码。旧子命令和参数不会恢复原行为。 + +旧命令实现文件暂时保留以保存历史和已有回归测试,但正式命令树不再注册这些实现;不以隐藏帮助代替停用执行。 + +## 三、迁移方法 + +1. 保留用于启动服务、查看状态、诊断、备份恢复及平台服务管理的现有运维流程。 +2. 停止依赖旧业务命令的定时任务和自动化调用,避免非零退出被误当成成功。 +3. 在 Web 中管理域名、DNS 账户、服务器和 SSH 凭据,发起手动申请、续期、导入及部署。 +4. 自动续期继续由服务端执行,不需要额外配置 `scert renew` 定时任务。 + +这次变更只收敛 CLI 入口,不代表 Web 的统一任务中心、项目隔离、完整审计或生产门禁已全部完成;这些仍按对应整改项执行。 + +## 四、验证要求 + +- 根命令树只公开范围内运维入口,默认帮助不列出停用命令。 +- 旧命令包含原有参数、无效配置或模拟凭据时,都在访问数据前拒绝;不改动配置、不新建运行数据、不回显凭据。 +- 保留证书诊断、DNS 诊断、配置、备份恢复、初始化和服务管理测试。 +- 从实际构建的可执行文件检查帮助与旧入口退出码;单元测试不替代发布产物验证。