增加SSH连接。
This commit is contained in:
parent
0e88904267
commit
b69c3f58c5
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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("*"); // 生产环境建议配置具体的域名
|
||||
}
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
log.debug("解析字段: {} = {} -> {}", entry.getKey(), strValue, resolvedValue);
|
||||
resolvedMap.put(entry.getKey(), resolvedValue);
|
||||
|
||||
// ⚠️ 关键修复:如果解析后仍然是未解析的占位符(如${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);
|
||||
|
||||
@ -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终端审计日志表';
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user