补全Linux系统服务注册并完成代码复审
This commit is contained in:
parent
9f627ca4f0
commit
0563a060d9
@ -4,7 +4,13 @@ package servicehost
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Install(ctx context.Context, options Options) error {
|
func Install(ctx context.Context, options Options) error {
|
||||||
@ -12,6 +18,12 @@ func Install(ctx context.Context, options Options) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := runSystemctl(ctx, "enable", o.Name); err != nil {
|
||||||
return commandError("注册", err)
|
return commandError("注册", err)
|
||||||
}
|
}
|
||||||
@ -25,6 +37,12 @@ func Uninstall(ctx context.Context, name string) error {
|
|||||||
if err := runSystemctl(ctx, "disable", name); err != nil {
|
if err := runSystemctl(ctx, "disable", name); err != nil {
|
||||||
return commandError("取消注册", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +75,110 @@ func runSystemctl(ctx context.Context, args ...string) error {
|
|||||||
return nil
|
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 {
|
type commandFailure struct {
|
||||||
output string
|
output string
|
||||||
err error
|
err error
|
||||||
|
|||||||
46
backend/internal/servicehost/linux_test.go
Normal file
46
backend/internal/servicehost/linux_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3165,3 +3165,31 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无
|
|||||||
- `[!]` Let’s Encrypt Production、真实 SSH 主机、公钥安装、证书部署、Nginx 反向代理和生产 HTTPS 仍需在用户授权的目标环境执行实机验收。
|
- `[!]` Let’s Encrypt Production、真实 SSH 主机、公钥安装、证书部署、Nginx 反向代理和生产 HTTPS 仍需在用户授权的目标环境执行实机验收。
|
||||||
- `[!]` Windows Service、Linux systemd、Wails 桌面打包、Windows ACL、MySQL 实例和真实防火墙链路仍需目标环境验收。
|
- `[!]` Windows Service、Linux systemd、Wails 桌面打包、Windows ACL、MySQL 实例和真实防火墙链路仍需目标环境验收。
|
||||||
- `[!]` 工作区中已有的 `desktop/frontend/dist/index.html`、`dist/` 和 `backend/.patch*` 未删除、未纳入本轮提交,等待用户单独授权清理。
|
- `[!]` 工作区中已有的 `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` 未删除、未纳入本轮源码提交。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user