package com.tongran.agent.client.security; import com.tongran.agent.client.utils.AssertLog; import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** * 系统命令执行器(底层) * *

仅被 {@link SecureCommandExecutor} 调用,不对外暴露。 * 使用 {@link ProcessBuilder} 直接传参数数组,不经过 shell,杜绝命令注入。 * *

设计为可注入的 Bean,方便单元测试 mock。 * * @author Senior Developer */ @Component public class SystemCommandRunner { /** 业务线程池:命令执行是 IO 密集型,固定大小足够 */ private final ExecutorService executor = Executors.newFixedThreadPool( Math.max(2, Runtime.getRuntime().availableProcessors()), r -> { Thread t = new Thread(r, "agent-cmd-exec"); t.setDaemon(true); return t; } ); /** * 异步执行脚本 * * @param scriptPath 脚本绝对路径(已通过白名单校验) * @param args 参数列表(已与脚本路径分离,不经过 shell) * @param timeout 超时 * @param unit 超时单位 */ public CompletableFuture execute( Path scriptPath, List args, long timeout, TimeUnit unit) { return CompletableFuture.supplyAsync(() -> { Process process = null; try { // 1. 确保脚本有执行权限(Linux/Unix) ensureExecutable(scriptPath); // 2. 构建命令:第一个元素是脚本路径,后续是参数 // 关键:不使用 /bin/sh -c,参数直接传给 ProcessBuilder,不经过 shell 解析 List command = new ArrayList<>(); command.add(scriptPath.toString()); if (args != null) { command.addAll(args); } AssertLog.info("[CMD-EXEC] exec={} args={}", scriptPath, args); // 3. 启动进程 ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); // 分别读 stdout/stderr process = pb.start(); // 4. 异步读取输出(防止 buffer 满死锁) Process finalProcess = process; CompletableFuture stdoutFuture = CompletableFuture.supplyAsync( () -> readStream(finalProcess.getInputStream()), executor); CompletableFuture stderrFuture = CompletableFuture.supplyAsync( () -> readStream(finalProcess.getErrorStream()), executor); // 5. 等待进程结束(带超时) boolean finished = process.waitFor(timeout, unit); if (!finished) { process.destroyForcibly(); AssertLog.error("[CMD-EXEC] 超时强杀: {}", scriptPath); return SecureCommandExecutor.CommandResult.fail("执行超时"); } // 6. 读取输出(给 2 秒缓冲让读流线程收尾) String stdout = stdoutFuture.get(2, TimeUnit.SECONDS); String stderr = stderrFuture.get(2, TimeUnit.SECONDS); int exitCode = process.exitValue(); AssertLog.info("[CMD-EXEC] done exit={} script={}", exitCode, scriptPath); return SecureCommandExecutor.CommandResult.ok(exitCode, stdout, stderr); } catch (TimeoutException te) { return SecureCommandExecutor.CommandResult.fail("读取输出超时"); } catch (Exception e) { AssertLog.error("[CMD-EXEC] 执行异常: {}", e.getMessage()); return SecureCommandExecutor.CommandResult.fail("执行异常: " + e.getMessage()); } finally { if (process != null && process.isAlive()) { process.destroyForcibly(); } } }, executor); } /** 确保脚本有执行权限(仅对 POSIX 系统生效) */ private void ensureExecutable(Path path) { try { Set fileStores = new HashSet<>(); path.getFileSystem().getFileStores().forEach(fs -> fileStores.add(fs.type())); // 简单判断:如果是 POSIX 文件系统,加执行权限 if (Files.getFileAttributeView(path, java.nio.file.attribute.PosixFileAttributeView.class) != null) { Set perms = new HashSet<>(Files.getPosixFilePermissions(path)); perms.add(PosixFilePermission.OWNER_EXECUTE); perms.add(PosixFilePermission.GROUP_EXECUTE); Files.setPosixFilePermissions(path, perms); } // Windows 不需要执行权限,直接跳过 } catch (Exception e) { AssertLog.warn("设置执行权限失败(非致命): {} - {}", path, e.getMessage()); } } /** 读取输入流到字符串 */ private String readStream(java.io.InputStream is) { StringBuilder sb = new StringBuilder(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { sb.append(line).append('\n'); } } catch (Exception e) { sb.append("[read-error] ").append(e.getMessage()); } // 限制输出长度,防止日志爆炸 String result = sb.toString(); if (result.length() > 8192) { return result.substring(0, 8192) + "...[truncated]"; } return result; } /** 优雅关闭 */ public void shutdown() { executor.shutdown(); try { if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } } }