// Package oscmd holds small helpers shared by modules that shell out to system // tools (hostnamectl, timedatectl, systemctl, …): a command runner that // surfaces stderr, and the common "status: ok" HTTP response. package oscmd import ( "bufio" "bytes" "context" "errors" "fmt" "log" "os" "os/exec" "strings" "sync" "syscall" "time" ) // cmdTimeout caps how long a synchronous system command may run before it is // killed, so a wedged tool can't tie up a request indefinitely. // // It bounds hangs for the plain Run path (which uses context.Background()). // RunContext additionally propagates client cancellation. Long ops (package // install/upgrade) use the streaming runners below, which take a ctx and are // uncapped. const cmdTimeout = 60 * time.Second // CommandRunner allows overriding the command execution function for testing. // In tests, register a handler with SetMock; the runner translates the // MockCommand into a /bin/sh script that reproduces the stdout/stderr/exit // behavior — no helper process, no temp file. var CommandRunner = func(ctx context.Context, name string, args ...string) *exec.Cmd { mockMu.Lock() handler, ok := mockCmds[name] mockMu.Unlock() if !ok { return exec.CommandContext(ctx, name, args...) } return exec.CommandContext(ctx, "/bin/sh", "-c", mockScript(handler(args))) } // mockScript builds the shell script that emits a MockCommand's behavior. // Uses printf so backslashes and percent signs in output pass through verbatim. func mockScript(m MockCommand) string { var b strings.Builder if len(m.Lines) > 0 { for _, line := range m.Lines { b.WriteString("printf '%s\\n' ") b.WriteString(shellQuote(line)) b.WriteByte('\n') if m.DelayMs > 0 { fmt.Fprintf(&b, "sleep %g\n", float64(m.DelayMs)/1000) } } } else { if m.Stdout != "" { b.WriteString("printf '%s' ") b.WriteString(shellQuote(m.Stdout)) b.WriteByte('\n') } if m.Stderr != "" { b.WriteString("printf '%s' ") b.WriteString(shellQuote(m.Stderr)) b.WriteString(" 1>&2\n") } } fmt.Fprintf(&b, "exit %d\n", m.ExitCode) return b.String() } // shellQuote returns s wrapped in single quotes, with embedded single quotes escaped. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // Run executes name with args and returns trimmed stdout. On failure it wraps // the command's stderr (falling back to the exec error) so handlers can surface // a meaningful message instead of a bare "exit status 1". // // Run uses context.Background(): it is bounded by cmdTimeout but not tied to any // request. Callers running a slow command on behalf of a request (so a client // disconnect should kill it) should use RunContext instead. func Run(name string, args ...string) (string, error) { return RunContext(context.Background(), name, args...) } // RunContext is Run with a caller-supplied context. The command is killed when // ctx is cancelled (e.g. the client disconnected) or after cmdTimeout, whichever // comes first. func RunContext(ctx context.Context, name string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(ctx, cmdTimeout) defer cancel() cmd := CommandRunner(ctx, name, args...) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if msg := strings.TrimSpace(stderr.String()); msg != "" { return "", errors.New(msg) } return "", err } return strings.TrimSpace(string(out)), nil } // RunStdin is Run with data fed to the command's stdin. Use it for secrets // (e.g. piping "user:password" to chpasswd) so they never appear in argv/ps. func RunStdin(stdin, name string, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() cmd := CommandRunner(ctx, name, args...) cmd.Stdin = strings.NewReader(stdin) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if msg := strings.TrimSpace(stderr.String()); msg != "" { return "", errors.New(msg) } return "", err } return strings.TrimSpace(string(out)), nil } // RunLines runs the command and splits stdout into non-empty lines. func RunLines(name string, args ...string) ([]string, error) { out, err := Run(name, args...) if err != nil { return nil, err } if out == "" { return []string{}, nil } return strings.Split(out, "\n"), nil } // RunStatus runs name and returns trimmed stdout plus the process exit code. It // does NOT treat a non-zero exit as an error, because some tools signal state // that way (dnf `check-update` exits 100 when updates exist; pacman `-Qu` exits // 1 when there are none). err is set only when the command could not be run. func RunStatus(name string, args ...string) (string, int, error) { ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout) defer cancel() cmd := CommandRunner(ctx, name, args...) out, err := cmd.Output() if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { return strings.TrimSpace(string(out)), ee.ExitCode(), nil } return "", -1, err } return strings.TrimSpace(string(out)), 0, nil } // RunStream runs a long-lived command (e.g. `journalctl -f`) and pushes its // stdout lines onto the returned channel until the process exits or ctx is // cancelled. Cancelling ctx kills the process (exec.CommandContext) and closes // the channel, so an SSE handler can stop simply by cancelling its request // context (client disconnect). func RunStream(ctx context.Context, name string, args ...string) (<-chan string, error) { cmd := CommandRunner(ctx, name, args...) stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } ch := make(chan string) go func() { defer close(ch) defer cmd.Wait() // reap; process is already killed on ctx cancel sc := bufio.NewScanner(stdout) sc.Buffer(make([]byte, 64*1024), 1024*1024) // tolerate long log lines for sc.Scan() { select { case ch <- sc.Text(): case <-ctx.Done(): return } } // A scan error (e.g. a line over the buffer cap) ends the loop silently // otherwise; surface it so a truncated stream is diagnosable. Context // cancellation returns above, so this only fires on a real read error. if err := sc.Err(); err != nil && ctx.Err() == nil { log.Printf("oscmd: stream %s: %v", name, err) } }() return ch, nil } // RunStreamCombined runs a command and streams its merged stdout+stderr line by // line, the way the command's own terminal output looks. Lines are split on \r // as well as \n so progress redraws (apt/dnf) stream instead of buffering until // a newline. extraEnv is appended to the process environment. // // It returns a lines channel and a one-shot error channel that delivers the // process's exit status after the lines channel closes (nil = success). // Cancelling ctx kills the process. func RunStreamCombined(ctx context.Context, extraEnv []string, name string, args ...string) (<-chan string, <-chan error, error) { cmd := CommandRunner(ctx, name, args...) if len(extraEnv) > 0 { if cmd.Env == nil { cmd.Env = append(os.Environ(), extraEnv...) } else { cmd.Env = append(cmd.Env, extraEnv...) } } // One OS pipe for both streams gives the natural interleaving; passing an // *os.File hands the fd straight to the child (no copy goroutine). pr, pw, err := os.Pipe() if err != nil { return nil, nil, err } cmd.Stdout, cmd.Stderr = pw, pw if err := cmd.Start(); err != nil { pr.Close() pw.Close() return nil, nil, err } pw.Close() // the child holds its own dup; the reader sees EOF when it exits lines := make(chan string) errc := make(chan error, 1) go func() { defer close(lines) defer func() { errc <- cmd.Wait() }() defer pr.Close() sc := bufio.NewScanner(pr) sc.Buffer(make([]byte, 64*1024), 1024*1024) sc.Split(scanLinesCR) for sc.Scan() { if line := sc.Text(); line != "" { select { case lines <- line: case <-ctx.Done(): return } } } // Surface a read error (e.g. a line over the buffer cap) so a truncated // stream is diagnosable. ctx cancellation returns above, so this only // fires on a real error; the exit status still travels via errc. if err := sc.Err(); err != nil && ctx.Err() == nil { log.Printf("oscmd: stream %s: %v", name, err) } }() return lines, errc, nil } // scanLinesCR is a bufio.SplitFunc that breaks on either \r or \n, so terminal // progress redraws (carriage returns) surface as they happen. func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) { if atEOF && len(data) == 0 { return 0, nil, nil } if i := bytes.IndexAny(data, "\r\n"); i >= 0 { return i + 1, data[:i], nil } if atEOF { return len(data), data, nil } return 0, nil, nil // request more data } // ParseKV parses key=value lines (e.g. from timedatectl/systemctl show) into a map. func ParseKV(lines []string) map[string]string { m := make(map[string]string, len(lines)) for _, line := range lines { if k, v, ok := strings.Cut(line, "="); ok { m[k] = v } } return m } // RunDetached starts name with args in a new process group (Setsid) and returns // immediately once the child has started. The child is reaped in the background // so it does not become a zombie. // // Use this for operations where the synchronous path would kill the caller // (e.g. "systemctl restart nadir" would SIGTERM the process serving the // request before the response is written). func RunDetached(name string, args ...string) (*StatusOutput, error) { cmd := exec.Command(name, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return nil, err } go cmd.Wait() return OK(), nil } // StatusOutput is the shared response for write operations that just report // success. Reusing one type means all such endpoints share a single OpenAPI // schema. type StatusOutput struct { Body struct { Status string `json:"status" example:"ok" doc:"Always \"ok\" on success"` } } // OK returns a populated StatusOutput. func OK() *StatusOutput { out := &StatusOutput{} out.Body.Status = "ok" return out } // --- Mocking helpers for testing --------------------------------------------- // MockCommand holds the behavior for a mocked command. type MockCommand struct { Stdout string Stderr string ExitCode int Lines []string DelayMs int } var ( mockMu sync.Mutex mockCmds = make(map[string]func(args []string) MockCommand) ) // SetMock registers a mock handler function for the given command name. func SetMock(name string, handler func(args []string) MockCommand) { mockMu.Lock() defer mockMu.Unlock() mockCmds[name] = handler } // ClearMocks removes all registered mock command handlers. func ClearMocks() { mockMu.Lock() defer mockMu.Unlock() clear(mockCmds) }