Scert/backend/internal/servicehost/linux.go

217 lines
5.5 KiB
Go

//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",
"ConditionPathExists=" + quoteUnitValue(normalized.ConfigPath),
"",
"[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",
"KillSignal=SIGTERM",
"TimeoutStopSec=30",
"Restart=on-failure",
"UMask=0077",
"NoNewPrivileges=yes",
"PrivateTmp=yes",
"ProtectHome=yes",
"ProtectSystem=full",
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
)
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 }