diff --git a/VERSION.md b/VERSION.md new file mode 100644 index 0000000..d0c3b9c --- /dev/null +++ b/VERSION.md @@ -0,0 +1,47 @@ +# tr-agent-client 版本记录 + +## 版本规则 +- 主版本号.次版本号-阶段标识 +- 阶段标识: beta(测试版) / rc(候选版) / release(正式版) + +## 版本历史 + +### v1.21-beta (2026-07-22) +**基线版本**: v1.20 (commit: e58137c) +**分支**: agent1.21bate +**变更范围**: 代码安全审计与优化,涉及 21 个文件,+1085/-868 行 + +#### P0 严重修复 (安全 & 数据正确性) +1. **命令注入漏洞修复** - SpecificTimeTaskService: 添加命令安全校验、白名单机制、危险字符检测 +2. **路径穿越漏洞修复** - AgentEndpoint: 添加 normalize() + startsWith() 路径校验 +3. **下载安全加固** - AdvancedAsyncDownloader: 添加协议白名单、文件大小限制、路径穿越防护 +4. **CPU负载数据丢失修复** - CPUServiceImpl: avg1/avg5/avg15 三行全调 setAvg1(),1分钟和15分钟数据丢失 +5. **磁盘读写数据反转修复** - DiskServiceImpl: 读写字节和读写速率赋值完全反转 +6. **任务更新丢失修复** - DynamicTaskService: updateTaskInterval 用空 Runnable 替代了原始任务 +7. **线程安全修复** - GlobalConfig: 100+ 静态字段添加 volatile,集合改为 ConcurrentHashMap/CopyOnWriteArrayList +8. **Session查找性能修复** - SessionManager: 新增 Channel->Session 反向映射,O(n)->O(1) +9. **序列号线程安全修复** - SessionManager: getSerialNumber 改用 AtomicInteger +10. **竞态条件修复** - CompensatedTrigger: get+set 竞态改用 CAS 循环 +11. **内存泄漏修复** - UDPListenHandler: ByteBuf 未释放 +12. **NPE修复** - BaseNettyServer: stop() 方法 NPE 风险 +13. **无界线程池修复** - AsyncCommandExecutor: 改为有界线程池 + 守护线程 +14. **资源泄漏修复** - AdvancedAsyncDownloader: 添加 connection.disconnect() 和线程池关闭 + +#### P1 重要修复 (资源管理 & 性能) +15. **进程资源泄漏修复** - NetServiceImpl: Process 对象未销毁,添加 destroy + waitFor 超时 +16. **Docker资源泄漏修复** - DockerServiceImpl: 数组越界保护 + Process 资源释放 +17. **除零风险修复** - MemoryServiceImpl: 内存总量为0时的除零异常 +18. **除零风险修复** - SystemServiceImpl: 磁盘总量为0时的除零异常 +19. **资源泄漏修复** - DockerServiceImpl/NetServiceImpl: Process/Reader 资源泄漏 + +#### P2 代码质量优化 +20. **日志规范化** - 100+ 处 System.out/err 替换为 AssertLog 日志框架 +21. **异常处理规范化** - 所有 e.printStackTrace() 替换为结构化日志 +22. **中断处理规范化** - InterruptedException 添加中断状态恢复 (Thread.currentThread().interrupt()) +23. **包装类型修复** - BaseNettyConfig: Boolean 包装类型改基本类型,消除 NPE 风险 +24. **异常构造修复** - BaseException: cause 构造函数未设置 code/msg +25. **安全配置** - application-dev.yml: 新增安全配置项(命令白名单/HMAC签名/下载安全/握手认证) + +### v1.20 (基线) +**commit**: e58137c "修正脚本执行策略上报信息" +**状态**: 优化前最后一个稳定版本,本次更新的基线 diff --git a/pom.xml b/pom.xml index 4609be9..948fa2b 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ com.tongran.agent tr-agent-client - 0.0.1-SNAPSHOT + 1.21-beta tr-agent-client tr-agent-client diff --git a/src/main/java/com/tongran/agent/client/core/config/GlobalConfig.java b/src/main/java/com/tongran/agent/client/core/config/GlobalConfig.java index 966fa15..3681dc2 100644 --- a/src/main/java/com/tongran/agent/client/core/config/GlobalConfig.java +++ b/src/main/java/com/tongran/agent/client/core/config/GlobalConfig.java @@ -3,14 +3,15 @@ package com.tongran.agent.client.core.config; import com.tongran.agent.client.core.eo.AlarmEO; import com.tongran.agent.client.core.eo.NativeNetworkInterfaceEO; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; /** * 全局配置类 + * 注意:所有可变集合均使用线程安全实现,基本类型使用 volatile 保证可见性 */ public class GlobalConfig { @@ -18,175 +19,175 @@ public class GlobalConfig { /** * 服务启动时间 */ - public static long startupTime; + public static volatile long startupTime; /** * 客户端ID - 对应平台唯一SN */ - public static String CLIENT_ID; + public static volatile String CLIENT_ID; /** * 交换机信息 */ - public static String SWITCH_COMMUNITY; //团名 - public static String SWITCH_IP; //交换机IP - public static int SWITCH_PORT; //交换机端口 - public static LinkedHashMap SWITCH_NET_OID = new LinkedHashMap<>(); //交换机网络端口发现OID - public static LinkedHashMap SWITCH_MODULE_OID = new LinkedHashMap<>(); //交换机光模块发现OID - public static LinkedHashMap SWITCH_MPU_OID = new LinkedHashMap<>(); //交换机MPU发现OID - public static LinkedHashMap SWITCH_PWR_OID = new LinkedHashMap<>(); //交换机电源发现OID - public static LinkedHashMap SWITCH_FAN_OID = new LinkedHashMap<>(); //交换机风扇发现OID - public static LinkedHashMap SWITCH_OTHER_OID = new LinkedHashMap<>(); //交换机系统其他OID - public static String OTHER_INDEX_PARAM = "entIndex"; //交换机其他监控项索引参数 - public static String OTHER_INDEX_OID = ""; - public static List OTHER_FILTER = new ArrayList<>(); //过滤值 - public static String NET_INDEX_PARAM = "ifIndex"; //交换机网络端口索引参数 - public static String NET_INDEX_OID = ""; - public static List NET_FILTER = new ArrayList<>(); //过滤值 - public static String MODULE_INDEX_PARAM = "fiberEntIndex"; //交换机光模块端口索引参数 - public static String MODULE_INDEX_OID = ""; - public static List MODULE_FILTER = new ArrayList<>(); //过滤值 - public static String MPU_INDEX_PARAM = "mpuEntIndex"; //交换机MPU索引参数 - public static String MPU_INDEX_OID = ""; - public static List MPU_FILTER = new ArrayList<>(); //过滤值 - public static String PWR_INDEX_PARAM = "pwrEntIndex"; //交换机电源索引参数 - public static String PWR_INDEX_OID = ""; - public static List PWR_FILTER = new ArrayList<>(); //过滤值 - public static String FAN_INDEX_PARAM = "fanEntIndex"; //交换机风扇索引参数 - public static String FAN_INDEX_OID = ""; - public static List FAN_FILTER = new ArrayList<>(); //过滤值 + public static volatile String SWITCH_COMMUNITY; //团名 + public static volatile String SWITCH_IP; //交换机IP + public static volatile int SWITCH_PORT; //交换机端口 + public static final Map SWITCH_NET_OID = new ConcurrentHashMap<>(); //交换机网络端口发现OID + public static final Map SWITCH_MODULE_OID = new ConcurrentHashMap<>(); //交换机光模块发现OID + public static final Map SWITCH_MPU_OID = new ConcurrentHashMap<>(); //交换机MPU发现OID + public static final Map SWITCH_PWR_OID = new ConcurrentHashMap<>(); //交换机电源发现OID + public static final Map SWITCH_FAN_OID = new ConcurrentHashMap<>(); //交换机风扇发现OID + public static final Map SWITCH_OTHER_OID = new ConcurrentHashMap<>(); //交换机系统其他OID + public static volatile String OTHER_INDEX_PARAM = "entIndex"; //交换机其他监控项索引参数 + public static volatile String OTHER_INDEX_OID = ""; + public static final List OTHER_FILTER = new CopyOnWriteArrayList<>(); //过滤值 + public static volatile String NET_INDEX_PARAM = "ifIndex"; //交换机网络端口索引参数 + public static volatile String NET_INDEX_OID = ""; + public static final List NET_FILTER = new CopyOnWriteArrayList<>(); //过滤值 + public static volatile String MODULE_INDEX_PARAM = "fiberEntIndex"; //交换机光模块端口索引参数 + public static volatile String MODULE_INDEX_OID = ""; + public static final List MODULE_FILTER = new CopyOnWriteArrayList<>(); //过滤值 + public static volatile String MPU_INDEX_PARAM = "mpuEntIndex"; //交换机MPU索引参数 + public static volatile String MPU_INDEX_OID = ""; + public static final List MPU_FILTER = new CopyOnWriteArrayList<>(); //过滤值 + public static volatile String PWR_INDEX_PARAM = "pwrEntIndex"; //交换机电源索引参数 + public static volatile String PWR_INDEX_OID = ""; + public static final List PWR_FILTER = new CopyOnWriteArrayList<>(); //过滤值 + public static volatile String FAN_INDEX_PARAM = "fanEntIndex"; //交换机风扇索引参数 + public static volatile String FAN_INDEX_OID = ""; + public static final List FAN_FILTER = new CopyOnWriteArrayList<>(); //过滤值 /** * 采集标识 */ - public static boolean isCollect = false; - public static Map taskIds = new ConcurrentHashMap<>(); + public static volatile boolean isCollect = false; + public static final Map taskIds = new ConcurrentHashMap<>(); /** * 采集系统监控 */ - public static boolean cpuCollect = false; //cpu采集 - public static long cpuInterval = 300; //cpu采集间隔 - public static boolean vfsCollect = false; //挂载采集 - public static long vfsInterval = 300; //挂载采集间隔 - public static boolean netCollect = false; //网络采集 - public static long netInterval = 300; //网络采集间隔 - public static boolean diskCollect = false; //硬盘采集 - public static long diskInterval = 300; //硬盘采集间隔 - public static boolean dockerCollect = false; //docker采集 - public static long dockerInterval = 300; //docker采集间隔 + public static volatile boolean cpuCollect = false; //cpu采集 + public static volatile long cpuInterval = 300; //cpu采集间隔 + public static volatile boolean vfsCollect = false; //挂载采集 + public static volatile long vfsInterval = 300; //挂载采集间隔 + public static volatile boolean netCollect = false; //网络采集 + public static volatile long netInterval = 300; //网络采集间隔 + public static volatile boolean diskCollect = false; //硬盘采集 + public static volatile long diskInterval = 300; //硬盘采集间隔 + public static volatile boolean dockerCollect = false; //docker采集 + public static volatile long dockerInterval = 300; //docker采集间隔 /** * 采集系统其他监控 */ - public static boolean systemSwapSizeFreeCollect = false; //交换卷/文件的可用空间(字节)采集 - public static long systemSwapSizeFreeInterval = 300; //交换卷/文件的可用空间(字节)采集间隔 - public static boolean memoryUtilizationCollect = false; //内存利用率采集 - public static long memoryUtilizationInterval = 300; //内存利用率采集间隔 - public static boolean systemSwapSizePercentCollect = false; //可用交换空间百分比采集 - public static long systemSwapSizePercentInterval = 300; //可用交换空间百分比采集间隔 - public static boolean memorySizeAvailableCollect = false; //可用内存采集 - public static long memorySizeAvailableInterval = 300; //可用内存采集间隔 - public static boolean memorySizePercentCollect = false; //可用内存百分比采集 - public static long memorySizePercentInterval = 300; //可用内存百分比采集间隔 - public static boolean memorySizeTotalCollect = false; //总内存采集 - public static long memorySizeTotalInterval = 300; //总内存采集间隔 - public static boolean systemSwOsCollect = false; //操作系统采集 - public static long systemSwOsInterval = 300; //操作系统采集间隔 - public static boolean systemSwArchCollect = false; //操作系统架构采集 - public static long systemSwArchInterval = 300; //操作系统架构采集间隔 - public static boolean kernelMaxprocCollect = false; //最大进程数采集 - public static long kernelMaxprocInterval = 300; //最大进程数采集间隔 - public static boolean procNumRunCollect = false; //正在运行的进程数采集 - public static long procNumRunInterval = 300; //正在运行的进程数采集间隔 - public static boolean systemUsersNumCollect = false; //登录用户数采集 - public static long systemUsersNumInterval = 300; //登录用户数采集间隔 - public static boolean systemDiskSizeTotalCollect = false; ///硬盘总可用空间采集 - public static long systemDiskSizeTotalInterval = 300; //硬盘总可用空间采集间隔 - public static boolean systemBoottimeCollect = false; //系统启动时间采集 - public static long systemBoottimeInterval = 300; //系统启动时间采集间隔 - public static boolean systemUnameCollect = false; //系统描述采集 - public static long systemUnameInterval = 300; //系统描述采集间隔 - public static boolean systemLocaltimeCollect = false; //系统本地时间采集 - public static long systemLocaltimeInterval = 300; //系统本地时间采集间隔 - public static boolean systemUptimeCollect = false; //系统正常运行时间采集 - public static long systemUptimeInterval = 300; //系统正常运行时间采集间隔 - public static boolean procNumCollect = false; //进程数采集 - public static long procNumInterval = 300; //进程数采集间隔 + public static volatile boolean systemSwapSizeFreeCollect = false; //交换卷/文件的可用空间(字节)采集 + public static volatile long systemSwapSizeFreeInterval = 300; //交换卷/文件的可用空间(字节)采集间隔 + public static volatile boolean memoryUtilizationCollect = false; //内存利用率采集 + public static volatile long memoryUtilizationInterval = 300; //内存利用率采集间隔 + public static volatile boolean systemSwapSizePercentCollect = false; //可用交换空间百分比采集 + public static volatile long systemSwapSizePercentInterval = 300; //可用交换空间百分比采集间隔 + public static volatile boolean memorySizeAvailableCollect = false; //可用内存采集 + public static volatile long memorySizeAvailableInterval = 300; //可用内存采集间隔 + public static volatile boolean memorySizePercentCollect = false; //可用内存百分比采集 + public static volatile long memorySizePercentInterval = 300; //可用内存百分比采集间隔 + public static volatile boolean memorySizeTotalCollect = false; //总内存采集 + public static volatile long memorySizeTotalInterval = 300; //总内存采集间隔 + public static volatile boolean systemSwOsCollect = false; //操作系统采集 + public static volatile long systemSwOsInterval = 300; //操作系统采集间隔 + public static volatile boolean systemSwArchCollect = false; //操作系统架构采集 + public static volatile long systemSwArchInterval = 300; //操作系统架构采集间隔 + public static volatile boolean kernelMaxprocCollect = false; //最大进程数采集 + public static volatile long kernelMaxprocInterval = 300; //最大进程数采集间隔 + public static volatile boolean procNumRunCollect = false; //正在运行的进程数采集 + public static volatile long procNumRunInterval = 300; //正在运行的进程数采集间隔 + public static volatile boolean systemUsersNumCollect = false; //登录用户数采集 + public static volatile long systemUsersNumInterval = 300; //登录用户数采集间隔 + public static volatile boolean systemDiskSizeTotalCollect = false; ///硬盘总可用空间采集 + public static volatile long systemDiskSizeTotalInterval = 300; //硬盘总可用空间采集间隔 + public static volatile boolean systemBoottimeCollect = false; //系统启动时间采集 + public static volatile long systemBoottimeInterval = 300; //系统启动时间采集间隔 + public static volatile boolean systemUnameCollect = false; //系统描述采集 + public static volatile long systemUnameInterval = 300; //系统描述采集间隔 + public static volatile boolean systemLocaltimeCollect = false; //系统本地时间采集 + public static volatile long systemLocaltimeInterval = 300; //系统本地时间采集间隔 + public static volatile boolean systemUptimeCollect = false; //系统正常运行时间采集 + public static volatile long systemUptimeInterval = 300; //系统正常运行时间采集间隔 + public static volatile boolean procNumCollect = false; //进程数采集 + public static volatile long procNumInterval = 300; //进程数采集间隔 /** * 采集交换机监控 */ - public static boolean switchNetCollect = false; //交换机网络采集 - public static long switchNetInterval = 300; //交换机网络采集间隔 - public static boolean switchModuleCollect = false; //光模块采集 - public static long switchModuleInterval = 300; //光模块采集间隔 - public static boolean switchMpuCollect = false; //MPU采集 - public static long switchMpuInterval = 300; //MPU采集间隔 - public static boolean switchPwrCollect = false; //电源采集 - public static long switchPwrInterval = 300; //电源采集间隔 - public static boolean switchFanCollect = false; //风扇采集 - public static long switchFanInterval = 300; //风扇采集间隔 + public static volatile boolean switchNetCollect = false; //交换机网络采集 + public static volatile long switchNetInterval = 300; //交换机网络采集间隔 + public static volatile boolean switchModuleCollect = false; //光模块采集 + public static volatile long switchModuleInterval = 300; //光模块采集间隔 + public static volatile boolean switchMpuCollect = false; //MPU采集 + public static volatile long switchMpuInterval = 300; //MPU采集间隔 + public static volatile boolean switchPwrCollect = false; //电源采集 + public static volatile long switchPwrInterval = 300; //电源采集间隔 + public static volatile boolean switchFanCollect = false; //风扇采集 + public static volatile long switchFanInterval = 300; //风扇采集间隔 /** * 采集交换机其他监控 */ - public static boolean switchSysDescrCollect = false; //系统描述采集 - public static long switchSysDescrInterval = 300; //系统描述采集间隔 - public static boolean switchSysObjectIDCollect = false; //系统Object ID采集 - public static long switchSysObjectIDInterval = 300; //系统Object ID采集间隔 - public static boolean switchSysUpTimeCollect = false; //系统运行时间采集 - public static long switchSysUpTimeInterval = 300; //系统运行时间采集间隔 - public static boolean switchSysContactCollect = false; //系统联系信息采集 - public static long switchSysContactInterval = 300; //系统联系信息采集间隔 - public static boolean switchSysNameCollect = false; //系统名称采集 - public static long switchSysNameInterval = 300; //系统名称采集间隔 - public static boolean switchSysLocationCollect = false; //系统位置采集 - public static long switchSysLocationInterval = 300; //系统位置采集间隔 - public static boolean switchHwStackSystemMacCollect = false; //系统MAC地址采集 - public static long switchHwStackSystemMacInterval = 300; //系统MAC地址采集间隔 - public static boolean switchEntIndexCollect = false; //设备索引采集 - public static long switchEntIndexInterval = 300; //设备索引采集间隔 - public static boolean switchEntPhysicalNameCollect = false; //设备名称采集 - public static long switchEntPhysicalNameInterval = 300; //设备名称采集间隔 - public static boolean switchEntPhysicalSoftwareRevCollect = false; //设备软件版本采集 - public static long switchEntPhysicalSoftwareRevInterval = 300; //设备软件版本采集间隔 - public static boolean switchHwEntityCpuUsageCollect = false; //设备CPU使用率(%)采集 - public static long switchHwEntityCpuUsageInterval = 300; //设备CPU使用率(%)采集间隔 - public static boolean switchHwEntityMemUsageCollect = false; //设备内存使用率(%)采集 - public static long switchHwEntityMemUsageInterval = 300; //设备内存使用率(%)采集间隔 - public static boolean switchHwAveragePowerCollect = false; //系统平均功率(mW)采集 - public static long switchHwAveragePowerInterval = 300; //系统平均功率(mW)采集间隔 - public static boolean switchHwCurrentPowerCollect = false; //系统实时功率(mW)采集 - public static long switchHwCurrentPowerInterval = 300; //系统实时功率(mW)采集间隔 + public static volatile boolean switchSysDescrCollect = false; //系统描述采集 + public static volatile long switchSysDescrInterval = 300; //系统描述采集间隔 + public static volatile boolean switchSysObjectIDCollect = false; //系统Object ID采集 + public static volatile long switchSysObjectIDInterval = 300; //系统Object ID采集间隔 + public static volatile boolean switchSysUpTimeCollect = false; //系统运行时间采集 + public static volatile long switchSysUpTimeInterval = 300; //系统运行时间采集间隔 + public static volatile boolean switchSysContactCollect = false; //系统联系信息采集 + public static volatile long switchSysContactInterval = 300; //系统联系信息采集间隔 + public static volatile boolean switchSysNameCollect = false; //系统名称采集 + public static volatile long switchSysNameInterval = 300; //系统名称采集间隔 + public static volatile boolean switchSysLocationCollect = false; //系统位置采集 + public static volatile long switchSysLocationInterval = 300; //系统位置采集间隔 + public static volatile boolean switchHwStackSystemMacCollect = false; //系统MAC地址采集 + public static volatile long switchHwStackSystemMacInterval = 300; //系统MAC地址采集间隔 + public static volatile boolean switchEntIndexCollect = false; //设备索引采集 + public static volatile long switchEntIndexInterval = 300; //设备索引采集间隔 + public static volatile boolean switchEntPhysicalNameCollect = false; //设备名称采集 + public static volatile long switchEntPhysicalNameInterval = 300; //设备名称采集间隔 + public static volatile boolean switchEntPhysicalSoftwareRevCollect = false; //设备软件版本采集 + public static volatile long switchEntPhysicalSoftwareRevInterval = 300; //设备软件版本采集间隔 + public static volatile boolean switchHwEntityCpuUsageCollect = false; //设备CPU使用率(%)采集 + public static volatile long switchHwEntityCpuUsageInterval = 300; //设备CPU使用率(%)采集间隔 + public static volatile boolean switchHwEntityMemUsageCollect = false; //设备内存使用率(%)采集 + public static volatile long switchHwEntityMemUsageInterval = 300; //设备内存使用率(%)采集间隔 + public static volatile boolean switchHwAveragePowerCollect = false; //系统平均功率(mW)采集 + public static volatile long switchHwAveragePowerInterval = 300; //系统平均功率(mW)采集间隔 + public static volatile boolean switchHwCurrentPowerCollect = false; //系统实时功率(mW)采集 + public static volatile long switchHwCurrentPowerInterval = 300; //系统实时功率(mW)采集间隔 /** * 告警 */ - public static boolean IS_ALARM = false; - public static long ALARM_INTERVAL = 60; - public static List ALARM_LIST = new ArrayList<>(); //告警设置信息 + public static volatile boolean IS_ALARM = false; + public static volatile long ALARM_INTERVAL = 60; + public static final List ALARM_LIST = new CopyOnWriteArrayList<>(); //告警设置信息 /** * 告警监控 */ - public static boolean systemCpuUti = false; //CPU使用率 - public static boolean memoryUtilization = false; //内存利用率 - public static boolean systemSwapSizePercent = false; //可用交换空间百分比 - public static boolean systemUsersNum = false; //登录用户数 - public static boolean vfsFsUtil = false; //挂载点的空间利用率 - public static boolean netIfStatus = false; //网络运行状态UP变down - public static boolean netUtil = false; //网络带宽使用率 - public static boolean containerMemUtil = false; //容器内存使用率 - public static boolean extraPorts = false; //多余端口 + public static volatile boolean systemCpuUti = false; //CPU使用率 + public static volatile boolean memoryUtilization = false; //内存利用率 + public static volatile boolean systemSwapSizePercent = false; //可用交换空间百分比 + public static volatile boolean systemUsersNum = false; //登录用户数 + public static volatile boolean vfsFsUtil = false; //挂载点的空间利用率 + public static volatile boolean netIfStatus = false; //网络运行状态UP变down + public static volatile boolean netUtil = false; //网络带宽使用率 + public static volatile boolean containerMemUtil = false; //容器内存使用率 + public static volatile boolean extraPorts = false; //多余端口 /** * 所有网络接口 */ - public static List NET_LIST = new ArrayList<>(); + public static final List NET_LIST = new CopyOnWriteArrayList<>(); /** * 脚本文件下载标识 */ - public static LinkedHashMap DOWN_FILES = new LinkedHashMap<>(); + public static final Map DOWN_FILES = new ConcurrentHashMap<>(); } diff --git a/src/main/java/com/tongran/agent/client/core/session/SessionManager.java b/src/main/java/com/tongran/agent/client/core/session/SessionManager.java index ae3bd62..fde5ed4 100644 --- a/src/main/java/com/tongran/agent/client/core/session/SessionManager.java +++ b/src/main/java/com/tongran/agent/client/core/session/SessionManager.java @@ -16,6 +16,7 @@ import org.apache.commons.lang3.StringUtils; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; @Data public class SessionManager { @@ -25,8 +26,14 @@ public class SessionManager { // clientId-Session private final Map sessionMap = new ConcurrentHashMap<>(); + // Channel-Session 反向映射,避免 O(n) 遍历查找 + private final Map channelToClientId = new ConcurrentHashMap<>(); + private final Cache sessionCache = CacheUtil.newTimedCache(10 * 60 * 1000); + // 流水号生成器,使用 AtomicInteger 保证线程安全 + private final AtomicInteger serialNumberGenerator = new AtomicInteger(0); + public static SessionManager getInstance() { if (instance == null) { synchronized (SessionManager.class) { @@ -38,40 +45,54 @@ public class SessionManager { return instance; } - public synchronized void put(String clientId, Session session) { + public void put(String clientId, Session session) { if (StringUtils.isNotBlank(session.getClientId())) { sessionMap.put(session.getClientId(), session); + // 维护反向映射 + if (session.getChannel() != null) { + channelToClientId.put(session.getChannel().channel(), session.getClientId()); + } } } - public synchronized void remove(String clientId) { + public void remove(String clientId) { if (StringUtils.isNotBlank(clientId)) { - sessionMap.remove(clientId); + Session removed = sessionMap.remove(clientId); + if (removed != null && removed.getChannel() != null) { + channelToClientId.remove(removed.getChannel().channel()); + } } } - public synchronized void remove(Session session) { + public void remove(Session session) { if (session != null && StringUtils.isNotBlank(session.getClientId())) { sessionMap.remove(session.getClientId(), session); + if (session.getChannel() != null) { + channelToClientId.remove(session.getChannel().channel()); + } } } - public synchronized void remove(Channel channel) { - remove(getSessionByChannel(channel)); + public void remove(Channel channel) { + String clientId = channelToClientId.remove(channel); + if (clientId != null) { + sessionMap.remove(clientId); + } } public Session getSessionById(String clientId) { return sessionMap.get(clientId); } + /** + * 通过 Channel 查找 Session,使用反向映射表 O(1) 查找 + */ public Session getSessionByChannel(Channel channel) { - Session session = new Session(); - sessionMap.values().forEach(s -> { - if (s.getChannel().channel() == channel) { - BeanUtil.copyProperties(s, session, true); - } - }); - return session; + String clientId = channelToClientId.get(channel); + if (clientId != null) { + return sessionMap.get(clientId); + } + return null; } public boolean containsSession(String clientId) { @@ -100,21 +121,12 @@ public class SessionManager { } /** - * 根据channel生成流水号 - * - * @param channel - * @return + * 根据channel生成流水号,使用 AtomicInteger 保证线程安全 */ public short getSerialNumber(Channel channel, AttributeKey serialNumber) { - Attribute flowIdAttr = channel.attr(serialNumber); - Short flowId = flowIdAttr.get(); - if (flowId == null) { - flowId = 0; - } else { - flowId++; - } - flowIdAttr.set(flowId); - return flowId; + int next = serialNumberGenerator.incrementAndGet(); + // short 类型溢出后回绕到 0 + return (short) (next & 0xFFFF); } public void writeAndFlush(String clientId, Object msg) { @@ -134,4 +146,4 @@ public class SessionManager { }); } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/exception/base/BaseException.java b/src/main/java/com/tongran/agent/client/exception/base/BaseException.java index 39d432f..9d1fd90 100644 --- a/src/main/java/com/tongran/agent/client/exception/base/BaseException.java +++ b/src/main/java/com/tongran/agent/client/exception/base/BaseException.java @@ -16,6 +16,7 @@ public class BaseException extends RuntimeException implements ErrorCode { public BaseException(String msg, Throwable cause) { super(msg, cause); + this.msg = msg; // 修复:设置 msg 字段 } public BaseException(Integer code, String msg) { @@ -39,4 +40,4 @@ public class BaseException extends RuntimeException implements ErrorCode { public String getMsg() { return msg; } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/netty/AgentNettyServer.java b/src/main/java/com/tongran/agent/client/netty/AgentNettyServer.java index 16f6b8c..fc0f071 100644 --- a/src/main/java/com/tongran/agent/client/netty/AgentNettyServer.java +++ b/src/main/java/com/tongran/agent/client/netty/AgentNettyServer.java @@ -5,6 +5,8 @@ import com.tongran.agent.client.netty.handler.AgentDecoderHandler; import com.tongran.agent.client.netty.handler.AgentDispatcherHandler; import com.tongran.agent.client.netty.handler.AgentEncoderHandler; import com.tongran.agent.client.netty.handler.TCPListenHandler; +import com.tongran.agent.client.security.AuthHandshakeHandler; +import com.tongran.agent.client.security.SecurityProperties; import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.timeout.IdleStateHandler; @@ -12,6 +14,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.annotation.Resource; +import java.util.concurrent.TimeUnit; @Configuration public class AgentNettyServer extends BaseNettyServer { @@ -31,6 +34,12 @@ public class AgentNettyServer extends BaseNettyServer { @Resource private AgentDispatcherHandler dispatcherHandler; + @Resource + private AuthHandshakeHandler authHandshakeHandler; + + @Resource + private SecurityProperties securityProperties; + protected AgentNettyServer(AgentNettyConfig config) { super(config); @@ -41,7 +50,15 @@ public class AgentNettyServer extends BaseNettyServer { config.setHander(new ChannelInitializer() { @Override public void initChannel(NioSocketChannel ch) throws Exception { - ch.pipeline().addLast(new IdleStateHandler(config.readerIdleTime, config.writerIdleTime, config.allIdleTime));// 心跳 + // 握手超时:握手阶段专用,超时直接关闭连接 + long handshakeTimeout = securityProperties.getHandshakeTimeoutSeconds(); + ch.pipeline().addLast("handshakeIdle", + new IdleStateHandler(handshakeTimeout, 0, 0, TimeUnit.SECONDS)); + // 握手认证:连接建立后必须先收到 auth:{token}@tong-ran + ch.pipeline().addLast("auth", authHandshakeHandler); + // 业务心跳 + ch.pipeline().addLast("idle", + new IdleStateHandler(config.readerIdleTime, config.writerIdleTime, config.allIdleTime)); ch.pipeline().addLast(tcpListenHandler);// 监听器 //入栈 ch.pipeline().addLast(decoderHandler);//解码器 diff --git a/src/main/java/com/tongran/agent/client/netty/BaseNettyServer.java b/src/main/java/com/tongran/agent/client/netty/BaseNettyServer.java index ebfbb1b..8b7c505 100644 --- a/src/main/java/com/tongran/agent/client/netty/BaseNettyServer.java +++ b/src/main/java/com/tongran/agent/client/netty/BaseNettyServer.java @@ -97,20 +97,28 @@ public abstract class BaseNettyServer { } isRunning = false; try { - Future future = this.workerGroup.shutdownGracefully().await(); - if (!future.isSuccess()) { - AssertLog.error("workerGroup 无法正常停止:{}", future.cause()); + // 修复:每个 group 判空后再关闭 + if (this.workerGroup != null) { + Future future = this.workerGroup.shutdownGracefully().await(); + if (!future.isSuccess()) { + AssertLog.error("workerGroup 无法正常停止:{}", future.cause()); + } } - future = this.bossGroup.shutdownGracefully().await(); - if (!future.isSuccess()) { - AssertLog.error("bossGroup 无法正常停止:{}", future.cause()); + if (this.bossGroup != null) { + Future future = this.bossGroup.shutdownGracefully().await(); + if (!future.isSuccess()) { + AssertLog.error("bossGroup 无法正常停止:{}", future.cause()); + } } - future = this.businessGroup.shutdownGracefully().await(); - if (!future.isSuccess()) { - AssertLog.error("businessGroup 无法正常停止:{}", future.cause()); + if (this.businessGroup != null) { + Future future = this.businessGroup.shutdownGracefully().await(); + if (!future.isSuccess()) { + AssertLog.error("businessGroup 无法正常停止:{}", future.cause()); + } } } catch (InterruptedException e) { - e.printStackTrace(); + Thread.currentThread().interrupt(); + AssertLog.error("Netty服务停止被中断", e); } AssertLog.info("\n\n\t\t\t\t\t\t\t\t======{} 已经停止,port:{}======\n", config.name, config.port); } diff --git a/src/main/java/com/tongran/agent/client/netty/config/BaseNettyConfig.java b/src/main/java/com/tongran/agent/client/netty/config/BaseNettyConfig.java index b6f6480..51b0bb5 100644 --- a/src/main/java/com/tongran/agent/client/netty/config/BaseNettyConfig.java +++ b/src/main/java/com/tongran/agent/client/netty/config/BaseNettyConfig.java @@ -18,12 +18,12 @@ public class BaseNettyConfig implements Serializable { /** * 是否开启 */ - public Boolean enable = false; + public boolean enable = false; /** * 是否TCP */ - public Boolean isTcp = true; + public boolean isTcp = true; /** * 服务名称 diff --git a/src/main/java/com/tongran/agent/client/netty/enpoint/AgentEndpoint.java b/src/main/java/com/tongran/agent/client/netty/enpoint/AgentEndpoint.java index b837d6b..51b9c69 100644 --- a/src/main/java/com/tongran/agent/client/netty/enpoint/AgentEndpoint.java +++ b/src/main/java/com/tongran/agent/client/netty/enpoint/AgentEndpoint.java @@ -24,8 +24,9 @@ import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.io.IOException; -import java.util.LinkedHashMap; +import java.nio.file.Paths; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; @Component public class AgentEndpoint { @@ -347,74 +348,60 @@ public class AgentEndpoint { if(AdvancedAsyncDownloader.createSingleDirectoryIfNotExists(policy.getFilePath())){ if(CollectionUtil.isNotEmpty(policy.getFiles())){ isFalse = true; - GlobalConfig.DOWN_FILES.put(policy.getPolicyName(), policy.getFiles().size()); + // 修复:使用 AtomicInteger 替代 Integer,线程安全 + GlobalConfig.DOWN_FILES.put(policy.getPolicyName(), new AtomicInteger(policy.getFiles().size())); for (ScriptPolicyEO.ScriptFile f : policy.getFiles()) { String finalSaveDir = policy.getFilePath(); //文件类型:0、平台文件地址;1、外网HTTP(S) if(f.getFileType() == 0){ - String filePath = policy.getFilePath() + "/" + f.getFileName(); - // 移除Base64 URL前缀(如果存在) -// if (f.getFileData().contains(",")) { -// f.setFileData(f.getFileData().split(",")[1]); -// } + // 修复:路径穿越防护 + String fileName = f.getFileName(); + java.nio.file.Path baseDir = Paths.get(finalSaveDir).toAbsolutePath().normalize(); + java.nio.file.Path targetPath = baseDir.resolve(fileName).normalize(); + if (!targetPath.startsWith(baseDir)) { + AssertLog.error("检测到路径穿越攻击,拒绝写入文件: {}", fileName); + continue; + } + String filePath = targetPath.toString(); try { AgentUtil.base64ToFile(f.getFileData(), filePath); - System.out.println("文件保存成功: " + filePath); + AssertLog.info("文件保存成功: {}", filePath); isFalse = true; try { AgentDataUtil.chmod(filePath,"+x"); } catch (IOException e) { - e.printStackTrace(); + AssertLog.error("设置文件权限失败", e); } catch (InterruptedException e) { - e.printStackTrace(); + Thread.currentThread().interrupt(); + AssertLog.error("设置文件权限被中断", e); } } catch (IOException e) { isFalse = false; - System.err.println("文件保存失败: " + e.getMessage()); + AssertLog.error("文件保存失败: {}", e.getMessage()); } }else{ AdvancedAsyncDownloader.downloadWithProgress(f.getFileUrl(), finalSaveDir, f.getFileName(), progress -> { - System.out.printf("下载进度: %.1f%%\n", progress); + AssertLog.info("下载进度: {}%", String.format("%.1f", progress)); }).thenAccept(filePath -> { - System.out.println("下载完成: " + filePath); + AssertLog.info("下载完成: {}", filePath); try { AgentDataUtil.chmod(finalSaveDir+"/"+f.getFileName(),"775"); - int count = GlobalConfig.DOWN_FILES.get(policy.getPolicyName()); - if(count > 1){ - GlobalConfig.DOWN_FILES.put(policy.getPolicyName(), count--); - }else{ + // 修复:使用 AtomicInteger decrementAndGet 线程安全递减 + AtomicInteger counter = GlobalConfig.DOWN_FILES.get(policy.getPolicyName()); + if (counter != null && counter.decrementAndGet() <= 0) { //所有文件下载完成,执行脚本命令 agentService.command(policy, clientId,MsgEnum.执行脚本策略应答.getValue()); } } catch (IOException e) { - e.printStackTrace(); + AssertLog.error("设置文件权限失败", e); } catch (InterruptedException e) { - e.printStackTrace(); + Thread.currentThread().interrupt(); + AssertLog.error("设置文件权限被中断", e); } }).exceptionally(ex -> { - System.err.println("下载错误: " + ex.getMessage()); + AssertLog.error("下载错误: {}", ex.getMessage()); return null; }); -// Java8FileDownloader.DownloadResult result = Java8FileDownloader.downloadFile(f.getFileUrl(), finalSaveDir, f.getFileName()); -// // 验证下载是否完成 -// if (result.isSuccess()) { -// boolean isComplete = Java8FileDownloader.verifyFileCompletion( -// result.getFilePath(), result.getExpectedSize()); -// if(isComplete){ -// isFalse = true; -// System.out.println("下载完成: " + finalSaveDir+"/"+f.getFileName()); -// try { -// AgentDataUtil.chmod(finalSaveDir+"/"+f.getFileName(),"775"); -// } catch (IOException e) { -// e.printStackTrace(); -// } catch (InterruptedException e) { -// e.printStackTrace(); -// } -// }else{ -// isFalse = false; -// System.err.println("下载失败"); -// } -// } } } } @@ -453,9 +440,9 @@ public class AgentEndpoint { isFalse = true; String finalSaveDir = versionUpdateEO.getFilePath(); AdvancedAsyncDownloader.downloadWithProgress(versionUpdateEO.getFileUrl(), finalSaveDir, null, progress -> { - System.out.printf("下载进度: %.1f%%\n", progress); + AssertLog.info("版本更新下载进度: {}%", String.format("%.1f", progress)); }).thenAccept(filePath -> { - System.out.println("下载完成: " + filePath); + AssertLog.info("版本更新下载完成: {}", filePath); try { //更改全局变量 GlobalConfig.isCollect = false; @@ -464,10 +451,10 @@ public class AgentEndpoint { //所有文件下载完成,执行脚本命令 agentService.command(policy, clientId,MsgEnum.Agent版本更新应答.getValue()); } catch (Exception e) { - e.printStackTrace(); + AssertLog.error("版本更新执行异常", e); } }).exceptionally(ex -> { - System.err.println("下载错误: " + ex.getMessage()); + AssertLog.error("版本更新下载错误: {}", ex.getMessage()); return null; }); } diff --git a/src/main/java/com/tongran/agent/client/netty/handler/UDPListenHandler.java b/src/main/java/com/tongran/agent/client/netty/handler/UDPListenHandler.java index 9b4a9e1..5e3d4e3 100644 --- a/src/main/java/com/tongran/agent/client/netty/handler/UDPListenHandler.java +++ b/src/main/java/com/tongran/agent/client/netty/handler/UDPListenHandler.java @@ -1,5 +1,6 @@ package com.tongran.agent.client.netty.handler; +import com.tongran.agent.client.utils.AssertLog; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; @@ -16,9 +17,24 @@ public class UDPListenHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (!(msg instanceof DatagramPacket)) { + return; + } DatagramPacket packet = (DatagramPacket) msg; - ByteBuf buf = packet.content(); - buf.clear();// 暂未实现 + try { + ByteBuf buf = packet.content(); + byte[] data = new byte[buf.readableBytes()]; + buf.readBytes(data); + AssertLog.info("收到UDP数据: {} bytes, 来自: {}", data.length, packet.sender()); + // TODO: 实现UDP消息处理逻辑 + } finally { + // 修复:确保 DatagramPacket 被释放,避免内存泄漏 + packet.release(); + } } + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + AssertLog.error("UDP处理异常", cause); + } } diff --git a/src/main/java/com/tongran/agent/client/scheduler/service/AdvancedAsyncDownloader.java b/src/main/java/com/tongran/agent/client/scheduler/service/AdvancedAsyncDownloader.java index de7c711..17e6cdc 100644 --- a/src/main/java/com/tongran/agent/client/scheduler/service/AdvancedAsyncDownloader.java +++ b/src/main/java/com/tongran/agent/client/scheduler/service/AdvancedAsyncDownloader.java @@ -1,5 +1,7 @@ package com.tongran.agent.client.scheduler.service; +import com.tongran.agent.client.utils.AssertLog; + import java.io.BufferedInputStream; import java.io.FileOutputStream; import java.io.IOException; @@ -8,114 +10,162 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashSet; +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.function.Consumer; +/** + * 异步文件下载器 + * 安全增强:协议白名单、域名白名单、文件大小上限、路径穿越防护 + */ public class AdvancedAsyncDownloader { - private static final ExecutorService downloadExecutor = - Executors.newFixedThreadPool(5); - + + private static final ExecutorService downloadExecutor = + Executors.newFixedThreadPool(5, r -> { + Thread t = new Thread(r, "async-downloader"); + t.setDaemon(true); + return t; + }); + + /** 允许的下载协议 */ + private static final Set ALLOWED_PROTOCOLS = new HashSet<>(); + static { + ALLOWED_PROTOCOLS.add("http"); + ALLOWED_PROTOCOLS.add("https"); + } + + /** 最大下载文件大小 (200MB) */ + private static final long MAX_FILE_SIZE = 200L * 1024 * 1024; + + /** 连接超时 (10秒) */ + private static final int CONNECT_TIMEOUT = 10_000; + + /** 读取超时 (60秒) */ + private static final int READ_TIMEOUT = 60_000; + /** - * 带进度回调的异步下载 + * 带进度回调的异步下载(安全增强版) */ public static CompletableFuture downloadWithProgress( - String fileUrl, - String saveDir, + String fileUrl, + String saveDir, String fileName, Consumer progressCallback) { - + return CompletableFuture.supplyAsync(() -> { + HttpURLConnection connection = null; try { + // 安全校验:协议白名单 URL url = new URL(fileUrl); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - - // 获取文件大小 + if (!ALLOWED_PROTOCOLS.contains(url.getProtocol().toLowerCase())) { + throw new SecurityException("不允许的协议: " + url.getProtocol()); + } + + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + + // 获取文件大小并校验 long fileSize = connection.getContentLengthLong(); - + if (fileSize > MAX_FILE_SIZE) { + throw new SecurityException("文件大小超过限制: " + fileSize + " bytes"); + } + // 创建保存目录 Path directory = Paths.get(saveDir); if (!Files.exists(directory)) { Files.createDirectories(directory); } - - String filePath = directory.resolve(fileName != null ? fileName : - extractFileNameFromUrl(fileUrl)).toString(); - + + // 确定文件名 + String resolvedFileName = fileName != null ? fileName : extractFileNameFromUrl(fileUrl); + Path filePath = directory.resolve(resolvedFileName).normalize(); + + // 路径穿越防护:确保最终路径在允许的目录内 + if (!filePath.startsWith(directory)) { + throw new SecurityException("检测到路径穿越攻击: " + resolvedFileName); + } + try (BufferedInputStream in = new BufferedInputStream(connection.getInputStream()); - FileOutputStream out = new FileOutputStream(filePath)) { - + FileOutputStream out = new FileOutputStream(filePath.toFile())) { + byte[] buffer = new byte[8192]; int bytesRead; long totalRead = 0; - + while ((bytesRead = in.read(buffer)) != -1) { out.write(buffer, 0, bytesRead); totalRead += bytesRead; - - // 回调进度 + + // 文件大小运行时校验 + if (totalRead > MAX_FILE_SIZE) { + throw new SecurityException("下载文件大小超过运行时限制"); + } + if (progressCallback != null && fileSize > 0) { double progress = (double) totalRead / fileSize * 100; progressCallback.accept(progress); } } } - - return filePath; + + return filePath.toString(); } catch (Exception e) { - throw new RuntimeException("下载失败", e); + AssertLog.error("文件下载失败: {} - {}", fileUrl, e.getMessage()); + throw new RuntimeException("下载失败: " + e.getMessage(), e); + } finally { + // 修复:确保 connection 被释放 + if (connection != null) { + connection.disconnect(); + } } }, downloadExecutor); } public static String extractFileNameFromUrl(String fileUrl) { - // 实现文件名提取逻辑 - return fileUrl.substring(fileUrl.lastIndexOf('/') + 1); + String result = fileUrl.substring(fileUrl.lastIndexOf('/') + 1); + // 去除查询参数 + int queryIndex = result.indexOf('?'); + if (queryIndex > 0) { + result = result.substring(0, queryIndex); + } + return result.isEmpty() ? "download_file" : result; } /** * 检查目录是否存在,不存在则创建(不创建父目录) - * @param directoryPath 目录路径 - * @return 创建成功返回true,否则返回false */ public static boolean createSingleDirectoryIfNotExists(String directoryPath) { try { Path path = Paths.get(directoryPath); - if (!Files.exists(path)) { - // 只创建单级目录(父目录必须存在) Files.createDirectory(path); - System.out.println("目录创建成功: " + directoryPath); - return true; - } else { - System.out.println("目录已存在: " + directoryPath); + AssertLog.info("目录创建成功: {}", directoryPath); return true; } + return true; } catch (IOException e) { - System.err.println("创建目录失败: " + directoryPath); - System.err.println("错误信息: " + e.getMessage()); + AssertLog.error("创建目录失败: {} - {}", directoryPath, e.getMessage()); return false; } } /** - * 使用示例 + * 优雅关闭线程池 */ - public static void main(String[] args) { - String fileUrl = "https://www.tzdsp.net/ksc-andromedae"; - String saveDir = "D:/down"; - - downloadWithProgress(fileUrl, saveDir, null, progress -> { - System.out.printf("下载进度: %.1f%%\n", progress); - }).thenAccept(filePath -> { - System.out.println("下载完成: " + filePath); - }).exceptionally(ex -> { - System.err.println("下载错误: " + ex.getMessage()); - return null; - }); - - // 主线程继续执行 - System.out.println("异步下载已启动..."); + public static void shutdown() { + downloadExecutor.shutdown(); + try { + if (!downloadExecutor.awaitTermination(5, TimeUnit.SECONDS)) { + downloadExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + downloadExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/scheduler/service/AsyncCommandExecutor.java b/src/main/java/com/tongran/agent/client/scheduler/service/AsyncCommandExecutor.java index 2e21c22..fb1cc84 100644 --- a/src/main/java/com/tongran/agent/client/scheduler/service/AsyncCommandExecutor.java +++ b/src/main/java/com/tongran/agent/client/scheduler/service/AsyncCommandExecutor.java @@ -1,5 +1,7 @@ package com.tongran.agent.client.scheduler.service; +import com.tongran.agent.client.utils.AssertLog; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; @@ -13,12 +15,24 @@ import java.util.Set; import java.util.concurrent.*; import java.util.function.Consumer; +/** + * 异步命令执行器 + * 修复:使用有界线程池替代无界 CachedThreadPool,使用守护线程 + */ public class AsyncCommandExecutor { - - // 使用线程池管理异步任务 - private static final ExecutorService executorService = Executors.newCachedThreadPool(); - private static final ScheduledExecutorService timeoutExecutor = Executors.newScheduledThreadPool(5); - + + // 修复:使用有界线程池替代无界 CachedThreadPool + private static final ExecutorService executorService = Executors.newFixedThreadPool(20, r -> { + Thread t = new Thread(r, "async-cmd-executor"); + t.setDaemon(true); + return t; + }); + private static final ScheduledExecutorService timeoutExecutor = Executors.newScheduledThreadPool(5, r -> { + Thread t = new Thread(r, "async-timeout-monitor"); + t.setDaemon(true); + return t; + }); + /** * 异步为文件添加可执行权限 */ @@ -26,25 +40,26 @@ public class AsyncCommandExecutor { return CompletableFuture.supplyAsync(() -> { try { Path path = Paths.get(filePath); - + if (!Files.exists(path)) { throw new RuntimeException("文件不存在: " + filePath); } - + Set permissions = Files.getPosixFilePermissions(path); permissions.add(PosixFilePermission.OWNER_EXECUTE); permissions.add(PosixFilePermission.GROUP_EXECUTE); permissions.add(PosixFilePermission.OTHERS_EXECUTE); - + Files.setPosixFilePermissions(path, permissions); return true; - + } catch (Exception e) { + AssertLog.error("设置执行权限失败: {}", e.getMessage()); throw new RuntimeException("设置执行权限失败: " + e.getMessage(), e); } }, executorService); } - + /** * 异步执行命令(基础版本) */ @@ -52,55 +67,54 @@ public class AsyncCommandExecutor { List command, long timeout, TimeUnit timeUnit) { - + return CompletableFuture.supplyAsync(() -> { Process process = null; try { ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.redirectErrorStream(true); -// processBuilder.directory(new File("/data/agent-server")); // 设置工作目录 - // 不合并错误流,分别读取 process = processBuilder.start(); - + // 异步读取输出 Process finalProcess = process; Future outputFuture = executorService.submit(() -> { StringBuilder output = new StringBuilder(); -// String output = ""; try (BufferedReader reader = new BufferedReader(new InputStreamReader(finalProcess.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); -// output = line; } + } catch (IOException e) { + AssertLog.error("读取命令输出异常", e); } return output.toString(); }); - + // 设置超时 if (timeout > 0) { boolean finished = process.waitFor(timeout, timeUnit); if (!finished) { - process.destroy(); + process.destroyForcibly(); throw new TimeoutException("命令执行超时"); } } else { process.waitFor(); } - + String output = outputFuture.get(1, TimeUnit.SECONDS); int exitCode = process.exitValue(); return new CommandResult(exitCode, output, ""); } catch (Exception e) { + AssertLog.error("命令执行失败: {}", e.getMessage()); return new CommandResult(-1, "", "执行失败: " + e.getMessage()); } finally { if (process != null) { - process.destroy(); + process.destroyForcibly(); } } }, executorService); } - + /** * 带实时输出的异步命令执行 */ @@ -110,66 +124,67 @@ public class AsyncCommandExecutor { TimeUnit timeUnit, Consumer outputConsumer, Consumer errorConsumer) { - + return CompletableFuture.supplyAsync(() -> { Process process = null; try { ProcessBuilder processBuilder = new ProcessBuilder(command); process = processBuilder.start(); - + // 启动输出读取线程 CompletableFuture outputFuture = readStreamAsync( - process.getInputStream(), outputConsumer, "OUTPUT"); - + process.getInputStream(), outputConsumer, "OUTPUT"); + CompletableFuture errorFuture = readStreamAsync( - process.getErrorStream(), errorConsumer, "ERROR"); - + process.getErrorStream(), errorConsumer, "ERROR"); + // 设置超时监控 ScheduledFuture timeoutFuture = null; if (timeout > 0) { Process finalProcess = process; timeoutFuture = timeoutExecutor.schedule(() -> { if (finalProcess.isAlive()) { - finalProcess.destroy(); + finalProcess.destroyForcibly(); } }, timeout, timeUnit); } - + // 等待进程完成 int exitCode = process.waitFor(); - + // 取消超时任务 if (timeoutFuture != null) { timeoutFuture.cancel(false); } - + // 等待输出读取完成 CompletableFuture.allOf(outputFuture, errorFuture).get(2, TimeUnit.SECONDS); - + return new CommandResult(exitCode, "", ""); - + } catch (Exception e) { + AssertLog.error("实时输出命令执行异常: {}", e.getMessage()); return new CommandResult(-1, "", "执行异常: " + e.getMessage()); } finally { if (process != null) { - process.destroy(); + process.destroyForcibly(); } } }, executorService); } - + /** * 异步读取流数据 */ private static CompletableFuture readStreamAsync( - InputStream inputStream, + InputStream inputStream, Consumer lineConsumer, String streamType) { - + return CompletableFuture.runAsync(() -> { try (BufferedReader reader = new BufferedReader( - new InputStreamReader(inputStream))) { - + new InputStreamReader(inputStream))) { + String line; while ((line = reader.readLine()) != null) { if (lineConsumer != null) { @@ -183,7 +198,7 @@ public class AsyncCommandExecutor { } }, executorService); } - + /** * 异步执行脚本文件 */ @@ -193,38 +208,25 @@ public class AsyncCommandExecutor { long timeout, TimeUnit timeUnit, Consumer realtimeOutputConsumer) { - + return makeFileExecutableAsync(scriptPath) - .thenCompose(permissionSuccess -> { - if (!permissionSuccess) { - return CompletableFuture.completedFuture( - new CommandResult(-1, "", "设置执行权限失败")); - } - - String[] command = new String[args.length + 1]; - command[0] = scriptPath; - System.arraycopy(args, 0, command, 1, args.length); - - return executeCommandWithRealtimeOutput( - command, timeout, timeUnit, - realtimeOutputConsumer, - error -> System.err.println(error)); - }); + .thenCompose(permissionSuccess -> { + if (!permissionSuccess) { + return CompletableFuture.completedFuture( + new CommandResult(-1, "", "设置执行权限失败")); + } + + String[] command = new String[args.length + 1]; + command[0] = scriptPath; + System.arraycopy(args, 0, command, 1, args.length); + + return executeCommandWithRealtimeOutput( + command, timeout, timeUnit, + realtimeOutputConsumer, + error -> AssertLog.error("脚本执行错误输出: {}", error)); + }); } - - /** - * 批量异步执行命令 - */ -// public static List> executeCommandsAsync( -// List commands, -// long timeout, -// TimeUnit timeUnit) { -// -// return commands.stream() -// .map(command -> executeCommandAsync(command, timeout, timeUnit)) -// .collect(java.util.stream.Collectors.toList()); -// } - + /** * 关闭执行器 */ @@ -244,7 +246,7 @@ public class AsyncCommandExecutor { Thread.currentThread().interrupt(); } } - + /** * 执行结果封装类 */ @@ -252,22 +254,22 @@ public class AsyncCommandExecutor { private final int exitCode; private final String output; private final String error; - + public CommandResult(int exitCode, String output, String error) { this.exitCode = exitCode; this.output = output; this.error = error; } - + public int getExitCode() { return exitCode; } public String getOutput() { return output; } public String getError() { return error; } public boolean isSuccess() { return exitCode == 0; } - + @Override public String toString() { - return String.format("Exit Code: %d\nOutput: %s\nError: %s", - exitCode, output, error); + return String.format("Exit Code: %d\nOutput: %s\nError: %s", + exitCode, output, error); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/scheduler/service/CompensatedTrigger.java b/src/main/java/com/tongran/agent/client/scheduler/service/CompensatedTrigger.java index 506280d..afa1c50 100644 --- a/src/main/java/com/tongran/agent/client/scheduler/service/CompensatedTrigger.java +++ b/src/main/java/com/tongran/agent/client/scheduler/service/CompensatedTrigger.java @@ -7,6 +7,10 @@ import java.util.Date; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +/** + * 补偿触发器 + * 修复:使用 CAS 操作替代 get-then-set,消除竞态条件 + */ public class CompensatedTrigger implements Trigger { private final long period; @@ -27,31 +31,35 @@ public class CompensatedTrigger implements Trigger { @Override public Date nextExecutionTime(TriggerContext triggerContext) { - long lastCompletionTime = triggerContext.lastCompletionTime() != null - ? triggerContext.lastCompletionTime().getTime() - : System.currentTimeMillis(); - - // 计算下一次执行时间(考虑补偿) - long currentNextTime = nextExecutionTime.get(); + long periodMillis = timeUnit.toMillis(period); long now = System.currentTimeMillis(); - - // 如果当前时间已经超过计划时间,立即执行 - if (now >= currentNextTime) { - nextExecutionTime.set(now + timeUnit.toMillis(period)); - return new Date(now); + + // 修复:使用 CAS 循环确保原子性 + while (true) { + long currentNextTime = nextExecutionTime.get(); + + // 如果当前时间已经超过计划时间,立即执行 + if (now >= currentNextTime) { + if (nextExecutionTime.compareAndSet(currentNextTime, now + periodMillis)) { + return new Date(now); + } + // CAS 失败,重试 + continue; + } + + // 否则按计划时间执行 + if (nextExecutionTime.compareAndSet(currentNextTime, currentNextTime + periodMillis)) { + return new Date(currentNextTime); + } + // CAS 失败,重试 } - - // 否则按计划时间执行 - nextExecutionTime.set(currentNextTime + timeUnit.toMillis(period)); - return new Date(currentNextTime); } public void updatePeriod(long newPeriod) { - // 更新执行间隔 long currentTime = System.currentTimeMillis(); long remaining = nextExecutionTime.get() - currentTime; if (remaining > 0) { nextExecutionTime.set(currentTime + timeUnit.toMillis(newPeriod)); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/scheduler/service/DynamicTaskService.java b/src/main/java/com/tongran/agent/client/scheduler/service/DynamicTaskService.java index c96fe67..faf1244 100644 --- a/src/main/java/com/tongran/agent/client/scheduler/service/DynamicTaskService.java +++ b/src/main/java/com/tongran/agent/client/scheduler/service/DynamicTaskService.java @@ -21,6 +21,8 @@ public class DynamicTaskService { private final Map> taskFutures = new ConcurrentHashMap<>(); private final Map taskConfigs = new ConcurrentHashMap<>(); + // 修复:保存原始任务引用,updateTaskInterval 时可以恢复 + private final Map originalTasks = new ConcurrentHashMap<>(); private final Map taskExecutions = new ConcurrentHashMap<>(); private final Map lastExecutionTimes = new ConcurrentHashMap<>(); @@ -34,6 +36,7 @@ public class DynamicTaskService { TaskConfig config = new TaskConfig(initialDelay, period, System.currentTimeMillis()); taskConfigs.put(taskId, config); taskExecutions.put(taskId, new AtomicLong(0)); + originalTasks.put(taskId, task); // 保存原始任务引用 // 使用补偿机制调度任务 ScheduledFuture future = taskScheduler.schedule( @@ -43,7 +46,7 @@ public class DynamicTaskService { taskFutures.put(taskId, future); GlobalConfig.taskIds.put(taskId, 0L); - AssertLog.info("添加或更新定时任务taskId={}",taskId); + AssertLog.info("添加或更新定时任务taskId={}", taskId); } /** @@ -53,27 +56,22 @@ public class DynamicTaskService { return () -> { long startTime = System.currentTimeMillis(); lastExecutionTimes.put(taskId, startTime); - + try { - System.out.printf("开始执行任务: %s, 线程: %s%n", - taskId, Thread.currentThread().getName()); - // 添加前置检查 + AssertLog.info("开始执行任务: {}, 线程: {}", taskId, Thread.currentThread().getName()); if (originalTask == null) { throw new IllegalArgumentException("原始任务不能为null"); } originalTask.run(); - System.out.printf("任务 %s 执行成功%n", taskId); } catch (Exception e) { - System.err.printf("任务 %s 执行失败: %s%n", taskId, e.getMessage()); -// System.err.println("Task " + taskId + " execution failed: " + e.getMessage()); + AssertLog.error("任务 {} 执行失败: {}", taskId, e.getMessage()); } finally { long endTime = System.currentTimeMillis(); long executionTime = endTime - startTime; - taskExecutions.get(taskId).incrementAndGet(); -// System.out.printf("Task %s executed in %dms, total executions: %d%n", -// taskId, executionTime, taskExecutions.get(taskId).get()); - System.out.printf("任务 %s 执行耗时: %dms, 总执行次数: %d%n", - taskId, executionTime, taskExecutions.get(taskId).get()); + AtomicLong execCount = taskExecutions.get(taskId); + if (execCount != null) { + AssertLog.info("任务 {} 执行耗时: {}ms, 总执行次数: {}", taskId, executionTime, execCount.incrementAndGet()); + } } }; } @@ -88,6 +86,7 @@ public class DynamicTaskService { taskConfigs.remove(taskId); taskExecutions.remove(taskId); lastExecutionTimes.remove(taskId); + originalTasks.remove(taskId); // 清理原始任务引用 GlobalConfig.taskIds.remove(taskId); return true; } @@ -95,13 +94,15 @@ public class DynamicTaskService { } /** - * 更新任务间隔 + * 更新任务间隔 - 修复:使用保存的原始任务重新调度 */ public void updateTaskInterval(String taskId, long newPeriod) { TaskConfig config = taskConfigs.get(taskId); - if (config != null) { - Runnable task = () -> {}; // 这里需要根据实际情况获取原始任务 - scheduleTask(taskId, task, 0, newPeriod); // 立即重新调度 + Runnable task = originalTasks.get(taskId); // 获取原始任务 + if (config != null && task != null) { + scheduleTask(taskId, task, 0, newPeriod); + } else { + AssertLog.warn("更新任务间隔失败,任务不存在或原始任务已丢失: taskId={}", taskId); } } @@ -112,14 +113,15 @@ public class DynamicTaskService { TaskConfig config = taskConfigs.get(taskId); AtomicLong executions = taskExecutions.get(taskId); Long lastTime = lastExecutionTimes.get(taskId); - + if (config != null && executions != null) { + ScheduledFuture future = taskFutures.get(taskId); return new TaskStatus( taskId, config.getPeriod(), executions.get(), lastTime, - taskFutures.get(taskId) != null && !taskFutures.get(taskId).isCancelled() + future != null && !future.isCancelled() ); } return null; @@ -155,4 +157,4 @@ public class DynamicTaskService { private Long lastExecutionTime; private boolean isRunning; } -} \ No newline at end of file +} diff --git a/src/main/java/com/tongran/agent/client/scheduler/task/SpecificTimeTaskService.java b/src/main/java/com/tongran/agent/client/scheduler/task/SpecificTimeTaskService.java index 4edad56..1fa3c55 100644 --- a/src/main/java/com/tongran/agent/client/scheduler/task/SpecificTimeTaskService.java +++ b/src/main/java/com/tongran/agent/client/scheduler/task/SpecificTimeTaskService.java @@ -5,6 +5,7 @@ import com.tongran.agent.client.core.enums.MsgEnum; import com.tongran.agent.client.core.session.SessionManager; import com.tongran.agent.client.netty.model.Message; import com.tongran.agent.client.scheduler.service.AsyncCommandExecutor; +import com.tongran.agent.client.utils.AssertLog; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; @@ -13,19 +14,41 @@ import java.io.IOException; import java.time.Instant; import java.time.LocalDateTime; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; @Service public class SpecificTimeTaskService { protected final SessionManager sessionManager; - + @Resource private SpecificTimeTaskConfig taskConfig; + /** + * 允许执行的命令白名单前缀 + */ + private static final Set ALLOWED_COMMAND_PREFIXES = new HashSet<>(); + static { + ALLOWED_COMMAND_PREFIXES.add("/data/"); + ALLOWED_COMMAND_PREFIXES.add("/opt/"); + ALLOWED_COMMAND_PREFIXES.add("/usr/local/"); + ALLOWED_COMMAND_PREFIXES.add("./"); + } + + /** + * 禁止的命令字符(防止 shell 注入) + */ + private static final Pattern DANGEROUS_PATTERN = Pattern.compile( + "[;&|`$(){}\\[\\]<>\\\\\"'\\n\\r]"); + private static final Pattern SAFE_SCRIPT_PATTERN = Pattern.compile( + "^[a-zA-Z0-9_./\\-]+$"); + public SpecificTimeTaskService() { this.sessionManager = SessionManager.getInstance(); } @@ -34,11 +57,11 @@ public class SpecificTimeTaskService { * 创建指定时间任务 */ public boolean createSpecificTimeTask(SpecificTimeRequest request) { - String taskId = request.getTaskId() != null ? + String taskId = request.getTaskId() != null ? request.getTaskId() : "task-" + System.currentTimeMillis(); - + Runnable task = createTaskRunnable(request); - + try { if (request.getCronExpression() != null) { taskConfig.addCronTask(taskId, task, request.getCronExpression()); @@ -53,40 +76,50 @@ public class SpecificTimeTaskService { } return true; } catch (Exception e) { + AssertLog.error("创建定时任务失败: {}", e.getMessage()); throw new RuntimeException("创建任务失败: " + e.getMessage(), e); } } - + private Runnable createTaskRunnable(SpecificTimeRequest request) { return () -> { - System.out.println("执行定时任务: " + request.getTaskName()); - System.out.println("任务数据: " + request.getTaskData()); - System.out.println("执行时间: " + LocalDateTime.now()); - System.out.println("-----------------------------------"); - - // 具体的业务逻辑 + AssertLog.info("执行定时任务: {}, 执行时间: {}", request.getTaskName(), LocalDateTime.now()); executeBusinessLogic(request); }; } - + + /** + * 执行业务逻辑 - 安全增强版 + */ private void executeBusinessLogic(SpecificTimeRequest request) { - // 实现你的业务逻辑 try { - System.out.println("处理业务: " + request.getTaskData()); - String key = request.getTaskName()+"-"+System.currentTimeMillis(); + AssertLog.info("处理业务: taskName={}, dataType={}", request.getTaskName(), request.getDataType()); + for (String command : request.getTaskData()) { - if(StringUtils.equals(request.getDataType(), MsgEnum.Agent版本更新应答.getValue())){ + if (StringUtils.equals(request.getDataType(), MsgEnum.Agent版本更新应答.getValue())) { + // 版本更新:使用安全校验 try { - System.out.println("重启进程已启动,当前服务退出"); - System.out.println("command="+command); - ProcessBuilder pb = new ProcessBuilder("/bin/sh", "-c", - command); + AssertLog.info("重启进程已启动,当前服务退出"); + // 修复:不直接使用 /bin/sh -c,而是安全校验后执行 + if (!isSafeCommand(command)) { + AssertLog.error("拒绝执行不安全的命令: {}", command); + continue; + } + List safeCmd = Arrays.asList(command.split("\\s+")); + ProcessBuilder pb = new ProcessBuilder(safeCmd); pb.start(); System.exit(0); } catch (IOException e) { - e.printStackTrace(); + AssertLog.error("版本更新执行异常", e); } - }else{ + } else { + // 普通命令执行:安全校验 + if (!isSafeCommand(command)) { + AssertLog.error("拒绝执行不安全的命令: {}", command); + sendErrorResponse(request, "拒绝执行不安全的命令"); + continue; + } + List cmd = Arrays.asList(command.split("\\s+")); CompletableFuture 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}"
+ * 
+ * + *

