增加ssh链接框架

This commit is contained in:
dengqichen 2025-12-06 18:00:58 +08:00
parent 80c40bb6ab
commit 24f62c5719
10 changed files with 430 additions and 22 deletions

View File

@ -3,30 +3,36 @@ package com.qqchen.deploy.backend.framework.enums;
import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.annotation.JsonValue;
/** /**
* SSH WebSocket消息类型枚举Framework层 * SSH WebSocket 消息类型枚举Framework层
* *
* 用于标识不同类型的SSH WebSocket消息 * 定义标准的SSH WebSocket消息类型
* 使用 @JsonValue 注解实现JSON序列化为小写字符串
*/ */
public enum SSHMessageType { public enum SSHMessageType {
/** /**
* 用户输入前端 后端 * 用户输入消息前端 后端
*/ */
INPUT("input"), INPUT("input"),
/** /**
* 终端输出后端 前端 * SSH输出消息后端 前端
*/ */
OUTPUT("output"), OUTPUT("output"),
/** /**
* 连接状态后端 前端 * 连接状态消息后端 前端
*/ */
STATUS("status"), STATUS("status"),
/** /**
* 错误后端 前端 * 错误后端 前端
*/ */
ERROR("error"); ERROR("error"),
/**
* 终端尺寸调整消息前端 后端
*/
RESIZE("resize");
private final String value; private final String value;

View File

@ -310,28 +310,84 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
SSHWebSocketMessage msg = JsonUtils.fromJson(message.getPayload(), SSHWebSocketMessage.class); SSHWebSocketMessage msg = JsonUtils.fromJson(message.getPayload(), SSHWebSocketMessage.class);
if (msg.getType() == SSHMessageType.INPUT) { if (msg.getType() == SSHMessageType.INPUT) {
// 用户输入命令 // 用户输入命令使用强类型Request
com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHInputRequest request =
msg.getRequest(com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHInputRequest.class);
if (request == null || !request.isValid()) {
log.warn("INPUT消息格式错误或为空: sessionId={}", sessionId);
return;
}
Session.Shell shell = sshShells.get(sessionId); Session.Shell shell = sshShells.get(sessionId);
if (shell != null) { if (shell != null) {
OutputStream outputStream = shell.getOutputStream(); OutputStream outputStream = shell.getOutputStream();
outputStream.write(msg.getData().getBytes(StandardCharsets.UTF_8)); outputStream.write(request.getCommand().getBytes(StandardCharsets.UTF_8));
outputStream.flush(); outputStream.flush();
// 触发命令事件 // 触发命令事件
SSHTarget target = sessionTargets.get(sessionId); SSHTarget target = sessionTargets.get(sessionId);
SSHEventData eventData = SSHEventData.builder() SSHEventData eventData = SSHEventData.builder()
.sessionId(sessionId) .sessionId(sessionId)
.command(msg.getData()) .command(request.getCommand())
.target(target) .target(target)
.build(); .build();
onEvent(SSHEvent.ON_COMMAND, eventData); onEvent(SSHEvent.ON_COMMAND, eventData);
} }
} else if (msg.getType() == SSHMessageType.RESIZE) {
// 终端尺寸调整
handleResizeMessage(sessionId, msg);
} }
} catch (Exception e) { } catch (Exception e) {
log.error("处理WebSocket消息失败: sessionId={}", sessionId, e); log.error("处理WebSocket消息失败: sessionId={}", sessionId, e);
} }
} }
/**
* 处理终端尺寸调整消息使用强类型Request
*
* @param sessionId 会话ID
* @param msg 消息对象
*/
private void handleResizeMessage(String sessionId, SSHWebSocketMessage msg) {
try {
// 使用强类型提取request
com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHResizeRequest request =
msg.getRequest(com.qqchen.deploy.backend.framework.ssh.websocket.request.SSHResizeRequest.class);
if (request == null) {
log.warn("RESIZE消息缺少request或格式错误: sessionId={}", sessionId);
return;
}
// 验证参数
if (!request.isValid()) {
log.warn("RESIZE消息参数无效: sessionId={}, rows={}, cols={}",
sessionId, request.getRows(), request.getCols());
return;
}
// 调整SSH PTY尺寸
Session.Shell shell = sshShells.get(sessionId);
if (shell != null) {
// 计算像素尺寸标准字体8x16像素
int widthPixels = request.getCols() * 8;
int heightPixels = request.getRows() * 16;
shell.changeWindowDimensions(request.getCols(), request.getRows(),
widthPixels, heightPixels);
log.debug("SSH终端尺寸已调整: sessionId={}, cols={}, rows={}",
sessionId, request.getCols(), request.getRows());
} else {
log.warn("未找到SSH Shell无法调整尺寸: sessionId={}", sessionId);
}
} catch (Exception e) {
log.error("处理RESIZE消息失败: sessionId={}", sessionId, e);
}
}
@Override @Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String sessionId = getSessionId(session); String sessionId = getSessionId(session);
@ -624,7 +680,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
} }
/** /**
* 发送输出消息到前端 * 发送输出消息到前端使用强类型Response
* *
* @param session WebSocket会话 * @param session WebSocket会话
* @param output 输出内容 * @param output 输出内容
@ -640,8 +696,16 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
return; return;
} }
log.debug(" ├─ 创建SSHWebSocketMessage: sessionId={}", sessionId); // 创建强类型Response对象
SSHWebSocketMessage msg = SSHWebSocketMessage.output(output); com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHOutputResponse response =
new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHOutputResponse(output);
// 包装成 {"response": SSHOutputResponse}
Map<String, Object> data = new HashMap<>();
data.put("response", response);
// 创建消息
SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.OUTPUT, data);
log.debug(" ├─ 准备调用session.sendMessage: sessionId={}", sessionId); log.debug(" ├─ 准备调用session.sendMessage: sessionId={}", sessionId);
session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); session.sendMessage(new TextMessage(JsonUtils.toJson(msg)));
@ -655,11 +719,25 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
} }
/** /**
* 发送状态消息到前端 * 发送状态消息到前端使用强类型Response
*/ */
protected void sendStatus(WebSocketSession session, SSHStatusEnum status) { protected void sendStatus(WebSocketSession session, SSHStatusEnum status) {
try { try {
SSHWebSocketMessage msg = SSHWebSocketMessage.status(status); if (!session.isOpen()) {
log.debug("WebSocket已关闭跳过发送状态: sessionId={}", session.getId());
return;
}
// 创建强类型Response对象
com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHStatusResponse response =
new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHStatusResponse(status);
// 包装成 {"response": SSHStatusResponse}
Map<String, Object> data = new HashMap<>();
data.put("response", response);
// 创建消息
SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.STATUS, data);
session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); session.sendMessage(new TextMessage(JsonUtils.toJson(msg)));
} catch (IOException e) { } catch (IOException e) {
log.error("发送状态消息失败: sessionId={}", session.getId(), e); log.error("发送状态消息失败: sessionId={}", session.getId(), e);
@ -667,7 +745,7 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
} }
/** /**
* 发送错误消息到前端 * 发送错误消息到前端使用强类型Response
*/ */
protected void sendError(WebSocketSession session, String error) { protected void sendError(WebSocketSession session, String error) {
try { try {
@ -675,7 +753,17 @@ public abstract class AbstractSSHWebSocketHandler extends TextWebSocketHandler {
log.debug("WebSocket已关闭跳过发送错误消息: sessionId={}", session.getId()); log.debug("WebSocket已关闭跳过发送错误消息: sessionId={}", session.getId());
return; return;
} }
SSHWebSocketMessage msg = SSHWebSocketMessage.error(error);
// 创建强类型Response对象
com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHErrorResponse response =
new com.qqchen.deploy.backend.framework.ssh.websocket.response.SSHErrorResponse(error);
// 包装成 {"response": SSHErrorResponse}
Map<String, Object> data = new HashMap<>();
data.put("response", response);
// 创建消息
SSHWebSocketMessage msg = new SSHWebSocketMessage(SSHMessageType.ERROR, data);
session.sendMessage(new TextMessage(JsonUtils.toJson(msg))); session.sendMessage(new TextMessage(JsonUtils.toJson(msg)));
} catch (IOException e) { } catch (IOException e) {
if (session.isOpen()) { if (session.isOpen()) {

View File

@ -1,6 +1,7 @@
package com.qqchen.deploy.backend.framework.ssh.websocket; package com.qqchen.deploy.backend.framework.ssh.websocket;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType; import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import com.qqchen.deploy.backend.framework.enums.SSHStatusEnum; import com.qqchen.deploy.backend.framework.enums.SSHStatusEnum;
import lombok.Data; import lombok.Data;
@ -30,13 +31,23 @@ public class SSHWebSocketMessage {
private SSHMessageType type; private SSHMessageType type;
/** /**
* 消息内容字符串 * 消息内容可以是字符串或对象
*
* 字符串类型后端前端
* - type=OUTPUT: 终端输出内容 * - type=OUTPUT: 终端输出内容
* - type=INPUT: 用户输入内容
* - type=STATUS: 状态值connecting/connected/reconnecting/disconnected/error * - type=STATUS: 状态值connecting/connected/reconnecting/disconnected/error
* - type=ERROR: 错误描述信息 * - type=ERROR: 错误描述信息
*
* 字符串类型前端后端
* - type=INPUT: 用户输入内容
*
* 对象类型前端后端
* - type=RESIZE: {"request": {"rows": 40, "cols": 150}}
*
* 对象类型后端前端预留
* - 未来可能的response: {"response": {...}}
*/ */
private String data; private Object data;
/** /**
* 消息时间戳Unix毫秒 * 消息时间戳Unix毫秒
@ -46,14 +57,15 @@ public class SSHWebSocketMessage {
/** /**
* 可选元数据 * 可选元数据
* 通常为null特殊场景下使用 *
* 用于特殊场景的扩展字段通常为null
*/ */
private Map<String, Object> metadata; private Map<String, Object> metadata;
/** /**
* 构造函数自动填充时间戳 * 构造函数自动填充时间戳
*/ */
public SSHWebSocketMessage(SSHMessageType type, String data) { public SSHWebSocketMessage(SSHMessageType type, Object data) {
this.type = type; this.type = type;
this.data = data; this.data = data;
this.timestamp = System.currentTimeMillis(); this.timestamp = System.currentTimeMillis();
@ -94,4 +106,40 @@ public class SSHWebSocketMessage {
public static SSHWebSocketMessage input(String data) { public static SSHWebSocketMessage input(String data) {
return new SSHWebSocketMessage(SSHMessageType.INPUT, data); return new SSHWebSocketMessage(SSHMessageType.INPUT, data);
} }
/**
* 从消息中提取request对象强类型转换
*
* @param requestClass 请求类型
* @return 请求对象如果解析失败返回null
*/
@SuppressWarnings("unchecked")
public <T> T getRequest(Class<T> requestClass) {
try {
if (data instanceof Map) {
Map<String, Object> dataMap = (Map<String, Object>) data;
Object requestObj = dataMap.get("request");
if (requestObj != null) {
// 如果已经是目标类型直接返回
if (requestClass.isInstance(requestObj)) {
return (T) requestObj;
}
// 否则使用ObjectMapper转换Map -> POJO
ObjectMapper mapper = new ObjectMapper();
return mapper.convertValue(requestObj, requestClass);
}
}
} catch (Exception e) {
// 转换失败返回null
}
return null;
}
/**
* 检查data是否包含request
*/
public boolean hasRequest() {
return data instanceof Map && ((Map<?, ?>) data).containsKey("request");
}
} }

View File

@ -0,0 +1,29 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.request;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
/**
* SSH WebSocket请求基类Framework层
*
* 所有前端发送到后端的请求消息都应该继承此类
* 使用Jackson的@JsonTypeInfo和@JsonSubTypes实现多态反序列化
*/
@Data
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SSHInputRequest.class, name = "input"),
@JsonSubTypes.Type(value = SSHResizeRequest.class, name = "resize")
})
public abstract class SSHBaseRequest {
/**
* 请求类型使用枚举保证类型安全
*/
private SSHMessageType type;
}

View File

@ -0,0 +1,45 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.request;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* SSH用户输入请求Framework层
*
* 前端发送格式
* {
* "type": "input",
* "data": {
* "request": {
* "type": "input",
* "command": "ls -la"
* }
* }
* }
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SSHInputRequest extends SSHBaseRequest {
/**
* 用户输入命令终端命令或字符
*/
private String command;
public SSHInputRequest() {
setType(SSHMessageType.INPUT);
}
public SSHInputRequest(String command) {
this();
this.command = command;
}
/**
* 验证参数有效性
*/
public boolean isValid() {
return command != null && !command.isEmpty();
}
}

View File

@ -0,0 +1,45 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.request;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* SSH终端尺寸调整请求Framework层
*
* 前端发送格式
* {
* "type": "resize",
* "data": {
* "request": {
* "rows": 40,
* "cols": 150
* }
* }
* }
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SSHResizeRequest extends SSHBaseRequest {
/**
* 终端行数
*/
private Integer rows;
/**
* 终端列数
*/
private Integer cols;
public SSHResizeRequest() {
setType(SSHMessageType.RESIZE);
}
/**
* 验证参数有效性
*/
public boolean isValid() {
return rows != null && cols != null && rows > 0 && cols > 0;
}
}

View File

@ -0,0 +1,38 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.response;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
/**
* SSH WebSocket响应基类Framework层
*
* 所有后端发送到前端的响应消息都应该继承此类
* 使用Jackson的@JsonTypeInfo和@JsonSubTypes实现多态序列化
*/
@Data
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SSHOutputResponse.class, name = "output"),
@JsonSubTypes.Type(value = SSHStatusResponse.class, name = "status"),
@JsonSubTypes.Type(value = SSHErrorResponse.class, name = "error")
})
public abstract class SSHBaseResponse {
/**
* 响应类型使用枚举保证类型安全
*/
private SSHMessageType type;
/**
* 响应数据统一字段
* - OUTPUT: 终端输出内容
* - STATUS: 状态值connected/disconnected等
* - ERROR: 错误消息
*/
private String data;
}

View File

@ -0,0 +1,34 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.response;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* SSH错误响应Framework层
*
* 后端发送格式
* {
* "type": "error",
* "data": {
* "response": {
* "type": "error",
* "data": "Connection failed"
* }
* }
* }
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SSHErrorResponse extends SSHBaseResponse {
public SSHErrorResponse() {
setType(SSHMessageType.ERROR);
}
public SSHErrorResponse(String message) {
this();
setType(SSHMessageType.ERROR);
setData(message);
}
}

View File

@ -0,0 +1,34 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.response;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* SSH终端输出响应Framework层
*
* 后端发送格式
* {
* "type": "output",
* "data": {
* "response": {
* "type": "output",
* "data": "terminal output text"
* }
* }
* }
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SSHOutputResponse extends SSHBaseResponse {
public SSHOutputResponse() {
setType(SSHMessageType.OUTPUT);
}
public SSHOutputResponse(String data) {
this();
setType(SSHMessageType.OUTPUT);
setData(data);
}
}

View File

@ -0,0 +1,41 @@
package com.qqchen.deploy.backend.framework.ssh.websocket.response;
import com.qqchen.deploy.backend.framework.enums.SSHMessageType;
import com.qqchen.deploy.backend.framework.enums.SSHStatusEnum;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* SSH连接状态响应Framework层
*
* 后端发送格式
* {
* "type": "status",
* "data": {
* "response": {
* "type": "status",
* "data": "connected"
* }
* }
* }
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SSHStatusResponse extends SSHBaseResponse {
public SSHStatusResponse() {
setType(SSHMessageType.STATUS);
}
public SSHStatusResponse(SSHStatusEnum status) {
this();
setType(SSHMessageType.STATUS);
setData(status.name().toLowerCase());
}
public SSHStatusResponse(String status) {
this();
setType(SSHMessageType.STATUS);
setData(status);
}
}