future =
AsyncCommandExecutor.executeCommandAsync(
@@ -96,35 +129,79 @@ public class SpecificTimeTaskService {
JSONObject jsonObject = new JSONObject();
jsonObject.put("command", command);
jsonObject.put("resOut", result.getOutput());
- System.out.println("JSON: " + jsonObject.toJSONString()); // 注意:toJSONString()
JSONObject json = new JSONObject();
- json.put("resCode",1);
+ json.put("resCode", 1);
json.put("resMsg", "");
json.put("result", jsonObject.toJSONString());
json.put("timestamp", Instant.now().getEpochSecond());
- Message message = Message.builder().clientId(request.getClientId()).dataType(request.getDataType()).data(json.toJSONString()).build();
+ Message message = Message.builder()
+ .clientId(request.getClientId())
+ .dataType(request.getDataType())
+ .data(json.toJSONString())
+ .build();
if (Objects.nonNull(sessionManager.getSessionById(request.getClientId()))) {
- System.out.println("发送执行结果: " + json.toJSONString()); // 注意:toJSONString()
- sessionManager.writeAndFlush(sessionManager.getSessionById(request.getClientId()).getChannel(), message);
+ AssertLog.info("发送执行结果: taskId={}", request.getTaskName());
+ sessionManager.writeAndFlush(
+ sessionManager.getSessionById(request.getClientId()).getChannel(), message);
}
}).exceptionally(ex -> {
- System.err.println("执行失败: " + ex.getMessage());
- JSONObject json = new JSONObject();
- json.put("resCode",0);
- json.put("resMsg", "执行失败:Policy execute filed");
- json.put("result", "");
- Message message = Message.builder().clientId(request.getClientId()).dataType(request.getDataType()).data(json.toJSONString()).build();
- if (Objects.nonNull(sessionManager.getSessionById(request.getClientId()))) {
- sessionManager.writeAndFlush(sessionManager.getSessionById(request.getClientId()).getChannel(), message);
- }
+ AssertLog.error("命令执行失败: {}", ex.getMessage());
+ sendErrorResponse(request, "执行失败:Policy execute failed");
return null;
});
}
}
- // 调用其他服务等
} catch (Exception e) {
- System.err.println("任务执行异常: " + e.getMessage());
+ AssertLog.error("任务执行异常", e);
}
}
-}
\ No newline at end of file
+
+ /**
+ * 安全校验:检查命令是否安全可执行
+ */
+ private boolean isSafeCommand(String command) {
+ if (StringUtils.isBlank(command)) {
+ return false;
+ }
+ // 检查是否包含危险字符(shell 注入防护)
+ if (DANGEROUS_PATTERN.matcher(command).find()) {
+ return false;
+ }
+ // 检查命令路径是否在白名单内
+ boolean pathAllowed = false;
+ for (String prefix : ALLOWED_COMMAND_PREFIXES) {
+ if (command.startsWith(prefix) || command.startsWith("./")) {
+ pathAllowed = true;
+ break;
+ }
+ }
+ // 如果不在白名单路径,检查是否是安全的脚本/命令名
+ if (!pathAllowed) {
+ String firstToken = command.split("\\s+")[0];
+ if (!SAFE_SCRIPT_PATTERN.matcher(firstToken).matches()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 发送错误响应
+ */
+ private void sendErrorResponse(SpecificTimeRequest request, String errorMsg) {
+ JSONObject json = new JSONObject();
+ json.put("resCode", 0);
+ json.put("resMsg", errorMsg);
+ json.put("result", "");
+ Message message = Message.builder()
+ .clientId(request.getClientId())
+ .dataType(request.getDataType())
+ .data(json.toJSONString())
+ .build();
+ if (Objects.nonNull(sessionManager.getSessionById(request.getClientId()))) {
+ sessionManager.writeAndFlush(
+ sessionManager.getSessionById(request.getClientId()).getChannel(), message);
+ }
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/AuthHandshakeHandler.java b/src/main/java/com/tongran/agent/client/security/AuthHandshakeHandler.java
new file mode 100644
index 0000000..2083eaf
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/AuthHandshakeHandler.java
@@ -0,0 +1,164 @@
+package com.tongran.agent.client.security;
+
+import com.tongran.agent.client.utils.AssertLog;
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.util.AttributeKey;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Netty 握手认证处理器
+ *
+ * 放在 pipeline 最前面(在 {@code AgentDecoderHandler} 之前)。
+ * 连接建立后,客户端必须先发一个握手包:
+ *
+ * auth:{token}@tong-ran
+ *
+ * 服务端校验通过后,移除自身,放行后续业务消息。失败则关闭连接。
+ *
+ * 同时配合 {@link io.netty.handler.timeout.IdleStateHandler} 做握手超时:
+ * 如果在 {@code handshake-timeout-seconds} 内未完成握手,IdleStateHandler 会触发
+ * readerIdle 事件,本 handler 在 userEventTriggered 中关闭连接。
+ *
+ *
注意:本 handler 必须是 {@code @Sharable} 的,因为所有连接共用一个实例。
+ *
+ * @author Senior Developer
+ */
+@Component
+@io.netty.channel.ChannelHandler.Sharable
+public class AuthHandshakeHandler extends ChannelInboundHandlerAdapter {
+
+ /** 握手包前缀 */
+ public static final String HANDSHAKE_PREFIX = "auth:";
+
+ /** 握手包后缀(与业务消息一致,方便客户端复用分隔符) */
+ public static final String HANDSHAKE_SUFFIX = "@tong-ran";
+
+ /** Channel 属性:标记是否已通过认证 */
+ public static final AttributeKey AUTHENTICATED =
+ AttributeKey.valueOf("agent-authenticated");
+
+ /** Channel 属性:记录客户端 IP,便于审计 */
+ public static final AttributeKey CLIENT_IP =
+ AttributeKey.valueOf("agent-client-ip");
+
+ private final SecurityProperties properties;
+
+ public AuthHandshakeHandler(SecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ // 连接建立:记录 IP
+ String clientIp = ctx.channel().remoteAddress() != null
+ ? ctx.channel().remoteAddress().toString()
+ : "unknown";
+ ctx.channel().attr(CLIENT_IP).set(clientIp);
+ ctx.channel().attr(AUTHENTICATED).set(false);
+
+ AssertLog.info("[AUTH] 新连接 ip={}", clientIp);
+ super.channelActive(ctx);
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ // 1. 已认证:直接放行,移除自身(后续消息走业务 handler)
+ Boolean authenticated = ctx.channel().attr(AUTHENTICATED).get();
+ if (Boolean.TRUE.equals(authenticated)) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ // 2. 未认证:必须是 ByteBuf,且内容是握手包
+ if (!(msg instanceof ByteBuf)) {
+ rejectAndClose(ctx, "握手阶段收到非 ByteBuf 消息");
+ return;
+ }
+
+ ByteBuf buf = (ByteBuf) msg;
+ String content = buf.toString(StandardCharsets.UTF_8);
+
+ // 握手包格式:auth:{token}@tong-ran
+ if (!content.startsWith(HANDSHAKE_PREFIX) || !content.endsWith(HANDSHAKE_SUFFIX)) {
+ rejectAndClose(ctx, "握手包格式错误: " + truncate(content));
+ buf.release();
+ return;
+ }
+
+ // 提取 token
+ String token = content.substring(
+ HANDSHAKE_PREFIX.length(),
+ content.length() - HANDSHAKE_SUFFIX.length()
+ ).trim();
+
+ // 3. token 校验
+ String expectedToken = properties.getNettyAuthToken();
+ if (expectedToken == null || expectedToken.isEmpty()) {
+ rejectAndClose(ctx, "服务端未配置握手 token");
+ buf.release();
+ return;
+ }
+
+ if (!constantTimeEquals(token, expectedToken)) {
+ rejectAndClose(ctx, "握手 token 不匹配");
+ buf.release();
+ return;
+ }
+
+ // 4. 认证通过:标记、移除自身、释放 ByteBuf
+ ctx.channel().attr(AUTHENTICATED).set(true);
+ buf.release();
+
+ AssertLog.info("[AUTH] 认证通过 ip={}",
+ ctx.channel().attr(CLIENT_IP).get());
+
+ // 移除自身,后续消息直接走 AgentDecoderHandler
+ ctx.pipeline().remove(this);
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+ // IdleStateHandler 触发的超时事件:握手阶段超时直接关闭
+ if (evt instanceof io.netty.handler.timeout.IdleStateEvent) {
+ Boolean authenticated = ctx.channel().attr(AUTHENTICATED).get();
+ if (!Boolean.TRUE.equals(authenticated)) {
+ rejectAndClose(ctx, "握手超时");
+ return;
+ }
+ }
+ super.userEventTriggered(ctx, evt);
+ }
+
+ /** 拒绝并关闭连接 */
+ private void rejectAndClose(ChannelHandlerContext ctx, String reason) {
+ String clientIp = ctx.channel().attr(CLIENT_IP).get();
+ AssertLog.error("[AUTH] 认证失败 ip={} reason={}", clientIp, reason);
+
+ // 可选:发送拒绝消息(便于客户端排查)
+ String rejectMsg = "auth-failed:" + reason + HANDSHAKE_SUFFIX;
+ ctx.writeAndFlush(Unpooled.copiedBuffer(rejectMsg, StandardCharsets.UTF_8))
+ .addListener(future -> ctx.close());
+ }
+
+ /** 常量时间字符串比较(防时序攻击) */
+ private boolean constantTimeEquals(String a, String b) {
+ if (a == null || b == null) return false;
+ if (a.length() != b.length()) return false;
+ int result = 0;
+ for (int i = 0; i < a.length(); i++) {
+ result |= a.charAt(i) ^ b.charAt(i);
+ }
+ return result == 0;
+ }
+
+ /** 截断日志内容(防止超长日志) */
+ private String truncate(String s) {
+ if (s == null) return "null";
+ return s.length() > 100 ? s.substring(0, 100) + "..." : s;
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/HmacSignVerifier.java b/src/main/java/com/tongran/agent/client/security/HmacSignVerifier.java
new file mode 100644
index 0000000..37c4843
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/HmacSignVerifier.java
@@ -0,0 +1,161 @@
+package com.tongran.agent.client.security;
+
+import com.tongran.agent.client.utils.AssertLog;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+
+/**
+ * HMAC-SHA256 签名校验器
+ *
+ * 用于校验服务端下发的命令、下载任务等是否被篡改,以及是否过期(防重放)。
+ *
+ *
签名规则:
+ *
+ * payload = timestamp + "\n" + body
+ * signature = HMAC_SHA256(secret, payload)
+ * 下发格式: sign = "v1:{timestamp}:{hexSignature}"
+ *
+ *
+ * 校验流程:
+ *
+ * - 解析 sign,取出 timestamp 和 signature
+ * - 校验 timestamp 是否在 TTL 内(防重放)
+ * - 用本地 secret 重新计算签名,常量时间比较
+ *
+ *
+ * @author Senior Developer
+ */
+@Component
+public class HmacSignVerifier {
+
+ private static final String HMAC_ALGORITHM = "HmacSHA256";
+ private static final String SIGN_PREFIX = "v1:";
+
+ private final SecurityProperties properties;
+
+ public HmacSignVerifier(SecurityProperties properties) {
+ this.properties = properties;
+ }
+
+ /**
+ * 校验签名
+ *
+ * @param sign 服务端下发的签名,格式 "v1:{timestamp}:{hexSig}"
+ * @param body 业务内容(命令字符串、URL 等)
+ * @param nowMillis 当前时间戳(毫秒),由调用方传入方便测试
+ * @return true 校验通过
+ */
+ public VerifyResult verify(String sign, String body, long nowMillis) {
+ // 1. 配置校验:未配置 secret 直接拒绝
+ String secret = properties.getSignSecret();
+ if (secret == null || secret.isEmpty()) {
+ AssertLog.error("签名校验失败:未配置 agent.security.sign-secret");
+ return VerifyResult.fail("签名密钥未配置");
+ }
+
+ // 2. 格式校验
+ if (sign == null || !sign.startsWith(SIGN_PREFIX)) {
+ return VerifyResult.fail("签名格式错误");
+ }
+ String[] parts = sign.substring(SIGN_PREFIX.length()).split(":", 2);
+ if (parts.length != 2) {
+ return VerifyResult.fail("签名格式错误");
+ }
+
+ long timestamp;
+ String receivedSig;
+ try {
+ timestamp = Long.parseLong(parts[0]);
+ } catch (NumberFormatException e) {
+ return VerifyResult.fail("签名时间戳非法");
+ }
+ receivedSig = parts[1];
+
+ // 3. 时间窗口校验(防重放)
+ long ageSeconds = (nowMillis - timestamp) / 1000;
+ if (ageSeconds < 0) {
+ // 允许 60 秒时钟偏差
+ if (ageSeconds < -60) {
+ return VerifyResult.fail("签名时间戳超前过多");
+ }
+ } else if (ageSeconds > properties.getSignTtlSeconds()) {
+ AssertLog.error("签名过期:age={}s,ttl={}s", ageSeconds, properties.getSignTtlSeconds());
+ return VerifyResult.fail("签名已过期");
+ }
+
+ // 4. 重新计算签名
+ String payload = timestamp + "\n" + (body == null ? "" : body);
+ String computedSig;
+ try {
+ computedSig = computeHmac(secret, payload);
+ } catch (Exception e) {
+ AssertLog.error("签名计算异常:{}", e.getMessage());
+ return VerifyResult.fail("签名计算异常");
+ }
+
+ // 5. 常量时间比较(防时序攻击)
+ if (!constantTimeEquals(receivedSig, computedSig)) {
+ AssertLog.error("签名不匹配:received={},computed={}", receivedSig, computedSig);
+ return VerifyResult.fail("签名不匹配");
+ }
+
+ return VerifyResult.ok();
+ }
+
+ /** 便捷重载:用系统当前时间 */
+ public VerifyResult verify(String sign, String body) {
+ return verify(sign, body, System.currentTimeMillis());
+ }
+
+ /** 计算 HMAC-SHA256,返回 hex 字符串 */
+ private String computeHmac(String secret, String payload) throws Exception {
+ Mac mac = Mac.getInstance(HMAC_ALGORITHM);
+ SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
+ mac.init(keySpec);
+ byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(raw);
+ }
+
+ /** 常量时间字符串比较 */
+ private boolean constantTimeEquals(String a, String b) {
+ if (a == null || b == null) return false;
+ if (a.length() != b.length()) return false;
+ int result = 0;
+ for (int i = 0; i < a.length(); i++) {
+ result |= a.charAt(i) ^ b.charAt(i);
+ }
+ return result == 0;
+ }
+
+ /** SHA-256 工具(供下载校验使用) */
+ public static String sha256Hex(byte[] data) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(md.digest(data));
+ } catch (Exception e) {
+ throw new RuntimeException("SHA-256 计算失败", e);
+ }
+ }
+
+ /** 校验结果 */
+ public static class VerifyResult {
+ private final boolean success;
+ private final String reason;
+
+ private VerifyResult(boolean success, String reason) {
+ this.success = success;
+ this.reason = reason;
+ }
+
+ public static VerifyResult ok() { return new VerifyResult(true, null); }
+ public static VerifyResult fail(String reason) { return new VerifyResult(false, reason); }
+
+ public boolean isSuccess() { return success; }
+ public String getReason() { return reason; }
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/SecureCommandExecutor.java b/src/main/java/com/tongran/agent/client/security/SecureCommandExecutor.java
new file mode 100644
index 0000000..154c647
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/SecureCommandExecutor.java
@@ -0,0 +1,144 @@
+package com.tongran.agent.client.security;
+
+import com.tongran.agent.client.utils.AssertLog;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 安全命令执行器
+ *
+ * 替代原 {@code AgentServiceImpl} 中直接 {@code /bin/sh -c command} 的危险写法。
+ *
+ *
核心策略:
+ *
+ * - 白名单:只允许执行 {@code agent.security.allowed-scripts-dir} 目录下、
+ * 且文件名在 {@code allowed-script-names} 列表中的脚本
+ * - 路径穿越防护:解析后路径必须以白名单目录开头
+ * - 签名校验:服务端下发必须携带 HMAC 签名,客户端用 {@link HmacSignVerifier} 校验
+ * - 参数隔离:脚本路径和参数分开传递给 {@link ProcessBuilder},
+ * 不经过 shell 解析,杜绝注入
+ * - 审计日志:所有执行请求(无论通过与否)都记入日志
+ *
+ *
+ * 调用约定:服务端不再下发 shell 字符串,而是下发 JSON:
+ *
+ * {
+ * "script": "restart.sh", // 必须在白名单内
+ * "args": ["--service", "agent"], // 参数数组,不经过 shell
+ * "sign": "v1:1735724123:abc..." // HMAC 签名
+ * }
+ *
+ *
+ * @author Senior Developer
+ */
+@Component
+public class SecureCommandExecutor {
+
+ private final SecurityProperties properties;
+ private final HmacSignVerifier signVerifier;
+ private final SystemCommandRunner commandRunner;
+
+ public SecureCommandExecutor(SecurityProperties properties,
+ HmacSignVerifier signVerifier,
+ SystemCommandRunner commandRunner) {
+ this.properties = properties;
+ this.signVerifier = signVerifier;
+ this.commandRunner = commandRunner;
+ }
+
+ /**
+ * 执行脚本(安全入口)
+ *
+ * @param scriptName 脚本名(必须存在于白名单)
+ * @param args 参数数组(不经过 shell 解析)
+ * @param sign HMAC 签名,签名内容为 {@code scriptName + "\n" + String.join(" ", args)}
+ * @return 执行结果
+ */
+ public CompletableFuture execute(String scriptName,
+ List args,
+ String sign) {
+ // 1. 入参非空校验
+ if (scriptName == null || scriptName.isEmpty()) {
+ audit("REJECT", scriptName, args, "脚本名为空");
+ return CompletableFuture.completedFuture(CommandResult.fail("脚本名为空"));
+ }
+
+ // 2. 签名校验
+ String signPayload = scriptName + "\n" + (args == null ? "" : String.join(" ", args));
+ HmacSignVerifier.VerifyResult signResult = signVerifier.verify(sign, signPayload);
+ if (!signResult.isSuccess()) {
+ audit("REJECT", scriptName, args, "签名校验失败: " + signResult.getReason());
+ return CompletableFuture.completedFuture(CommandResult.fail("签名校验失败"));
+ }
+
+ // 3. 白名单校验:脚本名必须在 allowed-script-names 中
+ if (!properties.getAllowedScriptNames().contains(scriptName)) {
+ audit("REJECT", scriptName, args, "脚本不在白名单");
+ return CompletableFuture.completedFuture(CommandResult.fail("脚本不在白名单: " + scriptName));
+ }
+
+ // 4. 解析路径并防穿越
+ Path scriptDir = Paths.get(properties.getAllowedScriptsDir()).normalize();
+ Path scriptPath = scriptDir.resolve(scriptName).normalize();
+ if (!scriptPath.startsWith(scriptDir)) {
+ audit("REJECT", scriptName, args, "路径穿越: " + scriptPath);
+ return CompletableFuture.completedFuture(CommandResult.fail("路径非法"));
+ }
+
+ // 5. 文件存在性 + 普通文件校验
+ if (!Files.isRegularFile(scriptPath)) {
+ audit("REJECT", scriptName, args, "脚本文件不存在: " + scriptPath);
+ return CompletableFuture.completedFuture(CommandResult.fail("脚本不存在"));
+ }
+
+ // 6. 审计:通过校验,准备执行
+ audit("ACCEPT", scriptName, args, "path=" + scriptPath);
+
+ // 7. 委托给底层 runner 执行(参数隔离,不经过 shell)
+ long timeout = properties.getCommandTimeoutSeconds();
+ return commandRunner.execute(scriptPath, args == null ? List.of() : args, timeout, TimeUnit.SECONDS);
+ }
+
+ /** 审计日志(统一格式,便于事后追溯) */
+ private void audit(String decision, String scriptName, List args, String detail) {
+ AssertLog.info("[CMD-AUDIT] decision={} script={} args={} detail={}",
+ decision, scriptName, args, detail);
+ }
+
+ /** 执行结果 */
+ public static class CommandResult {
+ private final boolean success;
+ private final int exitCode;
+ private final String output;
+ private final String error;
+ private final String reason; // 失败原因(校验失败时)
+
+ public CommandResult(boolean success, int exitCode, String output, String error, String reason) {
+ this.success = success;
+ this.exitCode = exitCode;
+ this.output = output;
+ this.error = error;
+ this.reason = reason;
+ }
+
+ public static CommandResult ok(int exitCode, String output, String error) {
+ return new CommandResult(exitCode == 0, exitCode, output, error, null);
+ }
+
+ public static CommandResult fail(String reason) {
+ return new CommandResult(false, -1, "", "", reason);
+ }
+
+ public boolean isSuccess() { return success; }
+ public int getExitCode() { return exitCode; }
+ public String getOutput() { return output; }
+ public String getError() { return error; }
+ public String getReason() { return reason; }
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/SecureFileDownloader.java b/src/main/java/com/tongran/agent/client/security/SecureFileDownloader.java
new file mode 100644
index 0000000..8c6a6d7
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/SecureFileDownloader.java
@@ -0,0 +1,223 @@
+package com.tongran.agent.client.security;
+
+import com.tongran.agent.client.utils.AssertLog;
+import org.springframework.stereotype.Component;
+
+import javax.net.ssl.HttpsURLConnection;
+import java.io.BufferedInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.function.Consumer;
+
+/**
+ * 安全文件下载器
+ *
+ * 替代原 {@code AdvancedAsyncDownloader} 中无防护的下载逻辑。
+ *
+ *
安全策略:
+ *
+ * - 协议白名单:只允许 HTTPS(可配置)
+ * - 域名白名单:URL host 必须在 {@code allowed-hosts} 列表内
+ * - 大小上限:Content-Length 超过 {@code max-size-bytes} 直接拒绝
+ * - 路径穿越防护:savePath 必须在 {@code allowed-save-dir} 内
+ * - SHA-256 校验:下载完成计算摘要,与预期值(若提供)对比
+ * - HTTPS 证书校验:不绕过,严格校验(开发环境可通过 system property 临时关闭)
+ *
+ *
+ * @author Senior Developer
+ */
+@Component
+public class SecureFileDownloader {
+
+ private static final int BUFFER_SIZE = 8192;
+
+ private final SecurityProperties properties;
+ private final ExecutorService executor;
+
+ public SecureFileDownloader(SecurityProperties properties) {
+ this.properties = properties;
+ this.executor = Executors.newFixedThreadPool(5, r -> {
+ Thread t = new Thread(r, "agent-downloader");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+
+ /**
+ * 异步下载文件(安全入口)
+ *
+ * @param fileUrl 下载 URL(必须 HTTPS,host 在白名单内)
+ * @param savePath 保存路径(必须在 allowed-save-dir 内)
+ * @param expectedSha256 预期 SHA-256(可为 null,但开启 sha256-required 时必传)
+ * @param progressCallback 进度回调(可为 null)
+ * @return 下载结果
+ */
+ public CompletableFuture download(
+ String fileUrl,
+ String savePath,
+ String expectedSha256,
+ Consumer progressCallback) {
+
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ // 1. URL 解析与协议白名单
+ URL url = new URL(fileUrl);
+ String protocol = url.getProtocol().toLowerCase();
+ List allowedProtocols = properties.getDownload().getAllowedProtocols();
+ if (allowedProtocols.isEmpty() || !allowedProtocols.contains(protocol)) {
+ return DownloadResult.fail("协议不在白名单: " + protocol);
+ }
+
+ // 2. 域名白名单
+ String host = url.getHost();
+ List allowedHosts = properties.getDownload().getAllowedHosts();
+ if (allowedHosts.isEmpty() || !allowedHosts.contains(host)) {
+ return DownloadResult.fail("域名不在白名单: " + host);
+ }
+
+ // 3. 保存路径穿越防护
+ Path allowedDir = Paths.get(properties.getDownload().getAllowedSaveDir()).normalize();
+ Path targetPath = Paths.get(savePath).normalize();
+ if (!targetPath.startsWith(allowedDir)) {
+ return DownloadResult.fail("保存路径非法: " + targetPath);
+ }
+
+ // 4. SHA-256 必传校验
+ if (properties.getDownload().isSha256Required()
+ && (expectedSha256 == null || expectedSha256.isEmpty())) {
+ return DownloadResult.fail("未提供 SHA-256 校验值");
+ }
+
+ // 5. 创建目录
+ Files.createDirectories(targetPath.getParent());
+
+ // 6. 建立连接
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ if (connection instanceof HttpsURLConnection) {
+ // HTTPS:严格证书校验,不做任何绕过
+ ((HttpsURLConnection) connection).setSSLSocketFactory(
+ javax.net.ssl.HttpsURLConnection.getDefaultSSLSocketFactory());
+ ((HttpsURLConnection) connection).setHostnameVerifier(
+ javax.net.ssl.HttpsURLConnection.getDefaultHostnameVerifier());
+ }
+ connection.setConnectTimeout(10_000);
+ connection.setReadTimeout(60_000);
+ connection.setRequestMethod("GET");
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode != 200) {
+ return DownloadResult.fail("HTTP 响应码异常: " + responseCode);
+ }
+
+ // 7. 大小上限校验
+ long fileSize = connection.getContentLengthLong();
+ long maxSize = properties.getDownload().getMaxSizeBytes();
+ if (fileSize > 0 && fileSize > maxSize) {
+ return DownloadResult.fail("文件过大: " + fileSize + " > " + maxSize);
+ }
+
+ // 8. 下载 + SHA-256 计算
+ java.security.MessageDigest shaDigest = java.security.MessageDigest.getInstance("SHA-256");
+ long totalRead = 0;
+
+ try (BufferedInputStream in = new BufferedInputStream(connection.getInputStream());
+ FileOutputStream out = new FileOutputStream(targetPath.toFile())) {
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ shaDigest.update(buffer, 0, bytesRead);
+ totalRead += bytesRead;
+
+ // 实时大小校验(防止服务端不返回 Content-Length 时被绕过)
+ if (totalRead > maxSize) {
+ Files.deleteIfExists(targetPath);
+ return DownloadResult.fail("下载超过大小上限: " + totalRead);
+ }
+
+ if (progressCallback != null && fileSize > 0) {
+ progressCallback.accept((double) totalRead / fileSize * 100);
+ }
+ }
+ }
+
+ // 9. SHA-256 校验
+ String actualSha256 = java.util.HexFormat.of()
+ .formatHex(shaDigest.digest());
+ if (expectedSha256 != null && !expectedSha256.isEmpty()) {
+ if (!actualSha256.equalsIgnoreCase(expectedSha256)) {
+ Files.deleteIfExists(targetPath);
+ return DownloadResult.fail("SHA-256 校验失败: expected=" + expectedSha256
+ + " actual=" + actualSha256);
+ }
+ }
+
+ AssertLog.info("[DOWNLOAD] 成功 url={} path={} size={} sha256={}",
+ fileUrl, targetPath, totalRead, actualSha256);
+
+ return DownloadResult.ok(targetPath.toString(), totalRead, actualSha256);
+
+ } catch (IOException e) {
+ AssertLog.error("[DOWNLOAD] 下载失败: {} - {}", fileUrl, e.getMessage());
+ return DownloadResult.fail("下载失败: " + e.getMessage());
+ } catch (Exception e) {
+ AssertLog.error("[DOWNLOAD] 异常: {} - {}", fileUrl, e.getMessage());
+ return DownloadResult.fail("下载异常: " + e.getMessage());
+ }
+ }, executor);
+ }
+
+ /** 下载结果 */
+ public static class DownloadResult {
+ private final boolean success;
+ private final String filePath;
+ private final long size;
+ private final String sha256;
+ private final String reason;
+
+ private DownloadResult(boolean success, String filePath, long size, String sha256, String reason) {
+ this.success = success;
+ this.filePath = filePath;
+ this.size = size;
+ this.sha256 = sha256;
+ this.reason = reason;
+ }
+
+ public static DownloadResult ok(String filePath, long size, String sha256) {
+ return new DownloadResult(true, filePath, size, sha256, null);
+ }
+
+ public static DownloadResult fail(String reason) {
+ return new DownloadResult(false, null, 0, null, reason);
+ }
+
+ public boolean isSuccess() { return success; }
+ public String getFilePath() { return filePath; }
+ public long getSize() { return size; }
+ public String getSha256() { return sha256; }
+ public String getReason() { return reason; }
+ }
+
+ /** 优雅关闭 */
+ public void shutdown() {
+ executor.shutdown();
+ try {
+ if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
+ executor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ executor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/SecurityProperties.java b/src/main/java/com/tongran/agent/client/security/SecurityProperties.java
new file mode 100644
index 0000000..1a3fd68
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/SecurityProperties.java
@@ -0,0 +1,106 @@
+package com.tongran.agent.client.security;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 安全配置
+ *
+ * 对应 application.yml 中 {@code agent.security} 前缀的所有配置项。
+ * 集中管理命令执行白名单、下载白名单、签名密钥、Netty 握手 token 等。
+ *
+ *
建议在 application.yml 中加入:
+ *
+ * agent:
+ * security:
+ * # 命令执行白名单:只允许执行该目录下的预置脚本,禁止直接 /bin/sh -c 传字符串
+ * allowed-scripts-dir: /opt/tongran/scripts
+ * # 允许执行的脚本名(不含路径),严格白名单
+ * allowed-script-names:
+ * - restart.sh
+ * - update.sh
+ * - cleanup.sh
+ * # 命令执行超时(秒)
+ * command-timeout-seconds: 100
+ *
+ * # HMAC 签名密钥(服务端必须用相同密钥签名,客户端校验)
+ * # 重要:生产环境务必通过环境变量 AGENT_SIGN_SECRET 注入,不要明文写 yml
+ * sign-secret: ${AGENT_SIGN_SECRET:}
+ * # 签名有效期(秒),超过则拒绝(防重放)
+ * sign-ttl-seconds: 300
+ *
+ * # 下载安全
+ * download:
+ * # 允许的协议(只允许 https)
+ * allowed-protocols:
+ * - https
+ * # 允许的域名白名单
+ * allowed-hosts:
+ * - oss.tongran.com
+ * - files.tongran.com
+ * # 单文件大小上限(字节),默认 100MB
+ * max-size-bytes: 104857600
+ * # 是否强制校验 SHA-256
+ * sha256-required: true
+ * # 允许下载到的工作目录(防路径穿越)
+ * allowed-save-dir: /opt/tongran/downloads
+ *
+ * # Netty 握手认证 token(生产环境用环境变量注入)
+ * netty-auth-token: ${AGENT_NETTY_TOKEN:}
+ * # 握手超时(秒),连接建立后多久未完成握手则关闭
+ * handshake-timeout-seconds: 10
+ *
+ *
+ * @author Senior Developer
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "agent.security")
+public class SecurityProperties {
+
+ /** 允许执行脚本的目录(只允许该目录下的预置脚本) */
+ private String allowedScriptsDir = "/opt/tongran/scripts";
+
+ /** 允许执行的脚本名白名单(不含路径,严格匹配) */
+ private List allowedScriptNames = new ArrayList<>();
+
+ /** 命令执行超时(秒) */
+ private long commandTimeoutSeconds = 100;
+
+ /** HMAC 签名密钥(服务端下发命令必须用此密钥签名) */
+ private String signSecret = "";
+
+ /** 签名有效期(秒),超过则拒绝,防重放 */
+ private long signTtlSeconds = 300;
+
+ /** 下载安全配置 */
+ private Download download = new Download();
+
+ /** Netty 握手认证 token */
+ private String nettyAuthToken = "";
+
+ /** 握手超时(秒) */
+ private long handshakeTimeoutSeconds = 10;
+
+ @Data
+ public static class Download {
+ /** 允许的协议 */
+ private List allowedProtocols = new ArrayList<>();
+
+ /** 允许的域名白名单 */
+ private List allowedHosts = new ArrayList<>();
+
+ /** 单文件大小上限(字节) */
+ private long maxSizeBytes = 104857600L; // 100MB
+
+ /** 是否强制校验 SHA-256 */
+ private boolean sha256Required = true;
+
+ /** 允许下载到的工作目录(防路径穿越) */
+ private String allowedSaveDir = "/opt/tongran/downloads";
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/security/SystemCommandRunner.java b/src/main/java/com/tongran/agent/client/security/SystemCommandRunner.java
new file mode 100644
index 0000000..ebca669
--- /dev/null
+++ b/src/main/java/com/tongran/agent/client/security/SystemCommandRunner.java
@@ -0,0 +1,163 @@
+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();
+ }
+ }
+}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/AgentServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/AgentServiceImpl.java
index 02ffbcc..658c6ed 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/AgentServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/AgentServiceImpl.java
@@ -15,6 +15,7 @@ import com.tongran.agent.client.scheduler.service.BusinessTasks;
import com.tongran.agent.client.scheduler.service.DynamicTaskService;
import com.tongran.agent.client.scheduler.task.SpecificTimeRequest;
import com.tongran.agent.client.scheduler.task.SpecificTimeTaskService;
+import com.tongran.agent.client.security.SecureCommandExecutor;
import com.tongran.agent.client.service.AgentService;
import com.tongran.agent.client.utils.AgentUtil;
import com.tongran.agent.client.utils.AssertLog;
@@ -26,6 +27,7 @@ import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
@@ -42,6 +44,9 @@ public class AgentServiceImpl implements AgentService {
@Resource
private SpecificTimeTaskService taskService;
+ @Resource
+ private SecureCommandExecutor secureCommandExecutor;
+
// 在注入点使用@Lazy
@Autowired
public AgentServiceImpl(DynamicTaskService dynamicTaskService,
@@ -840,76 +845,71 @@ public class AgentServiceImpl implements AgentService {
}
}
}else{
+ // 立即执行:遍历每条命令,逐个走 SecureCommandExecutor 安全通道
for (String command : policy.getCommands()) {
- if(StringUtils.equals(dataType, MsgEnum.Agent版本更新应答.getValue())){
- try {
- System.out.println("重启进程已启动,当前服务退出");
- System.out.println("command="+command);
- ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c",
- command);
- pb.start();
- System.exit(0);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }else{
- List cmd = Arrays.asList(command.split("\\s+"));
- CompletableFuture future =
- AsyncCommandExecutor.executeCommandAsync(
- cmd,
- 100, TimeUnit.SECONDS);
- future.thenAccept(result -> {
- if (result.isSuccess()) {
- System.out.println("脚本执行成功");
- System.out.println("[成功resOut] " + result.getOutput());
- } else {
- System.out.println("脚本执行失败");
- System.out.println("[失败resOut] " + result.getOutput());
- }
- JSONObject rse = new JSONObject();
- rse.put("command",command);
- rse.put("resOut", result.getOutput());
- long timestamps = System.currentTimeMillis();
- timestamps = Math.round(timestamps / 1000.0);
- JSONObject json = new JSONObject();
- json.put("resCode",1);
- json.put("resMsg", "");
- json.put("timestamp",timestamps);
- json.put("result", rse.toString());
- // 判定客户端与服务端是否连接
- if (Objects.nonNull(sessionManager.getSessionById(GlobalConfig.CLIENT_ID))) {
- Message message = Message.builder().clientId(GlobalConfig.CLIENT_ID).dataType(dataType)
- .data(json.toString()).build();
- sessionManager.writeAndFlush(sessionManager.getSessionById(GlobalConfig.CLIENT_ID).getChannel(), message);
- AssertLog.info("发送执行脚本策略应答={}",JSON.toJSONString(message));
- }
- }).exceptionally(ex -> {
- System.err.println("执行失败: " + ex.getMessage());
- JSONObject rse = new JSONObject();
- rse.put("command",command);
- rse.put("resOut", "脚本执行失败");
- long timestamps = System.currentTimeMillis();
- timestamps = Math.round(timestamps / 1000.0);
- JSONObject json = new JSONObject();
- json.put("resCode",1);
- json.put("resMsg", "");
- json.put("timestamp",timestamps);
- json.put("result", rse.toString());
- // 判定客户端与服务端是否连接
- if (Objects.nonNull(sessionManager.getSessionById(GlobalConfig.CLIENT_ID))) {
- Message message = Message.builder().clientId(GlobalConfig.CLIENT_ID).dataType(dataType)
- .data(json.toString()).build();
- sessionManager.writeAndFlush(sessionManager.getSessionById(GlobalConfig.CLIENT_ID).getChannel(), message);
- AssertLog.info("发送执行脚本策略应答={}",JSON.toJSONString(message));
- }
- return null;
- });
+ // 解析下发的 JSON:{"script":"xxx.sh","args":[...],"sign":"v1:..."}
+ // 兼容旧格式:如果 command 不是 JSON,直接拒绝(防止绕过白名单)
+ String scriptName;
+ List scriptArgs;
+ String sign;
+ try {
+ JSONObject cmdJson = JSONObject.parseObject(command);
+ scriptName = cmdJson.getString("script");
+ scriptArgs = cmdJson.getJSONArray("args") != null
+ ? cmdJson.getJSONArray("args").toJavaList(String.class)
+ : Collections.emptyList();
+ sign = cmdJson.getString("sign");
+ } catch (Exception e) {
+ // 非 JSON 格式:旧协议直传 shell 字符串,拒绝执行
+ AssertLog.error("[SECURITY] 拒绝非白名单格式命令: clientId={} dataType={}", clientId, dataType);
+ sendCommandResponse(dataType, command, "[rejected] 命令格式非法,必须为 JSON 且携带 script/args/sign", 0);
+ continue;
}
+
+ // 走安全执行器:白名单 + 签名 + 路径穿越防护
+ CompletableFuture future =
+ secureCommandExecutor.execute(scriptName, scriptArgs, sign);
+
+ future.thenAccept(result -> {
+ if (result.isSuccess()) {
+ AssertLog.info("[CMD] 脚本执行成功 script={}", scriptName);
+ } else {
+ AssertLog.error("[CMD] 脚本执行失败 script={} reason={}", scriptName, result.getReason());
+ }
+ sendCommandResponse(dataType, scriptName, result.getOutput(), result.isSuccess() ? 1 : 0);
+ }).exceptionally(ex -> {
+ AssertLog.error("[CMD] 执行异常: {}", ex.getMessage());
+ sendCommandResponse(dataType, scriptName, "脚本执行异常", 0);
+ return null;
+ });
}
}
}
}
+ /**
+ * 发送命令执行应答(抽取的公共方法,避免重复代码)
+ */
+ private void sendCommandResponse(String dataType, String command, String output, int resCode) {
+ JSONObject rse = new JSONObject();
+ rse.put("command", command);
+ rse.put("resOut", output);
+ long timestamps = System.currentTimeMillis();
+ timestamps = Math.round(timestamps / 1000.0);
+ JSONObject json = new JSONObject();
+ json.put("resCode", resCode);
+ json.put("resMsg", "");
+ json.put("timestamp", timestamps);
+ json.put("result", rse.toString());
+ // 判定客户端与服务端是否连接
+ if (Objects.nonNull(sessionManager.getSessionById(GlobalConfig.CLIENT_ID))) {
+ Message message = Message.builder().clientId(GlobalConfig.CLIENT_ID).dataType(dataType)
+ .data(json.toString()).build();
+ sessionManager.writeAndFlush(sessionManager.getSessionById(GlobalConfig.CLIENT_ID).getChannel(), message);
+ AssertLog.info("发送执行脚本策略应答={}", JSON.toJSONString(message));
+ }
+ }
+
public void caseTypeBySystem(String type, int interval, boolean collect){
diff --git a/src/main/java/com/tongran/agent/client/service/impl/CPUServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/CPUServiceImpl.java
index af9b602..9cc1682 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/CPUServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/CPUServiceImpl.java
@@ -2,6 +2,7 @@ package com.tongran.agent.client.service.impl;
import com.tongran.agent.client.core.vo.CpuVO;
import com.tongran.agent.client.service.CPUService;
+import com.tongran.agent.client.utils.AssertLog;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
@@ -14,6 +15,10 @@ import java.util.concurrent.TimeUnit;
@Service
public class CPUServiceImpl implements CPUService {
+ /** 缓存上次的 CPU ticks,避免每次采集都 sleep 1 秒 */
+ private volatile long[] prevTicks = null;
+ private volatile long prevTicksTimestamp = 0;
+
@Override
public CpuVO get() {
CpuVO cpuVO = CpuVO.builder().build();
@@ -22,76 +27,86 @@ public class CPUServiceImpl implements CPUService {
HardwareAbstractionLayer hal = si.getHardware();
CentralProcessor processor = hal.getProcessor();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
+
// 1. CPU基本信息
- cpuVO.setNum(processor.getPhysicalProcessorCount()); // CUP数量
- System.out.println("=== CPU基本信息 ===");
- System.out.println("CPU型号: " + processor.getProcessorIdentifier().getName());
- System.out.println("物理核心数: " + processor.getPhysicalProcessorCount());
- System.out.println("逻辑核心数: " + processor.getLogicalProcessorCount());
- System.out.println("最大频率: " + processor.getMaxFreq() / 1_000_000.0 + " GHz");
+ cpuVO.setNum(processor.getPhysicalProcessorCount());
+ AssertLog.info("CPU型号: {}, 物理核心数: {}, 逻辑核心数: {}, 最大频率: {} GHz",
+ processor.getProcessorIdentifier().getName(),
+ processor.getPhysicalProcessorCount(),
+ processor.getLogicalProcessorCount(),
+ processor.getMaxFreq() / 1_000_000.0);
+
// 2. CPU负载信息
- System.out.println("\n=== CPU负载信息 ===");
double[] loadAverage = processor.getSystemLoadAverage(3);
- cpuVO.setAvg1(loadAverage[0]);// CUP1分钟负载
- cpuVO.setAvg1(loadAverage[1]);// CUP5分钟负载
- cpuVO.setAvg1(loadAverage[2]);// CUP15分钟负载
- System.out.println("1分钟平均负载: " + loadAverage[0]);
- System.out.println("5分钟平均负载: " + loadAverage[1]);
- System.out.println("15分钟平均负载: " + loadAverage[2]);
- // 3. CPU使用率(需要两次采样)
- System.out.println("\n=== CPU使用率 ===");
- long[] prevTicks = processor.getSystemCpuLoadTicks();
- TimeUnit.SECONDS.sleep(1); // 等待1秒
- // 计算使用率
- double cpuUsage = processor.getSystemCpuLoadBetweenTicks(prevTicks);
- cpuVO.setUti(cpuUsage * 100); // CPU使用率
- System.out.printf("CPU 使用率: %.2f%%\n", cpuUsage * 100);
- long[] ticks = processor.getSystemCpuLoadTicks();
- long user = ticks[CentralProcessor.TickType.USER.getIndex()] -
+ cpuVO.setAvg1(loadAverage[0]); // CPU 1分钟负载
+ cpuVO.setAvg5(loadAverage[1]); // CPU 5分钟负载
+ cpuVO.setAvg15(loadAverage[2]); // CPU 15分钟负载
+
+ // 3. CPU使用率 - 使用缓存的 ticks 避免每次 sleep 1 秒
+ long[] currentTicks = processor.getSystemCpuLoadTicks();
+ long currentTime = System.currentTimeMillis();
+ double cpuUsage = 0;
+ if (prevTicks != null && currentTime - prevTicksTimestamp >= 500) {
+ // 使用上次缓存的 ticks 计算使用率
+ cpuUsage = processor.getSystemCpuLoadBetweenTicks(prevTicks);
+ } else {
+ // 首次采集或间隔太短,需要等待
+ prevTicks = processor.getSystemCpuLoadTicks();
+ prevTicksTimestamp = currentTime;
+ TimeUnit.MILLISECONDS.sleep(500);
+ currentTicks = processor.getSystemCpuLoadTicks();
+ cpuUsage = processor.getSystemCpuLoadBetweenTicks(prevTicks);
+ }
+
+ cpuVO.setUti(cpuUsage * 100);
+
+ long user = currentTicks[CentralProcessor.TickType.USER.getIndex()] -
prevTicks[CentralProcessor.TickType.USER.getIndex()];
- long nice = ticks[CentralProcessor.TickType.NICE.getIndex()] -
+ long nice = currentTicks[CentralProcessor.TickType.NICE.getIndex()] -
prevTicks[CentralProcessor.TickType.NICE.getIndex()];
- long sys = ticks[CentralProcessor.TickType.SYSTEM.getIndex()] -
+ long sys = currentTicks[CentralProcessor.TickType.SYSTEM.getIndex()] -
prevTicks[CentralProcessor.TickType.SYSTEM.getIndex()];
- long idle = ticks[CentralProcessor.TickType.IDLE.getIndex()] -
+ long idle = currentTicks[CentralProcessor.TickType.IDLE.getIndex()] -
prevTicks[CentralProcessor.TickType.IDLE.getIndex()];
- long iowait = ticks[CentralProcessor.TickType.IOWAIT.getIndex()] -
+ long iowait = currentTicks[CentralProcessor.TickType.IOWAIT.getIndex()] -
prevTicks[CentralProcessor.TickType.IOWAIT.getIndex()];
- long irq = ticks[CentralProcessor.TickType.IRQ.getIndex()] -
+ long irq = currentTicks[CentralProcessor.TickType.IRQ.getIndex()] -
prevTicks[CentralProcessor.TickType.IRQ.getIndex()];
- long softirq = ticks[CentralProcessor.TickType.SOFTIRQ.getIndex()] -
+ long softirq = currentTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()] -
prevTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()];
- long steal = ticks[CentralProcessor.TickType.STEAL.getIndex()] -
+ long steal = currentTicks[CentralProcessor.TickType.STEAL.getIndex()] -
prevTicks[CentralProcessor.TickType.STEAL.getIndex()];
long total = user + nice + sys + idle + iowait + irq + softirq + steal;
+
// 4. CPU时间累计值
- System.out.println("\n=== CPU时间累计值 ===");
- long[] allTicks = processor.getSystemCpuLoadTicks();
- System.out.println("中断累计时间: " +
- (allTicks[CentralProcessor.TickType.IRQ.getIndex()] +
- allTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()]));
- System.out.println("空闲累计时间: " + allTicks[CentralProcessor.TickType.IDLE.getIndex()]);
- System.out.printf("I/O等待时间(CPU等待响应时间): %.2f%%\n", 100d * iowait / total);
- System.out.println("系统累计时间: " + allTicks[CentralProcessor.TickType.SYSTEM.getIndex()]);
- long softwareTime = sys - irq - softirq;
- System.out.printf("软件相关时间(近似无响应时间): %.2f%%\n", 100d * softwareTime / total);
- System.out.println("用户进程累计时间: " + allTicks[CentralProcessor.TickType.USER.getIndex()]);
+ if (total > 0) {
+ cpuVO.setIowait(100d * iowait / total);
+ long softwareTime = sys - irq - softirq;
+ cpuVO.setNoresp(100d * softwareTime / total);
+ } else {
+ cpuVO.setIowait(0d);
+ cpuVO.setNoresp(0d);
+ }
- cpuVO.setInterrupt((allTicks[CentralProcessor.TickType.IRQ.getIndex()] +
- allTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()])); // CPU硬件中断提供服务时间
- cpuVO.setIdle(allTicks[CentralProcessor.TickType.IDLE.getIndex()]);// CPU空闲时间
- cpuVO.setIowait(100d * iowait / total);// CPU等待响应时间
- cpuVO.setSystem(allTicks[CentralProcessor.TickType.SYSTEM.getIndex()]);// CPU系统时间
- cpuVO.setNoresp(100d * softwareTime / total);// CPU软件无响应时间
- cpuVO.setUser(allTicks[CentralProcessor.TickType.USER.getIndex()]);// CPU用户进程所花费的时间
+ cpuVO.setInterrupt(currentTicks[CentralProcessor.TickType.IRQ.getIndex()] +
+ currentTicks[CentralProcessor.TickType.SOFTIRQ.getIndex()]);
+ cpuVO.setIdle(currentTicks[CentralProcessor.TickType.IDLE.getIndex()]);
+ cpuVO.setSystem(currentTicks[CentralProcessor.TickType.SYSTEM.getIndex()]);
+ cpuVO.setUser(currentTicks[CentralProcessor.TickType.USER.getIndex()]);
- // 5. 系统运行时间和CPU空闲时间
+ // 更新缓存
+ prevTicks = currentTicks;
+ prevTicksTimestamp = currentTime;
+
+ // 5. 系统运行时间
long uptime = os.getSystemUptime();
- cpuVO.setNormal(uptime);// CPU正常运行时间
- System.out.println("CPU正常运行时间: " + cpuVO.getNormal());
+ cpuVO.setNormal(uptime);
+
} catch (InterruptedException e) {
- e.printStackTrace();
+ Thread.currentThread().interrupt();
+ AssertLog.error("CPU采集被中断", e);
+ } catch (Exception e) {
+ AssertLog.error("CPU采集异常", e);
}
return cpuVO;
}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/DiskServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/DiskServiceImpl.java
index e88baac..80ff910 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/DiskServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/DiskServiceImpl.java
@@ -3,6 +3,7 @@ package com.tongran.agent.client.service.impl;
import com.tongran.agent.client.core.vo.DiskVO;
import com.tongran.agent.client.core.vo.PointVO;
import com.tongran.agent.client.service.DiskService;
+import com.tongran.agent.client.utils.AssertLog;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
@@ -20,29 +21,24 @@ public class DiskServiceImpl implements DiskService {
List tempList = new ArrayList<>();
List resultList = new ArrayList<>();
SystemInfo si = new SystemInfo();
- System.out.println("=========================================================");
+
// 获取磁盘IO信息
- System.out.println("\n=== 磁盘IO信息 ===");
for (HWDiskStore disk : si.getHardware().getDiskStores()) {
DiskVO diskVO = DiskVO.builder().timestamp(timestamp).build();
- diskVO.setName(disk.getName());//磁盘名称
- diskVO.setSerial(disk.getSerial());//序列号
- diskVO.setTotal(disk.getSize());//磁盘大小
- diskVO.setWriteTimes(disk.getWrites());//磁盘写入次数
- diskVO.setReadTimes(disk.getReads());//磁盘读取次数
- diskVO.setWriteBytes(disk.getReadBytes());//磁盘写入字节
- diskVO.setReadBytes(disk.getWriteBytes());//磁盘读取字节
+ diskVO.setName(disk.getName());
+ diskVO.setSerial(disk.getSerial());
+ diskVO.setTotal(disk.getSize());
+ diskVO.setWriteTimes(disk.getWrites());
+ diskVO.setReadTimes(disk.getReads());
+ diskVO.setWriteBytes(disk.getWriteBytes()); // 修复:写入字节
+ diskVO.setReadBytes(disk.getReadBytes()); // 修复:读取字节
tempList.add(diskVO);
- System.out.println("磁盘名称: " + diskVO.getName());
- System.out.println("序列号: " + diskVO.getSerial());
- System.out.println("磁盘大小: " + diskVO.getTotal());
- System.out.println("磁盘写入次数: " + diskVO.getWriteTimes());
- System.out.println("磁盘读取次数: " + diskVO.getReadTimes());
- System.out.println("磁盘写入字节: " + diskVO.getWriteBytes());
- System.out.println("磁盘读取字节: " + diskVO.getReadBytes());
+ AssertLog.info("磁盘: {}, 序列号: {}, 大小: {}, 写入字节: {}, 读取字节: {}",
+ diskVO.getName(), diskVO.getSerial(), diskVO.getTotal(),
+ diskVO.getWriteBytes(), diskVO.getReadBytes());
}
- try{
+ try {
// 第一次采样
List disks1 = si.getHardware().getDiskStores();
long[] readBytes1 = new long[disks1.size()];
@@ -59,18 +55,20 @@ public class DiskServiceImpl implements DiskService {
long readDiff = disks2.get(i).getReadBytes() - readBytes1[i];
long writeDiff = disks2.get(i).getWriteBytes() - writeBytes1[i];
String serial = disks2.get(i).getSerial();
- DiskVO diskVO = tempList.stream().filter(d -> StringUtils.equals(d.getSerial(),serial)).findFirst().orElse(null);
- if(Objects.nonNull(diskVO)){
- diskVO.setWriteSpeed(readDiff);//磁盘写入速率
- diskVO.setReadSpeed(writeDiff);//磁盘读取速率
- System.out.println("磁盘名称: " + diskVO.getName());
- System.out.println("磁盘写入速率: " + diskVO.getWriteSpeed());
- System.out.println("磁盘读取速率: " + diskVO.getReadSpeed());
+ DiskVO diskVO = tempList.stream().filter(d -> StringUtils.equals(d.getSerial(), serial)).findFirst().orElse(null);
+ if (Objects.nonNull(diskVO)) {
+ diskVO.setWriteSpeed(writeDiff); // 修复:写入速率 = 写入差值
+ diskVO.setReadSpeed(readDiff); // 修复:读取速率 = 读取差值
+ AssertLog.info("磁盘: {}, 写入速率: {}, 读取速率: {}",
+ diskVO.getName(), diskVO.getWriteSpeed(), diskVO.getReadSpeed());
}
resultList.add(diskVO);
}
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ AssertLog.error("磁盘采集被中断", e);
} catch (Exception e) {
- e.printStackTrace();
+ AssertLog.error("磁盘速率采集异常", e);
}
return resultList;
}
@@ -80,8 +78,6 @@ public class DiskServiceImpl implements DiskService {
List list = new ArrayList<>();
SystemInfo si = new SystemInfo();
// 获取文件系统信息
- System.out.println("=========================================================");
- System.out.println("=== 挂载信息 ===");
for (OSFileStore fs : si.getOperatingSystem().getFileSystem().getFileStores()) {
long totalSpace = fs.getTotalSpace();
long usableSpace = fs.getUsableSpace();
@@ -90,18 +86,15 @@ public class DiskServiceImpl implements DiskService {
(double) (totalSpace - freeSpace) / totalSpace * 100 : 0;
PointVO pointVO = PointVO.builder().timestamp(timestamp).build();
- pointVO.setMount(fs.getMount());//挂载点
- pointVO.setVfsType(fs.getType());//文件系统类型
- pointVO.setVfsTotal(totalSpace);//总空间
- pointVO.setVfsFree(usableSpace);//可用空间
- pointVO.setVfsUtil(usagePercentage);//空间利用率
+ pointVO.setMount(fs.getMount());
+ pointVO.setVfsType(fs.getType());
+ pointVO.setVfsTotal(totalSpace);
+ pointVO.setVfsFree(usableSpace);
+ pointVO.setVfsUtil(usagePercentage);
list.add(pointVO);
- System.out.println("挂载点: " + pointVO.getMount());
- System.out.println("文件系统类型: " + pointVO.getVfsType());
- System.out.println("总空间: " + pointVO.getVfsTotal());
- System.out.println("可用空间: " + pointVO.getVfsFree());
- System.out.printf("空间利用率: %.2f%%\n", usagePercentage);
-
+ AssertLog.info("挂载点: {}, 类型: {}, 总空间: {}, 可用: {}, 利用率: {}%",
+ pointVO.getMount(), pointVO.getVfsType(), pointVO.getVfsTotal(),
+ pointVO.getVfsFree(), String.format("%.2f", usagePercentage));
}
return list;
}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/DockerServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/DockerServiceImpl.java
index 155718c..374d13a 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/DockerServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/DockerServiceImpl.java
@@ -8,6 +8,7 @@ import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
import com.github.dockerjava.transport.DockerHttpClient;
import com.tongran.agent.client.core.vo.DockerVO;
import com.tongran.agent.client.service.DockerService;
+import com.tongran.agent.client.utils.AssertLog;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@@ -17,44 +18,32 @@ import java.io.InputStreamReader;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import java.util.concurrent.TimeUnit;
@Service
public class DockerServiceImpl implements DockerService {
+
+ /** 复用 DockerClient 配置,避免每次创建 */
+ private volatile DockerClient dockerClientCache;
+ private volatile DockerHttpClient httpClientCache;
+
@Override
public List dockerList(long timestamp) {
List list = new ArrayList<>();
- // 配置Docker客户端
- DefaultDockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
- .withDockerHost("unix:///var/run/docker.sock")
- .withDockerTlsVerify(false) // 根据你的配置调整
- .build();
-
- DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder()
- .dockerHost(config.getDockerHost())
- .sslConfig(config.getSSLConfig())
- .maxConnections(100)
- .connectionTimeout(Duration.ofSeconds(30))
- .responseTimeout(Duration.ofSeconds(45))
- .build();
-
- DockerClient dockerClient = DockerClientImpl.getInstance(config, httpClient);
+ DockerClient dockerClient = getDockerClient();
try {
- // 获取正在运行的容器列表
List containers = dockerClient.listContainersCmd()
- .withShowAll(false) // 只显示运行中的容器
+ .withShowAll(false)
.exec();
- // 打印容器信息
- System.out.println("运行中的Docker容器:");
- System.out.println("容器ID\t\t镜像\t\t状态\t\t名称");
for (Container container : containers) {
DockerVO dockerVO = DockerVO.builder().timestamp(timestamp).build();
- String id = container.getId().substring(0, 12); // 只显示短ID
- String image = container.getImage().length() > 15 ?
+ String id = container.getId().substring(0, 12);
+ String image = container.getImage() != null && container.getImage().length() > 15 ?
container.getImage().substring(0, 15) + "..." : container.getImage();
String status = container.getStatus();
String name = container.getNames()[0].replaceFirst("/", "");
- System.out.printf("%s\t%s\t%s\t%s%n", id, image, status, name);
+ AssertLog.info("Docker容器: id={}, image={}, status={}, name={}", id, image, status, name);
dockerVO.setId(id);
dockerVO.setName(name);
dockerVO.setStatus(status);
@@ -62,74 +51,115 @@ public class DockerServiceImpl implements DockerService {
list.add(res);
}
} catch (Exception e) {
- e.printStackTrace();
- } finally {
- try {
- dockerClient.close();
- } catch (IOException e) {
- e.printStackTrace();
- }
+ AssertLog.error("Docker容器列表获取异常", e);
}
return list;
}
+ /**
+ * 获取 Docker 客户端(复用连接)
+ */
+ private DockerClient getDockerClient() {
+ if (dockerClientCache == null) {
+ synchronized (this) {
+ if (dockerClientCache == null) {
+ DefaultDockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
+ .withDockerHost("unix:///var/run/docker.sock")
+ .withDockerTlsVerify(false)
+ .build();
+ httpClientCache = new ApacheDockerHttpClient.Builder()
+ .dockerHost(config.getDockerHost())
+ .sslConfig(config.getSSLConfig())
+ .maxConnections(100)
+ .connectionTimeout(Duration.ofSeconds(30))
+ .responseTimeout(Duration.ofSeconds(45))
+ .build();
+ dockerClientCache = DockerClientImpl.getInstance(config, httpClientCache);
+ }
+ }
+ }
+ return dockerClientCache;
+ }
+
+ /**
+ * 获取容器资源使用统计 - 修复:try-with-resources + 进程超时 + 数组越界保护
+ */
public DockerVO dockerStats(DockerVO dockerVO) {
- //判定目标容器 ID
- if(StringUtils.isBlank(dockerVO.getId())){
+ if (StringUtils.isBlank(dockerVO.getId())) {
return dockerVO;
}
+ Process process = null;
try {
- // 执行 docker stats 命令(--no-stream 表示只输出一次)
- Process process = new ProcessBuilder(
+ process = new ProcessBuilder(
"docker", "stats", "--no-stream", dockerVO.getId(),
"--format", "'table {{.ID}}\t{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.NetIO}}"
).start();
- // 读取命令输出
- BufferedReader reader = new BufferedReader(
- new InputStreamReader(process.getInputStream())
- );
- String line;
- while ((line = reader.readLine()) != null) {
- // 解析输出(示例输出格式):
- // "your_container_id 0.00% 0.000 CPU % 0 B / 0 B 0 packets / 0 packets"
- // 实际输出可能因 Docker 版本不同而变化,需根据实际情况调整正则表达式
- if (line.contains(dockerVO.getId())) {
- String[] parts = line.trim().split("\\s+");
- String cpuUtil = parts[3]; // cpu使用率
- String memUtil = parts[4]; // 内存使用率
- // 网络流量通常在第 4 列(接收)和第 5 列(发送),格式为 "B/s" 或 "B"
- dockerVO.setCpuUtil(cpuUtil);
- String rxRate = "0B";// 接收速率(如 "1.23kB/s")
- String txRate = "0B";// 发送速率(如 "4.56kB/s")
- //数据间有空格
- if(parts.length > 9){
- rxRate = parts[5]+parts[6];
- txRate = parts[8]+parts[9];
- }else{
- rxRate = parts[5];
- txRate = parts[7];
+ // 修复:使用 try-with-resources 管理 reader
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains(dockerVO.getId())) {
+ String[] parts = line.trim().split("\\s+");
+ // 修复:数组越界保护
+ if (parts.length < 6) {
+ AssertLog.warn("docker stats 输出格式异常,字段不足: {}", line);
+ continue;
+ }
+ String cpuUtil = parts[3];
+ String memUtil = parts[4];
+ String rxRate;
+ String txRate;
+ if (parts.length > 9) {
+ rxRate = parts[5] + parts[6];
+ txRate = parts[8] + parts[9];
+ } else {
+ rxRate = parts[5];
+ txRate = parts.length > 7 ? parts[7] : "0B";
+ }
+ dockerVO.setCpuUtil(cpuUtil);
+ dockerVO.setMemUtil(memUtil);
+ dockerVO.setNetInSpeed(rxRate);
+ dockerVO.setNetOutSpeed(txRate);
+
+ AssertLog.info("容器网络流量 - 接收: {}, 发送: {}", rxRate, txRate);
}
- // 网络流量通常在第 4 列(接收)和第 5 列(发送),格式为 "B/s" 或 "B"
- dockerVO.setCpuUtil(cpuUtil);
- dockerVO.setMemUtil(memUtil);
- dockerVO.setNetInSpeed(rxRate);
- dockerVO.setNetOutSpeed(txRate);
-
- System.out.println("==================== 容器网络流量 ====================");
- System.out.println("接收速率: " + rxRate);
- System.out.println("发送速率: " + txRate);
}
}
- // 等待命令执行完成并获取退出码
- int exitCode = process.waitFor();
- if (exitCode != 0) {
- System.err.println("命令执行失败,退出码: " + exitCode);
+ // 修复:设置超时
+ boolean finished = process.waitFor(10, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ AssertLog.warn("docker stats 命令超时");
+ } else if (process.exitValue() != 0) {
+ AssertLog.warn("docker stats 命令执行失败,退出码: {}", process.exitValue());
+ }
+ } catch (IOException e) {
+ AssertLog.error("docker stats IO异常", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ AssertLog.error("docker stats 被中断", e);
+ } finally {
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
}
- reader.close();
- } catch (IOException | InterruptedException e) {
- e.printStackTrace();
}
return dockerVO;
}
+
+ /**
+ * 应用关闭时清理资源
+ */
+ public void cleanup() {
+ try {
+ if (dockerClientCache != null) {
+ dockerClientCache.close();
+ }
+ if (httpClientCache != null) {
+ httpClientCache.close();
+ }
+ } catch (IOException e) {
+ AssertLog.error("Docker客户端关闭异常", e);
+ }
+ }
}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/MemoryServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/MemoryServiceImpl.java
index 52bb68f..d0f4904 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/MemoryServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/MemoryServiceImpl.java
@@ -3,6 +3,7 @@ package com.tongran.agent.client.service.impl;
import com.tongran.agent.client.core.vo.MemoryVO;
import com.tongran.agent.client.service.MemoryService;
import com.tongran.agent.client.utils.AgentDataUtil;
+import com.tongran.agent.client.utils.AssertLog;
import org.springframework.stereotype.Service;
import java.io.IOException;
@@ -15,60 +16,48 @@ public class MemoryServiceImpl implements MemoryService {
MemoryVO memoryVO = MemoryVO.builder().build();
try {
Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
+
// 1. 基本内存信息
- System.out.println("=== 基本内存信息 (单位KB) ===");
- System.out.println("总内存: " + memInfo.get("MemTotal"));
- System.out.println("空闲内存: " + memInfo.get("MemFree"));
- System.out.println("可用内存: " +
- memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
- memInfo.getOrDefault("Buffers", 0L) +
- memInfo.getOrDefault("Cached", 0L) +
- memInfo.getOrDefault("SReclaimable", 0L)));
- memoryVO.setAvailable(memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
- memInfo.getOrDefault("Buffers", 0L) +
- memInfo.getOrDefault("Cached", 0L) +
- memInfo.getOrDefault("SReclaimable", 0L))); //可用内存
- memoryVO.setTotal(memInfo.get("MemTotal")); //总内存
- memoryVO.setPercent((double) memoryVO.getAvailable() / memoryVO.getTotal() * 100); //可用内存百分比
- // 3. 交换空间信息
- System.out.println("\n=== 交换空间信息 ===");
- long swapTotal = memInfo.getOrDefault("SwapTotal", 0L);
- long swapFree = memInfo.getOrDefault("SwapFree", 0L);
- System.out.println("总交换空间: " + swapTotal);
- System.out.println("空闲交换空间: " + swapFree);
- System.out.println("已用交换空间: " + (swapTotal - swapFree));
- memoryVO.setSwapSizeFree(swapFree); //交换卷/文件的可用空间(字节)
- memoryVO.setSwapSizePercent((double) swapFree / swapTotal * 100); //可用交换空间百分比
- if (swapTotal > 0) {
- System.out.printf("交换空间使用率: %.2f%%\n",
- (double)(swapTotal - swapFree) / swapTotal * 100);
- }
- // 4. 内存使用率分析
- System.out.println("\n=== 内存使用率分析 ===");
- long total = memInfo.get("MemTotal");
- long available = memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
+ long memTotal = memInfo.getOrDefault("MemTotal", 0L);
+ long memAvailable = memInfo.getOrDefault("MemAvailable",
+ memInfo.getOrDefault("MemFree", 0L) +
memInfo.getOrDefault("Buffers", 0L) +
memInfo.getOrDefault("Cached", 0L) +
memInfo.getOrDefault("SReclaimable", 0L));
- // 总内存使用率
- double totalUsage = (double)(total - available) / total * 100;
- System.out.printf("总内存使用率: %.2f%%\n", totalUsage);
- // 实际内存使用率
+
+ memoryVO.setAvailable(memAvailable);
+ memoryVO.setTotal(memTotal);
+ // 修复:除零保护
+ memoryVO.setPercent(memTotal > 0 ? (double) memAvailable / memTotal * 100 : 0);
+
+ AssertLog.info("总内存: {} KB, 可用内存: {} KB, 可用百分比: {}%",
+ memTotal, memAvailable, String.format("%.2f", memoryVO.getPercent()));
+
+ // 2. 交换空间信息
+ long swapTotal = memInfo.getOrDefault("SwapTotal", 0L);
+ long swapFree = memInfo.getOrDefault("SwapFree", 0L);
+ memoryVO.setSwapSizeFree(swapFree);
+ // 修复:除零保护
+ memoryVO.setSwapSizePercent(swapTotal > 0 ? (double) swapFree / swapTotal * 100 : 0);
+
+ if (swapTotal > 0) {
+ AssertLog.info("交换空间 - 总计: {} KB, 空闲: {} KB, 已用: {} KB, 使用率: {}%",
+ swapTotal, swapFree, swapTotal - swapFree,
+ String.format("%.2f", (double) (swapTotal - swapFree) / swapTotal * 100));
+ }
+
+ // 3. 内存使用率分析
long cached = memInfo.getOrDefault("Cached", 0L) +
memInfo.getOrDefault("SReclaimable", 0L);
long buffers = memInfo.getOrDefault("Buffers", 0L);
- double actualUsage = (double)(total - available - cached - buffers) / total * 100;
- System.out.printf("实际内存使用率: %.2f%%\n", actualUsage);
- memoryVO.setUntilzation(actualUsage); //内存利用率
+ double actualUsage = memTotal > 0 ?
+ (double) (memTotal - memAvailable - cached - buffers) / memTotal * 100 : 0;
+ memoryVO.setUntilzation(actualUsage);
+
+ AssertLog.info("内存利用率: {}%", String.format("%.2f", actualUsage));
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("内存信息采集异常", e);
}
return memoryVO;
}
-
-
}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/NetServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/NetServiceImpl.java
index 2066874..d274eee 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/NetServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/NetServiceImpl.java
@@ -3,11 +3,11 @@ package com.tongran.agent.client.service.impl;
import com.tongran.agent.client.core.vo.NetVO;
import com.tongran.agent.client.service.NetService;
import com.tongran.agent.client.utils.AgentUtil;
+import com.tongran.agent.client.utils.AssertLog;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.hardware.NetworkIF;
-import oshi.util.FormatUtil;
import java.io.BufferedReader;
import java.io.InputStreamReader;
@@ -22,25 +22,20 @@ public class NetServiceImpl implements NetService {
try {
SystemInfo si = new SystemInfo();
HardwareAbstractionLayer hal = si.getHardware();
- // 获取所有网络接口
List networkIFs = hal.getNetworkIFs();
- System.out.println("=========================================================");
- System.out.println("===== 网卡流量统计 =====");
+
List temp = new ArrayList<>();
for (NetworkIF net : networkIFs) {
- System.out.println("接口名称: " + net.getName()+"("+net.getDisplayName()+")");
- System.out.println("MAC地址: " + net.getMacaddr());
- System.out.print("运行状态: ");
- System.out.println(net.isConnectorPresent() ? "已连接" : "未连接");
- System.out.println("接口类型: " + AgentUtil.getInterfaceType(net));
- System.out.println("IPv4地址: " + String.join(", ", net.getIPv4addr()));
+ AssertLog.info("接口: {} ({}), MAC: {}, 状态: {}",
+ net.getName(), net.getDisplayName(), net.getMacaddr(),
+ net.isConnectorPresent() ? "已连接" : "未连接");
NetVO netVO = NetVO.builder().build();
- netVO.setName(net.getName()+"("+net.getDisplayName()+")");//网卡名称
- netVO.setMac(net.getMacaddr());//MAC
- netVO.setStatus(net.isConnectorPresent() ? "已连接" : "未连接");//运行状态
- netVO.setType(AgentUtil.getInterfaceType(net));//接口类型
- netVO.setIpV4(String.join(", ", net.getIPv4addr()));//IPv4
+ netVO.setName(net.getName() + "(" + net.getDisplayName() + ")");
+ netVO.setMac(net.getMacaddr());
+ netVO.setStatus(net.isConnectorPresent() ? "已连接" : "未连接");
+ netVO.setType(AgentUtil.getInterfaceType(net));
+ netVO.setIpV4(String.join(", ", net.getIPv4addr()));
Map map = getNetworkMode(net.getName());
if (map != null && !map.isEmpty()) {
netVO.setSpeed(map.get("speed"));
@@ -48,75 +43,89 @@ public class NetServiceImpl implements NetService {
}
temp.add(netVO);
}
- // 2. 实时带宽监控(需要两次采样)
- System.out.println("\n=== 实时带宽监控 ===");
- // 第一次采样
+
+ // 实时带宽监控(需要两次采样)
for (NetworkIF net : networkIFs) {
net.updateAttributes();
}
- // 等待1秒
TimeUnit.SECONDS.sleep(1);
- // 第二次采样并计算速率
for (NetworkIF net : networkIFs) {
long prevBytesRecv = net.getBytesRecv();
long prevBytesSent = net.getBytesSent();
net.updateAttributes();
long bytesRecv = net.getBytesRecv() - prevBytesRecv;
long bytesSent = net.getBytesSent() - prevBytesSent;
- System.out.println("接口: " + net.getName());
- System.out.println("入站丢包: " + net.getInDrops());
- System.out.println("出站丢包: " + net.getCollisions());
- System.out.println("接收带宽: " + FormatUtil.formatBytes(bytesRecv) + "/s (" +
- bytesToMbps(bytesRecv) + " Mbps)");
- System.out.println("发送带宽: " + FormatUtil.formatBytes(bytesSent) + "/s (" +
- bytesToMbps(bytesSent) + " Mbps)");
+
+ AssertLog.info("接口: {}, 入站丢包: {}, 出站丢包: {}, 接收: {} Mbps, 发送: {} Mbps",
+ net.getName(), net.getInDrops(), net.getCollisions(),
+ String.format("%.2f", bytesToMbps(bytesRecv)), String.format("%.2f", bytesToMbps(bytesSent)));
NetVO netVO = temp.stream().filter(n -> n.getIpV4().equals(String.join(", ", net.getIPv4addr()))
&& n.getMac().equals(net.getMacaddr())).findFirst().orElse(null);
- if(Objects.nonNull(netVO)){
- netVO.setInDropped(net.getInDrops());//入站丢包
- netVO.setOutDropped(net.getCollisions());//出站丢包
- netVO.setInSpeed(bytesRecv);//接收流量
- netVO.setOutSpeed(bytesSent);//发送流量
+ if (Objects.nonNull(netVO)) {
+ netVO.setInDropped(net.getInDrops());
+ netVO.setOutDropped(net.getCollisions());
+ netVO.setInSpeed(bytesRecv);
+ netVO.setOutSpeed(bytesSent);
netVO.setTimestamp(timestamp);
list.add(netVO);
}
}
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ AssertLog.error("网络采集被中断", e);
} catch (Exception e) {
- e.printStackTrace();
+ AssertLog.error("网络采集异常", e);
}
return list;
}
private static double bytesToMbps(long bytes) {
- return bytes * 8.0 / 1_000_000; // bytes to megabits
+ return bytes * 8.0 / 1_000_000;
}
+ /**
+ * 获取网络接口模式信息 - 修复资源泄漏,使用 try-with-resources + 进程超时
+ */
public static Map getNetworkMode(String interfaceName) {
Map result = new HashMap<>();
ProcessBuilder pb = new ProcessBuilder("ethtool", interfaceName);
pb.redirectErrorStream(true);
+ Process process = null;
try {
- Process process = pb.start();
- BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
- String line;
-
- while ((line = reader.readLine()) != null) {
- if (line.contains("Speed:")) {
- result.put("speed", line.split(":")[1].trim());
- } else if (line.contains("Duplex:")) {
- result.put("duplex", line.split(":")[1].trim());
- } else if (line.contains("Auto-negotiation:")) {
- result.put("auto-negotiation", line.split(":")[1].trim());
+ process = pb.start();
+ // 修复:使用 try-with-resources 管理 reader
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains("Speed:")) {
+ String[] parts = line.split(":");
+ if (parts.length > 1) result.put("speed", parts[1].trim());
+ } else if (line.contains("Duplex:")) {
+ String[] parts = line.split(":");
+ if (parts.length > 1) result.put("duplex", parts[1].trim());
+ } else if (line.contains("Auto-negotiation:")) {
+ String[] parts = line.split(":");
+ if (parts.length > 1) result.put("auto-negotiation", parts[1].trim());
+ }
}
}
- int exitCode = process.waitFor();
- if (exitCode != 0) {
- result.put("error", "ethtool command failed with exit code " + exitCode);
+ // 修复:设置超时
+ boolean finished = process.waitFor(5, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ result.put("error", "ethtool command timed out");
+ } else if (process.exitValue() != 0) {
+ result.put("error", "ethtool command failed with exit code " + process.exitValue());
}
} catch (Exception e) {
result.put("error", e.getMessage());
+ } finally {
+ // 修复:确保进程被销毁
+ if (process != null && process.isAlive()) {
+ process.destroyForcibly();
+ }
}
return result;
}
diff --git a/src/main/java/com/tongran/agent/client/service/impl/SystemServiceImpl.java b/src/main/java/com/tongran/agent/client/service/impl/SystemServiceImpl.java
index eeed138..57174c5 100644
--- a/src/main/java/com/tongran/agent/client/service/impl/SystemServiceImpl.java
+++ b/src/main/java/com/tongran/agent/client/service/impl/SystemServiceImpl.java
@@ -5,6 +5,7 @@ import com.tongran.agent.client.core.vo.SystemVO;
import com.tongran.agent.client.service.SystemService;
import com.tongran.agent.client.utils.AgentDataUtil;
import com.tongran.agent.client.utils.AgentUtil;
+import com.tongran.agent.client.utils.AssertLog;
import org.springframework.stereotype.Service;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
@@ -31,44 +32,33 @@ public class SystemServiceImpl implements SystemService {
CentralProcessor processor = hal.getProcessor();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
// 1. 操作系统基本信息
- System.out.println("=== 操作系统信息 ===");
- systemVO.setOs(os.getFamily()); //操作系统
- systemVO.setArch(os.getVersionInfo().getVersion() + " " + os.getBitness() + "位"); //操作系统架构
+ systemVO.setOs(os.getFamily());
+ systemVO.setArch(os.getVersionInfo().getVersion() + " " + os.getBitness() + "位");
systemVO.setUuid(AgentUtil.getMotherboardUUID());
- System.out.println("操作系统: " + systemVO.getOs());
- System.out.println("操作系统架构: " + systemVO.getArch());
- System.out.println("UUID: " + AgentUtil.getMotherboardUUID());
+ AssertLog.info("操作系统: {}, 架构: {}, UUID: {}", systemVO.getOs(), systemVO.getArch(), systemVO.getUuid());
// 2. 进程信息
- System.out.println("\n=== 进程信息 ===");
long maxProcesses = getMaxProcessesLinux();
int runningProcesses = getRunningProcessesLinux();
- System.out.println("最大进程数: " + maxProcesses);
- System.out.println("正在运行的进程数: " + runningProcesses);
- systemVO.setMaxProc(maxProcesses); //最大进程数
- systemVO.setRunProcNum(runningProcesses); //正在运行的进程数
+ systemVO.setMaxProc(maxProcesses);
+ systemVO.setRunProcNum(runningProcesses);
+ AssertLog.info("最大进程数: {}, 运行中进程数: {}", maxProcesses, runningProcesses);
// 3. 登录用户数
- System.out.println("\n=== 登录用户 ===");
- systemVO.setUsersNum(os.getSessions().size()); //登录用户数
- System.out.println("登录用户数: " + systemVO.getUsersNum());
+ systemVO.setUsersNum(os.getSessions().size());
+ AssertLog.info("登录用户数: {}", systemVO.getUsersNum());
// 4. 磁盘信息
- System.out.println("\n=== 磁盘信息 ===");
- systemVO.setDiskSizeTotal(diskSpace()); //硬盘:总可用空间
- systemVO.setBootTime(systemBootTime(hal.getProcessor())); //系统启动时间
- systemVO.setUname(systemDescription(si)); //系统描述
- systemVO.setLocalTime(localTime()); //系统本地时间
- systemVO.setUpTime(systemUptime(os)); //系统正常运行时间
- System.out.println("硬盘:总可用空间: " + systemVO.getDiskSizeTotal());
- System.out.println("系统启动时间: " + systemVO.getBootTime());
- System.out.println("系统描述: " + systemVO.getUname());
- System.out.println("系统本地时间: " + systemVO.getLocalTime());
- System.out.println("系统正常运行时间: " + systemVO.getUpTime());
+ systemVO.setDiskSizeTotal(diskSpace());
+ systemVO.setBootTime(systemBootTime(hal.getProcessor()));
+ systemVO.setUname(systemDescription(si));
+ systemVO.setLocalTime(localTime());
+ systemVO.setUpTime(systemUptime(os));
+ AssertLog.info("硬盘总可用空间: {}, 系统启动时间: {}, 系统正常运行时间: {}",
+ systemVO.getDiskSizeTotal(), systemVO.getBootTime(), systemVO.getUpTime());
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("系统信息采集异常", e);
}
return systemVO;
}
@@ -76,8 +66,8 @@ public class SystemServiceImpl implements SystemService {
@Override
public String otherSystem(String type) {
JSONObject json = new JSONObject();
- json.put("type",type);
- switch(type){
+ json.put("type", type);
+ switch (type) {
case "systemSwapSizeFreeCollect":
json.put("value", String.valueOf(handleSystemSwapSizeFree()));
break;
@@ -97,10 +87,10 @@ public class SystemServiceImpl implements SystemService {
json.put("value", String.valueOf(handleMemorySizeTotal()));
break;
case "systemSwOsCollect":
- json.put("value",handleSystemSwOs());
+ json.put("value", handleSystemSwOs());
break;
case "systemSwArchCollect":
- json.put("value",handleSystemSwArch());
+ json.put("value", handleSystemSwArch());
break;
case "kernelMaxprocCollect":
json.put("value", String.valueOf(handleKernelMaxproc()));
@@ -118,10 +108,10 @@ public class SystemServiceImpl implements SystemService {
json.put("value", String.valueOf(handleSystemBoottime()));
break;
case "systemUnameCollect":
- json.put("value",handleSystemUname());
+ json.put("value", handleSystemUname());
break;
case "systemLocaltimeCollect":
- json.put("value",handleSystemLocaltime());
+ json.put("value", handleSystemLocaltime());
break;
case "systemUptimeCollect":
json.put("value", String.valueOf(handleSystemUptime()));
@@ -130,224 +120,209 @@ public class SystemServiceImpl implements SystemService {
json.put("value", String.valueOf(handleProcNum()));
break;
default:
- json.put("value",handleDefault());
+ json.put("value", handleDefault());
break;
}
return json.toString();
}
- private long handleSystemSwapSizeFree(){
+ private long handleSystemSwapSizeFree() {
try {
Map memInfo = AgentDataUtil.parseMemInfo();
long swapFree = memInfo.getOrDefault("SwapFree", 0L);
- System.out.println("=========================================================");
- System.out.println("交换卷/文件的可用空间(字节): " + swapFree);
+ AssertLog.info("交换卷可用空间: {} KB", swapFree);
return swapFree;
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取交换卷可用空间异常", e);
}
return 0L;
}
- private double handleMemoryUtilization(){
+ private double handleMemoryUtilization() {
try {
Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
- long total = memInfo.get("MemTotal");
+ long total = memInfo.getOrDefault("MemTotal", 0L);
long available = memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
+ memInfo.getOrDefault("MemFree", 0L) +
memInfo.getOrDefault("Buffers", 0L) +
memInfo.getOrDefault("Cached", 0L) +
memInfo.getOrDefault("SReclaimable", 0L));
- // 实际内存使用率
long cached = memInfo.getOrDefault("Cached", 0L) +
memInfo.getOrDefault("SReclaimable", 0L);
long buffers = memInfo.getOrDefault("Buffers", 0L);
- double actualUsage = (double)(total - available - cached - buffers) / total * 100;
- System.out.printf("实际内存使用率: %.2f%%\n", actualUsage);
+ // 修复:除零保护
+ double actualUsage = total > 0 ? (double) (total - available - cached - buffers) / total * 100 : 0;
+ AssertLog.info("实际内存使用率: {}%", String.format("%.2f", actualUsage));
return actualUsage;
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取内存利用率异常", e);
}
return 0;
}
- private double handleSystemSwapSizePercent(){
+ private double handleSystemSwapSizePercent() {
try {
Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
long swapTotal = memInfo.getOrDefault("SwapTotal", 0L);
long swapFree = memInfo.getOrDefault("SwapFree", 0L);
- System.out.printf("交换空间使用率: %.2f%%\n",
- (double)(swapTotal - swapFree) / swapTotal * 100);
- return (double) swapFree / swapTotal * 100;
+ // 修复:除零保护
+ if (swapTotal > 0) {
+ AssertLog.info("交换空间使用率: {}%",
+ String.format("%.2f", (double) (swapTotal - swapFree) / swapTotal * 100));
+ return (double) swapFree / swapTotal * 100;
+ }
+ return 0;
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取交换空间百分比异常", e);
}
return 0;
}
- private long handleMemorySizeAvailable(){
+ private long handleMemorySizeAvailable() {
try {
Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
- System.out.println("可用内存: " +
- memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
- memInfo.getOrDefault("Buffers", 0L) +
- memInfo.getOrDefault("Cached", 0L) +
- memInfo.getOrDefault("SReclaimable", 0L)));
- return memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
- memInfo.getOrDefault("Buffers", 0L) +
- memInfo.getOrDefault("Cached", 0L) +
- memInfo.getOrDefault("SReclaimable", 0L));
- } catch (IOException e) {
- e.printStackTrace();
- }
- return 0;
- }
-
- private double handleMemorySizePercent(){
- try {
- Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
long available = memInfo.getOrDefault("MemAvailable",
- memInfo.get("MemFree") +
+ memInfo.getOrDefault("MemFree", 0L) +
memInfo.getOrDefault("Buffers", 0L) +
memInfo.getOrDefault("Cached", 0L) +
memInfo.getOrDefault("SReclaimable", 0L));
- long total = memInfo.get("MemTotal");
- System.out.println("可用内存百分比: " + (double) available / total * 100);
- return (double) available / total * 100;
+ AssertLog.info("可用内存: {} KB", available);
+ return available;
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取可用内存异常", e);
}
return 0;
}
- private long handleMemorySizeTotal(){
+ private double handleMemorySizePercent() {
try {
Map memInfo = AgentDataUtil.parseMemInfo();
- System.out.println("=========================================================");
- long total = memInfo.get("MemTotal");
- System.out.println("总内存: " + total);
+ long available = memInfo.getOrDefault("MemAvailable",
+ memInfo.getOrDefault("MemFree", 0L) +
+ memInfo.getOrDefault("Buffers", 0L) +
+ memInfo.getOrDefault("Cached", 0L) +
+ memInfo.getOrDefault("SReclaimable", 0L));
+ long total = memInfo.getOrDefault("MemTotal", 0L);
+ // 修复:除零保护
+ double percent = total > 0 ? (double) available / total * 100 : 0;
+ AssertLog.info("可用内存百分比: {}%", String.format("%.2f", percent));
+ return percent;
+ } catch (IOException e) {
+ AssertLog.error("获取可用内存百分比异常", e);
+ }
+ return 0;
+ }
+
+ private long handleMemorySizeTotal() {
+ try {
+ Map memInfo = AgentDataUtil.parseMemInfo();
+ long total = memInfo.getOrDefault("MemTotal", 0L);
+ AssertLog.info("总内存: {} KB", total);
return total;
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取总内存异常", e);
}
return 0;
}
- private String handleSystemSwOs(){
+ private String handleSystemSwOs() {
SystemInfo si = new SystemInfo();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
- System.out.println("操作系统: " + os.getFamily());
+ AssertLog.info("操作系统: {}", os.getFamily());
return os.getFamily();
}
- private String handleSystemSwArch(){
+ private String handleSystemSwArch() {
SystemInfo si = new SystemInfo();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
String arch = os.getVersionInfo().getVersion() + " " + os.getBitness() + "位";
- System.out.println("操作系统架构: " + arch);
+ AssertLog.info("操作系统架构: {}", arch);
return arch;
}
- private long handleKernelMaxproc(){
- System.out.println("=========================================================");
+ private long handleKernelMaxproc() {
long maxProcesses = 0;
try {
maxProcesses = getMaxProcessesLinux();
} catch (IOException e) {
- e.printStackTrace();
+ AssertLog.error("获取最大进程数异常", e);
}
- System.out.println("最大进程数: " + maxProcesses);
+ AssertLog.info("最大进程数: {}", maxProcesses);
return maxProcesses;
}
- private long handleProcNumRun(){
- System.out.println("=========================================================");
+ private long handleProcNumRun() {
int runningProcesses = getRunningProcessesLinux();
- System.out.println("正在运行的进程数: " + runningProcesses);
+ AssertLog.info("正在运行的进程数: {}", runningProcesses);
return runningProcesses;
}
- private int handleUsersNum(){
+ private int handleUsersNum() {
SystemInfo si = new SystemInfo();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
int usersNum = os.getSessions().size();
- System.out.println("登录用户数: " + usersNum);
+ AssertLog.info("登录用户数: {}", usersNum);
return usersNum;
}
- private long handleSystemDiskSizeTotal(){
- System.out.println("=========================================================");
+ private long handleSystemDiskSizeTotal() {
long diskSizeTotal = diskSpace();
- System.out.println("硬盘:总可用空间: " + diskSizeTotal);
+ AssertLog.info("硬盘总可用空间: {}", diskSizeTotal);
return diskSizeTotal;
}
- public long handleSystemBoottime(){
+ public long handleSystemBoottime() {
SystemInfo si = new SystemInfo();
HardwareAbstractionLayer hal = si.getHardware();
long boottime = systemBootTime(hal.getProcessor());
- System.out.println("=========================================================");
- System.out.println("系统启动时间: " + boottime);
+ AssertLog.info("系统启动时间: {}", boottime);
return boottime;
}
- private String handleSystemUname(){
+ private String handleSystemUname() {
SystemInfo si = new SystemInfo();
String uname = systemDescription(si);
- System.out.println("=========================================================");
- System.out.println("系统描述: " + uname);
+ AssertLog.info("系统描述: {}", uname);
return uname;
}
- private String handleSystemLocaltime(){
- System.out.println("=========================================================");
+ private String handleSystemLocaltime() {
String time = localTime();
- System.out.println("系统本地时间: " + time);
+ AssertLog.info("系统本地时间: {}", time);
return time;
}
- private long handleSystemUptime(){
+ private long handleSystemUptime() {
SystemInfo si = new SystemInfo();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
long uptime = systemUptime(os);
- System.out.println("系统正常运行时间: " + uptime);
+ AssertLog.info("系统正常运行时间: {}", uptime);
return uptime;
}
- private long handleProcNum(){
+ private long handleProcNum() {
SystemInfo si = new SystemInfo();
OperatingSystem os = si.getOperatingSystem();
- System.out.println("=========================================================");
long procNum = os.getProcessCount();
- System.out.println("进程数: " + procNum);
+ AssertLog.info("进程数: {}", procNum);
return procNum;
}
-
- private String handleDefault(){
+ private String handleDefault() {
return "";
}
- // 读取 /proc/sys/kernel/pid_max 获取最大进程数
private static long getMaxProcessesLinux() throws IOException {
File pidMaxFile = new File("/proc/sys/kernel/pid_max");
+ if (!pidMaxFile.exists()) {
+ return 0;
+ }
try (BufferedReader reader = new BufferedReader(new FileReader(pidMaxFile))) {
- String line = reader.readLine().trim();
- return Long.parseLong(line);
+ String line = reader.readLine();
+ return line != null ? Long.parseLong(line.trim()) : 0;
}
}
- // 统计 /proc 下的数字目录数(每个目录对应一个进程)
private static int getRunningProcessesLinux() {
File procDir = new File("/proc");
File[] files = procDir.listFiles();
@@ -362,7 +337,6 @@ public class SystemServiceImpl implements SystemService {
return count;
}
- // 获取硬盘总可用空间
public long diskSpace() {
long diskSizeTotal = 0;
File[] roots = File.listRoots();
@@ -372,30 +346,23 @@ public class SystemServiceImpl implements SystemService {
return diskSizeTotal;
}
- // 获取系统启动时间
public long systemBootTime(CentralProcessor processor) {
- long[] systemCpuLoadTicks = processor.getSystemCpuLoadTicks();
- long bootTime = ManagementFactory.getRuntimeMXBean().getStartTime();
- return bootTime;
+ return ManagementFactory.getRuntimeMXBean().getStartTime();
}
- // 获取系统描述
public String systemDescription(SystemInfo si) {
OperatingSystem os = si.getOperatingSystem();
HardwareAbstractionLayer hal = si.getHardware();
- return "操作系统: " + os.toString() + ",系统版本: " + os.getVersionInfo().toString() + "" +
+ return "操作系统: " + os.toString() + ",系统版本: " + os.getVersionInfo().toString() +
",处理器: " + hal.getProcessor().getProcessorIdentifier().getName() +
",物理内存: " + hal.getMemory().getTotal() / (1024 * 1024 * 1024) + " GB";
}
- // 获取系统本地时间
public String localTime() {
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
- // 获取系统正常运行时间
public long systemUptime(OperatingSystem os) {
- long uptimeSeconds = os.getSystemUptime();
- return uptimeSeconds;
+ return os.getSystemUptime();
}
}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 498fd0b..d2f8f5b 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -19,4 +19,37 @@ tcp:
enable: true
name: AGENT-CLIENT-服务
port: 6610
- readerIdleTime: 300
\ No newline at end of file
+ readerIdleTime: 300
+
+# 安全配置(P0 修复)
+# 重要:生产环境务必通过环境变量注入 secret 和 token,不要明文写 yml
+agent:
+ security:
+ # === 命令执行白名单 ===
+ allowed-scripts-dir: /opt/tongran/scripts
+ allowed-script-names:
+ - restart.sh
+ - update.sh
+ - cleanup.sh
+ command-timeout-seconds: 100
+
+ # === HMAC 签名 ===
+ # 生产环境:export AGENT_SIGN_SECRET=xxxxxx
+ sign-secret: ${AGENT_SIGN_SECRET:tongran-dev-secret-change-me}
+ sign-ttl-seconds: 300
+
+ # === 下载安全 ===
+ download:
+ allowed-protocols:
+ - https
+ allowed-hosts:
+ - oss.tongran.com
+ - files.tongran.com
+ max-size-bytes: 104857600 # 100MB
+ sha256-required: true
+ allowed-save-dir: /opt/tongran/downloads
+
+ # === Netty 握手认证 ===
+ # 生产环境:export AGENT_NETTY_TOKEN=xxxxxx
+ netty-auth-token: ${AGENT_NETTY_TOKEN:tongran-dev-token-change-me}
+ handshake-timeout-seconds: 10
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 721702c..c31eed8 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -6,7 +6,7 @@ spring:
matching-strategy: ant_path_matcher
application:
name: tr-agent-client
- version: 1.0
+ version: 1.21
web:
resources:
static-locations: classpath*:/META-INF/resources/
diff --git a/src/test/java/com/tongran/agent/client/security/AuthHandshakeHandlerTest.java b/src/test/java/com/tongran/agent/client/security/AuthHandshakeHandlerTest.java
new file mode 100644
index 0000000..dd748fc
--- /dev/null
+++ b/src/test/java/com/tongran/agent/client/security/AuthHandshakeHandlerTest.java
@@ -0,0 +1,158 @@
+package com.tongran.agent.client.security;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.util.AttributeKey;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link AuthHandshakeHandler} 单元测试
+ *
+ * 覆盖:正确 token 通过、错误 token 拒绝、格式错误拒绝、未配置 token 拒绝、
+ * 已认证后放行后续消息等。
+ *
+ *
使用 Netty 的 {@link EmbeddedChannel} 做内存级测试,不真实开端口。
+ *
+ * @author Senior Developer
+ */
+@DisplayName("Netty 握手认证处理器测试")
+class AuthHandshakeHandlerTest {
+
+ private SecurityProperties properties;
+ private AuthHandshakeHandler handler;
+
+ private static final String TOKEN = "valid-token-123";
+
+ @BeforeEach
+ void setUp() {
+ properties = new SecurityProperties();
+ properties.setNettyAuthToken(TOKEN);
+ properties.setHandshakeTimeoutSeconds(10);
+ handler = new AuthHandshakeHandler(properties);
+ }
+
+ @Test
+ @DisplayName("正确 token 应通过握手")
+ void shouldPassWithValidToken() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ // 发送握手包
+ String handshake = AuthHandshakeHandler.HANDSHAKE_PREFIX + TOKEN
+ + AuthHandshakeHandler.HANDSHAKE_SUFFIX;
+ channel.writeInbound(Unpooled.copiedBuffer(handshake, StandardCharsets.UTF_8));
+
+ // 验证已认证
+ Boolean authenticated = channel.attr(AuthHandshakeHandler.AUTHENTICATED).get();
+ assertTrue(authenticated, "正确 token 后应标记为已认证");
+
+ // handler 应已被移除(认证通过后从 pipeline 摘除)
+ assertNull(channel.pipeline().context(AuthHandshakeHandler.class),
+ "认证通过后 handler 应从 pipeline 移除");
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("错误 token 应拒绝并关闭连接")
+ void shouldRejectWrongToken() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ String wrongHandshake = AuthHandshakeHandler.HANDSHAKE_PREFIX + "wrong-token"
+ + AuthHandshakeHandler.HANDSHAKE_SUFFIX;
+ channel.writeInbound(Unpooled.copiedBuffer(wrongHandshake, StandardCharsets.UTF_8));
+
+ // 应该有拒绝响应写出
+ ByteBuf response = channel.readOutbound();
+ assertNotNull(response, "应返回拒绝消息");
+ String responseStr = response.toString(StandardCharsets.UTF_8);
+ assertTrue(responseStr.startsWith("auth-failed:"), "响应应是 auth-failed 开头");
+
+ // 连接应被关闭
+ assertFalse(channel.isActive(), "连接应被关闭");
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("格式错误的消息应被拒绝")
+ void shouldRejectMalformedMessage() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ // 不带前缀
+ channel.writeInbound(Unpooled.copiedBuffer("just-some-data@tong-ran", StandardCharsets.UTF_8));
+ assertFalse(channel.isActive(), "格式错误应关闭连接");
+
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("未配置 token 应拒绝所有连接")
+ void shouldRejectWhenTokenNotConfigured() {
+ properties.setNettyAuthToken("");
+ AuthHandshakeHandler handlerNoToken = new AuthHandshakeHandler(properties);
+
+ EmbeddedChannel channel = new EmbeddedChannel(handlerNoToken);
+
+ String handshake = AuthHandshakeHandler.HANDSHAKE_PREFIX + TOKEN
+ + AuthHandshakeHandler.HANDSHAKE_SUFFIX;
+ channel.writeInbound(Unpooled.copiedBuffer(handshake, StandardCharsets.UTF_8));
+
+ assertFalse(channel.isActive(), "未配置 token 时应拒绝所有连接");
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("非 ByteBuf 消息应被拒绝")
+ void shouldRejectNonByteBufMessage() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ // 直接传一个 String(模拟错误用法)
+ channel.writeInbound("not-a-bytebuf");
+
+ assertFalse(channel.isActive(), "非 ByteBuf 消息应关闭连接");
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("已认证后应放行后续消息")
+ void shouldForwardMessagesAfterAuth() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ // 第一次:握手包
+ String handshake = AuthHandshakeHandler.HANDSHAKE_PREFIX + TOKEN
+ + AuthHandshakeHandler.HANDSHAKE_SUFFIX;
+ channel.writeInbound(Unpooled.copiedBuffer(handshake, StandardCharsets.UTF_8));
+
+ // handler 已移除,后续消息应直接走 inbound pipeline
+ // 由于 EmbeddedChannel 没有 AgentDecoderHandler,消息会到 inbound queue
+ ByteBuf businessMsg = Unpooled.copiedBuffer("business-data", StandardCharsets.UTF_8);
+ channel.writeInbound(businessMsg);
+
+ // 读取 inbound queue:应有业务消息(握手包被 handler 消费了)
+ Object inbound = channel.readInbound();
+ assertNotNull(inbound, "已认证后业务消息应被放行");
+ channel.finishAndReleaseAll();
+ }
+
+ @Test
+ @DisplayName("新连接应记录客户端 IP 并标记未认证")
+ void shouldRecordClientIpOnConnect() {
+ EmbeddedChannel channel = new EmbeddedChannel(handler);
+
+ // channelActive 应该被触发(EmbeddedChannel 构造时会调用)
+ String clientIp = channel.attr(AuthHandshakeHandler.CLIENT_IP).get();
+ // EmbeddedChannel 没有 remoteAddress,但属性应被设置
+ assertNotNull(clientIp, "CLIENT_IP 属性应被设置");
+
+ Boolean authenticated = channel.attr(AuthHandshakeHandler.AUTHENTICATED).get();
+ assertEquals(false, authenticated, "初始应为未认证");
+
+ channel.finishAndReleaseAll();
+ }
+}
diff --git a/src/test/java/com/tongran/agent/client/security/HmacSignVerifierTest.java b/src/test/java/com/tongran/agent/client/security/HmacSignVerifierTest.java
new file mode 100644
index 0000000..b6f06f6
--- /dev/null
+++ b/src/test/java/com/tongran/agent/client/security/HmacSignVerifierTest.java
@@ -0,0 +1,158 @@
+package com.tongran.agent.client.security;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.util.HexFormat;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link HmacSignVerifier} 单元测试
+ *
+ *
覆盖:正确签名、错误签名、过期签名、未来签名、格式错误、未配置 secret 等场景。
+ *
+ * @author Senior Developer
+ */
+@DisplayName("HMAC 签名校验器测试")
+class HmacSignVerifierTest {
+
+ private SecurityProperties properties;
+ private HmacSignVerifier verifier;
+
+ private static final String SECRET = "test-secret-key-123456";
+ private static final String BODY = "restart.sh\n--service agent";
+
+ @BeforeEach
+ void setUp() {
+ properties = new SecurityProperties();
+ properties.setSignSecret(SECRET);
+ properties.setSignTtlSeconds(300); // 5 分钟
+ verifier = new HmacSignVerifier(properties);
+ }
+
+ @Test
+ @DisplayName("正确签名应校验通过")
+ void shouldPassWithValidSignature() {
+ long now = System.currentTimeMillis();
+ String sign = buildSign(SECRET, BODY, now);
+
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, BODY, now);
+ assertTrue(result.isSuccess(), "正确签名应通过");
+ }
+
+ @Test
+ @DisplayName("签名内容不匹配应拒绝")
+ void shouldRejectTamperedBody() {
+ long now = System.currentTimeMillis();
+ String sign = buildSign(SECRET, BODY, now);
+
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, "tampered-body", now);
+ assertFalse(result.isSuccess());
+ assertEquals("签名不匹配", result.getReason());
+ }
+
+ @Test
+ @DisplayName("过期签名应拒绝(防重放)")
+ void shouldRejectExpiredSignature() {
+ long now = System.currentTimeMillis();
+ long oldTimestamp = now - 600_000; // 10 分钟前,超过 TTL
+
+ String sign = buildSign(SECRET, BODY, oldTimestamp);
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, BODY, now);
+
+ assertFalse(result.isSuccess());
+ assertEquals("签名已过期", result.getReason());
+ }
+
+ @Test
+ @DisplayName("未来签名(60秒内时钟偏差)应通过")
+ void shouldPassWithFutureSignatureWithinClockSkew() {
+ long now = System.currentTimeMillis();
+ long futureTimestamp = now + 30_000; // 30 秒后,在 60 秒偏差内
+
+ String sign = buildSign(SECRET, BODY, futureTimestamp);
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, BODY, now);
+
+ assertTrue(result.isSuccess(), "60秒内的时钟偏差应通过");
+ }
+
+ @Test
+ @DisplayName("远未来签名应拒绝")
+ void shouldRejectFarFutureSignature() {
+ long now = System.currentTimeMillis();
+ long futureTimestamp = now + 120_000; // 2 分钟后,超过 60 秒偏差
+
+ String sign = buildSign(SECRET, BODY, futureTimestamp);
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, BODY, now);
+
+ assertFalse(result.isSuccess());
+ assertEquals("签名时间戳超前过多", result.getReason());
+ }
+
+ @Test
+ @DisplayName("格式错误的签名应拒绝")
+ void shouldRejectMalformedSignature() {
+ HmacSignVerifier.VerifyResult r1 = verifier.verify(null, BODY, System.currentTimeMillis());
+ assertFalse(r1.isSuccess());
+
+ HmacSignVerifier.VerifyResult r2 = verifier.verify("invalid-format", BODY, System.currentTimeMillis());
+ assertFalse(r2.isSuccess());
+
+ HmacSignVerifier.VerifyResult r3 = verifier.verify("v1:not-a-number:abc", BODY, System.currentTimeMillis());
+ assertFalse(r3.isSuccess());
+ assertEquals("签名时间戳非法", r3.getReason());
+ }
+
+ @Test
+ @DisplayName("未配置 secret 应拒绝")
+ void shouldRejectWhenSecretNotConfigured() {
+ properties.setSignSecret("");
+ HmacSignVerifier verifierNoSecret = new HmacSignVerifier(properties);
+
+ long now = System.currentTimeMillis();
+ String sign = buildSign(SECRET, BODY, now);
+ HmacSignVerifier.VerifyResult result = verifierNoSecret.verify(sign, BODY, now);
+
+ assertFalse(result.isSuccess());
+ assertEquals("签名密钥未配置", result.getReason());
+ }
+
+ @Test
+ @DisplayName("空 body 应正常校验")
+ void shouldHandleNullBody() {
+ long now = System.currentTimeMillis();
+ String sign = buildSign(SECRET, "", now);
+
+ HmacSignVerifier.VerifyResult result = verifier.verify(sign, null, now);
+ assertTrue(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("SHA-256 工具方法应正确计算")
+ void shouldComputeSha256Correctly() {
+ byte[] data = "hello world".getBytes(StandardCharsets.UTF_8);
+ String sha = HmacSignVerifier.sha256Hex(data);
+
+ // 已知值:echo -n "hello world" | sha256sum
+ assertEquals("b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
+ sha.toLowerCase());
+ }
+
+ /** 构造符合格式的签名:v1:{timestamp}:{hexSig} */
+ private String buildSign(String secret, String body, long timestamp) {
+ try {
+ String payload = timestamp + "\n" + body;
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] raw = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+ return "v1:" + timestamp + ":" + HexFormat.of().formatHex(raw);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/test/java/com/tongran/agent/client/security/SecureCommandExecutorTest.java b/src/test/java/com/tongran/agent/client/security/SecureCommandExecutorTest.java
new file mode 100644
index 0000000..c6f52fe
--- /dev/null
+++ b/src/test/java/com/tongran/agent/client/security/SecureCommandExecutorTest.java
@@ -0,0 +1,179 @@
+package com.tongran.agent.client.security;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link SecureCommandExecutor} 单元测试
+ *
+ *
覆盖:白名单通过/拒绝、路径穿越、签名校验、参数传递等。
+ * 底层 {@link SystemCommandRunner} 被 mock,不真实执行命令。
+ *
+ * @author Senior Developer
+ */
+@DisplayName("安全命令执行器测试")
+class SecureCommandExecutorTest {
+
+ private SecurityProperties properties;
+ private HmacSignVerifier signVerifier;
+ private SystemCommandRunner commandRunner;
+ private SecureCommandExecutor executor;
+
+ private static final String SECRET = "test-secret";
+ private static final String SCRIPT_NAME = "restart.sh";
+ private static final List ARGS = List.of("--service", "agent");
+
+ @BeforeEach
+ void setUp() {
+ properties = new SecurityProperties();
+ properties.setAllowedScriptsDir("/opt/tongran/scripts");
+ properties.setAllowedScriptNames(List.of("restart.sh", "update.sh"));
+ properties.setCommandTimeoutSeconds(60);
+ properties.setSignSecret(SECRET);
+
+ signVerifier = new HmacSignVerifier(properties);
+ commandRunner = mock(SystemCommandRunner.class);
+ executor = new SecureCommandExecutor(properties, signVerifier, commandRunner);
+ }
+
+ @Test
+ @DisplayName("合法脚本 + 正确签名 应执行成功")
+ void shouldExecuteValidScriptWithValidSign() throws Exception {
+ // mock runner 返回成功
+ when(commandRunner.execute(any(Path.class), anyList(), anyLong(), any(TimeUnit.class)))
+ .thenReturn(CompletableFuture.completedFuture(
+ SecureCommandExecutor.CommandResult.ok(0, "done", "")));
+
+ String sign = buildSign(SCRIPT_NAME, ARGS);
+ CompletableFuture future =
+ executor.execute(SCRIPT_NAME, ARGS, sign);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertTrue(result.isSuccess());
+ assertEquals(0, result.getExitCode());
+
+ // 验证 runner 被调用,且参数正确
+ ArgumentCaptor pathCaptor = ArgumentCaptor.forClass(Path.class);
+ verify(commandRunner).execute(pathCaptor.capture(), eq(ARGS), eq(60L), eq(TimeUnit.SECONDS));
+ assertEquals(Paths.get("/opt/tongran/scripts/restart.sh").normalize(),
+ pathCaptor.getValue().normalize());
+ }
+
+ @Test
+ @DisplayName("非白名单脚本应被拒绝")
+ void shouldRejectScriptNotInWhitelist() throws Exception {
+ String sign = buildSign("evil.sh", List.of());
+ CompletableFuture future =
+ executor.execute("evil.sh", List.of(), sign);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getReason().contains("白名单"));
+ verify(commandRunner, never()).execute(any(), anyList(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("路径穿越应被拒绝")
+ void shouldRejectPathTraversal() throws Exception {
+ // 即使脚本名带 ../ 也应被 normalize 后校验失败
+ String maliciousName = "../../etc/passwd";
+ String sign = buildSign(maliciousName, List.of());
+
+ // 先把恶意名加入白名单(模拟配置失误),验证路径穿越防护仍能拦住
+ properties.setAllowedScriptNames(List.of(maliciousName));
+
+ CompletableFuture future =
+ executor.execute(maliciousName, List.of(), sign);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ // 文件不存在或路径非法都会拦截
+ verify(commandRunner, never()).execute(any(), anyList(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("签名缺失应被拒绝")
+ void shouldRejectMissingSign() throws Exception {
+ CompletableFuture future =
+ executor.execute(SCRIPT_NAME, ARGS, null);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ verify(commandRunner, never()).execute(any(), anyList(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("签名不匹配应被拒绝")
+ void shouldRejectWrongSign() throws Exception {
+ CompletableFuture future =
+ executor.execute(SCRIPT_NAME, ARGS, "v1:123:wrong-signature");
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ verify(commandRunner, never()).execute(any(), anyList(), anyLong(), any());
+ }
+
+ @Test
+ @DisplayName("空脚本名应被拒绝")
+ void shouldRejectEmptyScriptName() throws Exception {
+ String sign = buildSign("", List.of());
+ CompletableFuture future =
+ executor.execute("", List.of(), sign);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertEquals("脚本名为空", result.getReason());
+ }
+
+ @Test
+ @DisplayName("null 脚本名应被拒绝")
+ void shouldRejectNullScriptName() throws Exception {
+ CompletableFuture future =
+ executor.execute(null, List.of(), "v1:123:abc");
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertFalse(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("null args 应被当作空参数处理")
+ void shouldHandleNullArgs() throws Exception {
+ when(commandRunner.execute(any(Path.class), anyList(), anyLong(), any(TimeUnit.class)))
+ .thenReturn(CompletableFuture.completedFuture(
+ SecureCommandExecutor.CommandResult.ok(0, "", "")));
+
+ String sign = buildSign(SCRIPT_NAME, null);
+ CompletableFuture future =
+ executor.execute(SCRIPT_NAME, null, sign);
+
+ SecureCommandExecutor.CommandResult result = future.get();
+ assertTrue(result.isSuccess());
+ }
+
+ /** 构造签名 */
+ private String buildSign(String scriptName, List args) {
+ long now = System.currentTimeMillis();
+ String payload = scriptName + "\n" + (args == null ? "" : String.join(" ", args));
+ try {
+ javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
+ mac.init(new javax.crypto.spec.SecretKeySpec(
+ SECRET.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256"));
+ byte[] raw = mac.doFinal(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ return "v1:" + now + ":" + java.util.HexFormat.of().formatHex(raw);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/test/java/com/tongran/agent/client/security/SecureFileDownloaderTest.java b/src/test/java/com/tongran/agent/client/security/SecureFileDownloaderTest.java
new file mode 100644
index 0000000..eb59ad6
--- /dev/null
+++ b/src/test/java/com/tongran/agent/client/security/SecureFileDownloaderTest.java
@@ -0,0 +1,175 @@
+package com.tongran.agent.client.security;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * {@link SecureFileDownloader} 单元测试
+ *
+ * 覆盖:协议白名单、域名白名单、路径穿越、SHA-256 校验、大小限制等。
+ * 真实发起 HTTPS/HTTP 请求测试成本高,这里重点测校验逻辑(在连接前就会拒绝)。
+ *
+ * @author Senior Developer
+ */
+@DisplayName("安全文件下载器测试")
+class SecureFileDownloaderTest {
+
+ @TempDir
+ Path tempDir;
+
+ private SecurityProperties properties;
+ private SecureFileDownloader downloader;
+
+ @BeforeEach
+ void setUp() {
+ properties = new SecurityProperties();
+ properties.getDownload().setAllowedProtocols(List.of("https"));
+ properties.getDownload().setAllowedHosts(List.of("oss.tongran.com", "files.tongran.com"));
+ properties.getDownload().setMaxSizeBytes(1024 * 1024); // 1MB
+ properties.getDownload().setSha256Required(true);
+ properties.getDownload().setAllowedSaveDir(tempDir.toString());
+
+ downloader = new SecureFileDownloader(properties);
+ }
+
+ @Test
+ @DisplayName("非白名单协议(http)应被拒绝")
+ void shouldRejectHttpProtocol() throws Exception {
+ CompletableFuture future =
+ downloader.download("http://oss.tongran.com/file.txt",
+ tempDir.resolve("file.txt").toString(),
+ "abc", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getReason().contains("协议"));
+ }
+
+ @Test
+ @DisplayName("非白名单域名应被拒绝")
+ void shouldRejectUnknownHost() throws Exception {
+ CompletableFuture future =
+ downloader.download("https://evil.com/file.txt",
+ tempDir.resolve("file.txt").toString(),
+ "abc", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getReason().contains("域名"));
+ }
+
+ @Test
+ @DisplayName("路径穿越应被拒绝")
+ void shouldRejectPathTraversal() throws Exception {
+ // 尝试保存到 allowed-save-dir 之外
+ Path escapePath = tempDir.resolve("../escape.txt").normalize();
+
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/file.txt",
+ escapePath.toString(),
+ "abc", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getReason().contains("路径"));
+ }
+
+ @Test
+ @DisplayName("sha256-required 开启时未提供校验值应被拒绝")
+ void shouldRejectMissingSha256WhenRequired() throws Exception {
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/file.txt",
+ tempDir.resolve("file.txt").toString(),
+ null, null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ assertTrue(result.getReason().contains("SHA-256"));
+ }
+
+ @Test
+ @DisplayName("sha256-required 关闭时未提供校验值应继续(到连接阶段)")
+ void shouldAllowMissingSha256WhenNotRequired() throws Exception {
+ properties.getDownload().setSha256Required(false);
+
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/nonexistent-file.txt",
+ tempDir.resolve("file.txt").toString(),
+ null, null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ // 协议、域名、路径都通过,会进入连接阶段(这里 DNS 解析失败或连接失败)
+ assertFalse(result.isSuccess());
+ // 不应是"未提供 SHA-256"的拒绝
+ assertFalse(result.getReason().contains("SHA-256"));
+ }
+
+ @Test
+ @DisplayName("空协议白名单应拒绝所有")
+ void shouldRejectAllWhenProtocolWhitelistEmpty() throws Exception {
+ properties.getDownload().setAllowedProtocols(List.of());
+
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/file.txt",
+ tempDir.resolve("file.txt").toString(),
+ "abc", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("空域名白名单应拒绝所有")
+ void shouldRejectAllWhenHostWhitelistEmpty() throws Exception {
+ properties.getDownload().setAllowedHosts(List.of());
+
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/file.txt",
+ tempDir.resolve("file.txt").toString(),
+ "abc", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ assertFalse(result.isSuccess());
+ }
+
+ @Test
+ @DisplayName("合法 HTTPS + 白名单域名 + 合法路径 应进入下载阶段")
+ void shouldProceedToDownloadWithValidRequest() throws Exception {
+ CompletableFuture future =
+ downloader.download("https://oss.tongran.com/nonexistent.txt",
+ tempDir.resolve("file.txt").toString(),
+ "expected-sha256", null);
+
+ SecureFileDownloader.DownloadResult result = future.get();
+ // 校验都过了,会真正去连 oss.tongran.com(测试环境大概率连不上)
+ assertFalse(result.isSuccess());
+ // 失败原因应是网络相关,而不是校验相关
+ assertFalse(result.getReason().contains("协议"));
+ assertFalse(result.getReason().contains("域名"));
+ assertFalse(result.getReason().contains("路径"));
+ assertFalse(result.getReason().contains("SHA-256"));
+ }
+
+ @Test
+ @DisplayName("DownloadResult 的 ok/fail 工厂方法应正确")
+ void downloadResultFactoryShouldWork() {
+ SecureFileDownloader.DownloadResult ok = SecureFileDownloader.DownloadResult.ok("/path", 100L, "abc");
+ assertTrue(ok.isSuccess());
+ assertEquals("/path", ok.getFilePath());
+ assertEquals(100L, ok.getSize());
+ assertEquals("abc", ok.getSha256());
+
+ SecureFileDownloader.DownloadResult fail = SecureFileDownloader.DownloadResult.fail("reason");
+ assertFalse(fail.isSuccess());
+ assertEquals("reason", fail.getReason());
+ }
+}