From dbef9c7a067ef32d193c05c3c9f0eaca1360afb1 Mon Sep 17 00:00:00 2001 From: gaoxq <376340421@qq.com> Date: Sat, 18 Apr 2026 10:49:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=BB=E6=9C=BA=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/jeesite/modules/utils/SftpUtil.java | 677 ++++++++++++++++++ 1 file changed, 677 insertions(+) create mode 100644 web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java diff --git a/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java b/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java new file mode 100644 index 0000000..d21ae9b --- /dev/null +++ b/web-api/src/main/java/com/jeesite/modules/utils/SftpUtil.java @@ -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); + } + } + + // ------------------------------------------------------------------ 下载 + + /** + * 下载远程文件到本地 + *

+ * 示例: + * - 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 entries = channel.ls(remoteDir); + boolean toDir = isDirectory(localPath); + String outDir = normalizeDir(localPath); + List 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); + } + } + + // ------------------------------------------------------------------ 删除 + + /** + * 删除远程文件 + * 支持通配符文件名匹配 + *

+ * 示例: + * - 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 entries = channel.ls(remoteDir); + List 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 SftpResult,data 为 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 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 parseCpu(String block) { + Map 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 parseMemory(String block) { + Map 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> parseDisk(String block) { + List> 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 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 entries = channel.ls(remoteDir); + String dir = normalizeDir(remoteDir); + + // 收集非目录文件名 + List 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 md5Map = batchMd5(account, dir, fileNames); + + List> files = new ArrayList<>(); + for (ChannelSftp.LsEntry entry : entries) { + if (".".equals(entry.getFilename()) || "..".equals(entry.getFilename())) continue; + Map 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 batchMd5(MySftpAccounts account, String dir, List fileNames) { + Map 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> + 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("\\")); + } +}