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();
}
}
}