停用历史业务命令并明确CLI运维范围

This commit is contained in:
xuans 2026-09-27 17:31:09 +08:00
parent 25fd4cf89c
commit d90ddaadbb
9 changed files with 202 additions and 25 deletions

View File

@ -6,6 +6,8 @@ Scert 是一个统一管理 SSL 证书、自动续期并部署到多台服务器
正式产品仅包含 Web 管理界面和 Windows/Linux 服务端。CLI 作为服务端运维入口,用于初始化、服务启动、运维诊断、数据库迁移、备份恢复、服务安装和卸载,不作为另一套业务管理界面。
旧业务 CLI 已停用并返回中文迁移提示,范围和迁移方法见 `doc/命令行运维范围说明.md`。自动续期仍由服务端调度,手动业务操作通过 Web 执行,不再提供绕过 Web 流程的第二套 CLI 入口。
`desktop/` 保留 Wails 历史源码,供参考和独立开发;桌面窗口、托盘、自动更新、桌面配置管理及桌面 UI 测试均不属于正式交付或验收范围。正式发布脚本不调用 Wails,也不需要 WebView2 或 GTK 开发依赖。
## 项目目录

View File

@ -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`

View File

@ -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)
}

View File

@ -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 [命令]",

View File

@ -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)
}
}
}

View File

@ -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)
}
})
}
}

View File

@ -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 项目后续开发、进度跟踪、阶段复审和验收的执行清单。

View File

@ -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 安装及服务运行仍需独立实机验证。

View File

@ -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 诊断、配置、备份恢复、初始化和服务管理测试。
- 从实际构建的可执行文件检查帮助与旧入口退出码;单元测试不替代发布产物验证。