mirror of
https://github.com/tnb-labs/panel.git
synced 2026-10-16 15:42:54 +08:00
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>
243 lines
6.5 KiB
Go
243 lines
6.5 KiB
Go
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
|
||
}
|