Files
panel/pkg/shell/exec.go
T
耗子andClaude Opus 5 d54f72e0ef fix: 恢复 shell 包的格式串检查并修正停机时的任务定性
go vet 的 printf 分析器有两个硬条件:包装器签名必须是 (format string,
args ...any),且函数体内不能给 format 形参赋值。pkg/shell 两条都不满足——
老实现的 shell = fmt.Sprintf(shell, args...) 踩了后者,上一轮抽出的
buildShell(shell string, args []any) 踩了前者。结果是 330 多个调用点的格式
串从来没被检查过,在 fe7cce62 上用探针验证过确实如此。

改掉签名与写法后检查生效,扫出的 23 处都不是真错配,只是「非常量格式串」。
其中 7 处本就在做格式化(路径用 + 拼好再传进去),还原成真正的格式串;其余
命令串是上游拼好的整条,为它们恢复 shell.Exec / ExecWithOutput / ExecWithPipe。

2fc68ed9 删掉的那个 Exec 删的是第二份实现:它自带 exec.CommandContext 与
ApplyEnv,没有 newCmd 的 setpgid、cmd.Cancel 和 WaitDelay,正是孙子进程杀不掉
的来源,合并掉是对的。现在恢复的只是 newCmd + runBuffered 的一行入口,没有可
漂移的行为,包内 ExecWithLog / ExecWithLogAppend 早就是这个命名档位。继续用
"%s" 透传还有个副作用:preCheckArg 按整值精确匹配单个元字符设计,整条命令送
进去过检必然恒真。

停机时正在跑的任务被记成 Failed 并跳过清理命令。systemd 默认 KillMode=
control-group,SIGTERM 发给整个 cgroup,任务的 bash 往往比运行器的 ctx 取消
先死,cmd.Wait() 返回时 taskCtx.Err() 还是 nil。根因是 runner 的 ctx 要经过
「信号 → quit 通道 → 主协程 select → runnerCancel」这条长链,改为直接挂在
signal.NotifyContext 上,取消与命令被杀几乎同时;定性前保留 200ms 宽限期兜住
剩余的调度差。关停顺序随之不再是决定性因素,上一轮为此做的调整可以撤掉。

CLI 此前用 context.TODO()。newCmd 统一 setpgid 之后 Ctrl+C 只打到前台进程组,
子进程照跑而 CLI 已经退出。改用 signal.NotifyContext,首次信号取消 ctx 整组
杀掉,再按一次恢复默认处理,可强退卡在不可取消段里的命令。

其余:

- 关停三段各 30 秒最坏叠到 90 秒,正好撞上 systemd 默认的 TimeoutStopSec,
  改为共用一个预算;runCancelShell 的清理超时同样从 60 秒收到 30 秒,否则
  停机时清理必被 Wait 放弃后的进程退出砍断
- nginx -t 与 apachectl configtest 的输出在 stderr,上一轮改成 out, _ := 只
  拿 stdout 永远是空串,补 2>&1
- apache 两处逐字相同的 reload 块抽成 reloadConfig;environment.go 的
  typ → 可执行文件映射不再与 IsInstalled 各存一份

已知未改:pkg/webserver 的 Dialect 已有 ConfigTest 与 reload,nginx/apache 仍
在 app 层手拼命令(两者的 apachectl 路径已经漂移,一个裸命令一个全路径),
caddy 与 openlitespeed 早已改用 d.Reload;收敛它会动到用户可见文案与 gotext
msgid,另行处理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:57:44 +08:00

