Scert/backend/internal/servicehost/linux.go
xuans 0b74886c45 修复Linux启动就绪通知并隔离命令行测试目录
通过本机Unix套接字报告HTTPS监听状态,同步服务配置与安装模板,保持初始化业务就绪隔离。补充生命周期回归并修复旧CLI测试误用正式目录的问题。更新Linux原生测试证据,明确新候选尚未部署、生产验收未放行。
2026-10-02 12:20:47 +08:00

239 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux
package servicehost
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
func Install(ctx context.Context, options Options) error {
o, err := options.normalized()
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)
}
return nil
}
func Uninstall(ctx context.Context, name string) error {
name, err := normalizeName(name)
if err != nil {
return err
}
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
}
func Start(ctx context.Context, name string) error {
name, err := normalizeName(name)
if err != nil {
return err
}
if err := runSystemctl(ctx, "start", name); err != nil {
return commandError("启动", err)
}
return nil
}
func Stop(ctx context.Context, name string) error {
name, err := normalizeName(name)
if err != nil {
return err
}
if err := runSystemctl(ctx, "stop", name); err != nil {
return commandError("停止", err)
}
return nil
}
func runSystemctl(ctx context.Context, args ...string) error {
command := exec.CommandContext(ctx, "systemctl", args...)
output, err := command.CombinedOutput()
if err != nil {
return &commandFailure{output: string(output), err: err}
}
return nil
}
const systemUnitDirectory = "/etc/systemd/system"
func unitFilePath(name string) (string, error) {
name, err := normalizeName(name)
if 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
}
for _, path := range normalized.ReadWritePaths {
if !filepath.IsAbs(path) {
return "", fmt.Errorf("服务可写路径必须是绝对路径:%s", path)
}
}
lines := []string{
"[Unit]",
"Description=" + normalized.Description,
"After=network-online.target",
"Wants=network-online.target",
"",
"[Service]",
"Type=notify",
"NotifyAccess=main",
"TimeoutStartSec=90",
}
if normalized.User != "" {
lines = append(lines, "User="+normalized.User, "Group="+normalized.Group)
}
lines = append(lines,
"WorkingDirectory="+quoteUnitValue(normalized.WorkDir),
"ExecStart="+quoteUnitValue(normalized.Executable)+" --config "+quoteUnitValue(normalized.ConfigPath)+" serve",
"Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external",
"Environment=SCERT_SERVER_HOST=127.0.0.1",
"KillSignal=SIGTERM",
"TimeoutStopSec=30",
"Restart=on-failure",
"UMask=0077",
"RuntimeDirectory=scert",
"RuntimeDirectoryMode=0700",
"NoNewPrivileges=yes",
"CapabilityBoundingSet=",
"AmbientCapabilities=",
"PrivateTmp=yes",
"PrivateDevices=yes",
"ProtectHome=yes",
"ProtectSystem=strict",
"ProtectHostname=yes",
"ProtectClock=yes",
"ProtectKernelTunables=yes",
"ProtectKernelModules=yes",
"ProtectControlGroups=yes",
"ProtectKernelLogs=yes",
"LockPersonality=yes",
"MemoryDenyWriteExecute=yes",
"RestrictNamespaces=yes",
"RestrictRealtime=yes",
"RestrictSUIDSGID=yes",
"RemoveIPC=yes",
"SystemCallArchitectures=native",
// 初始化证书枚举本机网卡需要 NETLINK_ROUTE,空能力集仍禁止网络管理操作。
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK",
)
if len(normalized.ReadWritePaths) > 0 {
quotedPaths := make([]string, 0, len(normalized.ReadWritePaths))
for _, path := range normalized.ReadWritePaths {
quotedPaths = append(quotedPaths, quoteUnitValue(path))
}
lines = append(lines, "ReadWritePaths="+strings.Join(quotedPaths, " "))
}
lines = append(lines, "", "[Install]", "WantedBy=multi-user.target", "")
return strings.Join(lines, "\n"), nil
}
func quoteUnitValue(value string) string {
return strconv.Quote(value)
}
type commandFailure struct {
output string
err error
}
func (e *commandFailure) Error() string {
if e.err != nil {
return e.output + ": " + e.err.Error()
}
return e.output
}
func (e *commandFailure) Unwrap() error { return e.err }