164 lines
6.5 KiB
Java
164 lines
6.5 KiB
Java
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;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 系统命令执行器(底层)
|
||
|
|
*
|
||
|
|
* <p>仅被 {@link SecureCommandExecutor} 调用,不对外暴露。
|
||
|
|
* 使用 {@link ProcessBuilder} 直接传参数数组,不经过 shell,杜绝命令注入。
|
||
|
|
*
|
||
|
|
* <p>设计为可注入的 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<SecureCommandExecutor.CommandResult> execute(
|
||
|
|
Path scriptPath, List<String> args, long timeout, TimeUnit unit) {
|
||
|
|
|
||
|
|
return CompletableFuture.supplyAsync(() -> {
|
||
|
|
Process process = null;
|
||
|
|
try {
|
||
|
|
// 1. 确保脚本有执行权限(Linux/Unix)
|
||
|
|
ensureExecutable(scriptPath);
|
||
|
|
|
||
|
|
// 2. 构建命令:第一个元素是脚本路径,后续是参数
|
||
|
|
// 关键:不使用 /bin/sh -c,参数直接传给 ProcessBuilder,不经过 shell 解析
|
||
|
|
List<String> 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<String> stdoutFuture = CompletableFuture.supplyAsync(
|
||
|
|
() -> readStream(finalProcess.getInputStream()), executor);
|
||
|
|
CompletableFuture<String> 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<String> 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<PosixFilePermission> 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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|