243 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package shell
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"slices"
"strings"
"syscall"
"time"
)
// waitDelay 组 kill 之后等待 stdout 管道关闭的宽限期
// 用 setsid 逃出进程组的孙子进程杀不掉,它占着管道写端会让 Wait 永不返回,超过宽限期就截断输出并报错
const waitDelay = 3 * time.Second
func ApplyEnv(cmd *exec.Cmd, env ...string) {
cmd.Env = append(os.Environ(), append([]string{"LC_ALL=C"}, env...)...)
}
// newCmd 构造受 ctx 控制的 bash 命令
// bash 遇到管道和 && 会 fork,CommandContext 默认只 Kill bash 本身,孙子进程不但活着还会拖住 Wait,
// 因此统一放进独立进程组整组杀死
func newCmd(ctx context.Context, shell string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "bash", "-c", shell)
ApplyEnv(cmd)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
cmd.WaitDelay = waitDelay
return cmd
}
// buildShell 校验参数并格式化命令
func buildShell(format string, args ...any) (string, error) {
if !preCheckArg(args) {
return "", errors.New("command contains illegal characters")
}
if len(args) == 0 {
return format, nil
}
return fmt.Sprintf(format, args...), nil
}
// runBuffered 执行命令并返回 stdout,失败时附带 stderr
func runBuffered(cmd *exec.Cmd, shell string) (string, error) {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return strings.TrimSpace(stdout.String()), fmt.Errorf("run %s failed, err: %w, stderr: %s", shell, err, strings.TrimSpace(stderr.String()))
}
return strings.TrimSpace(stdout.String()), nil
}
// Exec 执行已拼好的 shell 命令
func Exec(ctx context.Context, shell string) (string, error) {
return runBuffered(newCmd(ctx, shell), shell)
}
// Execf 按格式串拼出 shell 命令后执行
func Execf(ctx context.Context, format string, args ...any) (string, error) {
shell, err := buildShell(format, args...)
if err != nil {
return "", err
}
return Exec(ctx, shell)
}
// ExecfWithEnv 安全执行 shell 命令,环境变量仅注入子进程
func ExecfWithEnv(ctx context.Context, env []string, format string, args ...any) (string, error) {
shell, err := buildShell(format, args...)
if err != nil {
return "", err
}
cmd := newCmd(ctx, shell)
ApplyEnv(cmd, env...)
return runBuffered(cmd, shell)
}
// ExecfWithDir 在指定目录下执行 shell 命令
func ExecfWithDir(ctx context.Context, dir, format string, args ...any) (string, error) {
shell, err := buildShell(format, args...)
if err != nil {
return "", err
}
cmd := newCmd(ctx, shell)
cmd.Dir = dir
return runBuffered(cmd, shell)
}
// ExecfWithTimeout 执行 shell 命令并设置超时时间,ctx 取消或超时到期均终止进程
func ExecfWithTimeout(ctx context.Context, timeout time.Duration, format string, args ...any) (string, error) {
shell, err := buildShell(format, args...)
if err != nil {
return "", err
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
out, err := runBuffered(newCmd(timeoutCtx, shell), shell)
// 只有本函数的 timeout 到期才算超时,父 ctx 自带的 deadline 要如实上报
if err != nil && ctx.Err() == nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return out, fmt.Errorf("run %s failed, err: timeout", shell)
}
return out, err
}
// ExecfAsync 异步执行 shell 命令
// 异步即命令要活过调用方,入口断开取消链,否则 sleep 1 && systemctl restart acepanel 这类自杀式操作
// 会在 HTTP 响应写完的瞬间被杀且无人察觉
func ExecfAsync(ctx context.Context, format string, args ...any) error {
shell, err := buildShell(format, args...)
if err != nil {
return err
}
cmd := exec.CommandContext(context.WithoutCancel(ctx), "bash", "-c", shell)
ApplyEnv(cmd)
// 独立进程组,面板自身被信号终止时不牵连它
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err = cmd.Start(); err != nil {
return err
}
go func() {
if err := cmd.Wait(); err != nil {
fmt.Println(fmt.Errorf("run %s failed, err: %s", shell, strings.TrimSpace(err.Error())))
}
}()
return nil
}
// ExecWithOutput 执行已拼好的 shell 命令并输出到终端
func ExecWithOutput(ctx context.Context, shell string) error {
cmd := newCmd(ctx, shell)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// ExecfWithOutput 按格式串拼出 shell 命令后执行并输出到终端
func ExecfWithOutput(ctx context.Context, format string, args ...any) error {
shell, err := buildShell(format, args...)
if err != nil {
return err
}
return ExecWithOutput(ctx, shell)
}
// ExecWithPipe 执行已拼好的 shell 命令并返回管道
func ExecWithPipe(ctx context.Context, shell string) (io.ReadCloser, error) {
cmd := newCmd(ctx, shell)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = cmd.Stdout
if err = cmd.Start(); err != nil {
return nil, err
}
pr, pw := io.Pipe()
go func() {
_, _ = io.Copy(pw, stdout)
_ = cmd.Wait()
_ = pw.Close()
}()
return pr, nil
}
// ExecfWithPipe 按格式串拼出 shell 命令后执行并返回管道
func ExecfWithPipe(ctx context.Context, format string, args ...any) (io.ReadCloser, error) {
shell, err := buildShell(format, args...)
if err != nil {
return nil, err
}
return ExecWithPipe(ctx, shell)
}
// ExecWithLog 执行 shell 命令并将输出覆盖写入指定的日志文件
func ExecWithLog(ctx context.Context, shell string, logFile string) error {
return execWithLog(ctx, shell, logFile, os.O_TRUNC)
}
// ExecWithLogAppend 执行 shell 命令并将输出追加到指定的日志文件
func ExecWithLogAppend(ctx context.Context, shell string, logFile string) error {
return execWithLog(ctx, shell, logFile, os.O_APPEND)
}
func execWithLog(ctx context.Context, shell string, logFile string, flag int) error {
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|flag, 0644)
if err != nil {
return err
}
defer func(f *os.File) { _ = f.Close() }(f)
cmd := newCmd(ctx, shell)
cmd.Stdout = f
cmd.Stderr = f
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("run shell failed: %w", err)
}
return nil
}
func preCheckArg(args []any) bool {
illegals := []any{`&`, `|`, `;`, `$`, `'`, `"`, "`", `(`, `)`, "\n", "\r", `>`, `<`}
for arg := range slices.Values(args) {
if slices.Contains(illegals, arg) {
return false
}
}
return true
}