//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=simple", } 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 }