package procctl import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" "time" "safesight-agent/internal/config" "safesight-agent/internal/files" ) var ErrNotSupported = errors.New("not supported") var ErrConflict = errors.New("conflict") var ErrInvalidConfig = errors.New("invalid config") var ErrConfigNotFound = errors.New("config not found") // ProcessController 定义进程控制器接口 type ProcessController interface { Enabled() bool Status() (Status, error) Version() (string, error) Start(configName string) (Status, error) Stop() (Status, error) Restart(configName string) (Status, error) BinaryInfo() (BinaryUpdateResult, error) UpdateBinary(r io.Reader, contentLength int64, expectedSha256 string) (BinaryUpdateResult, error) RollbackBinary(backupPath string) (BinaryUpdateResult, error) } type Status struct { Running bool `json:"running"` Pid int `json:"pid"` ConfigPath string `json:"config_path"` StartedAtMS int64 `json:"started_at_ms"` } type BinaryUpdateResult struct { Path string `json:"path"` Sha256 string `json:"sha256"` Size int64 `json:"size"` MtimeMS int64 `json:"mtime_ms"` BackupPath string `json:"backup_path"` } type pidFile struct { Pid int `json:"pid"` ConfigPath string `json:"config_path"` StartedAtMS int64 `json:"started_at_ms"` } type Controller struct { mu sync.Mutex proc config.MediaServerProcessConfig defCfg string } func New(agentCfg config.AgentConfig, baseDir string) *Controller { p := agentCfg.MediaServerProcess if baseDir != "" { if p.ExecPath != "" && !filepath.IsAbs(p.ExecPath) { p.ExecPath = filepath.Join(baseDir, p.ExecPath) } if p.WorkDir != "" && !filepath.IsAbs(p.WorkDir) { p.WorkDir = filepath.Join(baseDir, p.WorkDir) } if p.ConfigsDir != "" && !filepath.IsAbs(p.ConfigsDir) { p.ConfigsDir = filepath.Join(baseDir, p.ConfigsDir) } if p.PidFile != "" && !filepath.IsAbs(p.PidFile) { p.PidFile = filepath.Join(baseDir, p.PidFile) } } return &Controller{proc: p, defCfg: agentCfg.ConfigPath} } func (c *Controller) Enabled() bool { return c != nil && c.proc.Enable } func (c *Controller) Status() (Status, error) { c.mu.Lock() defer c.mu.Unlock() pf, err := c.readPidFile() if err != nil { return Status{}, err } if pf == nil { return Status{Running: false}, nil } alive, aerr := isAlive(pf.Pid) if errors.Is(aerr, ErrNotSupported) { return Status{}, ErrNotSupported } if !alive { _ = os.Remove(c.proc.PidFile) return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil } return Status{Running: true, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil } func (c *Controller) Version() (string, error) { c.mu.Lock() defer c.mu.Unlock() if strings.TrimSpace(c.proc.ExecPath) == "" { return "", errors.New("exec_path is empty") } cmd := exec.Command(c.proc.ExecPath, "--version") if strings.TrimSpace(c.proc.WorkDir) != "" { cmd.Dir = c.proc.WorkDir } out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("get version failed: %w", err) } ver := strings.TrimSpace(string(out)) if ver == "" { return "", errors.New("version output empty") } return ver, nil } func (c *Controller) Start(configName string) (Status, error) { c.mu.Lock() defer c.mu.Unlock() resolved, err := c.resolveConfigPath(configName) if err != nil { return Status{}, err } pf, _ := c.readPidFile() if pf != nil { alive, _ := isAlive(pf.Pid) if alive { if filepath.Clean(pf.ConfigPath) == filepath.Clean(resolved) { return Status{Running: true, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil } return Status{}, fmt.Errorf("%w: already running with config %s", ErrConflict, pf.ConfigPath) } _ = os.Remove(c.proc.PidFile) } pid, err := startProcess(c.proc.ExecPath, c.proc.WorkDir, resolved) if err != nil { return Status{}, err } pf2 := pidFile{Pid: pid, ConfigPath: resolved, StartedAtMS: time.Now().UnixMilli()} b, _ := json.Marshal(pf2) b = append(b, '\n') if err := files.WriteFileAtomic(c.proc.PidFile, b, 0o644); err != nil { _ = stopProcess(pid, 1*time.Second) return Status{}, fmt.Errorf("write pid file: %w", err) } return Status{Running: true, Pid: pid, ConfigPath: resolved, StartedAtMS: pf2.StartedAtMS}, nil } func (c *Controller) Stop() (Status, error) { c.mu.Lock() defer c.mu.Unlock() pf, err := c.readPidFile() if err != nil { return Status{}, err } if pf == nil { return Status{Running: false}, nil } alive, _ := isAlive(pf.Pid) if !alive { _ = os.Remove(c.proc.PidFile) return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil } if err := stopProcess(pf.Pid, time.Duration(c.proc.GracefulTimeoutMS)*time.Millisecond); err != nil { return Status{}, err } _ = os.Remove(c.proc.PidFile) return Status{Running: false, Pid: pf.Pid, ConfigPath: pf.ConfigPath, StartedAtMS: pf.StartedAtMS}, nil } func (c *Controller) Restart(configName string) (Status, error) { _, _ = c.Stop() return c.Start(configName) } func (c *Controller) resolveConfigPath(name string) (string, error) { n := strings.TrimSpace(name) if n == "" { if strings.TrimSpace(c.defCfg) == "" { return "", fmt.Errorf("%w: default config_path is empty", ErrInvalidConfig) } return c.defCfg, nil } if strings.Contains(n, "..") || strings.ContainsAny(n, "/\\") { return "", fmt.Errorf("%w: contains invalid characters", ErrInvalidConfig) } if !strings.HasSuffix(n, ".json") { n += ".json" } base := strings.TrimSpace(c.proc.ConfigsDir) if base == "" { return "", fmt.Errorf("%w: configs_dir is empty", ErrInvalidConfig) } p := filepath.Join(base, n) st, err := os.Stat(p) if err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("%w: %s", ErrConfigNotFound, p) } return "", fmt.Errorf("stat config: %w", err) } if st.IsDir() { return "", fmt.Errorf("%w: is a directory", ErrInvalidConfig) } return p, nil } func (c *Controller) UpdateBinary(r io.Reader, contentLength int64, expectedSha256 string) (BinaryUpdateResult, error) { c.mu.Lock() defer c.mu.Unlock() if contentLength <= 0 { return BinaryUpdateResult{}, errors.New("missing Content-Length") } if strings.TrimSpace(c.proc.ExecPath) == "" { return BinaryUpdateResult{}, errors.New("exec_path is empty") } pf, _ := c.readPidFile() if pf != nil { alive, _ := isAlive(pf.Pid) if alive { return BinaryUpdateResult{}, fmt.Errorf("%w: media-server is running", ErrConflict) } _ = os.Remove(c.proc.PidFile) } dir := filepath.Dir(c.proc.ExecPath) if err := files.EnsureDir(dir, 0o755); err != nil { return BinaryUpdateResult{}, err } f, err := os.CreateTemp(dir, ".tmp-*") if err != nil { return BinaryUpdateResult{}, fmt.Errorf("create temp: %w", err) } tmp := f.Name() ok := false defer func() { _ = f.Close() if !ok { _ = os.Remove(tmp) } }() h := sha256.New() mw := io.MultiWriter(f, h) if _, err := io.CopyN(mw, r, contentLength); err != nil { return BinaryUpdateResult{}, fmt.Errorf("read body: %w", err) } if err := f.Chmod(0o755); err != nil { return BinaryUpdateResult{}, fmt.Errorf("chmod temp: %w", err) } if err := f.Sync(); err != nil { return BinaryUpdateResult{}, fmt.Errorf("fsync temp: %w", err) } if err := f.Close(); err != nil { return BinaryUpdateResult{}, fmt.Errorf("close temp: %w", err) } sha := hex.EncodeToString(h.Sum(nil)) if expectedSha256 != "" && !strings.EqualFold(expectedSha256, sha) { return BinaryUpdateResult{}, errors.New("sha256 mismatch") } backup := "" if st, err := os.Stat(c.proc.ExecPath); err == nil && !st.IsDir() { backup = fmt.Sprintf("%s.bak.%s", c.proc.ExecPath, time.Now().Format("20060102-150405")) if err := os.Rename(c.proc.ExecPath, backup); err != nil { return BinaryUpdateResult{}, fmt.Errorf("backup old binary: %w", err) } } if err := files.ReplaceFile(tmp, c.proc.ExecPath); err != nil { if backup != "" { _ = os.Rename(backup, c.proc.ExecPath) } return BinaryUpdateResult{}, err } st, err := os.Stat(c.proc.ExecPath) if err != nil { return BinaryUpdateResult{}, fmt.Errorf("stat binary: %w", err) } ok = true return BinaryUpdateResult{ Path: filepath.ToSlash(c.proc.ExecPath), Sha256: sha, Size: st.Size(), MtimeMS: st.ModTime().UnixMilli(), BackupPath: filepath.ToSlash(backup), }, nil } func (c *Controller) RollbackBinary(backupPath string) (BinaryUpdateResult, error) { c.mu.Lock() defer c.mu.Unlock() if strings.TrimSpace(backupPath) == "" { return BinaryUpdateResult{}, errors.New("backup_path is empty") } if strings.TrimSpace(c.proc.ExecPath) == "" { return BinaryUpdateResult{}, errors.New("exec_path is empty") } pf, _ := c.readPidFile() if pf != nil { alive, _ := isAlive(pf.Pid) if alive { return BinaryUpdateResult{}, fmt.Errorf("%w: media-server is running", ErrConflict) } _ = os.Remove(c.proc.PidFile) } if st, err := os.Stat(backupPath); err != nil { return BinaryUpdateResult{}, fmt.Errorf("stat backup: %w", err) } else if st.IsDir() { return BinaryUpdateResult{}, errors.New("backup_path is a directory") } oldBackup := "" if st, err := os.Stat(c.proc.ExecPath); err == nil && !st.IsDir() { oldBackup = fmt.Sprintf("%s.bak.rollback.%s", c.proc.ExecPath, time.Now().Format("20060102-150405")) if err := os.Rename(c.proc.ExecPath, oldBackup); err != nil { return BinaryUpdateResult{}, fmt.Errorf("backup current binary: %w", err) } } if err := files.ReplaceFile(backupPath, c.proc.ExecPath); err != nil { if oldBackup != "" { _ = os.Rename(oldBackup, c.proc.ExecPath) } return BinaryUpdateResult{}, err } st, err := os.Stat(c.proc.ExecPath) if err != nil { return BinaryUpdateResult{}, fmt.Errorf("stat binary: %w", err) } sha, err := sha256File(c.proc.ExecPath) if err != nil { return BinaryUpdateResult{}, err } return BinaryUpdateResult{ Path: filepath.ToSlash(c.proc.ExecPath), Sha256: sha, Size: st.Size(), MtimeMS: st.ModTime().UnixMilli(), BackupPath: filepath.ToSlash(oldBackup), }, nil } func (c *Controller) BinaryInfo() (BinaryUpdateResult, error) { c.mu.Lock() defer c.mu.Unlock() if strings.TrimSpace(c.proc.ExecPath) == "" { return BinaryUpdateResult{}, errors.New("exec_path is empty") } st, err := os.Stat(c.proc.ExecPath) if err != nil { return BinaryUpdateResult{}, err } if st.IsDir() { return BinaryUpdateResult{}, errors.New("exec_path is a directory") } sha, err := sha256File(c.proc.ExecPath) if err != nil { return BinaryUpdateResult{}, err } return BinaryUpdateResult{ Path: filepath.ToSlash(c.proc.ExecPath), Sha256: sha, Size: st.Size(), MtimeMS: st.ModTime().UnixMilli(), }, nil } func (c *Controller) readPidFile() (*pidFile, error) { b, err := os.ReadFile(c.proc.PidFile) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read pid file: %w", err) } var pf pidFile if err := json.Unmarshal(b, &pf); err != nil { return nil, fmt.Errorf("parse pid file: %w", err) } if pf.Pid <= 0 { return nil, fmt.Errorf("pid file invalid pid: %d", pf.Pid) } return &pf, nil } func sha256File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() H := sha256.New() if _, err := io.Copy(H, f); err != nil { return "", err } return hex.EncodeToString(H.Sum(nil)), nil }