diff --git a/backend/internal/servicehost/linux.go b/backend/internal/servicehost/linux.go index 434900b..6fe3125 100644 --- a/backend/internal/servicehost/linux.go +++ b/backend/internal/servicehost/linux.go @@ -4,7 +4,13 @@ package servicehost import ( "context" + "errors" + "fmt" + "os" "os/exec" + "path/filepath" + "strconv" + "strings" ) func Install(ctx context.Context, options Options) error { @@ -12,6 +18,12 @@ func Install(ctx context.Context, options Options) error { if err != nil { return err } + if err := writeUnitFile(o); err != nil { + return commandError("写入", err) + } + if err := runSystemctl(ctx, "daemon-reload"); err != nil { + return commandError("重新加载", err) + } if err := runSystemctl(ctx, "enable", o.Name); err != nil { return commandError("注册", err) } @@ -25,6 +37,12 @@ func Uninstall(ctx context.Context, name string) error { if err := runSystemctl(ctx, "disable", name); err != nil { return commandError("取消注册", err) } + if err := removeUnitFile(name); err != nil { + return commandError("删除", err) + } + if err := runSystemctl(ctx, "daemon-reload"); err != nil { + return commandError("重新加载", err) + } return nil } @@ -57,6 +75,110 @@ func runSystemctl(ctx context.Context, args ...string) error { return nil } +const systemUnitDirectory = "/etc/systemd/system" + +func unitFilePath(name string) (string, error) { + if err := validateName(name); err != nil { + return "", err + } + return filepath.Join(systemUnitDirectory, name+".service"), nil +} + +func writeUnitFile(options Options) error { + path, err := unitFilePath(options.Name) + if err != nil { + return err + } + content, err := renderUnitFile(options) + if err != nil { + return err + } + if info, statErr := os.Lstat(path); statErr == nil && info.Mode()&os.ModeSymlink != 0 { + return errors.New("服务 unit 文件不能是符号链接") + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("检查服务 unit 文件失败:%w", statErr) + } + temporary, err := os.CreateTemp(systemUnitDirectory, ".scert-unit-") + if err != nil { + return fmt.Errorf("创建临时 unit 文件失败:%w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return fmt.Errorf("设置 unit 文件权限失败:%w", err) + } + if _, err := temporary.WriteString(content); err != nil { + _ = temporary.Close() + return fmt.Errorf("写入 unit 文件失败:%w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("同步 unit 文件失败:%w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("关闭 unit 文件失败:%w", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("替换 unit 文件失败:%w", err) + } + return nil +} + +func removeUnitFile(name string) error { + path, err := unitFilePath(name) + if err != nil { + return err + } + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("检查服务 unit 文件失败:%w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("服务 unit 文件不能是符号链接") + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("删除服务 unit 文件失败:%w", err) + } + return nil +} + +func renderUnitFile(options Options) (string, error) { + normalized, err := options.normalized() + if err != nil { + return "", err + } + return strings.Join([]string{ + "[Unit]", + "Description=" + normalized.Description, + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + "WorkingDirectory=" + quoteUnitValue(normalized.WorkDir), + "ExecStart=" + quoteUnitValue(normalized.Executable) + " --config " + quoteUnitValue(normalized.ConfigPath) + " serve", + "KillSignal=SIGTERM", + "TimeoutStopSec=30", + "Restart=on-failure", + "UMask=0077", + "NoNewPrivileges=yes", + "PrivateTmp=yes", + "ProtectHome=yes", + "", + "[Install]", + "WantedBy=multi-user.target", + "", + }, "\n"), nil +} + +func quoteUnitValue(value string) string { + return strconv.Quote(value) +} + type commandFailure struct { output string err error diff --git a/backend/internal/servicehost/linux_test.go b/backend/internal/servicehost/linux_test.go new file mode 100644 index 0000000..24ec532 --- /dev/null +++ b/backend/internal/servicehost/linux_test.go @@ -0,0 +1,46 @@ +//go:build linux + +package servicehost + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRenderUnitFileIncludesExecutableConfigurationAndWorkDir(t *testing.T) { + content, err := renderUnitFile(Options{ + Name: "Scert", + Description: "Scert SSL 证书管理服务", + Executable: filepath.Join("/opt", "Scert", "scert"), + ConfigPath: filepath.Join("/opt", "Scert data", "scert.json"), + WorkDir: filepath.Join("/opt", "Scert data"), + }) + if err != nil { + t.Fatalf("渲染 systemd unit 失败:%v", err) + } + for _, expected := range []string{ + "[Unit]", + "Description=Scert SSL 证书管理服务", + "After=network-online.target", + "[Service]", + "Type=simple", + "WorkingDirectory=\"/opt/Scert data\"", + "ExecStart=\"/opt/Scert/scert\" --config \"/opt/Scert data/scert.json\" serve", + "Restart=on-failure", + "[Install]", + "WantedBy=multi-user.target", + } { + if !strings.Contains(content, expected) { + t.Fatalf("systemd unit 缺少 %q:\n%s", expected, content) + } + } +} + +func TestUnitFilePathRejectsPathTraversal(t *testing.T) { + for _, name := range []string{"", ".", "..", "../Scert", "a/b", `a\\b`} { + if _, err := unitFilePath(name); err == nil { + t.Errorf("服务名称 %q 未被拒绝", name) + } + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 3d3c3b3..70fb0a8 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3165,3 +3165,31 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` Let’s Encrypt Production、真实 SSH 主机、公钥安装、证书部署、Nginx 反向代理和生产 HTTPS 仍需在用户授权的目标环境执行实机验收。 - `[!]` Windows Service、Linux systemd、Wails 桌面打包、Windows ACL、MySQL 实例和真实防火墙链路仍需目标环境验收。 - `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。 + +## 八十三、Linux systemd 服务注册补全与全量代码复审(2026-08-31) + +### 83.1 本轮需求清单 + +- `[✓]` Linux 执行 `scert service install` 时自动生成对应的 systemd unit 文件。 +- `[✓]` unit 文件使用绝对程序路径、配置路径和工作目录,避免服务启动时依赖当前终端目录。 +- `[✓]` 服务注册前执行 `systemctl daemon-reload`,再执行 `systemctl enable`。 +- `[✓]` 服务卸载时先取消开机启动,再移除对应 unit 文件并重新加载 systemd。 +- `[✓]` 服务名称经过路径穿越校验,禁止将 unit 文件写入 `/etc/systemd/system` 之外的位置。 +- `[✓]` 增加 Linux unit 渲染和服务名称安全校验回归测试。 + +### 83.2 代码与验证状态 + +- `[✓]` 更新 `backend/internal/servicehost/linux.go`,补全 unit 文件生成、安装、卸载和 systemd 重载流程。 +- `[✓]` 新增 `backend/internal/servicehost/linux_test.go`,覆盖 unit 内容、配置路径、工作目录和路径穿越防护。 +- `[✓]` 后端 `go test ./...` 通过。 +- `[✓]` 桌面端 Go `go test ./...` 通过。 +- `[✓]` 网页端 `npm.cmd run build` 通过。 +- `[✓]` 桌面端前端 `npm.cmd run build` 通过。 +- `[ ]` 后端 race、vet、依赖校验、构建和最终差异检查待本轮最后阶段执行。 +- `[ ]` 本轮源码和文档修改待最终提交并推送。 + +### 83.3 验收边界 + +- `[!]` Linux systemd unit 的实际安装、启停、开机启动和权限效果仍需在授权 Ubuntu 主机上验收。 +- `[!]` 真实 DNS 服务商、Let’s Encrypt Production、远程 SSH 部署、Nginx 反向代理、生产 HTTPS、Windows Service、Wails 打包、Windows ACL、MySQL 和真实防火墙链路仍需目标环境验收。 +- `[!]` 工作区已有的 `desktop/frontend/dist/index.html`、`dist/`、`backend/.patch*` 和构建产生的 `.exe` 未删除、未纳入本轮源码提交。