补全Linux系统服务注册并完成代码复审

This commit is contained in:
xuans 2026-08-31 10:37:40 +08:00
parent 9f627ca4f0
commit 0563a060d9
3 changed files with 196 additions and 0 deletions

View File

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

View File

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

View File

@ -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` 未删除、未纳入本轮源码提交。