增加SSH连接。

This commit is contained in:
dengqichen 2025-12-05 16:16:06 +08:00
parent 0e88904267
commit b69c3f58c5
18 changed files with 1405 additions and 6 deletions

View File

@ -82,6 +82,12 @@
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.mysql</groupId>

View File

@ -122,4 +122,40 @@ public class ThreadPoolConfig {
executor.initialize();
return executor;
}
/**
* SSH输出监听线程池
* 用于异步读取SSH输出流并推送到WebSocket
*/
@Bean("sshOutputExecutor")
public ThreadPoolTaskExecutor sshOutputExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数预期同时活跃的SSH连接数
executor.setCorePoolSize(10);
// 最大线程数支持的最大SSH连接数
executor.setMaxPoolSize(50);
// 队列容量等待处理的SSH输出监听任务
executor.setQueueCapacity(100);
// 线程名前缀
executor.setThreadNamePrefix("ssh-output-");
// 线程空闲时间SSH会话关闭后线程60秒后回收
executor.setKeepAliveSeconds(60);
// 拒绝策略由调用线程处理确保SSH输出不会丢失
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 等待所有任务完成再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}

View File

@ -0,0 +1,147 @@
package com.qqchen.deploy.backend.deploy.config;
import com.qqchen.deploy.backend.framework.security.CustomUserDetails;
import com.qqchen.deploy.backend.framework.security.util.JwtTokenUtil;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* WebSocket认证拦截器
* 在WebSocket握手时验证JWT Token
*/
@Slf4j
@Component
public class WebSocketAuthInterceptor implements HandshakeInterceptor {
@Resource
private JwtTokenUtil jwtTokenUtil;
@Resource
private UserDetailsService userDetailsService;
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
log.debug("WebSocket握手认证开始: {}", request.getURI());
try {
// 1. 从请求中提取Token
String token = extractToken(request);
if (token == null || token.isEmpty()) {
log.warn("WebSocket连接缺少Token");
return false;
}
// 2. 解析Token获取用户名
String username = jwtTokenUtil.getUsernameFromToken(token);
if (username == null) {
log.warn("无效的Token");
return false;
}
// 3. 加载用户信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails == null) {
log.warn("用户不存在: {}", username);
return false;
}
// 4. 验证Token
if (!jwtTokenUtil.validateToken(token, userDetails)) {
log.warn("Token验证失败: {}", username);
return false;
}
// 5. 将用户信息存入attributes供Handler使用
if (userDetails instanceof CustomUserDetails) {
CustomUserDetails customUserDetails = (CustomUserDetails) userDetails;
attributes.put("userId", customUserDetails.getUserId());
attributes.put("username", customUserDetails.getUsername());
log.info("WebSocket认证成功: userId={}, username={}",
customUserDetails.getUserId(), customUserDetails.getUsername());
}
// 6. 提取客户端信息
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
String clientIp = getClientIp(servletRequest);
String userAgent = servletRequest.getServletRequest().getHeader("User-Agent");
attributes.put("clientIp", clientIp);
attributes.put("userAgent", userAgent);
}
return true;
} catch (Exception e) {
log.error("WebSocket认证异常", e);
return false;
}
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// 握手后处理可选
}
/**
* 从请求中提取Token
* 支持两种方式
* 1. URL参数: ?token=xxx
* 2. Header: Authorization: Bearer xxx
*/
private String extractToken(ServerHttpRequest request) {
// 1. 尝试从URL参数获取
String query = request.getURI().getQuery();
if (query != null && query.contains("token=")) {
String[] params = query.split("&");
for (String param : params) {
if (param.startsWith("token=")) {
return param.substring(6);
}
}
}
// 2. 尝试从Header获取
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
String authHeader = servletRequest.getServletRequest().getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
}
return null;
}
/**
* 获取客户端真实IP
*/
private String getClientIp(ServletServerHttpRequest request) {
String ip = request.getServletRequest().getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getServletRequest().getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getServletRequest().getRemoteAddr();
}
// 如果是多级代理取第一个IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@ -0,0 +1,34 @@
package com.qqchen.deploy.backend.deploy.config;
import com.qqchen.deploy.backend.deploy.handler.ServerSSHWebSocketHandler;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
/**
* WebSocket配置类
*/
@Slf4j
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Resource
private ServerSSHWebSocketHandler serverSSHWebSocketHandler;
@Resource
private WebSocketAuthInterceptor webSocketAuthInterceptor;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
log.info("注册WebSocket处理器: /api/v1/server-ssh/connect/*");
// 注册SSH终端WebSocket处理器添加认证拦截器
registry.addHandler(serverSSHWebSocketHandler, "/api/v1/server-ssh/connect/{serverId}")
.addInterceptors(webSocketAuthInterceptor) // 添加认证拦截器
.setAllowedOrigins("*"); // 生产环境建议配置具体的域名
}
}

View File

@ -0,0 +1,13 @@
package com.qqchen.deploy.backend.deploy.converter;
import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO;
import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog;
import com.qqchen.deploy.backend.framework.converter.BaseConverter;
import org.mapstruct.Mapper;
/**
* SSH审计日志转换器
*/
@Mapper(config = BaseConverter.class)
public interface SSHAuditLogConverter extends BaseConverter<SSHAuditLog, SSHAuditLogDTO> {
}

View File

@ -0,0 +1,62 @@
package com.qqchen.deploy.backend.deploy.dto;
import com.qqchen.deploy.backend.framework.dto.BaseDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* SSH审计日志DTO
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "SSH审计日志")
public class SSHAuditLogDTO extends BaseDTO {
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "服务器ID")
private Long serverId;
@Schema(description = "服务器名称")
private String serverName;
@Schema(description = "服务器IP")
private String serverIp;
@Schema(description = "会话ID")
private String sessionId;
@Schema(description = "连接时间")
private LocalDateTime connectTime;
@Schema(description = "断开时间")
private LocalDateTime disconnectTime;
@Schema(description = "会话时长(秒)")
private Integer durationSeconds;
@Schema(description = "客户端IP")
private String clientIp;
@Schema(description = "浏览器UA")
private String userAgent;
@Schema(description = "执行命令数量")
private Integer commandCount;
@Schema(description = "执行的命令记录JSON")
private String commands;
@Schema(description = "状态")
private String status;
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@ -0,0 +1,83 @@
package com.qqchen.deploy.backend.deploy.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.qqchen.deploy.backend.deploy.enums.SSHMessageTypeEnum;
import com.qqchen.deploy.backend.deploy.enums.SSHStatusEnum;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* SSH WebSocket消息DTO
*
* 消息格式规范
* 1. 状态消息{"type":"status","status":"connecting"}
* 2. SSH输出{"type":"output","data":"..."}
* 3. 错误消息{"type":"error","message":"..."}
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL) // 只序列化非null字段
public class SSHMessage {
/**
* 消息类型
*/
private SSHMessageTypeEnum type;
/**
* 消息数据input/output时使用
*/
private String data;
/**
* 错误消息error时使用
*/
private String message;
/**
* 连接状态仅type=STATUS时使用
*/
private SSHStatusEnum status;
/**
* 创建input类型消息
*/
public static SSHMessage input(String data) {
SSHMessage msg = new SSHMessage();
msg.setType(SSHMessageTypeEnum.INPUT);
msg.setData(data);
return msg;
}
/**
* 创建output类型消息
*/
public static SSHMessage output(String data) {
SSHMessage msg = new SSHMessage();
msg.setType(SSHMessageTypeEnum.OUTPUT);
msg.setData(data);
return msg;
}
/**
* 创建error类型消息
*/
public static SSHMessage error(String message) {
SSHMessage msg = new SSHMessage();
msg.setType(SSHMessageTypeEnum.ERROR);
msg.setMessage(message);
return msg;
}
/**
* 创建status类型消息
*/
public static SSHMessage status(SSHStatusEnum status) {
SSHMessage msg = new SSHMessage();
msg.setType(SSHMessageTypeEnum.STATUS);
msg.setStatus(status);
return msg;
}
}

View File

@ -0,0 +1,109 @@
package com.qqchen.deploy.backend.deploy.entity;
import com.qqchen.deploy.backend.framework.domain.Entity;
import jakarta.persistence.Column;
import jakarta.persistence.Table;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* SSH终端审计日志实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@jakarta.persistence.Entity
@Table(name = "deploy_ssh_audit_log")
public class SSHAuditLog extends Entity<Long> {
/**
* 用户ID
*/
@Column(name = "user_id", nullable = false)
private Long userId;
/**
* 用户名冗余
*/
@Column(name = "username", length = 50)
private String username;
/**
* 服务器ID
*/
@Column(name = "server_id", nullable = false)
private Long serverId;
/**
* 服务器名称冗余
*/
@Column(name = "server_name", length = 100)
private String serverName;
/**
* 服务器IP冗余
*/
@Column(name = "server_ip", length = 50)
private String serverIp;
/**
* WebSocket会话ID
*/
@Column(name = "session_id", nullable = false, length = 100)
private String sessionId;
/**
* 连接时间
*/
@Column(name = "connect_time", nullable = false)
private LocalDateTime connectTime;
/**
* 断开时间
*/
@Column(name = "disconnect_time")
private LocalDateTime disconnectTime;
/**
* 会话时长
*/
@Column(name = "duration_seconds")
private Integer durationSeconds;
/**
* 客户端IP
*/
@Column(name = "client_ip", length = 50)
private String clientIp;
/**
* 浏览器UA
*/
@Column(name = "user_agent", length = 500)
private String userAgent;
/**
* 执行命令数量
*/
@Column(name = "command_count", nullable = false)
private Integer commandCount = 0;
/**
* 执行的命令记录JSON数组
*/
@Column(name = "commands", columnDefinition = "LONGTEXT")
private String commands;
/**
* 状态SUCCESS成功, FAILED失败, TIMEOUT超时, INTERRUPTED中断
*/
@Column(name = "status", length = 20)
private String status;
/**
* 错误信息
*/
@Column(name = "error_message", length = 500)
private String errorMessage;
}

