diff --git a/cmd/samwaf/main.go b/cmd/samwaf/main.go index f96cb0f..dc50b64 100644 --- a/cmd/samwaf/main.go +++ b/cmd/samwaf/main.go @@ -4,6 +4,7 @@ import ( "SamWaf/cache" "SamWaf/common/gwebsocket" "SamWaf/common/tasklog" + "SamWaf/common/wafexec" "SamWaf/common/zlog" "SamWaf/enums" "SamWaf/global" @@ -865,6 +866,7 @@ func (m *wafSystenService) run() { cmd := exec.Command(global.GWAF_RUNTIME_CURRENT_EXEPATH, "restart") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + wafexec.FixStdin(cmd) if err := cmd.Start(); err != nil { zlog.Error("启动新进程失败:", err) os.Exit(0) @@ -876,6 +878,7 @@ func (m *wafSystenService) run() { cmd := exec.Command(global.GWAF_RUNTIME_CURRENT_EXEPATH) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + wafexec.FixStdin(cmd) if err := cmd.Start(); err != nil { zlog.Error("启动新进程失败:", err) os.Exit(0) @@ -1069,6 +1072,14 @@ func main() { pid := os.Getpid() zlog.Debug("SamWaf Current PID:" + strconv.Itoa(pid)) + // 环境自检:精简版 Windows(如 LTSC 纯净版)可能裁剪/禁用 Null 内核驱动,导致空设备不可用。 + // os/exec 在标准流为 nil 时会去打开该设备,缺失则所有子进程(含 Worker)都起不来。 + // SamWaf 已通过 wafexec 自动规避,这里只留一条线索:Supervisor 记在 supervisor.log、Worker 记在 log.log。 + if !wafexec.NullDeviceAvailable() { + zlog.Warn("[环境] 系统空设备 " + os.DevNull + " 不可用(精简版 Windows 常见:Null 内核驱动被裁剪或禁用)。" + + "SamWaf 已自动规避,不影响运行;如需彻底恢复,请以管理员执行 `sc config Null start=system` 后重启系统。") + } + // 调试标识:响应头标记处理进程,便于验证升级时新旧 Worker 交替。 // 主开关在 config.yml 的 debug_worker_header(已由上方 LoadAndInitConfig 读入,Supervisor/Worker 均生效, // 服务模式下务必用配置项——SCM/systemd 不会传入交互式 shell 的环境变量)。 diff --git a/common/wafexec/wafexec.go b/common/wafexec/wafexec.go new file mode 100644 index 0000000..4406950 --- /dev/null +++ b/common/wafexec/wafexec.go @@ -0,0 +1,99 @@ +// Package wafexec 统一子进程标准流的兜底处理。 +// +// Go 的 os/exec 在 Cmd.Stdin/Stdout/Stderr 为 nil 时,会去打开系统空设备 +// (os.DevNull:Windows 为 NUL,类 Unix 为 /dev/null),见 os/exec/exec.go 的 +// childStdin / writerDescriptor。某些精简版 Windows(如 LTSC 纯净版)裁掉或禁用了 +// Null 内核驱动,open NUL 直接失败,cmd.Start() 在创建进程前就报 +// "open NUL: The system cannot find the file specified." —— 所有子进程全起不来 +// (Supervisor 连首个 Worker 都拉不起来,SamWaf 完全无法启动)。 +// +// 本包采用"探测降级": +// - 空设备可用(绝大多数机器) → 原样返回,行为与改造前完全一致,零回归; +// - 空设备不可用(精简系统) → 注入等价的替代流,让子进程照常启动。 +// +// 约定:本项目内所有 exec.Cmd 在 Start/Run/Output/CombinedOutput 之前, +// 必须先经 FixStdin 或 FixStdio 处理,不得直接依赖空设备。 +package wafexec + +import ( + "bytes" + "os" + "os/exec" + "sync" +) + +// forceNoNullEnv 强制走"无空设备"降级分支的隐藏开关,仅用于在正常机器上验证降级路径。 +// 该变量随 os.Environ() 自动继承给 spawn 出来的 Worker 子进程,无需额外传递。 +const forceNoNullEnv = "SAMWAF_FORCE_NO_NULLDEV" + +var ( + nullOnce sync.Once + nullOK bool +) + +// nullAvailable 惰性探测一次系统空设备是否可读可写,结果进程内缓存。 +// 读写都要探:os/exec 对 Stdin 用 os.Open,对 Stdout/Stderr 用 os.OpenFile(O_WRONLY)。 +func nullAvailable() bool { + nullOnce.Do(func() { + if os.Getenv(forceNoNullEnv) == "1" { + return // nullOK 保持 false + } + r, err := os.Open(os.DevNull) + if err != nil { + return + } + _ = r.Close() + w, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + return + } + _ = w.Close() + nullOK = true + }) + return nullOK +} + +// NullDeviceAvailable 返回系统空设备是否可用,供启动自检使用。 +func NullDeviceAvailable() bool { return nullAvailable() } + +// FixStdin 仅在"空设备不可用"时,为 nil 的 Stdin 补一个立即 EOF 的读端;不动 Stdout/Stderr。 +// +// 适用于随后要调 Output()/CombinedOutput()/StdoutPipe()/StderrPipe() 的场景 —— +// 这些方法要求 Stdout/Stderr 保持 nil 或由其自行接管,被提前占用会报 "exec: Stdout already set"。 +// +// 替代流用 bytes.Reader 而非 os.Stdin:os.Stdin 在 Windows 服务模式下句柄可能无效, +// 前台模式下又会把真实控制台输入交给子进程(可能吞掉用户按键)。非 *os.File 的 Reader 会让 +// os/exec 自建匿名管道,读端由 Start() 收尾关闭、写端由其拷贝 goroutine 拷完 0 字节后主动关闭, +// 因此即使调用方只调 os.Process.Wait() 而不调 cmd.Wait()(supervisor 就是如此)也不会泄漏句柄, +// 子进程读 stdin 立即 EOF,与 NUL 语义等价。 +func FixStdin(cmd *exec.Cmd) *exec.Cmd { + if cmd == nil || nullAvailable() { + return cmd + } + if cmd.Stdin == nil { + cmd.Stdin = bytes.NewReader(nil) + } + return cmd +} + +// FixStdio 在"空设备不可用"时补齐三个标准流。 +// 仅用于"直接 Run()/Start() 且确定不会再取 Pipe/Output"的 Cmd。 +// +// Stdout/Stderr 必须填 *os.File(os.Stdout/os.Stderr),不能填 io.Discard: +// io.Discard 不是 *os.File,os/exec 会为它新建匿名管道 + 拷贝 goroutine,而 cmd.Run()/Wait() +// 要等管道 EOF 才返回。像 `cmd /C xxx` 这类命令的孙进程会继承管道写端,孙进程不退出则管道 +// 永不 EOF,Run() 将永久阻塞。*os.File 是直传句柄、零管道零 goroutine,与 NUL 行为等价; +// 服务模式下 os.Stdout 句柄为 0(子进程等于没有 stdout,天然丢弃),前台模式下输出到控制台。 +func FixStdio(cmd *exec.Cmd) *exec.Cmd { + if cmd == nil || nullAvailable() { + return cmd + } + FixStdin(cmd) + if cmd.Stdout == nil { + cmd.Stdout = os.Stdout + } + if cmd.Stderr == nil { + cmd.Stderr = os.Stderr + } + return cmd +} diff --git a/common/wafexec/wafexec_test.go b/common/wafexec/wafexec_test.go new file mode 100644 index 0000000..2e8ba26 --- /dev/null +++ b/common/wafexec/wafexec_test.go @@ -0,0 +1,172 @@ +package wafexec + +import ( + "os" + "os/exec" + "runtime" + "sync" + "testing" + "time" +) + +// resetProbe 重置空设备探测缓存,让下一次调用重新探测。 +func resetProbe() { + nullOnce = sync.Once{} + nullOK = false +} + +// trueCmd 返回一个立刻成功退出的短命命令。 +func trueCmd() *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "exit 0") + } + return exec.Command("true") +} + +// echoCmd 返回一个会向 stdout 打印内容的命令。 +func echoCmd(s string) *exec.Cmd { + if runtime.GOOS == "windows" { + return exec.Command("cmd", "/c", "echo "+s) + } + return exec.Command("echo", s) +} + +// TestNoopWhenNullAvailable 空设备可用时必须原样返回,三个流保持 nil。 +// 这是"正常机器零回归"的核心保证:os/exec 依旧走它原本的空设备路径。 +func TestNoopWhenNullAvailable(t *testing.T) { + resetProbe() + if !NullDeviceAvailable() { + t.Skipf("本机 %s 不可用,跳过", os.DevNull) + } + + cmd := trueCmd() + FixStdio(cmd) + if cmd.Stdin != nil || cmd.Stdout != nil || cmd.Stderr != nil { + t.Fatalf("空设备可用时不应改动标准流,实际 Stdin=%v Stdout=%v Stderr=%v", + cmd.Stdin, cmd.Stdout, cmd.Stderr) + } +} + +// TestFixStdinWhenDegraded 降级时只补 Stdin,不碰 Stdout/Stderr(否则 Output/Pipe 路径会报 Stdout already set)。 +func TestFixStdinWhenDegraded(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + cmd := trueCmd() + FixStdin(cmd) + if cmd.Stdin == nil { + t.Fatal("降级时 Stdin 应被补上") + } + if cmd.Stdout != nil || cmd.Stderr != nil { + t.Fatal("FixStdin 不应改动 Stdout/Stderr") + } +} + +// TestOutputStillWorksWhenDegraded 降级后 Output() 仍能正常取到输出(防止误伤采集类命令)。 +func TestOutputStillWorksWhenDegraded(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + out, err := FixStdin(echoCmd("samwaf")).Output() + if err != nil { + t.Fatalf("Output() 失败: %v", err) + } + if len(out) == 0 { + t.Fatal("Output() 未取到内容") + } +} + +// TestStdoutIsFileWhenDegraded 降级时 Stdout/Stderr 必须是 *os.File。 +// +// 回归断言:不能改成 io.Discard —— 非 *os.File 会让 os/exec 建匿名管道 + 拷贝 goroutine, +// 而 cmd.Run()/Wait() 要等管道 EOF 才返回;`cmd /C` 的孙进程会继承管道写端, +// 孙进程不退出则 Run() 永久阻塞(wafappengine 的 StopCmd/taskkill 会因此卡死优雅停止)。 +func TestStdoutIsFileWhenDegraded(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + cmd := trueCmd() + FixStdio(cmd) + if _, ok := cmd.Stdout.(*os.File); !ok { + t.Fatalf("降级时 Stdout 必须是 *os.File(直传句柄,零管道),实际为 %T", cmd.Stdout) + } + if _, ok := cmd.Stderr.(*os.File); !ok { + t.Fatalf("降级时 Stderr 必须是 *os.File,实际为 %T", cmd.Stderr) + } +} + +// TestStartWithoutCmdWait 降级后只调 os.Process.Wait()、不调 cmd.Wait() 也能正常收尾。 +// 对齐 supervisor.spawn 的实际用法(它保存 cmd.Process,用 proc.Wait() 监护)。 +func TestStartWithoutCmdWait(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + cmd := trueCmd() + FixStdio(cmd) + if err := cmd.Start(); err != nil { + t.Fatalf("Start() 失败: %v", err) + } + state, err := cmd.Process.Wait() + if err != nil { + t.Fatalf("proc.Wait() 失败: %v", err) + } + if state.ExitCode() != 0 { + t.Fatalf("子进程退出码应为 0,实际 %d", state.ExitCode()) + } +} + +// TestRunNotBlockedByGrandchild 降级后,即使子进程派生了长命孙进程,Run() 也必须立即返回。 +// +// 这是 io.Discard 方案会踩的坑:非 *os.File 的 Stdout 会让 os/exec 建匿名管道,孙进程继承写端, +// Run()/Wait() 要等管道 EOF 才返回 → 孙进程活多久就阻塞多久。wafappengine 的 StopCmd/taskkill +// 正是 `cmd /C` 起的,一旦阻塞会直接卡死应用的优雅停止。用 *os.File 则零管道、不阻塞。 +func TestRunNotBlockedByGrandchild(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("仅在 Windows 上验证 cmd /C 的孙进程场景") + } + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + // cmd /C start /b 派生一个脱离 cmd.exe 自身生命周期的孙进程(存活约 8s)。 + // 孙进程必须继承 Stdout —— 这正是 io.Discard 方案里"管道迟迟不 EOF"的成因,不能重定向掉。 + cmd := FixStdio(exec.Command("cmd", "/C", "start /b ping -n 8 127.0.0.1")) + + done := make(chan error, 1) + go func() { done <- cmd.Run() }() + + select { + case <-done: // 期望:cmd.exe 一退出 Run() 立刻返回,不等孙进程 + case <-time.After(3 * time.Second): + t.Fatal("Run() 被孙进程阻塞超过 3s —— Stdout 很可能被改成了非 *os.File(如 io.Discard),会卡死应用优雅停止") + } +} + +// TestNeverOverrideExisting 已显式赋值的流一律不覆盖。 +func TestNeverOverrideExisting(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + cmd := trueCmd() + cmd.Stdout = os.Stderr // 故意用 Stderr 占位,确认不被改写 + FixStdio(cmd) + if cmd.Stdout != os.Stderr { + t.Fatal("已设置的 Stdout 不应被覆盖") + } +} + +// TestNilCmdSafe 传 nil 不 panic。 +func TestNilCmdSafe(t *testing.T) { + t.Setenv(forceNoNullEnv, "1") + resetProbe() + defer resetProbe() + + if FixStdin(nil) != nil || FixStdio(nil) != nil { + t.Fatal("传 nil 应原样返回 nil") + } +} diff --git a/firewall/firewall.go b/firewall/firewall.go index 93db5e1..9c1ea2c 100644 --- a/firewall/firewall.go +++ b/firewall/firewall.go @@ -3,6 +3,7 @@ package firewall import ( + "SamWaf/common/wafexec" "bufio" "fmt" "os" @@ -45,7 +46,7 @@ type IPBlockInfo struct { func (fw *FireWallEngine) IsFirewallEnabled() bool { if runtime.GOOS == "linux" { - out, err := exec.Command("iptables", "-L").CombinedOutput() + out, err := wafexec.FixStdin(exec.Command("iptables", "-L")).CombinedOutput() if err != nil { return false } @@ -85,7 +86,7 @@ func (fw *FireWallEngine) checkAvailable() error { } } - out, err := exec.Command("iptables", "-S", "INPUT").CombinedOutput() + out, err := wafexec.FixStdin(exec.Command("iptables", "-S", "INPUT")).CombinedOutput() if err != nil { output := strings.TrimSpace(string(out)) if strings.Contains(output, "Permission denied") || @@ -104,6 +105,8 @@ func (fw *FireWallEngine) checkAvailable() error { } func (fw *FireWallEngine) executeCommand(cmd *exec.Cmd) (error, string) { + // 只补 Stdin:下面要取 StdoutPipe/StderrPipe,Stdout/Stderr 必须留给 os/exec 自己接管 + wafexec.FixStdin(cmd) stdout, err := cmd.StdoutPipe() if err != nil { fmt.Println(err) @@ -136,7 +139,7 @@ func (fw *FireWallEngine) executeCommand(cmd *exec.Cmd) (error, string) { // isIPInRules 直接用原始 IP 检查 iptables 中是否存在 DROP 规则,支持单 IP 和 CIDR func (fw *FireWallEngine) isIPInRules(ip string) (bool, error) { - cmd := exec.Command("iptables-save") + cmd := wafexec.FixStdin(exec.Command("iptables-save")) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[ERROR] 获取iptables规则失败: %v\n", err) @@ -285,7 +288,7 @@ func (fw *FireWallEngine) UnblockIPList(ips []string) (successCount int, failedI // GetBlockedIPList 获取所有已封禁的IP列表 func (fw *FireWallEngine) GetBlockedIPList() ([]string, error) { - cmd := exec.Command("iptables-save") + cmd := wafexec.FixStdin(exec.Command("iptables-save")) output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("failed to get blocked IP list: %v", err) diff --git a/firewall/firewall_darwin.go b/firewall/firewall_darwin.go index a056ae6..2e579f3 100644 --- a/firewall/firewall_darwin.go +++ b/firewall/firewall_darwin.go @@ -3,8 +3,10 @@ package firewall import ( + "SamWaf/common/wafexec" "bufio" "fmt" + "os" "os/exec" "strings" "time" @@ -52,6 +54,7 @@ type IPBlockInfo struct { func (fw *FireWallEngine) IsFirewallEnabled() bool { // 检查 pf 是否启用 cmd := exec.Command("pfctl", "-s", "info") + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[WARN] 检查防火墙状态失败: %v\n", err) @@ -74,6 +77,12 @@ func (fw *FireWallEngine) checkAvailable() error { } func (fw *FireWallEngine) executeCommand(cmd *exec.Cmd) (error error, printstr string) { + // 只补 Stdin:下面要取 StdoutPipe,Stdout 必须留给 os/exec 自己接管。 + // Stderr 本函数不接管,无空设备时需显式兜底,否则 os/exec 会去打开空设备而失败。 + wafexec.FixStdin(cmd) + if cmd.Stderr == nil && !wafexec.NullDeviceAvailable() { + cmd.Stderr = os.Stderr + } stdout, err := cmd.StdoutPipe() if err != nil { fmt.Println(err) @@ -104,6 +113,7 @@ func (fw *FireWallEngine) AddRule(ruleName, ipToAdd, action, proc, localport str cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "add", ipToAdd) fmt.Printf("[DEBUG] 执行命令: pfctl -t %s -T add %s\n", PF_TABLE_NAME, ipToAdd) + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[ERROR] 添加规则失败: %v, 输出: %s\n", err, string(output)) @@ -132,6 +142,7 @@ func (fw *FireWallEngine) DeleteRule(ruleName string) (bool, error) { cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "delete", ip) fmt.Printf("[DEBUG] 执行命令: pfctl -t %s -T delete %s\n", PF_TABLE_NAME, ip) + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() outputStr := string(output) @@ -163,6 +174,7 @@ func (fw *FireWallEngine) IsRuleExists(ruleName string) (bool, error) { // 检查 IP 是否在 table 中 cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "show") + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { // table 可能不存在 @@ -220,6 +232,7 @@ func (fw *FireWallEngine) BlockIP(ip string, reason string) error { // 添加 IP 到 pf table cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "add", ip) + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[ERROR] 封禁IP失败: %s, error: %v, output: %s\n", ip, err, string(output)) @@ -245,6 +258,7 @@ func (fw *FireWallEngine) UnblockIP(ip string) error { // 从 table 中删除 IP cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "delete", ip) + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[ERROR] 解除IP封禁失败: %s, error: %v, output: %s\n", ip, err, string(output)) @@ -317,6 +331,7 @@ func (fw *FireWallEngine) GetBlockedIPList() ([]string, error) { fmt.Printf("[DEBUG] 获取已封禁IP列表 (Mac)\n") cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "show") + wafexec.FixStdin(cmd) output, err := cmd.CombinedOutput() if err != nil { fmt.Printf("[ERROR] 获取IP列表失败: %v\n", err) @@ -382,7 +397,7 @@ func (fw *FireWallEngine) ensureTableExists() error { fmt.Printf("[DEBUG] 确保pf table存在\n") // 检查 table 是否存在 - cmd := exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "show") + cmd := wafexec.FixStdin(exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "show")) _, err := cmd.CombinedOutput() if err == nil { // table 已存在 @@ -397,7 +412,7 @@ func (fw *FireWallEngine) ensureTableExists() error { // 尝试添加并立即删除一个临时IP来初始化表 tempIP := "127.0.0.254" - cmd = exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "add", tempIP) + cmd = wafexec.FixStdin(exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "add", tempIP)) _, err = cmd.CombinedOutput() if err != nil { fmt.Printf("[WARN] 初始化table失败: %v\n", err) @@ -405,7 +420,7 @@ func (fw *FireWallEngine) ensureTableExists() error { } // 删除临时IP - cmd = exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "delete", tempIP) + cmd = wafexec.FixStdin(exec.Command("pfctl", "-t", PF_TABLE_NAME, "-T", "delete", tempIP)) cmd.CombinedOutput() return nil diff --git a/firewall/firewall_win.go b/firewall/firewall_win.go index eca5b39..2d18f4a 100644 --- a/firewall/firewall_win.go +++ b/firewall/firewall_win.go @@ -3,6 +3,7 @@ package firewall import ( + "SamWaf/common/wafexec" "bufio" "fmt" "os/exec" @@ -91,6 +92,8 @@ func (fw *FireWallEngine) checkAvailable() error { } func (fw *FireWallEngine) executeCommand(cmd *exec.Cmd) (error error, printstr string) { + // 只补 Stdin:下面要取 StdoutPipe/StderrPipe,Stdout/Stderr 必须留给 os/exec 自己接管 + wafexec.FixStdin(cmd) stdout, err := cmd.StdoutPipe() if err != nil { fmt.Println(err) diff --git a/supervisor/supervisor.go b/supervisor/supervisor.go index 721069d..e809c15 100644 --- a/supervisor/supervisor.go +++ b/supervisor/supervisor.go @@ -10,6 +10,7 @@ package supervisor import ( + "SamWaf/common/wafexec" "SamWaf/common/zlog" "SamWaf/wafipc" "SamWaf/wafupdate" @@ -295,6 +296,9 @@ func (s *Supervisor) spawn(takeover bool) (int, error) { cmd := exec.Command(s.opts.ExePath, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr + // Stdin 必须显式兜底:nil 时 os/exec 会去打开系统空设备(Windows 上是 NUL), + // 精简版系统缺失该设备会导致 Start() 直接失败,连首个 Worker 都拉不起来。 + wafexec.FixStdin(cmd) if err := cmd.Start(); err != nil { return 0, err } diff --git a/wafappengine/waf_app_engine.go b/wafappengine/waf_app_engine.go index eb479b5..9bef9fa 100644 --- a/wafappengine/waf_app_engine.go +++ b/wafappengine/waf_app_engine.go @@ -1,6 +1,7 @@ package wafappengine import ( + "SamWaf/common/wafexec" "SamWaf/common/zlog" "SamWaf/global" "SamWaf/model" @@ -150,6 +151,9 @@ func (e *WafAppEngine) startProcess(app model.WafApp, rt *wafappmodel.AppRuntime } cmd.Env = envs + // 只补 Stdin:下面要取 StdoutPipe/StderrPipe,Stdout/Stderr 必须留给 os/exec 自己接管 + wafexec.FixStdin(cmd) + stdoutPipe, err := cmd.StdoutPipe() if err != nil { return fmt.Errorf("获取stdout失败: %w", err) @@ -306,6 +310,9 @@ func (e *WafAppEngine) monitorApp(app model.WafApp, rt *wafappmodel.AppRuntime) } newCmd.Env = envs + // 只补 Stdin:下面要取 StdoutPipe/StderrPipe,Stdout/Stderr 必须留给 os/exec 自己接管 + wafexec.FixStdin(newCmd) + stdoutPipe, _ := newCmd.StdoutPipe() stderrPipe, _ := newCmd.StderrPipe() if startErr := newCmd.Start(); startErr != nil { @@ -374,11 +381,11 @@ func (e *WafAppEngine) StopApp(code string) error { stopCmd = exec.Command("/bin/sh", "-c", app.StopCmd) } stopCmd.Dir = app.AppDir - _ = stopCmd.Run() + _ = wafexec.FixStdio(stopCmd).Run() } else { if runtime.GOOS == "windows" { // 发送 WM_CLOSE 给进程,给其机会优雅退出(不带 /F) - exec.Command("taskkill", "/PID", strconv.Itoa(rt.Pid)).Run() + wafexec.FixStdio(exec.Command("taskkill", "/PID", strconv.Itoa(rt.Pid))).Run() } else { rt.Cmd.Process.Signal(os.Interrupt) } @@ -391,7 +398,7 @@ func (e *WafAppEngine) StopApp(code string) error { case <-time.After(time.Duration(timeout) * time.Second): if runtime.GOOS == "windows" { // /F 强制 + /T 终止整棵子进程树,解决 cmd.exe 子进程孤儿问题 - exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(rt.Pid)).Run() + wafexec.FixStdio(exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(rt.Pid))).Run() } else { if rt.Cmd.Process != nil { rt.Cmd.Process.Kill() @@ -586,7 +593,7 @@ func buildChildMapWindows(m map[int][]int) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // 列名字母序:ParentProcessId 排在 ProcessId 前面 - out, err := exec.CommandContext(ctx, "wmic", "process", "get", "ParentProcessId,ProcessId").Output() + out, err := wafexec.FixStdin(exec.CommandContext(ctx, "wmic", "process", "get", "ParentProcessId,ProcessId")).Output() if err != nil { return } @@ -644,7 +651,7 @@ func buildChildMapLinux(m map[int][]int) { func fetchNetStatsWindows(pidSet map[int]bool) (*wafappmodel.NetStatsResult, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - out, err := exec.CommandContext(ctx, "netstat", "-ano").Output() + out, err := wafexec.FixStdin(exec.CommandContext(ctx, "netstat", "-ano")).Output() if err != nil { return nil, fmt.Errorf("netstat failed: %w", err) } @@ -709,7 +716,7 @@ func fetchNetStatsLinux(pidSet map[int]bool) (*wafappmodel.NetStatsResult, error ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // ss -tanp: TCP all-states, numeric, with-process - out, err := exec.CommandContext(ctx, "ss", "-tanp").Output() + out, err := wafexec.FixStdin(exec.CommandContext(ctx, "ss", "-tanp")).Output() if err != nil { // 降级到 netstat return fetchNetStatsLinuxNetstat(pidSet) @@ -777,7 +784,7 @@ func fetchNetStatsLinuxNetstat(pidSet map[int]bool) (*wafappmodel.NetStatsResult ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // netstat -tnap: TCP numeric all programs - out, err := exec.CommandContext(ctx, "netstat", "-tnap").Output() + out, err := wafexec.FixStdin(exec.CommandContext(ctx, "netstat", "-tnap")).Output() if err != nil { return nil, fmt.Errorf("netstat failed: %w", err) }