增加主机信息功能

This commit is contained in:
2026-04-18 10:49:52 +08:00
parent d78366fed1
commit dbef9c7a06

View File

@@ -0,0 +1,677 @@
package com.jeesite.modules.utils;
import com.jcraft.jsch.*;
import com.jeesite.modules.apps.Module.SftpResult;
import com.jeesite.modules.biz.entity.MySftpAccounts;
import io.micrometer.common.util.StringUtils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* SFTP 工具类
* 支持:上传、下载、删除、创建目录、重命名
*
* @author gaoxq
*/
public class SftpUtil {
private static final int SSH_TIMEOUT = 5000;
// ------------------------------------------------------------------ 连接
/**
* 建立 SFTP Channel调用方负责关闭
*/
private static ChannelSftp openChannel(MySftpAccounts account) throws JSchException {
JSch jsch = new JSch();
int port = Optional.ofNullable(account.getHostPort()).orElse(22);
if ("key".equalsIgnoreCase(account.getAuthType())
&& StringUtils.isNotBlank(account.getPrivateKey())) {
jsch.addIdentity(
"tempKey",
account.getPrivateKey().getBytes(StandardCharsets.UTF_8),
null, null
);
}
Session session = jsch.getSession(account.getUsername(), account.getHostIp(), port);
session.setTimeout(SSH_TIMEOUT);
if (!"key".equalsIgnoreCase(account.getAuthType())) {
session.setPassword(account.getPassword());
}
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(SSH_TIMEOUT);
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
channel.connect(SSH_TIMEOUT);
return channel;
}
/**
* 安全关闭 channel 及其 session
*/
private static void close(ChannelSftp channel) {
if (channel == null) return;
try {
Session session = channel.getSession();
if (channel.isConnected()) channel.disconnect();
if (session != null && session.isConnected()) session.disconnect();
} catch (JSchException ignored) {
}
}
// ------------------------------------------------------------------ 上传
/**
* 上传本地文件到远程路径
*
* @param account SSH账号
* @param localPath 本地文件绝对路径
* @param remotePath 远程目标路径(含文件名),如 /data/app/test.txt
*/
public static SftpResult upload(MySftpAccounts account, String localPath, String remotePath) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
channel.put(localPath, remotePath, ChannelSftp.OVERWRITE);
return SftpResult.ok("上传成功: " + remotePath);
} catch (Exception e) {
return SftpResult.fail("上传失败: " + e.getMessage());
} finally {
close(channel);
}
}
/**
* 上传字节流到远程路径
*
* @param account SSH账号
* @param input 输入流
* @param remotePath 远程目标路径(含文件名)
*/
public static SftpResult upload(MySftpAccounts account, InputStream input, String remotePath) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
channel.put(input, remotePath, ChannelSftp.OVERWRITE);
return SftpResult.ok("上传成功: " + remotePath);
} catch (Exception e) {
return SftpResult.fail("上传失败: " + e.getMessage());
} finally {
close(channel);
}
}
// ------------------------------------------------------------------ 下载
/**
* 下载远程文件到本地
* <p>
* 示例:
* - download(account, "/data/logs", "*.txt", "D:/tmp/") → 下载目录下所有txt
* - download(account, "/data", "app*.zip", "D:/tmp/") → 匹配 app 开头的 zip
* - download(account, "/data", "test.txt", "D:/tmp/test.txt") → 精确下载单个文件
*
* @param account SSH账号
* @param remoteDir 远程目录,如 /data/logs
* @param fileName 文件名或通配符,如 *.txt、app*.zip、test.txt
* @param localPath 本地保存路径。以 / 或 \ 结尾则视为目录,自动用远程文件名;否则视为完整路径
*/
@SuppressWarnings("unchecked")
public static SftpResult download(MySftpAccounts account, String remoteDir, String fileName, String localPath) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
Vector<ChannelSftp.LsEntry> entries = channel.ls(remoteDir);
boolean toDir = isDirectory(localPath);
String outDir = normalizeDir(localPath);
List<String> downloaded = new ArrayList<>();
int skipped = 0;
for (ChannelSftp.LsEntry entry : entries) {
if (entry.getAttrs().isDir()) continue;
if (!globMatch(fileName, entry.getFilename())) continue;
String remoteFile = normalizeDir(remoteDir) + entry.getFilename();
String target = toDir ? outDir + entry.getFilename() : localPath;
try {
channel.get(remoteFile, target);
downloaded.add(entry.getFilename());
} catch (Exception e) {
skipped++;
}
}
if (downloaded.isEmpty()) {
return SftpResult.fail("无匹配文件: " + remoteDir + "/" + fileName);
}
String msg = "下载完成,成功 " + downloaded.size() + "" + (skipped > 0 ? ",失败 " + skipped + "" : "");
return SftpResult.ok(msg, downloaded);
} catch (Exception e) {
return SftpResult.fail("下载失败: " + e.getMessage());
} finally {
close(channel);
}
}
/**
* 下载远程文件,返回字节数组(单文件,不支持通配符)
*
* @param account SSH账号
* @param remoteDir 远程目录
* @param fileName 文件名(精确)
*/
public static SftpResult download(MySftpAccounts account, String remoteDir, String fileName) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
String remotePath = normalizeDir(remoteDir) + fileName;
ByteArrayOutputStream out = new ByteArrayOutputStream();
channel.get(remotePath, out);
return SftpResult.ok("下载成功", out.toByteArray());
} catch (Exception e) {
return SftpResult.fail("下载失败: " + e.getMessage());
} finally {
close(channel);
}
}
// ------------------------------------------------------------------ 删除
/**
* 删除远程文件
* 支持通配符文件名匹配
* <p>
* 示例:
* - delete(account, "/data/logs", "*.log") → 删除所有 log 文件
* - delete(account, "/data", "test.txt") → 删除单个文件
*
* @param account SSH账号
* @param remoteDir 远程目录
* @param fileName 文件名或通配符,如 *.log、test.txt
*/
@SuppressWarnings("unchecked")
public static SftpResult delete(MySftpAccounts account, String remoteDir, String fileName) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
Vector<ChannelSftp.LsEntry> entries = channel.ls(remoteDir);
List<String> deleted = new ArrayList<>();
for (ChannelSftp.LsEntry entry : entries) {
if (entry.getAttrs().isDir()) continue;
if (!globMatch(fileName, entry.getFilename())) continue;
try {
channel.rm(normalizeDir(remoteDir) + entry.getFilename());
deleted.add(entry.getFilename());
} catch (SftpException ignored) {
}
}
if (deleted.isEmpty()) {
return SftpResult.fail("无匹配文件: " + remoteDir + "/" + fileName);
}
return SftpResult.ok("删除完成,共 " + deleted.size() + " 个文件", deleted);
} catch (Exception e) {
return SftpResult.fail("删除失败: " + e.getMessage());
} finally {
close(channel);
}
}
/**
* 删除远程空目录
*
* @param account SSH账号
* @param remoteDir 远程目录路径
*/
public static SftpResult deleteDir(MySftpAccounts account, String remoteDir) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
channel.rmdir(remoteDir);
return SftpResult.ok("目录删除成功: " + remoteDir);
} catch (Exception e) {
return SftpResult.fail("目录删除失败: " + e.getMessage());
} finally {
close(channel);
}
}
// ------------------------------------------------------------------ 创建目录
/**
* 创建远程目录(单级)
*
* @param account SSH账号
* @param remoteDir 远程目录路径
*/
public static SftpResult mkdir(MySftpAccounts account, String remoteDir) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
channel.mkdir(remoteDir);
return SftpResult.ok("目录创建成功: " + remoteDir);
} catch (Exception e) {
return SftpResult.fail("目录创建失败: " + e.getMessage());
} finally {
close(channel);
}
}
/**
* 递归创建远程目录(多级,类似 mkdir -p
*
* @param account SSH账号
* @param remoteDir 远程目录路径,如 /data/a/b/c
*/
public static SftpResult mkdirs(MySftpAccounts account, String remoteDir) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
mkdirsInternal(channel, remoteDir);
return SftpResult.ok("目录创建成功: " + remoteDir);
} catch (Exception e) {
return SftpResult.fail("目录创建失败: " + e.getMessage());
} finally {
close(channel);
}
}
private static void mkdirsInternal(ChannelSftp channel, String path) throws SftpException {
String[] parts = path.replaceAll("^/", "").split("/");
StringBuilder current = new StringBuilder(path.startsWith("/") ? "/" : "");
for (String part : parts) {
if (part.isEmpty()) continue;
current.append(part);
try {
channel.stat(current.toString());
} catch (SftpException e) {
if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
channel.mkdir(current.toString());
} else {
throw e;
}
}
current.append("/");
}
}
// ------------------------------------------------------------------ 系统监控
/**
* 获取远程主机系统信息CPU、内存、磁盘、负载、在线时长
* 通过 SSH exec 执行系统命令采集
*
* @param account SSH账号
* @return SftpResultdata 为 Map 结构
*/
public static SftpResult systemInfo(MySftpAccounts account) {
Session session = null;
ChannelExec exec = null;
try {
session = openSession(account);
exec = (ChannelExec) session.openChannel("exec");
// 一次性采集所有指标
exec.setCommand(
"echo '===HOSTNAME===' && hostname && " +
"echo '===UPTIME===' && uptime -p 2>/dev/null || uptime && " +
"echo '===CPU===' && top -bn1 | head -5 && " +
"echo '===MEM===' && free -m && " +
"echo '===DISK===' && df -h"
);
InputStream in = exec.getInputStream();
InputStream errIn = exec.getErrStream();
exec.connect(SSH_TIMEOUT);
String output = readStream(in);
String err = readStream(errIn);
if (!err.isEmpty() && output.isEmpty()) {
return SftpResult.fail("采集失败: " + err);
}
Map<String, Object> info = new LinkedHashMap<>();
info.put("hostname", extract(output, "HOSTNAME", true));
info.put("uptime", extract(output, "UPTIME", true));
info.put("cpu", parseCpu(extract(output, "CPU", false)));
info.put("memory", parseMemory(extract(output, "MEM", false)));
info.put("disk", parseDisk(extract(output, "DISK", false)));
return SftpResult.ok("采集成功", info);
} catch (Exception e) {
return SftpResult.fail("采集失败: " + e.getMessage());
} finally {
if (exec != null && exec.isConnected()) exec.disconnect();
if (session != null && session.isConnected()) session.disconnect();
}
}
// ---- session 工具 ----
private static Session openSession(MySftpAccounts account) throws JSchException {
JSch jsch = new JSch();
int port = Optional.ofNullable(account.getHostPort()).orElse(22);
if ("key".equalsIgnoreCase(account.getAuthType())
&& StringUtils.isNotBlank(account.getPrivateKey())) {
jsch.addIdentity("tempKey",
account.getPrivateKey().getBytes(StandardCharsets.UTF_8),
null, null);
}
Session session = jsch.getSession(account.getUsername(), account.getHostIp(), port);
session.setTimeout(SSH_TIMEOUT);
if (!"key".equalsIgnoreCase(account.getAuthType())) {
session.setPassword(account.getPassword());
}
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(SSH_TIMEOUT);
return session;
}
private static String readStream(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int len;
while ((len = in.read(buf)) != -1) {
out.write(buf, 0, len);
}
return out.toString(StandardCharsets.UTF_8);
}
// ---- 解析工具 ----
/**
* 按 ===MARK=== 分割提取段落
*/
private static String extract(String output, String mark, boolean singleLine) {
String start = "===" + mark + "===";
int s = output.indexOf(start);
if (s < 0) return "";
s += start.length();
if (s >= output.length()) return "";
// 跳过紧跟的换行
if (output.charAt(s) == '\n') s++;
if (singleLine) {
int e = output.indexOf('\n', s);
return (e < 0 ? output.substring(s) : output.substring(s, e)).trim();
}
// 取到下一个 === 或末尾
int e = output.indexOf("\n===", s);
return (e < 0 ? output.substring(s) : output.substring(s, e)).trim();
}
/**
* 解析 CPU 段top -bn1 输出头部)
* 格式Cpu(s): 1.2 us, 0.3 sy, 0.0 ni, 98.3 id, 0.1 wa, 0.1 si
*/
private static Map<String, String> parseCpu(String block) {
Map<String, String> cpu = new LinkedHashMap<>();
for (String line : block.split("\n")) {
if (line.startsWith("Cpu") || line.startsWith("%Cpu")) {
// 统一去掉 %Cpu(s): 前缀
String data = line.replaceFirst(".*?:\\s*", "").replaceFirst("^%Cpu\\(s?\\):\\s*", "");
for (String part : data.split(",")) {
part = part.trim();
String[] kv = part.split("\\s+", 2);
if (kv.length >= 2) {
cpu.put(kv[1].trim(), kv[0].trim());
}
}
break;
}
}
return cpu;
}
/**
* 解析内存段free -m 输出)
* Mem: total used free shared buff/cache available
*/
private static Map<String, String> parseMemory(String block) {
Map<String, String> mem = new LinkedHashMap<>();
for (String line : block.split("\n")) {
if (line.startsWith("Mem:")) {
String[] cols = line.trim().split("\\s+");
if (cols.length >= 7) {
mem.put("total", cols[1] + " MB");
mem.put("used", cols[2] + " MB");
mem.put("free", cols[3] + " MB");
mem.put("available", cols[6] + " MB");
// 使用率
try {
double total = Double.parseDouble(cols[1]);
double used = Double.parseDouble(cols[2]);
double pct = total > 0 ? used / total * 100 : 0;
mem.put("usagePercent", String.format("%.1f%%", pct));
} catch (NumberFormatException ignored) {
}
}
break;
}
}
return mem;
}
/**
* 解析磁盘段df -h 输出)
* Filesystem Size Used Avail Use% Mounted on
*/
private static List<Map<String, String>> parseDisk(String block) {
List<Map<String, String>> disks = new ArrayList<>();
String[] lines = block.split("\n");
for (int i = 0; i < lines.length; i++) {
String line = lines[i].trim();
if (i == 0 && (line.startsWith("Filesystem") || line.startsWith("文件系统"))) continue;
if (line.isEmpty()) continue;
String[] cols = line.split("\\s+");
if (cols.length >= 6) {
Map<String, String> d = new LinkedHashMap<>();
d.put("filesystem", cols[0]);
d.put("size", cols[1]);
d.put("used", cols[2]);
d.put("avail", cols[3]);
d.put("usePercent", cols[4]);
d.put("mounted", cols[5]);
disks.add(d);
}
}
return disks;
}
// ------------------------------------------------------------------ 重命名/移动
/**
* 重命名或移动远程文件/目录
*
* @param account SSH账号
* @param oldPath 原路径
* @param newPath 新路径
*/
public static SftpResult rename(MySftpAccounts account, String oldPath, String newPath) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
channel.rename(oldPath, newPath);
return SftpResult.ok("重命名成功: " + oldPath + "" + newPath);
} catch (Exception e) {
return SftpResult.fail("重命名失败: " + e.getMessage());
} finally {
close(channel);
}
}
// ------------------------------------------------------------------ 列表
/**
* 列出远程目录下的文件列表含MD5
* 对非目录文件通过 SSH exec 批量执行 md5sum 获取摘要
*
* @param account SSH账号
* @param remoteDir 远程目录路径
*/
@SuppressWarnings("unchecked")
public static SftpResult list(MySftpAccounts account, String remoteDir) {
ChannelSftp channel = null;
try {
channel = openChannel(account);
Vector<ChannelSftp.LsEntry> entries = channel.ls(remoteDir);
String dir = normalizeDir(remoteDir);
// 收集非目录文件名
List<String> fileNames = new ArrayList<>();
for (ChannelSftp.LsEntry entry : entries) {
if (".".equals(entry.getFilename()) || "..".equals(entry.getFilename())) continue;
if (!entry.getAttrs().isDir()) {
fileNames.add(entry.getFilename());
}
}
// 批量获取 MD5一个 exec channel一次 md5sum 命令)
Map<String, String> md5Map = batchMd5(account, dir, fileNames);
List<Map<String, Object>> files = new ArrayList<>();
for (ChannelSftp.LsEntry entry : entries) {
if (".".equals(entry.getFilename()) || "..".equals(entry.getFilename())) continue;
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", entry.getFilename());
item.put("size", entry.getAttrs().getSize());
item.put("isDir", entry.getAttrs().isDir());
item.put("permissions", entry.getAttrs().getPermissionsString());
item.put("modifyTime", new Date((long) entry.getAttrs().getMTime() * 1000));
item.put("md5", md5Map.getOrDefault(entry.getFilename(), ""));
files.add(item);
}
return SftpResult.ok("获取列表成功", files);
} catch (Exception e) {
return SftpResult.fail("获取列表失败: " + e.getMessage());
} finally {
close(channel);
}
}
/**
* 批量执行 md5sum返回 {文件名: md5}
*/
private static Map<String, String> batchMd5(MySftpAccounts account, String dir, List<String> fileNames) {
Map<String, String> result = new HashMap<>();
if (fileNames.isEmpty()) return result;
Session session = null;
ChannelExec exec = null;
try {
JSch jsch = new JSch();
int port = Optional.ofNullable(account.getHostPort()).orElse(22);
if ("key".equalsIgnoreCase(account.getAuthType())
&& StringUtils.isNotBlank(account.getPrivateKey())) {
jsch.addIdentity("tempKey",
account.getPrivateKey().getBytes(StandardCharsets.UTF_8),
null, null);
}
session = jsch.getSession(account.getUsername(), account.getHostIp(), port);
session.setTimeout(SSH_TIMEOUT);
if (!"key".equalsIgnoreCase(account.getAuthType())) {
session.setPassword(account.getPassword());
}
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
session.setConfig(config);
session.connect(SSH_TIMEOUT);
exec = (ChannelExec) session.openChannel("exec");
// 构建批量命令md5sum dir/file1 dir/file2 ...
StringBuilder cmd = new StringBuilder("md5sum");
for (String name : fileNames) {
cmd.append(" ").append(dir).append(name);
}
exec.setCommand(cmd.toString());
ByteArrayOutputStream errOut = new ByteArrayOutputStream();
exec.setErrStream(errOut);
InputStream in = exec.getInputStream();
exec.connect(SSH_TIMEOUT);
// 读取输出格式hash ./filepath 或 hash filepath
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
// md5sum 输出格式:<32位hex> <filepath>
String[] parts = line.split("\\s+", 2);
if (parts.length < 2) continue;
String md5 = parts[0];
String filepath = parts[1];
// 去掉可能的 ./ 前缀,取纯文件名
String name = filepath;
if (name.startsWith("./")) name = name.substring(2);
int slashIdx = name.lastIndexOf('/');
if (slashIdx >= 0) name = name.substring(slashIdx + 1);
// 处理文件名含 \0 的情况md5sum -b 二进制模式输出)
name = name.replace("\\", "");
result.put(name, md5);
}
} catch (Exception e) {
// MD5 获取失败不影响列表,静默返回空
} finally {
if (exec != null && exec.isConnected()) exec.disconnect();
if (session != null && session.isConnected()) session.disconnect();
}
return result;
}
// ------------------------------------------------------------------ 通配符 & 路径工具
/**
* 简易 glob 匹配,支持 * 和 ?
* 将 glob 转为正则:转义其他正则特殊字符,*→.* ?→.
*/
private static boolean globMatch(String pattern, String name) {
if (pattern == null || name == null) return false;
// 先统一转义,再替换通配符
StringBuilder regex = new StringBuilder("^");
for (char c : pattern.toCharArray()) {
switch (c) {
case '*':
regex.append(".*");
break;
case '?':
regex.append(".");
break;
case '.':
case '(':
case ')':
case '[':
case ']':
case '{':
case '}':
case '+':
case '^':
case '$':
case '|':
case '\\':
regex.append("\\").append(c);
break;
default:
regex.append(c);
}
}
regex.append("$");
return name.matches(regex.toString());
}
/**
* 确保路径以 / 结尾
*/
private static String normalizeDir(String dir) {
if (dir == null) return "/";
return dir.endsWith("/") ? dir : dir + "/";
}
/**
* 判断本地路径是否为目录(简单判断以分隔符结尾)
*/
private static boolean isDirectory(String path) {
return path != null && (path.endsWith("/") || path.endsWith("\\"));
}
}