View File

@ -0,0 +1,22 @@
package com.qqchen.deploy.backend.deploy.enums;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* SSH WebSocket消息类型枚举
*/
@Getter
@AllArgsConstructor
public enum SSHMessageTypeEnum {
INPUT("input", "用户输入"),
OUTPUT("output", "SSH输出"),
ERROR("error", "错误消息"),
STATUS("status", "连接状态");
@JsonValue // JSON序列化时使用此字段的值
private final String value;
private final String description;
}

View File

@ -0,0 +1,21 @@
package com.qqchen.deploy.backend.deploy.enums;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* SSH连接状态枚举
*/
@Getter
@AllArgsConstructor
public enum SSHStatusEnum {
CONNECTING("connecting", "连接中"),
CONNECTED("connected", "已连接"),
DISCONNECTED("disconnected", "已断开");
@JsonValue // JSON序列化时使用此字段的值
private final String value;
private final String description;
}

View File

@ -0,0 +1,51 @@
package com.qqchen.deploy.backend.deploy.query;
import com.qqchen.deploy.backend.framework.annotation.QueryField;
import com.qqchen.deploy.backend.framework.enums.QueryType;
import com.qqchen.deploy.backend.framework.query.BaseQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* SSH审计日志查询条件
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "SSH审计日志查询条件")
public class SSHAuditLogQuery extends BaseQuery {
@Schema(description = "用户ID")
@QueryField(field = "userId")
private Long userId;
@Schema(description = "用户名")
@QueryField(field = "username", type = QueryType.LIKE)
private String username;
@Schema(description = "服务器ID")
@QueryField(field = "serverId")
private Long serverId;
@Schema(description = "服务器IP")
@QueryField(field = "serverIp", type = QueryType.LIKE)
private String serverIp;
@Schema(description = "会话ID")
@QueryField(field = "sessionId")
private String sessionId;
@Schema(description = "状态")
@QueryField(field = "status")
private String status;
@Schema(description = "连接开始时间")
@QueryField(field = "connectTime", type = QueryType.START_WITH)
private LocalDateTime connectTimeStart;
@Schema(description = "连接结束时间")
@QueryField(field = "connectTime", type = QueryType.END_WITH)
private LocalDateTime connectTimeEnd;
}

View File

@ -0,0 +1,22 @@
package com.qqchen.deploy.backend.deploy.repository;
import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog;
import com.qqchen.deploy.backend.framework.repository.IBaseRepository;
import org.springframework.stereotype.Repository;
/**
* SSH审计日志Repository
*/
@Repository
public interface ISSHAuditLogRepository extends IBaseRepository<SSHAuditLog, Long> {
/**
* 根据会话ID查询审计日志
*/
SSHAuditLog findBySessionId(String sessionId);
/**
* 统计用户当前活跃的SSH会话数
*/
long countByUserIdAndDisconnectTimeIsNull(Long userId);
}

View File

@ -0,0 +1,50 @@
package com.qqchen.deploy.backend.deploy.service;
import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO;
import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog;
import com.qqchen.deploy.backend.deploy.entity.Server;
import com.qqchen.deploy.backend.deploy.query.SSHAuditLogQuery;
import com.qqchen.deploy.backend.framework.service.IBaseService;
/**
* SSH审计日志服务接口
*/
public interface ISSHAuditLogService extends IBaseService<SSHAuditLog, SSHAuditLogDTO, SSHAuditLogQuery, Long> {
/**
* 创建审计日志连接时
*
* @param userId 用户ID
* @param server 服务器信息
* @param sessionId WebSocket会话ID
* @param clientIp 客户端IP
* @param userAgent 浏览器UA
* @return 审计日志ID
*/
Long createAuditLog(Long userId, Server server, String sessionId, String clientIp, String userAgent);
/**
* 记录命令每次用户输入
*
* @param sessionId WebSocket会话ID
* @param command 用户输入的命令
*/
void recordCommand(String sessionId, String command);
/**
* 关闭审计日志断开时
*
* @param sessionId WebSocket会话ID
* @param status 结束状态
* @param errorMessage 错误信息
*/
void closeAuditLog(String sessionId, String status, String errorMessage);
/**
* 检查用户当前活跃的SSH会话数
*
* @param userId 用户ID
* @return 活跃会话数
*/
long countUserActiveSessions(Long userId);
}

