package procctl import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "sync" "time" "rk3588sys/agent/internal/config" "rk3588sys/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") 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 }