1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267 |
- commit d053fa5449f328407b88d6d6092a6bfcd69acfd6
- Author: bwcx_jzy <bwcx_jzy@163.com>
- Date: Wed Jun 11 16:38:28 2025 +0800
- fix(server): 修复终端输入命令时按 Backspace 会退出终端的问题
-
- - 在 KeyEventCycle 类中添加了对 Backspace 按键的处理逻辑
- - 优化了 SshHandler 类中的数据接收流程,增加了异常捕获
- - 新增了两个测试类 SSHCommandRecorder 和 TerminalParserRecorder,用于记录和解析 SSH 终端命令
- diff --git a/CHANGELOG-BETA.md b/CHANGELOG-BETA.md
- index 98e437387..5c2378056 100644
- --- a/CHANGELOG-BETA.md
- +++ b/CHANGELOG-BETA.md
- @@ -9,6 +9,7 @@
- ### 🐞 解决BUG、优化功能
-
- 1. 【server】优化 数据库表支持配置前缀 `jpom.db.table-prefix` (感谢@ccx2480)
- +2. 【server】修复 终端输入命令,按Backspace 会退出终端(感谢[@dgs](https://gitee.com/dgs0924) [Gitee issues ICA57K](https://gitee.com/dromara/Jpom/issues/ICA57K) )
-
- ------
-
- diff --git a/modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java b/modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java
- index ac6da4493..f7189094e 100644
- --- a/modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java
- +++ b/modules/server/src/main/java/org/dromara/jpom/socket/handler/KeyEventCycle.java
- @@ -165,7 +165,8 @@ public class KeyEventCycle {
- }
- }
- str = new String(Arrays.copyOfRange(bytes, 0, bytes.length - backCount), charset);
- - buffer.insert(inputSelection-1, str);
- + // #https://gitee.com/dromara/Jpom/issues/ICA57K
- + buffer.insert(inputSelection - 1, str);
- inputSelection += str.length();
- }
- }
- @@ -175,7 +176,7 @@ public class KeyEventCycle {
- /**
- * 查找指定字节数组在原始字节数组中的位置
- *
- - * @param originalArray 原始字节数组
- + * @param originalArray 原始字节数组
- * @param byteArrayToFind 要查找的字节数组
- * @return 找到的位置索引,如果找不到返回 -1
- */
- diff --git a/modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java b/modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java
- index 965d4cbb0..1ac6bc69e 100644
- --- a/modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java
- +++ b/modules/server/src/main/java/org/dromara/jpom/socket/handler/SshHandler.java
- @@ -23,7 +23,6 @@ import com.alibaba.fastjson2.JSONValidator;
- import com.jcraft.jsch.ChannelShell;
- import com.jcraft.jsch.JSchException;
- import com.jcraft.jsch.Session;
- -import lombok.Setter;
- import lombok.extern.slf4j.Slf4j;
- import org.dromara.jpom.common.i18n.I18nMessageUtil;
- import org.dromara.jpom.common.i18n.I18nThreadUtil;
- @@ -42,7 +41,6 @@ import org.springframework.http.HttpHeaders;
- import org.springframework.web.socket.TextMessage;
- import org.springframework.web.socket.WebSocketSession;
-
- -import java.io.ByteArrayOutputStream;
- import java.io.IOException;
- import java.io.InputStream;
- import java.io.OutputStream;
- @@ -52,7 +50,6 @@ import java.util.Collections;
- import java.util.List;
- import java.util.Map;
- import java.util.concurrent.ConcurrentHashMap;
- -import java.util.function.Consumer;
-
- /**
- * ssh 处理2
- @@ -304,7 +301,11 @@ public class SshHandler extends BaseTerminalHandler {
- //如果没有数据来,线程会一直阻塞在这个地方等待数据。
- while ((i = inputStream.read(buffer)) != NioUtil.EOF) {
- byte[] tempBytes = Arrays.copyOfRange(buffer, 0, i);
- - keyEventCycle.receive(tempBytes);
- + try {
- + keyEventCycle.receive(tempBytes);
- + } catch (Exception e) {
- + log.warn("keyEventCycle.receive", e);
- + }
- sendBinary(session, new String(tempBytes, machineSshModel.charset()));
- }
- } catch (Exception e) {
- diff --git a/modules/sub-plugin/ssh-jsch/src/test/java/SSHCommandRecorder.java b/modules/sub-plugin/ssh-jsch/src/test/java/SSHCommandRecorder.java
- new file mode 100644
- index 000000000..8a863ba39
- --- /dev/null
- +++ b/modules/sub-plugin/ssh-jsch/src/test/java/SSHCommandRecorder.java
- @@ -0,0 +1,725 @@
- +/**
- + * @author bwcx_jzy
- + * @since 2025/6/11
- + */
- +import com.jcraft.jsch.*;
- +import java.io.*;
- +import java.nio.charset.StandardCharsets;
- +import java.util.*;
- +import java.util.concurrent.*;
- +import java.util.regex.Pattern;
- +
- +/**
- + * SSH终端命令记录器 - 重构版
- + * 通过双向流拦截和智能解析准确记录用户执行的所有命令
- + */
- +public class SSHCommandRecorder {
- +
- + // ANSI转义序列模式
- + private static final Pattern ANSI_ESCAPE = Pattern.compile("\\x1B\\[[0-?]*[ -/]*[@-~]");
- +
- + // 命令提示符模式(支持多种shell)
- + private static final Pattern PROMPT_PATTERN = Pattern.compile(
- + ".*?[\\$#>%]\\s*$|.*?\\w+[@:].*?[\\$#>%]\\s*$|.*?\\]\\s*[\\$#>%]\\s*$"
- + );
- +
- + // 会话相关
- + private Session session;
- + private ChannelShell channel;
- + private InputStream channelInput;
- + private OutputStream channelOutput;
- +
- + // 数据处理
- + private final BlockingQueue<String> inputQueue = new LinkedBlockingQueue<>();
- + private final BlockingQueue<String> outputQueue = new LinkedBlockingQueue<>();
- + private final List<CommandRecord> commandHistory = Collections.synchronizedList(new ArrayList<>());
- +
- + // 状态管理
- + private volatile boolean recording = false;
- + private final TerminalState terminalState = new TerminalState();
- +
- + // 线程池
- + private final ExecutorService executorService = Executors.newFixedThreadPool(4);
- +
- + /**
- + * 命令记录实体
- + */
- + public static class CommandRecord {
- + private final String command;
- + private final String user;
- + private final String host;
- + private final long timestamp;
- + private final String sessionId;
- + private String output;
- + private CommandType type;
- + private long duration;
- +
- + public enum CommandType {
- + TYPED, // 直接输入
- + HISTORY_UP, // 上键选择
- + HISTORY_DOWN, // 下键选择
- + SEARCH, // 搜索选择
- + TAB_COMPLETE // Tab补全
- + }
- +
- + public CommandRecord(String command, String user, String host, String sessionId) {
- + this.command = command.trim();
- + this.user = user;
- + this.host = host;
- + this.sessionId = sessionId;
- + this.timestamp = System.currentTimeMillis();
- + this.type = CommandType.TYPED;
- + }
- +
- + // Getters and Setters
- + public String getCommand() { return command; }
- + public String getUser() { return user; }
- + public String getHost() { return host; }
- + public long getTimestamp() { return timestamp; }
- + public String getSessionId() { return sessionId; }
- + public String getOutput() { return output; }
- + public CommandType getType() { return type; }
- + public long getDuration() { return duration; }
- +
- + public void setOutput(String output) { this.output = output; }
- + public void setType(CommandType type) { this.type = type; }
- + public void setDuration(long duration) { this.duration = duration; }
- +
- + @Override
- + public String toString() {
- + return String.format("[%s] %s@%s [%s] $ %s",
- + new Date(timestamp), user, host, type, command);
- + }
- + }
- +
- + /**
- + * 终端状态跟踪
- + */
- + private static class TerminalState {
- + private final StringBuilder currentLine = new StringBuilder();
- + private final StringBuilder commandBuffer = new StringBuilder();
- + private String lastPrompt = "";
- + private String currentCommand = "";
- + private boolean inCommand = false;
- + private boolean waitingForOutput = false;
- + private long commandStartTime = 0;
- + private int cursorPosition = 0;
- +
- + // 特殊键检测
- + private boolean upKeyPressed = false;
- + private boolean downKeyPressed = false;
- + private boolean ctrlRPressed = false;
- + private boolean tabPressed = false;
- +
- + public synchronized void reset() {
- + currentLine.setLength(0);
- + commandBuffer.setLength(0);
- + currentCommand = "";
- + inCommand = false;
- + waitingForOutput = false;
- + commandStartTime = 0;
- + resetKeyStates();
- + }
- +
- + public synchronized void resetKeyStates() {
- + upKeyPressed = false;
- + downKeyPressed = false;
- + ctrlRPressed = false;
- + tabPressed = false;
- + }
- + }
- +
- + /**
- + * 输入流拦截器
- + */
- + private class InputStreamInterceptor extends InputStream {
- + private final InputStream originalInput;
- + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
- +
- + public InputStreamInterceptor(InputStream originalInput) {
- + this.originalInput = originalInput;
- + }
- +
- + @Override
- + public int read() throws IOException {
- + int data = originalInput.read();
- + if (data != -1) {
- + buffer.write(data);
- + processInputByte((byte) data);
- + }
- + return data;
- + }
- +
- + @Override
- + public int read(byte[] b, int off, int len) throws IOException {
- + int bytesRead = originalInput.read(b, off, len);
- + if (bytesRead > 0) {
- + buffer.write(b, off, bytesRead);
- + processInputBytes(b, off, bytesRead);
- + }
- + return bytesRead;
- + }
- +
- + private void processInputByte(byte b) {
- + processInputBytes(new byte[]{b}, 0, 1);
- + }
- +
- + private void processInputBytes(byte[] bytes, int offset, int length) {
- + try {
- + String input = new String(bytes, offset, length, StandardCharsets.UTF_8);
- + if (!input.isEmpty()) {
- + inputQueue.offer(input);
- + }
- + } catch (Exception e) {
- + System.err.println("Error processing input: " + e.getMessage());
- + }
- + }
- + }
- +
- + /**
- + * 输出流拦截器
- + */
- + private class OutputStreamInterceptor extends OutputStream {
- + private final OutputStream originalOutput;
- + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
- +
- + public OutputStreamInterceptor(OutputStream originalOutput) {
- + this.originalOutput = originalOutput;
- + }
- +
- + @Override
- + public void write(int b) throws IOException {
- + originalOutput.write(b);
- + buffer.write(b);
- + processOutputByte((byte) b);
- + }
- +
- + @Override
- + public void write(byte[] b, int off, int len) throws IOException {
- + originalOutput.write(b, off, len);
- + buffer.write(b, off, len);
- + processOutputBytes(b, off, len);
- + }
- +
- + @Override
- + public void flush() throws IOException {
- + originalOutput.flush();
- + }
- +
- + private void processOutputByte(byte b) {
- + processOutputBytes(new byte[]{b}, 0, 1);
- + }
- +
- + private void processOutputBytes(byte[] bytes, int offset, int length) {
- + try {
- + String output = new String(bytes, offset, length, StandardCharsets.UTF_8);
- + if (!output.isEmpty()) {
- + outputQueue.offer(output);
- + }
- + } catch (Exception e) {
- + System.err.println("Error processing output: " + e.getMessage());
- + }
- + }
- + }
- +
- + /**
- + * 建立SSH连接并开始记录
- + */
- + public void connect(String host, int port, String username, String password) throws Exception {
- + JSch jsch = new JSch();
- + session = jsch.getSession(username, host, port);
- + session.setPassword(password);
- + session.setConfig("StrictHostKeyChecking", "no");
- + session.connect();
- +
- + channel = (ChannelShell) session.openChannel("shell");
- +
- + // 设置终端参数
- + channel.setPtyType("xterm-256color");
- + channel.setPtySize(120, 40, 960, 640);
- +
- + // 创建管道流
- + PipedInputStream toChannelInput = new PipedInputStream();
- + PipedOutputStream fromUserInput = new PipedOutputStream(toChannelInput);
- +
- + PipedInputStream fromChannelOutput = new PipedInputStream();
- + PipedOutputStream toUserOutput = new PipedOutputStream(fromChannelOutput);
- +
- + // 设置拦截器
- + channelInput = new InputStreamInterceptor(System.in);
- + channelOutput = new OutputStreamInterceptor(System.out);
- +
- + channel.setInputStream(toChannelInput);
- + channel.setOutputStream(toUserOutput);
- +
- + channel.connect();
- +
- + // 获取真实的输入输出流
- + InputStream realChannelOutput = channel.getInputStream();
- + OutputStream realChannelInput = channel.getOutputStream();
- +
- + recording = true;
- +
- + // 启动处理线程
- + startProcessingThreads(realChannelOutput, realChannelInput, fromUserInput, fromChannelOutput);
- +
- + System.out.println("SSH连接已建立,开始记录命令...");
- + }
- +
- + /**
- + * 启动处理线程
- + */
- + private void startProcessingThreads(InputStream channelOut, OutputStream channelIn,
- + OutputStream userIn, InputStream userOut) {
- +
- + // 处理用户输入 -> SSH服务器
- + executorService.submit(() -> {
- + try {
- + Scanner scanner = new Scanner(System.in);
- + while (recording && scanner.hasNextLine()) {
- + String line = scanner.nextLine();
- + processUserInput(line);
- + channelIn.write((line + "\r\n").getBytes(StandardCharsets.UTF_8));
- + channelIn.flush();
- + }
- + } catch (Exception e) {
- + if (recording) {
- + e.printStackTrace();
- + }
- + }
- + });
- +
- + // 处理SSH服务器输出 -> 用户
- + executorService.submit(() -> {
- + try {
- + byte[] buffer = new byte[1024];
- + int bytesRead;
- + while (recording && (bytesRead = channelOut.read(buffer)) != -1) {
- + String output = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8);
- + processServerOutput(output);
- + System.out.print(output);
- + }
- + } catch (Exception e) {
- + if (recording) {
- + e.printStackTrace();
- + }
- + }
- + });
- +
- + // 输入分析线程
- + executorService.submit(this::processInputQueue);
- +
- + // 输出分析线程
- + executorService.submit(this::processOutputQueue);
- + }
- +
- + /**
- + * 处理用户输入
- + */
- + private void processUserInput(String input) {
- + synchronized (terminalState) {
- + // 检测特殊键序列
- + if (input.contains("\u001B[A")) { // 上键
- + terminalState.upKeyPressed = true;
- + } else if (input.contains("\u001B[B")) { // 下键
- + terminalState.downKeyPressed = true;
- + } else if (input.contains("\u0012")) { // Ctrl+R
- + terminalState.ctrlRPressed = true;
- + } else if (input.contains("\t")) { // Tab
- + terminalState.tabPressed = true;
- + }
- +
- + // 处理正常命令输入
- + if (!input.trim().isEmpty() && !containsOnlyControlChars(input)) {
- + terminalState.currentCommand = input.trim();
- + terminalState.inCommand = true;
- + terminalState.commandStartTime = System.currentTimeMillis();
- + terminalState.waitingForOutput = true;
- + }
- + }
- + }
- +
- + /**
- + * 处理服务器输出
- + */
- + private void processServerOutput(String output) {
- + synchronized (terminalState) {
- + String cleanOutput = cleanAnsiEscapes(output);
- + terminalState.currentLine.append(cleanOutput);
- +
- + // 检测命令提示符
- + if (isPromptLine(cleanOutput)) {
- + if (terminalState.waitingForOutput && !terminalState.currentCommand.isEmpty()) {
- + // 记录命令
- + recordCommand(terminalState.currentCommand, determineCommandType());
- + terminalState.reset();
- + }
- +
- + // 提取新的提示符
- + String[] lines = cleanOutput.split("\\r?\\n");
- + for (String line : lines) {
- + if (isPromptLine(line.trim())) {
- + terminalState.lastPrompt = line.trim();
- + break;
- + }
- + }
- + }
- + }
- + }
- +
- + /**
- + * 处理输入队列
- + */
- + private void processInputQueue() {
- + while (recording) {
- + try {
- + String input = inputQueue.poll(100, TimeUnit.MILLISECONDS);
- + if (input != null) {
- + analyzeInput(input);
- + }
- + } catch (InterruptedException e) {
- + Thread.currentThread().interrupt();
- + break;
- + }
- + }
- + }
- +
- + /**
- + * 处理输出队列
- + */
- + private void processOutputQueue() {
- + while (recording) {
- + try {
- + String output = outputQueue.poll(100, TimeUnit.MILLISECONDS);
- + if (output != null) {
- + analyzeOutput(output);
- + }
- + } catch (InterruptedException e) {
- + Thread.currentThread().interrupt();
- + break;
- + }
- + }
- + }
- +
- + /**
- + * 分析输入数据
- + */
- + private void analyzeInput(String input) {
- + // 检测控制序列
- + if (input.contains("\u001B[A")) {
- + terminalState.upKeyPressed = true;
- + } else if (input.contains("\u001B[B")) {
- + terminalState.downKeyPressed = true;
- + } else if (input.contains("\u0012")) {
- + terminalState.ctrlRPressed = true;
- + }
- +
- + // 检测回车键(命令执行)
- + if (input.contains("\r") || input.contains("\n")) {
- + synchronized (terminalState) {
- + if (terminalState.inCommand) {
- + terminalState.waitingForOutput = true;
- + }
- + }
- + }
- + }
- +
- + /**
- + * 分析输出数据
- + */
- + private void analyzeOutput(String output) {
- + String cleanOutput = cleanAnsiEscapes(output);
- +
- + // 检测命令回显和提示符
- + String[] lines = cleanOutput.split("\\r?\\n");
- + for (String line : lines) {
- + String trimmedLine = line.trim();
- +
- + if (isPromptLine(trimmedLine)) {
- + synchronized (terminalState) {
- + if (terminalState.waitingForOutput && !terminalState.currentCommand.isEmpty()) {
- + recordCommand(terminalState.currentCommand, determineCommandType());
- + terminalState.reset();
- + }
- + terminalState.lastPrompt = trimmedLine;
- + }
- + } else if (!trimmedLine.isEmpty() && !terminalState.inCommand) {
- + // 可能是命令回显
- + synchronized (terminalState) {
- + if (isPotentialCommand(trimmedLine)) {
- + terminalState.currentCommand = trimmedLine;
- + terminalState.inCommand = true;
- + terminalState.commandStartTime = System.currentTimeMillis();
- + }
- + }
- + }
- + }
- + }
- +
- + /**
- + * 判断命令类型
- + */
- + private CommandRecord.CommandType determineCommandType() {
- + if (terminalState.upKeyPressed || terminalState.downKeyPressed) {
- + return terminalState.upKeyPressed ?
- + CommandRecord.CommandType.HISTORY_UP : CommandRecord.CommandType.HISTORY_DOWN;
- + } else if (terminalState.ctrlRPressed) {
- + return CommandRecord.CommandType.SEARCH;
- + } else if (terminalState.tabPressed) {
- + return CommandRecord.CommandType.TAB_COMPLETE;
- + } else {
- + return CommandRecord.CommandType.TYPED;
- + }
- + }
- +
- + /**
- + * 记录命令
- + */
- + private void recordCommand(String command, CommandRecord.CommandType type) {
- + if (command == null || command.trim().isEmpty() || !isValidCommand(command)) {
- + return;
- + }
- +
- + try {
- + String username = session.getUserName();
- + String hostname = session.getHost();
- + String sessionId = Integer.toHexString(session.hashCode());
- +
- + CommandRecord record = new CommandRecord(command, username, hostname, sessionId);
- + record.setType(type);
- +
- + if (terminalState.commandStartTime > 0) {
- + record.setDuration(System.currentTimeMillis() - terminalState.commandStartTime);
- + }
- +
- + commandHistory.add(record);
- +
- + // 记录到控制台和日志
- + System.out.println("\n[AUDIT] " + record);
- +
- + // 异步写入文件或数据库
- + executorService.submit(() -> writeToAuditLog(record));
- +
- + } catch (Exception e) {
- + System.err.println("Error recording command: " + e.getMessage());
- + }
- + }
- +
- + /**
- + * 写入审计日志
- + */
- + private void writeToAuditLog(CommandRecord record) {
- + try {
- + String logFile = "ssh_audit_" + new java.text.SimpleDateFormat("yyyy-MM-dd").format(new Date()) + ".log";
- + try (PrintWriter writer = new PrintWriter(new FileWriter(logFile, true))) {
- + writer.printf("%d|%s|%s|%s|%s|%s|%d%n",
- + record.getTimestamp(),
- + record.getUser(),
- + record.getHost(),
- + record.getSessionId(),
- + record.getType(),
- + record.getCommand().replace("|", "\\|"),
- + record.getDuration());
- + }
- + } catch (IOException e) {
- + System.err.println("Error writing to audit log: " + e.getMessage());
- + }
- + }
- +
- + /**
- + * 清理ANSI转义序列
- + */
- + private String cleanAnsiEscapes(String input) {
- + if (input == null) return "";
- + return ANSI_ESCAPE.matcher(input).replaceAll("");
- + }
- +
- + /**
- + * 检测是否为命令提示符行
- + */
- + private boolean isPromptLine(String line) {
- + return PROMPT_PATTERN.matcher(line).matches() ||
- + line.endsWith("$ ") || line.endsWith("# ") || line.endsWith("> ") ||
- + line.matches(".*?\\w+[@:].*?[\\$#>].*");
- + }
- +
- + /**
- + * 检测是否为潜在命令
- + */
- + private boolean isPotentialCommand(String line) {
- + return line.length() > 0 &&
- + line.length() < 500 &&
- + !line.matches("^\\s*$") &&
- + !line.startsWith("Welcome") &&
- + !line.startsWith("Last login") &&
- + !line.matches(".*?\\d{4}-\\d{2}-\\d{2}.*?");
- + }
- +
- + /**
- + * 验证是否为有效命令
- + */
- + private boolean isValidCommand(String command) {
- + if (command == null || command.trim().isEmpty()) {
- + return false;
- + }
- +
- + String trimmed = command.trim();
- + return trimmed.length() > 0 &&
- + trimmed.length() < 1000 &&
- + !trimmed.matches("^\\s*$") &&
- + !trimmed.matches("^\\d+$") &&
- + !containsOnlyControlChars(trimmed);
- + }
- +
- + /**
- + * 检查是否只包含控制字符
- + */
- + private boolean containsOnlyControlChars(String str) {
- + return str.chars().allMatch(ch -> ch < 32 || ch == 127);
- + }
- +
- + /**
- + * 发送命令(用于程序化调用)
- + */
- + public void sendCommand(String command) throws Exception {
- + if (channel != null && channel.isConnected()) {
- + OutputStream out = channel.getOutputStream();
- + out.write((command + "\r\n").getBytes(StandardCharsets.UTF_8));
- + out.flush();
- + }
- + }
- +
- + /**
- + * 获取命令历史记录
- + */
- + public List<CommandRecord> getCommandHistory() {
- + synchronized (commandHistory) {
- + return new ArrayList<>(commandHistory);
- + }
- + }
- +
- + /**
- + * 根据条件查询命令
- + */
- + public List<CommandRecord> queryCommands(String userFilter, long startTime, long endTime,
- + CommandRecord.CommandType typeFilter) {
- + synchronized (commandHistory) {
- + return commandHistory.stream()
- + .filter(cmd -> userFilter == null || userFilter.equals(cmd.getUser()))
- + .filter(cmd -> cmd.getTimestamp() >= startTime && cmd.getTimestamp() <= endTime)
- + .filter(cmd -> typeFilter == null || typeFilter.equals(cmd.getType()))
- + .collect(ArrayList::new, (list, item) -> list.add(item), (list1, list2) -> list1.addAll(list2));
- + }
- + }
- +
- + /**
- + * 导出审计报告
- + */
- + public void exportAuditReport(String filename) throws IOException {
- + try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
- + writer.println("SSH Command Audit Report");
- + writer.println("Generated: " + new Date());
- + // writer.println("=".repeat(80));
- + writer.println();
- +
- + synchronized (commandHistory) {
- + Map<String, Long> userStats = new HashMap<>();
- + Map<CommandRecord.CommandType, Long> typeStats = new HashMap<>();
- +
- + for (CommandRecord record : commandHistory) {
- + userStats.merge(record.getUser(), 1L, Long::sum);
- + typeStats.merge(record.getType(), 1L, Long::sum);
- + }
- +
- + writer.println("Statistics:");
- + writer.println("Total Commands: " + commandHistory.size());
- + writer.println("Users: " + userStats);
- + writer.println("Command Types: " + typeStats);
- + writer.println();
- +
- + writer.println("Command Details:");
- + // writer.println("-".repeat(80));
- + for (CommandRecord record : commandHistory) {
- + writer.printf("[%s] %s@%s [%s] [%dms] $ %s%n",
- + new Date(record.getTimestamp()),
- + record.getUser(),
- + record.getHost(),
- + record.getType(),
- + record.getDuration(),
- + record.getCommand());
- + }
- + }
- + }
- + }
- +
- + /**
- + * 断开连接
- + */
- + public void disconnect() {
- + recording = false;
- +
- + executorService.shutdown();
- + try {
- + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
- + executorService.shutdownNow();
- + }
- + } catch (InterruptedException e) {
- + executorService.shutdownNow();
- + Thread.currentThread().interrupt();
- + }
- +
- + if (channel != null && channel.isConnected()) {
- + channel.disconnect();
- + }
- +
- + if (session != null && session.isConnected()) {
- + session.disconnect();
- + }
- +
- + System.out.println("SSH连接已断开,命令记录已停止。");
- + }
- +
- + /**
- + * 使用示例和测试
- + */
- + public static void main(String[] args) {
- + SSHCommandRecorder recorder = new SSHCommandRecorder();
- +
- + try {
- + // 连接到SSH服务器
- + System.out.println("正在连接SSH服务器...");
- + recorder.connect("192.168.30.29", 22, "user", "123456+.");
- +
- + // 保持连接,让用户交互
- + System.out.println("请在终端中执行命令,所有命令将被记录...");
- + System.out.println("输入 'exit' 或按 Ctrl+C 结束记录");
- +
- + // 等待用户操作
- + Scanner scanner = new Scanner(System.in);
- + while (scanner.hasNextLine()) {
- + String input = scanner.nextLine();
- + if ("exit".equals(input)) {
- + break;
- + }
- + }
- +
- + } catch (Exception e) {
- + e.printStackTrace();
- + } finally {
- + // 导出审计报告
- + try {
- + List<CommandRecord> history = recorder.getCommandHistory();
- + System.out.println("\n=== 命令执行历史 ===");
- + history.forEach(System.out::println);
- +
- + recorder.exportAuditReport("ssh_audit_report.txt");
- + System.out.println("审计报告已导出到: ssh_audit_report.txt");
- +
- + } catch (Exception e) {
- + e.printStackTrace();
- + }
- +
- + recorder.disconnect();
- + }
- + }
- +}
- diff --git a/modules/sub-plugin/ssh-jsch/src/test/java/TerminalParserRecorder.java b/modules/sub-plugin/ssh-jsch/src/test/java/TerminalParserRecorder.java
- new file mode 100644
- index 000000000..02d14044d
- --- /dev/null
- +++ b/modules/sub-plugin/ssh-jsch/src/test/java/TerminalParserRecorder.java
- @@ -0,0 +1,444 @@
- +/**
- + * @author bwcx_jzy
- + * @since 2025/6/11
- + */
- +import com.jcraft.jsch.*;
- +import java.io.*;
- +import java.util.*;
- +import java.util.regex.Pattern;
- +import java.util.concurrent.BlockingQueue;
- +import java.util.concurrent.LinkedBlockingQueue;
- +
- +/**
- + * 基于终端解析的轻量级命令记录器
- + * 通过解析终端输出流来识别和记录执行的命令
- + */
- +public class TerminalParserRecorder {
- +
- + private static final Pattern PROMPT_PATTERNS = Pattern.compile(
- + ".*?[\\$#>]\\s*$|.*?\\w+@\\w+[:\\s]+.*?[\\$#>]\\s*$"
- + );
- +
- + private static final Pattern COMMAND_COMPLETION_PATTERN = Pattern.compile(
- + ".*?\\[\\d+\\]\\s*.*?|.*?\\+\\s*.*?|.*?>\\s*.*?"
- + );
- +
- + private Session session;
- + private ChannelShell channel;
- + private BlockingQueue<String> outputQueue;
- + private List<ExecutedCommand> commandLog;
- + private TerminalState currentState;
- + private Thread parsingThread;
- + private volatile boolean isRecording = false;
- +
- + public TerminalParserRecorder() {
- + this.outputQueue = new LinkedBlockingQueue<>();
- + this.commandLog = Collections.synchronizedList(new ArrayList<>());
- + this.currentState = new TerminalState();
- + }
- +
- + /**
- + * 终端状态跟踪
- + */
- + private static class TerminalState {
- + private StringBuilder currentLine = new StringBuilder();
- + private String lastPrompt = "";
- + private String pendingCommand = "";
- + private boolean waitingForPrompt = false;
- + private int cursorPosition = 0;
- +
- + public void reset() {
- + currentLine.setLength(0);
- + pendingCommand = "";
- + waitingForPrompt = false;
- + cursorPosition = 0;
- + }
- + }
- +
- + /**
- + * 执行命令记录
- + */
- + public static class ExecutedCommand {
- + private final String command;
- + private final String user;
- + private final String host;
- + private final long timestamp;
- + private final String sessionId;
- + private String output;
- + private int exitCode = -1;
- +
- + public ExecutedCommand(String command, String user, String host, String sessionId) {
- + this.command = command;
- + this.user = user;
- + this.host = host;
- + this.sessionId = sessionId;
- + this.timestamp = System.currentTimeMillis();
- + }
- +
- + // Getters
- + public String getCommand() { return command; }
- + public String getUser() { return user; }
- + public String getHost() { return host; }
- + public long getTimestamp() { return timestamp; }
- + public String getSessionId() { return sessionId; }
- + public String getOutput() { return output; }
- + public int getExitCode() { return exitCode; }
- +
- + public void setOutput(String output) { this.output = output; }
- + public void setExitCode(int exitCode) { this.exitCode = exitCode; }
- +
- + @Override
- + public String toString() {
- + return String.format("[%s] %s@%s $ %s",
- + new Date(timestamp).toString(), user, host, command);
- + }
- + }
- +
- + /**
- + * 连接SSH并开始记录
- + */
- + public void connect(String host, int port, String username, String password) throws Exception {
- + JSch jsch = new JSch();
- + session = jsch.getSession(username, host, port);
- + session.setPassword(password);
- + session.setConfig("StrictHostKeyChecking", "no");
- + session.connect();
- +
- + channel = (ChannelShell) session.openChannel("shell");
- +
- + // 设置终端类型以获得更好的解析效果
- + channel.setPtyType("vt100");
- + channel.setPtySize(80, 24, 640, 480);
- +
- + // 创建输出拦截流
- + ByteArrayOutputStream baos = new ByteArrayOutputStream();
- + TeeOutputStream teeOut = new TeeOutputStream(System.out, baos);
- + channel.setOutputStream(teeOut);
- +
- + channel.connect();
- +
- + // 启动解析线程
- + startParsing(channel.getInputStream());
- + isRecording = true;
- + }
- +
- + /**
- + * 开始解析终端输出
- + */
- + private void startParsing(InputStream inputStream) {
- + parsingThread = new Thread(() -> {
- + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
- + StringBuilder outputBuffer = new StringBuilder();
- + int ch;
- +
- + while (isRecording && (ch = reader.read()) != -1) {
- + char character = (char) ch;
- +
- + // 处理特殊字符
- + if (character == '\r') {
- + continue; // 忽略回车符
- + } else if (character == '\n') {
- + // 处理换行
- + String line = outputBuffer.toString();
- + processLine(line);
- + outputBuffer.setLength(0);
- + } else if (character == '\b') {
- + // 处理退格
- + if (outputBuffer.length() > 0) {
- + outputBuffer.deleteCharAt(outputBuffer.length() - 1);
- + }
- + } else if (character == '\u001B') {
- + // 处理ANSI转义序列
- + String escapeSequence = readEscapeSequence(reader);
- + processEscapeSequence(escapeSequence);
- + } else {
- + outputBuffer.append(character);
- + }
- + }
- + } catch (IOException e) {
- + if (isRecording) {
- + e.printStackTrace();
- + }
- + }
- + });
- +
- + parsingThread.setDaemon(true);
- + parsingThread.start();
- + }
- +
- + /**
- + * 处理每一行输出
- + */
- + private void processLine(String line) {
- + String cleanLine = cleanAnsiEscapes(line);
- +
- + // 检测命令提示符
- + if (isPromptLine(cleanLine)) {
- + if (currentState.waitingForPrompt && !currentState.pendingCommand.isEmpty()) {
- + // 记录已执行的命令
- + recordCommand(currentState.pendingCommand.trim());
- + currentState.reset();
- + }
- + currentState.lastPrompt = cleanLine;
- + } else if (!cleanLine.trim().isEmpty()) {
- + // 可能是命令行
- + String potentialCommand = extractPotentialCommand(cleanLine);
- + if (potentialCommand != null) {
- + currentState.pendingCommand = potentialCommand;
- + currentState.waitingForPrompt = true;
- + }
- + }
- + }
- +
- + /**
- + * 读取ANSI转义序列
- + */
- + private String readEscapeSequence(BufferedReader reader) throws IOException {
- + StringBuilder seq = new StringBuilder("\u001B");
- + int ch;
- +
- + // 读取完整的转义序列
- + while ((ch = reader.read()) != -1) {
- + char c = (char) ch;
- + seq.append(c);
- +
- + // 大多数转义序列以字母结束
- + if (Character.isLetter(c)) {
- + break;
- + }
- +
- + // 防止无限循环
- + if (seq.length() > 20) {
- + break;
- + }
- + }
- +
- + return seq.toString();
- + }
- +
- + /**
- + * 处理ANSI转义序列
- + */
- + private void processEscapeSequence(String sequence) {
- + // 这里可以处理光标移动、清屏等操作
- + // 对于命令记录,主要关注的是内容而不是格式
- + if (sequence.contains("K")) {
- + // 清除行的部分内容
- + currentState.currentLine.setLength(0);
- + }
- + }
- +
- + /**
- + * 清理ANSI转义字符
- + */
- + private String cleanAnsiEscapes(String input) {
- + return input.replaceAll("\\x1B\\[[0-9;]*[a-zA-Z]", "");
- + }
- +
- + /**
- + * 检测是否为命令提示符行
- + */
- + private boolean isPromptLine(String line) {
- + return PROMPT_PATTERNS.matcher(line).matches() ||
- + line.endsWith("$ ") ||
- + line.endsWith("# ") ||
- + line.endsWith("> ") ||
- + line.matches(".*?\\w+@\\w+.*?[\\$#>].*");
- + }
- +
- + /**
- + * 从行中提取潜在的命令
- + */
- + private String extractPotentialCommand(String line) {
- + // 移除命令提示符部分
- + String[] parts = line.split("[\\$#>]", 2);
- + if (parts.length > 1) {
- + String command = parts[1].trim();
- + if (!command.isEmpty() && isValidCommand(command)) {
- + return command;
- + }
- + }
- +
- + // 如果没有找到提示符,检查是否是直接的命令
- + String trimmed = line.trim();
- + if (isValidCommand(trimmed)) {
- + return trimmed;
- + }
- +
- + return null;
- + }
- +
- + /**
- + * 验证是否为有效命令
- + */
- + private boolean isValidCommand(String command) {
- + if (command == null || command.isEmpty()) {
- + return false;
- + }
- +
- + // 过滤掉明显不是命令的内容
- + return !command.matches(".*?\\d+\\s*$") && // 不是纯数字
- + !command.matches("^[\\s\\-=]+$") && // 不是分隔线
- + !command.startsWith("Welcome") && // 不是欢迎信息
- + !command.startsWith("Last login") && // 不是登录信息
- + command.length() < 1000; // 不是过长的输出
- + }
- +
- + /**
- + * 记录命令
- + */
- + private void recordCommand(String command) {
- + try {
- + String username = session.getUserName();
- + String hostname = session.getHost();
- + String sessionId = Integer.toHexString(session.hashCode());
- +
- + ExecutedCommand record = new ExecutedCommand(command, username, hostname, sessionId);
- + commandLog.add(record);
- +
- + // 记录日志
- + System.out.println("Recorded command: " + record);
- +
- + // 可以在这里添加其他处理逻辑,如写入数据库、发送审计日志等
- +
- + } catch (Exception e) {
- + System.err.println("Error recording command: " + e.getMessage());
- + }
- + }
- +
- + /**
- + * 发送命令
- + */
- + public void sendCommand(String command) throws Exception {
- + if (channel != null && channel.isConnected()) {
- + OutputStream out = channel.getOutputStream();
- + out.write((command + "\n").getBytes());
- + out.flush();
- + }
- + }
- +
- + /**
- + * 获取命令日志
- + */
- + public List<ExecutedCommand> getCommandLog() {
- + return new ArrayList<>(commandLog);
- + }
- +
- + /**
- + * 根据条件查询命令
- + */
- + public List<ExecutedCommand> queryCommands(String userFilter, long startTime, long endTime) {
- + synchronized (commandLog) {
- + return commandLog.stream()
- + .filter(cmd -> userFilter == null || userFilter.equals(cmd.getUser()))
- + .filter(cmd -> cmd.getTimestamp() >= startTime && cmd.getTimestamp() <= endTime)
- + .collect(ArrayList::new, (list, item) -> list.add(item), (list1, list2) -> list1.addAll(list2));
- + }
- + }
- +
- + /**
- + * 导出命令日志
- + */
- + public void exportToFile(String filename) throws IOException {
- + try (PrintWriter writer = new PrintWriter(new FileWriter(filename))) {
- + writer.println("Timestamp,User,Host,SessionId,Command");
- + synchronized (commandLog) {
- + for (ExecutedCommand cmd : commandLog) {
- + writer.printf("%d,%s,%s,%s,\"%s\"%n",
- + cmd.getTimestamp(),
- + cmd.getUser(),
- + cmd.getHost(),
- + cmd.getSessionId(),
- + cmd.getCommand().replace("\"", "\"\""));
- + }
- + }
- + }
- + }
- +
- + /**
- + * 断开连接
- + */
- + public void disconnect() {
- + isRecording = false;
- +
- + if (parsingThread != null) {
- + parsingThread.interrupt();
- + }
- +
- + if (channel != null && channel.isConnected()) {
- + channel.disconnect();
- + }
- +
- + if (session != null && session.isConnected()) {
- + session.disconnect();
- + }
- + }
- +
- + /**
- + * Tee输出流,同时写入两个流
- + */
- + private static class TeeOutputStream extends OutputStream {
- + private OutputStream out1;
- + private OutputStream out2;
- +
- + public TeeOutputStream(OutputStream out1, OutputStream out2) {
- + this.out1 = out1;
- + this.out2 = out2;
- + }
- +
- + @Override
- + public void write(int b) throws IOException {
- + out1.write(b);
- + out2.write(b);
- + }
- +
- + @Override
- + public void flush() throws IOException {
- + out1.flush();
- + out2.flush();
- + }
- +
- + @Override
- + public void close() throws IOException {
- + out1.close();
- + out2.close();
- + }
- + }
- +
- + /**
- + * 使用示例
- + */
- + public static void main(String[] args) {
- + TerminalParserRecorder recorder = new TerminalParserRecorder();
- +
- + try {
- + recorder.connect("192.168.30.29", 22, "user", "123456+.");
- +
- + // 等待连接稳定
- + Thread.sleep(2000);
- +
- + // 发送测试命令
- + recorder.sendCommand("ls -la");
- + Thread.sleep(3000);
- +
- + recorder.sendCommand("pwd");
- + Thread.sleep(2000);
- +
- + recorder.sendCommand("history | tail -5");
- + Thread.sleep(3000);
- +
- + // 查看记录的命令
- + List<ExecutedCommand> commands = recorder.getCommandLog();
- + System.out.println("\n=== Command Log ===");
- + commands.forEach(System.out::println);
- +
- + // 导出到文件
- + recorder.exportToFile("command_audit.csv");
- +
- + } catch (Exception e) {
- + e.printStackTrace();
- + } finally {
- + recorder.disconnect();
- + }
- + }
- +}
|