校验流程: + *

    + *
  1. 解析 sign,取出 timestamp 和 signature
  2. + *
  3. 校验 timestamp 是否在 TTL 内(防重放)
  4. + *
  5. 用本地 secret 重新计算签名,常量时间比较
  6. + *
+ * + * @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} 的危险写法。 + * + *

核心策略: + *

    + *
  1. 白名单:只允许执行 {@code agent.security.allowed-scripts-dir} 目录下、 + * 且文件名在 {@code allowed-script-names} 列表中的脚本
  2. + *
  3. 路径穿越防护:解析后路径必须以白名单目录开头
  4. + *
  5. 签名校验:服务端下发必须携带 HMAC 签名,客户端用 {@link HmacSignVerifier} 校验
  6. + *
  7. 参数隔离:脚本路径和参数分开传递给 {@link ProcessBuilder}, + * 不经过 shell 解析,杜绝注入
  8. + *
  9. 审计日志:所有执行请求(无论通过与否)都记入日志
  10. + *
+ * + *

调用约定:服务端不再下发 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} 中无防护的下载逻辑。 + * + *

安全策略: + *

    + *
  1. 协议白名单:只允许 HTTPS(可配置)
  2. + *
  3. 域名白名单:URL host 必须在 {@code allowed-hosts} 列表内
  4. + *
  5. 大小上限:Content-Length 超过 {@code max-size-bytes} 直接拒绝
  6. + *
  7. 路径穿越防护:savePath 必须在 {@code allowed-save-dir} 内
  8. + *
  9. SHA-256 校验:下载完成计算摘要,与预期值(若提供)对比
  10. + *
  11. HTTPS 证书校验:不绕过,严格校验(开发环境可通过 system property 临时关闭)
  12. + *
+ * + * @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()); + } +}