View File

@ -0,0 +1,181 @@
package com.qqchen.deploy.backend.deploy.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.qqchen.deploy.backend.deploy.dto.SSHAuditLogDTO;
import com.qqchen.deploy.backend.deploy.entity.SSHAuditLog;
import com.qqchen.deploy.backend.deploy.entity.Server;
import com.qqchen.deploy.backend.deploy.query.SSHAuditLogQuery;
import com.qqchen.deploy.backend.deploy.repository.ISSHAuditLogRepository;
import com.qqchen.deploy.backend.deploy.service.ISSHAuditLogService;
import com.qqchen.deploy.backend.framework.annotation.ServiceType;
import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl;
import com.qqchen.deploy.backend.framework.utils.JsonUtils;
import com.qqchen.deploy.backend.system.entity.User;
import com.qqchen.deploy.backend.system.service.IUserService;
import jakarta.annotation.Resource;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* SSH审计日志服务实现
*/
@Slf4j
@Service
@ServiceType(ServiceType.Type.DATABASE)
public class SSHAuditLogServiceImpl
extends BaseServiceImpl<SSHAuditLog, SSHAuditLogDTO, SSHAuditLogQuery, Long>
implements ISSHAuditLogService {
private final ISSHAuditLogRepository auditLogRepository;
@Resource
private IUserService userService;
public SSHAuditLogServiceImpl(ISSHAuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long createAuditLog(Long userId, Server server, String sessionId, String clientIp, String userAgent) {
log.info("创建SSH审计日志: userId={}, serverId={}, sessionId={}", userId, server.getId(), sessionId);
SSHAuditLog auditLog = new SSHAuditLog();
// 用户信息
auditLog.setUserId(userId);
User user = userService.findEntityById(userId);
if (user != null) {
auditLog.setUsername(user.getUsername());
}
// 服务器信息
auditLog.setServerId(server.getId());
auditLog.setServerName(server.getServerName());
auditLog.setServerIp(server.getHostIp());
// 会话信息
auditLog.setSessionId(sessionId);
auditLog.setConnectTime(LocalDateTime.now());
// 客户端信息
auditLog.setClientIp(clientIp);
auditLog.setUserAgent(userAgent);
// 初始化
auditLog.setCommandCount(0);
auditLog.setCommands("[]");
auditLog.setStatus("CONNECTED");
SSHAuditLog saved = auditLogRepository.save(auditLog);
log.info("SSH审计日志创建成功: id={}", saved.getId());
return saved.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void recordCommand(String sessionId, String command) {
try {
SSHAuditLog auditLog = auditLogRepository.findBySessionId(sessionId);
if (auditLog == null) {
log.warn("未找到审计日志: sessionId={}", sessionId);
return;
}
// 解析现有命令列表
List<CommandRecord> commands = parseCommands(auditLog.getCommands());
// 添加新命令
CommandRecord record = new CommandRecord();
record.setTimestamp(LocalDateTime.now());
record.setCommand(command);
commands.add(record);
// 更新
auditLog.setCommands(toJson(commands));
auditLog.setCommandCount(commands.size());
auditLogRepository.save(auditLog);
log.debug("记录命令: sessionId={}, commandCount={}", sessionId, commands.size());
} catch (Exception e) {
log.error("记录命令失败: sessionId={}", sessionId, e);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void closeAuditLog(String sessionId, String status, String errorMessage) {
try {
SSHAuditLog auditLog = auditLogRepository.findBySessionId(sessionId);
if (auditLog == null) {
log.warn("未找到审计日志: sessionId={}", sessionId);
return;
}
auditLog.setDisconnectTime(LocalDateTime.now());
auditLog.setStatus(status);
auditLog.setErrorMessage(errorMessage);
// 计算会话时长
if (auditLog.getConnectTime() != null) {
Duration duration = Duration.between(auditLog.getConnectTime(), auditLog.getDisconnectTime());
auditLog.setDurationSeconds((int) duration.getSeconds());
}
auditLogRepository.save(auditLog);
log.info("SSH审计日志关闭: sessionId={}, status={}, duration={}秒",
sessionId, status, auditLog.getDurationSeconds());
} catch (Exception e) {
log.error("关闭审计日志失败: sessionId={}", sessionId, e);
}
}
@Override
public long countUserActiveSessions(Long userId) {
return auditLogRepository.countByUserIdAndDisconnectTimeIsNull(userId);
}
/**
* 解析命令JSON
*/
private List<CommandRecord> parseCommands(String commandsJson) {
try {
if (commandsJson == null || commandsJson.trim().isEmpty() || "[]".equals(commandsJson.trim())) {
return new ArrayList<>();
}
List<CommandRecord> result = JsonUtils.fromJson(commandsJson, new TypeReference<List<CommandRecord>>() {});
return result != null ? result : new ArrayList<>();
} catch (Exception e) {
log.error("解析命令JSON失败", e);
return new ArrayList<>();
}
}
/**
* 转换为JSON
*/
private String toJson(List<CommandRecord> commands) {
String json = JsonUtils.toJson(commands);
return json != null ? json : "[]";
}
/**
* 命令记录内部类
*/
@Data
private static class CommandRecord {
private LocalDateTime timestamp;
private String command;
}
}

View File

@ -13,6 +13,7 @@ import com.qqchen.deploy.backend.framework.service.IBaseService;
import com.qqchen.deploy.backend.framework.service.impl.BaseServiceImpl;
import com.qqchen.deploy.backend.framework.utils.ReflectionUtils;
import com.querydsl.core.types.EntityPath;
import java.beans.Introspector;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanCreationException;
@ -206,8 +207,9 @@ public class DependencyInjectionPostProcessor implements BeanPostProcessor, Disp
@SuppressWarnings("unchecked")
private <T extends Entity<ID>, D extends BaseDTO, Q extends BaseQuery, ID extends Serializable>
void injectConverter(BaseServiceImpl<T, D, Q, ID> service, Class<T> entityClass) {
String converterBeanName = entityClass.getSimpleName().substring(0, 1).toLowerCase()
+ entityClass.getSimpleName().substring(1) + "ConverterImpl";
// 使用Java Bean规范的decapitalize方法正确处理连续大写字母的情况
// 例如SSHAuditLog -> sshAuditLog, XMLParser -> xmlParser
String converterBeanName = Introspector.decapitalize(entityClass.getSimpleName()) + "ConverterImpl";
log.debug("正在查找 Converter: {}", converterBeanName);
try {

View File

@ -207,6 +207,8 @@ public abstract class BaseNodeDelegate<I, O> implements JavaDelegate {
/**
* 解析Map中的表达式
* 自动处理纯表达式模板字符串纯文本三种情况
*
* 重要如果表达式解析失败变量不存在**不会设置该字段**让JSON反序列化使用类型默认值
*/
protected Map<String, Object> resolveExpressions(Map<String, Object> inputMap, DelegateExecution execution) {
Map<String, Object> resolvedMap = new HashMap<>();
@ -221,11 +223,20 @@ public abstract class BaseNodeDelegate<I, O> implements JavaDelegate {
try {
// 使用 SpelExpressionResolver 统一解析方法支持复杂表达式
String resolvedValue = SpelExpressionResolver.resolve(execution, strValue);
// 关键修复如果解析后仍然是未解析的占位符如${targetRepository.systemId}说明变量不存在
// 这种情况**不设置该字段**让JSON反序列化时使用类型默认值null或0等
// 让具体的节点去校验参数是否有效并抛出更友好的业务异常
if (isUnresolvedPlaceholder(resolvedValue)) {
log.warn("表达式未解析成功(变量不存在): {} = {},跳过该字段", entry.getKey(), strValue);
// 不设置该字段到resolvedMap中
} else {
log.debug("解析字段: {} = {} -> {}", entry.getKey(), strValue, resolvedValue);
resolvedMap.put(entry.getKey(), resolvedValue);
}
} catch (Exception e) {
log.warn("解析失败: {},使用原始值", strValue, e);
resolvedMap.put(entry.getKey(), value);
log.warn("解析失败: {}跳过该字段", strValue, e);
// 不设置该字段到resolvedMap中
}
} else {
resolvedMap.put(entry.getKey(), value);
@ -236,6 +247,18 @@ public abstract class BaseNodeDelegate<I, O> implements JavaDelegate {
return resolvedMap;
}
/**
* 判断是否为未解析的占位符
* 例如${targetRepository.systemId}${some.variable}
*/
private boolean isUnresolvedPlaceholder(String value) {
if (value == null || value.isEmpty()) {
return false;
}
// 检查是否以 ${ 开头且以 } 结尾说明SpEL解析失败保留了原样
return value.startsWith("${") && value.endsWith("}");
}
protected String getFieldValue(Expression expression, DelegateExecution execution) {
if (expression == null) return null;
Object value = expression.getValue(execution);

View File

@ -1187,3 +1187,48 @@ CREATE TABLE sys_notification_template
KEY idx_channel_type (channel_type),
KEY idx_enabled (enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知模板表';
-- SSH终端审计日志表
CREATE TABLE deploy_ssh_audit_log
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
create_by VARCHAR(100) NULL COMMENT '创建人',
create_time DATETIME(6) NULL COMMENT '创建时间',
update_by VARCHAR(100) NULL COMMENT '更新人',
update_time DATETIME(6) NULL COMMENT '更新时间',
version INT NOT NULL DEFAULT 1 COMMENT '版本号',
deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除',
-- 基本信息
user_id BIGINT NOT NULL COMMENT '用户ID',
username VARCHAR(50) NULL COMMENT '用户名(冗余)',
server_id BIGINT NOT NULL COMMENT '服务器ID',
server_name VARCHAR(100) NULL COMMENT '服务器名称(冗余)',
server_ip VARCHAR(50) NULL COMMENT '服务器IP冗余',
-- 会话信息
session_id VARCHAR(100) NOT NULL COMMENT 'WebSocket会话ID',
connect_time DATETIME(6) NOT NULL COMMENT '连接时间',
disconnect_time DATETIME(6) NULL COMMENT '断开时间',
duration_seconds INT NULL COMMENT '会话时长(秒)',
-- 客户端信息
client_ip VARCHAR(50) NULL COMMENT '客户端IP',
user_agent VARCHAR(500) NULL COMMENT '浏览器UA',
-- 操作记录
command_count INT NOT NULL DEFAULT 0 COMMENT '执行命令数量',
commands LONGTEXT NULL COMMENT '执行的命令记录JSON数组',
-- 状态
status VARCHAR(20) NULL COMMENT 'SUCCESS成功, FAILED失败, TIMEOUT超时, INTERRUPTED中断',
error_message VARCHAR(500) NULL COMMENT '错误信息',
KEY idx_user_id (user_id),
KEY idx_server_id (server_id),
KEY idx_connect_time (connect_time),
KEY idx_session_id (session_id),
KEY idx_status (status),
CONSTRAINT fk_ssh_audit_user FOREIGN KEY (user_id) REFERENCES sys_user (id),
CONSTRAINT fk_ssh_audit_server FOREIGN KEY (server_id) REFERENCES deploy_server (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SSH终端审计日志表';

View File

@ -0,0 +1,492 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import 'xterm/css/xterm.css';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Loader2, XCircle } from 'lucide-react';
import { message } from 'antd';
import type { ServerResponse } from '../types';
// 添加自定义样式
const customStyles = `
/* 自定义滚动条样式 */
.terminal-container .xterm-viewport::-webkit-scrollbar {
width: 8px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
background: #2d2d2d;
border-radius: 4px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* 可调整大小的Dialog */
.resizable-dialog {
resize: both;
overflow: hidden;
min-width: 600px;
min-height: 400px;
max-width: 98vw;
max-height: 95vh;
}
`;
interface SSHTerminalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
server: ServerResponse;
}
type ConnectionStatus = 'initializing' | 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'error';
interface WebSocketMessage {
type: 'output' | 'error' | 'status';
data?: string;
message?: string;
status?: ConnectionStatus;
}
export const SSHTerminalDialog: React.FC<SSHTerminalDialogProps> = ({
open,
onOpenChange,
server,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<Terminal | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('initializing');
const [errorMessage, setErrorMessage] = useState<string>('');
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
const isClosingRef = useRef<boolean>(false);
const dialogContentRef = useRef<HTMLDivElement>(null);
// 初始化终端和建立连接
const initializeTerminalAndConnect = useCallback(() => {
console.log('🚀 准备初始化SSH终端, terminalRef.current:', terminalRef.current);
if (!terminalRef.current) {
console.error('❌ terminalRef.current 为 null无法初始化');
return;
}
console.log('✅ 开始初始化SSH终端');
setConnectionStatus('initializing');
setErrorMessage('');
// 1. 初始化终端
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5',
},
rows: 30,
cols: 100,
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(terminalRef.current);
terminalInstanceRef.current = terminal;
fitAddonRef.current = fitAddon;
// 延迟fit确保容器已完全渲染
setTimeout(() => {
fitAddon.fit();
console.log('📐 终端尺寸已调整:', terminal.cols, 'x', terminal.rows);
}, 100);
// 2. 监听终端输入
terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
console.log('\u2328\ufe0f \u53d1\u9001\u8f93\u5165:', data.replace(/\r/g, '\\r').replace(/\n/g, '\\n'));
wsRef.current.send(JSON.stringify({
type: 'input',
data: data,
}));
} else {
console.warn('\u26a0\ufe0f WebSocket\u672a\u8fde\u63a5\uff0c\u65e0\u6cd5\u53d1\u9001\u8f93\u5165');
}
});
// 3. 延迟一点时间再连接让loading状态显示出来
setTimeout(() => {
connectWebSocket();
}, 300);
}, [server.id]);
useEffect(() => {
if (!open) return;
// 延迟执行,确保 DOM 已经挂载
const timer = setTimeout(() => {
initializeTerminalAndConnect();
}, 50);
// 窗口大小调整
const handleResize = () => {
fitAddonRef.current?.fit();
console.log('📐 窗口resize触发终端尺寸调整');
};
window.addEventListener('resize', handleResize);
// 监听Dialog内容区域大小变化用户拖动resize
let resizeObserver: ResizeObserver | null = null;
if (terminalRef.current) {
resizeObserver = new ResizeObserver(() => {
fitAddonRef.current?.fit();
console.log('📐 Dialog大小变化触发终端尺寸调整');
});
resizeObserver.observe(terminalRef.current);
}
return () => {
clearTimeout(timer);
window.removeEventListener('resize', handleResize);
if (resizeObserver && terminalRef.current) {
resizeObserver.unobserve(terminalRef.current);
resizeObserver.disconnect();
}
// 清理时直接关闭,不显示断开中状态
if (wsRef.current) {
wsRef.current.close(1000, 'Component unmounted');
wsRef.current = null;
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
isClosingRef.current = false;
};
}, [open, initializeTerminalAndConnect]);
const connectWebSocket = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
console.log('\ud83d\udd0c 开始建立WebSocket连接');
setConnectionStatus('connecting');
setErrorMessage('');
// 获取token
const token = localStorage.getItem('token');
if (!token) {
const errorMsg = '认证失败:未登录或登录已过期';
console.error('❌', errorMsg);
setConnectionStatus('error');
setErrorMessage(errorMsg);
message.error(errorMsg);
return;
}
// 获取WebSocket URL - 根据当前协议自动判断并携带token
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/v1/server-ssh/connect/${server.id}?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('\u2705 WebSocket\u8fde\u63a5\u5df2\u5efa\u7acb');
// \u6ce8\u610f\uff1a\u8fd9\u91cc\u4e0d\u76f4\u63a5\u8bbe\u7f6e\u4e3aconnected\uff0c\u7b49\u5f85\u540e\u7aef\u53d1\u9001status:connected
terminalInstanceRef.current?.writeln('\x1b[32m✓ WebSocket连接已建立\x1b[0m');
terminalInstanceRef.current?.writeln('\x1b[36m正在建立SSH会话...\x1b[0m\r\n');
};
ws.onmessage = (event) => {
console.log('📥 收到WebSocket消息:', event.data.substring(0, 200));
try {
const msg: WebSocketMessage = JSON.parse(event.data);
console.log('📦 解析后的消息:', msg.type, msg);
switch (msg.type) {
case 'output':
if (msg.data) {
console.log('📝 输出数据长度:', msg.data.length);
terminalInstanceRef.current?.write(msg.data);
}
break;
case 'error':
console.error('❌ SSH错误:', msg.message);
terminalInstanceRef.current?.writeln(`\r\n\x1b[31m错误: ${msg.message}\x1b[0m\r\n`);
message.error(msg.message || '连接错误');
break;
case 'status':
console.log('🔄 状态变更:', msg.status);
if (msg.status) {
setConnectionStatus(msg.status);
if (msg.status === 'connected') {
terminalInstanceRef.current?.writeln('\x1b[32m✓ SSH会话已建立\x1b[0m\r\n');
// 连接成功后重新fit确保尺寸正确
setTimeout(() => {
fitAddonRef.current?.fit();
console.log('📐 连接成功后重新调整终端尺寸');
}, 100);
} else if (msg.status === 'disconnected') {
terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已断开\x1b[0m');
}
}
break;
}
} catch (error) {
console.error('❌ 解析WebSocket消息失败:', error, '原始数据:', event.data);
}
};
ws.onerror = (error) => {
console.error('❌ WebSocket错误:', error);
const errorMsg = 'WebSocket连接失败请检查后端服务';
setConnectionStatus('error');
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m');
message.error(errorMsg);
};
ws.onclose = (event) => {
console.log('🔌 WebSocket连接已关闭, code:', event.code, 'reason:', event.reason);
wsRef.current = null;
if (isClosingRef.current) {
// 用户主动关闭,直接关闭弹窗
handleConnectionClosed();
} else {
// 非主动关闭,显示断开状态
setConnectionStatus('disconnected');
if (!event.wasClean) {
const errorMsg = event.reason || '连接异常断开';
setErrorMessage(errorMsg);
terminalInstanceRef.current?.writeln('\r\n\x1b[31m✗ ' + errorMsg + '\x1b[0m');
} else {
terminalInstanceRef.current?.writeln('\r\n\x1b[33m连接已正常关闭\x1b[0m');
}
}
};
};
const disconnectWebSocket = (showDisconnecting: boolean = false) => {
if (wsRef.current) {
console.log('🔌 主动关闭WebSocket连接');
if (showDisconnecting) {
setConnectionStatus('disconnecting');
terminalInstanceRef.current?.writeln('\r\n\x1b[33m正在断开连接...\x1b[0m');
}
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close(1000, 'User closed');
} else {
// 如果连接已经断开,直接清理
wsRef.current = null;
if (isClosingRef.current) {
handleConnectionClosed();
}
}
} else if (isClosingRef.current) {
// 没有WebSocket连接直接关闭
handleConnectionClosed();
}
};
const handleConnectionClosed = () => {
console.log('✅ 连接已完全关闭,关闭弹窗');
isClosingRef.current = false;
// 延迟一点关闭,让用户看到断开完成
setTimeout(() => {
// 关闭弹窗并重置所有状态
setConnectionStatus('initializing');
setErrorMessage('');
onOpenChange(false);
}, 100);
};
const handleDialogClose = (open: boolean) => {
if (!open && connectionStatus !== 'disconnected') {
// 用户点击X或ESC关闭需要先断开连接
console.log('🚪 用户关闭弹窗,开始断开连接');
isClosingRef.current = true;
disconnectWebSocket(true);
} else if (!open) {
// 已经断开,直接关闭
onOpenChange(false);
} else {
// 打开弹窗
onOpenChange(true);
}
};
const handleReconnect = () => {
console.log('🔄 用户重新连接');
isClosingRef.current = false;
disconnectWebSocket();
if (terminalInstanceRef.current) {
terminalInstanceRef.current.clear();
terminalInstanceRef.current.dispose();
terminalInstanceRef.current = null;
}
// 重新初始化
initializeTerminalAndConnect();
};
const getStatusBadge = () => {
switch (connectionStatus) {
case 'initializing':
return (
<Badge variant="outline" className="bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'connecting':
return (
<Badge variant="outline" className="bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'connected':
return (
<Badge variant="outline" className="bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300">
<div className="mr-1 h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
</Badge>
);
case 'error':
return (
<Badge variant="outline" className="bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-300">
<XCircle className="mr-1 h-3 w-3" />
</Badge>
);
case 'disconnecting':
return (
<Badge variant="outline" className="bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-300">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
</Badge>
);
case 'disconnected':
return (
<Badge variant="outline" className="bg-gray-100 text-gray-700 dark:bg-gray-500/10 dark:text-gray-300">
<XCircle className="mr-1 h-3 w-3" />
</Badge>
);
}
};
return (
<>
{/* 注入自定义样式 */}
<style>{customStyles}</style>
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="resizable-dialog max-w-[95vw] h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-start justify-between pr-8">
<div className="flex-1">
<DialogTitle>SSH终端</DialogTitle>
<DialogDescription className="mt-1">
{server.serverName} ({server.hostIp})
</DialogDescription>
</div>
<div className="flex items-center gap-2 mt-0.5">
{getStatusBadge()}
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<Button
size="sm"
variant="outline"
onClick={handleReconnect}
>
</Button>
)}
</div>
</div>
</DialogHeader>
<div className="flex-1 bg-[#1e1e1e] overflow-hidden relative">
{/* 终端容器 - 始终渲染确保fit能正确计算尺寸 */}
<div
ref={terminalRef}
className="w-full h-full p-2 terminal-container"
/>
{/* Loading 状态 - 覆盖在终端上方 */}
{connectionStatus === 'initializing' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">SSH终端...</p>
</div>
)}
{/* 断开中状态 - 覆盖在终端上方 */}
{connectionStatus === 'disconnecting' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-orange-400 bg-[#1e1e1e]">
<Loader2 className="h-12 w-12 animate-spin mb-4" />
<p className="text-lg">...</p>
</div>
)}
{/* 错误状态 - 覆盖在终端上方 */}
{connectionStatus === 'error' && errorMessage && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-red-400 bg-[#1e1e1e]">
<XCircle className="h-12 w-12 mb-4" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-400">{errorMessage}</p>
<Button
className="mt-6"
variant="outline"
onClick={handleReconnect}
>
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
</>
);
};