远程连接代码逻辑修改,改用websocket(联调任存在问题),新增远程机器对文件树的操作,代码工整格式化
This commit is contained in:
parent
38ba61ed3a
commit
2f3a2ae28f
@ -28,6 +28,12 @@
|
|||||||
<version>3.15.1</version>
|
<version>3.15.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- 机器连接-->
|
<!-- 机器连接-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.jcraft</groupId>
|
<groupId>com.jcraft</groupId>
|
||||||
@ -63,7 +69,14 @@
|
|||||||
<artifactId>module-infra-biz</artifactId>
|
<artifactId>module-infra-biz</artifactId>
|
||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
|
<artifactId>httpclient</artifactId>
|
||||||
|
<version>4.5.13</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
</project>
|
</project>
|
||||||
|
@ -0,0 +1,85 @@
|
|||||||
|
package cd.casic.module.machine.Interceptor;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.server.ServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServerHttpResponse;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.WebSocketHandler;
|
||||||
|
import org.springframework.web.socket.server.HandshakeInterceptor;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
|
||||||
|
@Override
|
||||||
|
public boolean beforeHandshake(
|
||||||
|
@NotNull ServerHttpRequest request,
|
||||||
|
@NotNull ServerHttpResponse response,
|
||||||
|
@NotNull WebSocketHandler wsHandler,
|
||||||
|
@NotNull Map<String, Object> attributes
|
||||||
|
) {
|
||||||
|
log.info("WebSocket握手请求: {}", request.getURI());
|
||||||
|
|
||||||
|
// 从URL参数中获取id
|
||||||
|
String id = extractIdFromUrl(request.getURI());
|
||||||
|
|
||||||
|
System.out.println("-----------------------------------------");
|
||||||
|
if (id != null) {
|
||||||
|
attributes.put("machineId", id); // 将id存入attributes
|
||||||
|
log.info("握手验证成功 - ID: {}", id);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
response.setStatusCode(HttpStatus.BAD_REQUEST);
|
||||||
|
log.warn("握手验证失败 - 缺少 id URL 参数");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterHandshake(
|
||||||
|
@NotNull ServerHttpRequest request,
|
||||||
|
@NotNull ServerHttpResponse response,
|
||||||
|
@NotNull WebSocketHandler wsHandler,
|
||||||
|
Exception exception
|
||||||
|
) {
|
||||||
|
if (exception == null) {
|
||||||
|
log.info("WebSocket握手完成 - URI: {}",
|
||||||
|
request.getURI());
|
||||||
|
} else {
|
||||||
|
log.error("WebSocket握手异常 - URI: {}, 异常信息: {}",
|
||||||
|
request.getURI(),
|
||||||
|
exception.getMessage(),
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从URI中提取id参数的辅助方法
|
||||||
|
private String extractIdFromUrl(URI uri) {
|
||||||
|
try {
|
||||||
|
String query = uri.getQuery();
|
||||||
|
if (query != null) {
|
||||||
|
String[] params = query.split("&");
|
||||||
|
for (String param : params) {
|
||||||
|
String[] keyValue = param.split("=");
|
||||||
|
if (keyValue.length == 2 && "id".equalsIgnoreCase(keyValue[0])) { // 修改为匹配id
|
||||||
|
return URLDecoder.decode(keyValue[1], StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析URL参数失败", e);
|
||||||
|
throw exception(FAILED_TO_PARSE_URL_PARAMETERS);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,214 @@
|
|||||||
|
package cd.casic.module.machine.component;
|
||||||
|
|
||||||
|
import cd.casic.module.machine.dal.model.FileNode;
|
||||||
|
import com.jcraft.jsch.Channel;
|
||||||
|
import com.jcraft.jsch.ChannelSftp;
|
||||||
|
import com.jcraft.jsch.JSchException;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.jcraft.jsch.SftpATTRS;
|
||||||
|
import com.jcraft.jsch.SftpException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class FileTreeComponent {
|
||||||
|
|
||||||
|
private ChannelSftp sftp;
|
||||||
|
//todo缓存过期还未设置
|
||||||
|
private final Map<String, FileNode> directoryCache = new ConcurrentHashMap<>();
|
||||||
|
private static final long CACHE_EXPIRE_TIME = 60 * 1000; // 缓存过期时间:1分钟
|
||||||
|
private final Map<String, Long> cacheTimeStamp = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 将文件树转换为JSON友好的Map结构(仅一级目录)
|
||||||
|
public static Map<String, Object> convertToMap(FileNode node) {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put("name", node.getName());
|
||||||
|
map.put("isDirectory", node.isDirectory());
|
||||||
|
map.put("size", node.getSize());
|
||||||
|
map.put("permissions", node.getPermissions());
|
||||||
|
map.put("modifiedTime", node.getModifiedTime());
|
||||||
|
// 仅添加直接子项,不递归
|
||||||
|
if (node.isDirectory() && !node.getChildren().isEmpty()) {
|
||||||
|
List<Map<String, Object>> children = node.getChildren().stream()
|
||||||
|
.map(FileTreeComponent::convertToMap)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
map.put("children", children);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取指定路径的直接子项(不递归)
|
||||||
|
public List<ChannelSftp.LsEntry> listDirectChildren(String path) {
|
||||||
|
if (sftp == null || !isSftpConnected()) {
|
||||||
|
log.error("SFTP连接无效,无法列出文件");
|
||||||
|
throw exception(CONNECTION_LOST);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ChannelSftp.LsEntry> entries = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
//定义ChannelSftp下LsEntrySelector接口中select方法
|
||||||
|
ChannelSftp.LsEntrySelector selector = entry -> {
|
||||||
|
entries.add(entry);
|
||||||
|
return ChannelSftp.LsEntrySelector.CONTINUE;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
sftp.ls(path, selector);
|
||||||
|
} catch (SftpException e) {
|
||||||
|
log.error("读取远程文件目录结构失败 [错误码: {}, 信息: {}]",
|
||||||
|
e.id, e.getMessage(), e);
|
||||||
|
throw exception(READ_REMOTE_DIRECTORY_FAIL);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建文件节点(支持使用完整路径或文件名)
|
||||||
|
private static FileNode createFileNode(String name, SftpATTRS attrs, boolean useFullPath) {
|
||||||
|
String displayName = useFullPath ? name : name.substring(name.lastIndexOf('/') + 1);
|
||||||
|
return new FileNode(
|
||||||
|
displayName,
|
||||||
|
attrs.isDir(),
|
||||||
|
attrs.getSize(),
|
||||||
|
attrs.getPermissionsString(),
|
||||||
|
attrs.getMTime() * 1000L // 转换为毫秒
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取远程文件树的方法(仅展示下一级)
|
||||||
|
public Map<String, Object> getRemoteFileTree(Session session, String path) {
|
||||||
|
path = normalizePath(path);
|
||||||
|
Channel channel = null;
|
||||||
|
try {
|
||||||
|
// 缓存有效性检查
|
||||||
|
String cacheKey = "ls:" + path;
|
||||||
|
long currentTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
// 检查缓存是否存在且未过期
|
||||||
|
if (directoryCache.containsKey(cacheKey) &&
|
||||||
|
currentTime - cacheTimeStamp.getOrDefault(cacheKey, 0L) < CACHE_EXPIRE_TIME) {
|
||||||
|
log.debug("从缓存获取目录内容: {}", path);
|
||||||
|
// 构建缓存中的根节点
|
||||||
|
FileNode root = directoryCache.get(cacheKey);
|
||||||
|
return sortFileInfo(convertToMap(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开SFTP通道
|
||||||
|
channel = session.openChannel("sftp");
|
||||||
|
channel.connect();
|
||||||
|
sftp = (ChannelSftp) channel;
|
||||||
|
|
||||||
|
// 先通过stat获取当前路径信息
|
||||||
|
SftpATTRS rootAttrs = sftp.stat(path);
|
||||||
|
FileNode root = createFileNode(path, rootAttrs, true);
|
||||||
|
|
||||||
|
// 仅获取直接子项,不递归
|
||||||
|
List<ChannelSftp.LsEntry> entries = listDirectChildren(path);
|
||||||
|
//循环添加子节点
|
||||||
|
for (ChannelSftp.LsEntry entry : entries) {
|
||||||
|
String fileName = entry.getFilename();
|
||||||
|
if (fileName.equals(".") || fileName.equals("..")) continue;
|
||||||
|
SftpATTRS attrs = entry.getAttrs();
|
||||||
|
FileNode childNode = createFileNode(fileName, attrs, false);
|
||||||
|
root.addChild(childNode);
|
||||||
|
}
|
||||||
|
// 更新缓
|
||||||
|
directoryCache.put(cacheKey, root);
|
||||||
|
cacheTimeStamp.put(cacheKey, currentTime);
|
||||||
|
return sortFileInfo(convertToMap(root));
|
||||||
|
} catch (JSchException e) {
|
||||||
|
log.error("SFTP通道创建或连接失败: {}", e.getMessage(), e);
|
||||||
|
if (e.getMessage().contains("open")) {
|
||||||
|
throw exception(CREATE_CHANEL_ERROR);
|
||||||
|
} else {
|
||||||
|
throw exception(CHANEL_CONNECT_FAIL);
|
||||||
|
}
|
||||||
|
} catch (SftpException e) {
|
||||||
|
if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
||||||
|
// 仅捕获路径不存在的错误
|
||||||
|
log.error("路径不存在: {}", path);
|
||||||
|
throw exception(PATH_NOT_EXISTS); // 自定义异常
|
||||||
|
} else if (e.id == ChannelSftp.SSH_FX_PERMISSION_DENIED) {
|
||||||
|
// 处理权限问题(非路径不存在)
|
||||||
|
log.error("无路径访问权限: {}", path);
|
||||||
|
throw exception(NO_PATH_PERMISSION);
|
||||||
|
} else {
|
||||||
|
log.error("获取目录内容失败: {}", e.getMessage(), e);
|
||||||
|
throw exception(READ_REMOTE_DIRECTORY_FAIL);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 确保资源释放
|
||||||
|
if (sftp != null) {
|
||||||
|
try {
|
||||||
|
sftp.disconnect();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("关闭SFTP连接失败", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (channel != null && channel.isConnected()) {
|
||||||
|
channel.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查SFTP连接状态
|
||||||
|
private boolean isSftpConnected() {
|
||||||
|
if (sftp == null) return false;
|
||||||
|
try {
|
||||||
|
sftp.pwd();
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("SFTP连接状态检查失败", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范化路径:确保末尾有斜杠(根路径除外)
|
||||||
|
private String normalizePath(String path) {
|
||||||
|
// 移除多余的斜杠(多个连续的斜杠会被替换为一个)
|
||||||
|
path = path.replaceAll("/+", "/");
|
||||||
|
|
||||||
|
// 如果路径不为根路径且末尾没有斜杠,则添加斜杠
|
||||||
|
if (!"/".equals(path) && !path.endsWith("/")) {
|
||||||
|
path += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对文件和目录信息进行排序(仅处理直接子级)
|
||||||
|
*
|
||||||
|
* @param fileInfoMap 文件/目录信息映射
|
||||||
|
* @return 排序后的映射
|
||||||
|
*/
|
||||||
|
public Map<String, Object> sortFileInfo(Map<String, Object> fileInfoMap) {
|
||||||
|
// 检查是否包含子节点
|
||||||
|
if (fileInfoMap.containsKey("children")) {
|
||||||
|
List<Map<String, Object>> children = (List<Map<String, Object>>) fileInfoMap.get("children");
|
||||||
|
// 对子节点列表进行排序
|
||||||
|
if (children != null && !children.isEmpty()) {
|
||||||
|
children.sort((a, b) -> {
|
||||||
|
// 获取isDirectory属性并比较
|
||||||
|
boolean isADirectory = (boolean) a.get("isDirectory");
|
||||||
|
boolean isBDirectory = (boolean) b.get("isDirectory");
|
||||||
|
// 目录排在文件前面
|
||||||
|
return Boolean.compare(isBDirectory, isADirectory);
|
||||||
|
});
|
||||||
|
// 更新排序后的子节点列表
|
||||||
|
fileInfoMap.put("children", children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileInfoMap;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,230 @@
|
|||||||
|
package cd.casic.module.machine.component;
|
||||||
|
|
||||||
|
import cd.casic.framework.commons.exception.ServiceException;
|
||||||
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
|
import cd.casic.module.machine.enums.AuthenticationType;
|
||||||
|
import cd.casic.module.machine.enums.ConnectionStatus;
|
||||||
|
import cd.casic.module.machine.enums.SSHChanelType;
|
||||||
|
import cd.casic.module.machine.service.SecretKeyService;
|
||||||
|
import com.jcraft.jsch.Channel;
|
||||||
|
import com.jcraft.jsch.ChannelExec;
|
||||||
|
import com.jcraft.jsch.JSch;
|
||||||
|
import com.jcraft.jsch.JSchException;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.SECRET_KEY_NULL;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
public class WebSocketConnection {
|
||||||
|
private SecretKeyService secretKeyService;
|
||||||
|
|
||||||
|
private MachineInfoDO machineInfo;
|
||||||
|
private ConnectionStatus connectionStatus = ConnectionStatus.DISCONNECTED;
|
||||||
|
private Session sshSession;
|
||||||
|
|
||||||
|
public WebSocketConnection(SecretKeyService secretKeyService) {
|
||||||
|
this.secretKeyService = secretKeyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int CONNECTION_TIMEOUT = 5000; // 连接超时时间(毫秒)
|
||||||
|
|
||||||
|
public void initConnection(MachineInfoDO machineInfo) {
|
||||||
|
try {
|
||||||
|
this.machineInfo = machineInfo;
|
||||||
|
this.sshSession = doConnect(machineInfo);
|
||||||
|
log.info("已成功建立 SSH 连接至 {} ", machineInfo.getHostIp());
|
||||||
|
this.connectionStatus = ConnectionStatus.CONNECTING;
|
||||||
|
} catch (ServiceException e) {
|
||||||
|
log.warn("SSH 连接失败: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void disconnect() {
|
||||||
|
if (sshSession != null && sshSession.isConnected()) {
|
||||||
|
try {
|
||||||
|
sshSession.disconnect();
|
||||||
|
log.info("SSH连接关闭: {}", machineInfo.getHostIp());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("关闭SSH连接失败: {}", e.getMessage());
|
||||||
|
throw exception(CLOSE_CLOSE_SESSION_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connectionStatus = ConnectionStatus.DISCONNECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行远程命令,支持超时和中断处理
|
||||||
|
*/
|
||||||
|
public void executeCommand(WebSocketSession webSocketSession, String command) {
|
||||||
|
// 1. 检查连接状态
|
||||||
|
if (sshSession == null || !sshSession.isConnected()) {
|
||||||
|
sendErrorMessage(webSocketSession, "SSH连接未建立或已断开");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 2. 创建SSH命令执行通道
|
||||||
|
Channel channel;
|
||||||
|
try {
|
||||||
|
channel = sshSession.openChannel(SSHChanelType.EXEC.getMessage());
|
||||||
|
} catch (JSchException e) {
|
||||||
|
throw exception(CREATE_CHANEL_ERROR);
|
||||||
|
}
|
||||||
|
((ChannelExec) channel).setCommand(command);
|
||||||
|
// 3. 设置输入/输出流
|
||||||
|
channel.setInputStream(null);
|
||||||
|
((ChannelExec) channel).setErrStream(System.err);
|
||||||
|
// 4. 获取命令输出流
|
||||||
|
InputStream inputStream = channel.getInputStream();
|
||||||
|
InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
|
||||||
|
BufferedReader reader = new BufferedReader(inputStreamReader);
|
||||||
|
// 5. 连接并执行命令
|
||||||
|
channel.connect();
|
||||||
|
// 6. 读取命令输出并实时发送给客户端
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
// 实时发送输出到客户端
|
||||||
|
webSocketSession.sendMessage(new TextMessage(line));
|
||||||
|
}
|
||||||
|
// 7. 等待命令执行完成
|
||||||
|
int exitStatus = channel.getExitStatus();
|
||||||
|
// 8. 发送命令执行完毕的消息
|
||||||
|
webSocketSession.sendMessage(new TextMessage(
|
||||||
|
"[系统] 命令执行完毕,退出状态: " + exitStatus
|
||||||
|
));
|
||||||
|
// 9. 关闭通道
|
||||||
|
channel.disconnect();
|
||||||
|
} catch (JSchException | IOException e) {
|
||||||
|
throw exception(EXECUTE_COMMAND_FAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送错误消息的辅助方法
|
||||||
|
public void sendErrorMessage(WebSocketSession webSocketSession, String message) {
|
||||||
|
try {
|
||||||
|
if (webSocketSession.isOpen()) {
|
||||||
|
webSocketSession.sendMessage(new TextMessage("[错误] " + message));
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("发送错误消息失败", e);
|
||||||
|
throw exception(WEBSOCKET_SEND_MESSAGE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际执行连接逻辑
|
||||||
|
*/
|
||||||
|
private Session doConnect(MachineInfoDO machineInfo) {
|
||||||
|
JSch jsch = new JSch();
|
||||||
|
// 配置认证方式
|
||||||
|
configureAuthentication(jsch, machineInfo);
|
||||||
|
Session session;
|
||||||
|
// 创建SSH会话
|
||||||
|
try {
|
||||||
|
session = jsch.getSession(
|
||||||
|
machineInfo.getUsername(),
|
||||||
|
machineInfo.getHostIp(),
|
||||||
|
machineInfo.getSshPort() != null ? machineInfo.getSshPort() : 22
|
||||||
|
);
|
||||||
|
} catch (JSchException e) {
|
||||||
|
throw exception(CREATE_SESSION_ERROR);
|
||||||
|
}
|
||||||
|
// 配置连接参数
|
||||||
|
configureSession(session, machineInfo);
|
||||||
|
// 建立连接
|
||||||
|
try {
|
||||||
|
session.connect(CONNECTION_TIMEOUT);
|
||||||
|
} catch (JSchException e) {
|
||||||
|
throw exception(SESSION_CONNECT_ERROR);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置认证方式(密码或密钥)
|
||||||
|
*/
|
||||||
|
private void configureAuthentication(JSch jsch, MachineInfoDO machineInfo) {
|
||||||
|
if (machineInfo.getAuthenticationType() == AuthenticationType.SECRET_KEY.getCode()) {
|
||||||
|
// 密钥认证
|
||||||
|
if (machineInfo.getSecretKeyId() == null) {
|
||||||
|
throw exception(SECRET_KEY_NULL);
|
||||||
|
}
|
||||||
|
String pubKeyContent = secretKeyService.getKeyContent(machineInfo.getSecretKeyId());
|
||||||
|
// 验证秘钥格式
|
||||||
|
if (!pubKeyContent.startsWith("-----BEGIN")) {
|
||||||
|
log.error("无效的密钥格式{}", pubKeyContent);
|
||||||
|
throw exception(INVALID_kEY_FORMAT);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 尝试加载秘钥私钥
|
||||||
|
jsch.addIdentity(
|
||||||
|
machineInfo.getName(),
|
||||||
|
pubKeyContent.getBytes(StandardCharsets.UTF_8),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
log.info("密钥加载成功 {}", machineInfo.getHostIp());
|
||||||
|
} catch (JSchException e) {
|
||||||
|
log.error("密钥加载失败: {}", e.getMessage());
|
||||||
|
throw exception(READ_SECRET_CONTENT_ERROR);
|
||||||
|
}
|
||||||
|
} else if (machineInfo.getAuthenticationType() == AuthenticationType.PASSWORD.getCode()) {
|
||||||
|
// 密码认证
|
||||||
|
if (!StringUtils.hasText(machineInfo.getPassword())) {
|
||||||
|
throw exception(PASSWORD_NOT_EXISTS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("不支持该验证类型:{}", machineInfo.getAuthenticationType());
|
||||||
|
throw exception(NOT_SUPPORT_AUTHENTICATION_TYPE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置SSH会话参数(安全增强)
|
||||||
|
*/
|
||||||
|
private void configureSession(Session session, MachineInfoDO machineInfo) {
|
||||||
|
Properties config = new Properties();
|
||||||
|
// 根据认证类型配置不同的认证策略
|
||||||
|
if (machineInfo.getAuthenticationType() == 1) { // 密码认证
|
||||||
|
// 设置密码
|
||||||
|
session.setPassword(machineInfo.getPassword());
|
||||||
|
config.put("StrictHostKeyChecking", "no");
|
||||||
|
// 仅使用密码认证(禁用其他认证方式)
|
||||||
|
config.put("PreferredAuthentications", "password");
|
||||||
|
// 禁用公钥相关配置(避免干扰)
|
||||||
|
config.put("PubkeyAuthentication", "no");
|
||||||
|
} else { // 密钥认证
|
||||||
|
try {
|
||||||
|
String preKeyPath = secretKeyService.getSecretKey(machineInfo.getSecretKeyId()).getPath();
|
||||||
|
JSch jsch = new JSch();
|
||||||
|
jsch.addIdentity(preKeyPath); // 添加私钥
|
||||||
|
// 保持默认认证顺序(公钥优先)
|
||||||
|
config.put("PreferredAuthentications", "publicKey,password,keyboard-interactive");
|
||||||
|
} catch (JSchException e) {
|
||||||
|
log.error("SSH密钥配置失败", e);
|
||||||
|
throw exception(SSH_KEY_CONFIGURATION_FAIL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.put("ServerAliveInterval", "30"); // 每30秒发送一次心跳
|
||||||
|
config.put("ServerAliveCountMax", "3"); // 允许3次心跳失败
|
||||||
|
session.setConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,114 @@
|
|||||||
|
package cd.casic.module.machine.component;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component("machineWebSocketSessionManger")
|
||||||
|
//管理webSocketSession
|
||||||
|
public class WebSocketSessionManager {
|
||||||
|
|
||||||
|
//webSocketSessionId - WebSocketSession 保存 WebSocketSession 对象与会话 ID 的映射
|
||||||
|
private static final ConcurrentHashMap<String, WebSocketSession> WebSocketSessionMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
//webSocketSessionId - WebSocketConnection 与远程机器的会话管理
|
||||||
|
private static final ConcurrentHashMap<String, WebSocketConnection> sessionConnectionMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
//机器id - WebSocketConnection
|
||||||
|
private static final ConcurrentHashMap<Long, WebSocketConnection> webSocketSessionConnectionMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public static void addWebSocketSession(String sessionId, WebSocketSession session) {
|
||||||
|
WebSocketSessionMap.put(sessionId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocketSession
|
||||||
|
*/
|
||||||
|
public static WebSocketSession getWebSocketSession(String sessionId) {
|
||||||
|
return WebSocketSessionMap.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 WebSocketSession
|
||||||
|
*/
|
||||||
|
public static void removeWebSocketSession(String sessionId) {
|
||||||
|
WebSocketSessionMap.remove(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 sessionId 是否存在
|
||||||
|
*/
|
||||||
|
public static boolean containsWebSocketSession(String sessionId) {
|
||||||
|
return WebSocketSessionMap.containsKey(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 WebSocketSession
|
||||||
|
*/
|
||||||
|
public static Collection<WebSocketSession> getAllWebSocketSessions() {
|
||||||
|
return WebSocketSessionMap.values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addWebSocketConnection(String sessionId, WebSocketConnection connection) {
|
||||||
|
sessionConnectionMap.put(sessionId, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static WebSocketConnection getWebSocketConnection(String sessionId) {
|
||||||
|
return sessionConnectionMap.get(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static void removeWebSocketConnection(String sessionId) {
|
||||||
|
sessionConnectionMap.remove(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 sessionId 是否存在
|
||||||
|
*/
|
||||||
|
public static boolean containsWebSocketConnection(String sessionId) {
|
||||||
|
return sessionConnectionMap.containsKey(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static ConcurrentHashMap<Long, WebSocketConnection> getAllWebSocketConnections() {
|
||||||
|
return webSocketSessionConnectionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static void addWebSocketConnectionByMachineId(Long machineId, WebSocketConnection connection) {
|
||||||
|
webSocketSessionConnectionMap.put(machineId, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static WebSocketConnection getWebSocketConnectionByMachineId(Long machineId) {
|
||||||
|
return webSocketSessionConnectionMap.get(machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 WebSocketConnection
|
||||||
|
*/
|
||||||
|
public static void removeWebSocketConnectionByMachineId(Long machineId) {
|
||||||
|
webSocketSessionConnectionMap.remove(machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 machineId 是否存在
|
||||||
|
*/
|
||||||
|
public static boolean containsMachineId(Long machineId) {
|
||||||
|
return webSocketSessionConnectionMap.containsKey(machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -7,7 +7,7 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class AliYunOssConfig extends S3FileClientConfig{
|
public class AliYunOssConfig extends S3FileClientConfig{
|
||||||
@Value("${aliyun.oss.endpoint}")
|
@Value("${aliyun.oss.endpoint}")
|
||||||
private String endpoint;
|
private String endpoint;
|
||||||
@Value("${aliyun.oss.accessKeyId}")
|
@Value("${aliyun.oss.accessKeyId}")
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package cd.casic.module.machine.configuration;
|
||||||
|
|
||||||
|
import cd.casic.module.machine.Interceptor.WebSocketHandshakeInterceptor;
|
||||||
|
import cd.casic.module.machine.handler.MachineWebSocketHandler;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
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;
|
||||||
|
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
|
||||||
|
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocket
|
||||||
|
//WebSocket端点配置
|
||||||
|
public class WebSocketConfig implements WebSocketConfigurer {
|
||||||
|
@Resource
|
||||||
|
private MachineWebSocketHandler machineWebSocketHandler;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private WebSocketHandshakeInterceptor webSocketHandshakeInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||||
|
registry.addHandler(machineWebSocketHandler, "/ssh/terminal")
|
||||||
|
.addInterceptors(webSocketHandshakeInterceptor)
|
||||||
|
.setAllowedOrigins("*"); // 允许跨域(生产环境需限制)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||||
|
return new ServletServerContainerFactoryBean();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ServerEndpointExporter serverEndpointExporter() {
|
||||||
|
return new ServerEndpointExporter();
|
||||||
|
}
|
||||||
|
}
|
@ -41,13 +41,33 @@ public interface MachineErrorCodeConstants {
|
|||||||
// ========== 密钥模块 1-003-004-000 ==========
|
// ========== 密钥模块 1-003-004-000 ==========
|
||||||
ErrorCode SECRET_KEY_NULL = new ErrorCode(1_003_004_000, "密钥为空");
|
ErrorCode SECRET_KEY_NULL = new ErrorCode(1_003_004_000, "密钥为空");
|
||||||
ErrorCode SECRET_KEY_NOT_EXISTS = new ErrorCode(1_003_004_001, "密钥不存在");
|
ErrorCode SECRET_KEY_NOT_EXISTS = new ErrorCode(1_003_004_001, "密钥不存在");
|
||||||
ErrorCode SECRET_KEY_NAME_ILLEGAL = new ErrorCode(1_003_004_002, "密钥名称不合法");
|
ErrorCode SECRET_KEY_PATH_NULL = new ErrorCode(1_003_004_002, "密钥路径为空");
|
||||||
ErrorCode SECRET_KEY_PATH_ILLEGAL = new ErrorCode(1_003_004_003, "密钥路径不合法");
|
ErrorCode INVALID_kEY_FORMAT = new ErrorCode(1_003_004_003, "无效的密钥格式");
|
||||||
ErrorCode SECRET_KEY_PATH_NULL = new ErrorCode(1_003_004_004, "密钥路径为空");
|
ErrorCode READ_SECRET_CONTENT_ERROR = new ErrorCode(1_003_004_004, "读取密钥加载失败");
|
||||||
ErrorCode SECRET_KEY_UPLOAD_FAIL = new ErrorCode(1_003_004_005, "密钥上传失败");
|
|
||||||
|
|
||||||
// ========== 其他模块 1-003-005-000 ==========
|
// ========== 其他模块 1-003-005-000 ==========
|
||||||
ErrorCode OSS_PARAM_NULL = new ErrorCode(1_003_005_000, "oss参数无法读取");
|
ErrorCode OSS_PARAM_NULL = new ErrorCode(1_003_005_000, "oss参数无法读取");
|
||||||
ErrorCode SECRETKEY_NULL = new ErrorCode(1_003_005_001, "密钥为空");
|
ErrorCode FILE_UPLOAD_ERROR = new ErrorCode(1_003_005_001, "文件上传失败");
|
||||||
ErrorCode PARAMETER_ERROR = new ErrorCode(1_003_005_002, "参数错误");
|
ErrorCode FILE_DOWNLOAD_ERROR = new ErrorCode(1_003_005_002, "文件下载失败");
|
||||||
|
|
||||||
|
//========== 会话连接模块 1-003-006-000 ==========
|
||||||
|
ErrorCode SESSION_CONNECT_ERROR = new ErrorCode(1_003_006_001, "会话连接失败");
|
||||||
|
ErrorCode CLOSE_CLOSE_SESSION_ERROR = new ErrorCode(1_003_006_002, "关闭连接失败");
|
||||||
|
ErrorCode SESSION_NOT_CONNECT = new ErrorCode(1_003_006_003, "会话未连接");
|
||||||
|
ErrorCode EXECUTE_COMMAND_FAIL = new ErrorCode(1_003_006_004, "命令执行失败");
|
||||||
|
ErrorCode PASSWORD_NOT_EXISTS = new ErrorCode(1_003_006_005, "密码不存在");
|
||||||
|
ErrorCode SSH_KEY_CONFIGURATION_FAIL = new ErrorCode(1_003_006_006, "SSH密钥配置失败");
|
||||||
|
ErrorCode NOT_SUPPORT_AUTHENTICATION_TYPE = new ErrorCode(1_003_006_007, "认证类型不支持");
|
||||||
|
ErrorCode CREATE_SESSION_ERROR = new ErrorCode(1_003_006_008, "创建会话失败");
|
||||||
|
ErrorCode WEBSOCKET_SEND_MESSAGE_ERROR = new ErrorCode(1_003_006_009, "websocket发送消息失败");
|
||||||
|
ErrorCode CREATE_CHANEL_ERROR = new ErrorCode(1_003_006_010, "执行通道创建失败");
|
||||||
|
ErrorCode CHANEL_CONNECT_FAIL = new ErrorCode(1_003_006_011, "通道连接失败");
|
||||||
|
ErrorCode FAILED_TO_PARSE_URL_PARAMETERS = new ErrorCode(1_003_006_012, "解析URL参数失败");
|
||||||
|
|
||||||
|
//========== 远程文件树模块 1-003-007-000 ==========
|
||||||
|
ErrorCode NOT_DIRECTORY_NODE = new ErrorCode(1_003_007_001, "非目录节点不能添加子节点");
|
||||||
|
ErrorCode READ_REMOTE_DIRECTORY_FAIL = new ErrorCode(1_003_007_002, "读取远程文件目录结构失败");
|
||||||
|
ErrorCode CONNECTION_LOST = new ErrorCode(1_003_007_003, "SFTP连接无效,无法列出文件");
|
||||||
|
ErrorCode PATH_NOT_EXISTS = new ErrorCode(1_003_007_004, "路径不存在");
|
||||||
|
ErrorCode NO_PATH_PERMISSION = new ErrorCode(1_003_007_005, "无路径访问权限");
|
||||||
}
|
}
|
||||||
|
@ -65,8 +65,8 @@ public class MachineInfoController {
|
|||||||
@DeleteMapping("/delete")
|
@DeleteMapping("/delete")
|
||||||
@Operation(summary = "机器信息删除")
|
@Operation(summary = "机器信息删除")
|
||||||
// @PreAuthorize("@ss.hasPermission('ci:machineInfo:delete')")
|
// @PreAuthorize("@ss.hasPermission('ci:machineInfo:delete')")
|
||||||
public CommonResult<Boolean> deleteMachineInfo(@RequestParam("id") Long id) {
|
public CommonResult<Boolean> deleteMachineInfo(@RequestParam("machineInfoId") Long machineInfoId) {
|
||||||
machineInfoService.deleteMachineInfo(id);
|
machineInfoService.deleteMachineInfo(machineInfoId);
|
||||||
return success(true);
|
return success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,60 +78,54 @@ public class MachineInfoController {
|
|||||||
return success(true);
|
return success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/test")
|
@GetMapping("/test")
|
||||||
@Operation(summary = "测试机器连接")
|
@Operation(summary = "测试机器连接")
|
||||||
public CommonResult<Boolean> testConnection(@RequestParam("id") Long id) {
|
public CommonResult<Boolean> testConnection(@RequestParam("id") Long id) {
|
||||||
return success(machineInfoService.testConnection(id));
|
return success(machineInfoService.testConnection(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status/{machineName}")
|
@GetMapping("/status")
|
||||||
@Operation(summary = "获取机器连接状态")
|
@Operation(summary = "获取机器连接状态")
|
||||||
public CommonResult<ConnectionStatus> getConnectionStatus(@PathVariable String machineName) {
|
public CommonResult<ConnectionStatus> getConnectionStatus(@RequestParam Long id) {
|
||||||
return success(machineInfoService.getConnectionStatus(machineName));
|
return success(machineInfoService.getConnectionStatus(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status/all")
|
@GetMapping("/status/all")
|
||||||
@Operation(summary = "获取所有机器连接状态")
|
@Operation(summary = "获取所有机器连接状态")
|
||||||
public CommonResult<Map<String, ConnectionStatus>> getAllConnectionStatus() {
|
public CommonResult<Map<Long, ConnectionStatus>> getAllConnectionStatus() {
|
||||||
return success(machineInfoService.getAllConnectionStatus());
|
return success(machineInfoService.getAllConnectionStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/connect")
|
@GetMapping("/connect")
|
||||||
@Operation(summary = "建立机器连接")
|
@Operation(summary = "建立连接")
|
||||||
public CommonResult<String> connect(@Valid @RequestBody MachineInfoDO machineInfoDO) {
|
public CommonResult<Map<String, Object>> connect(@RequestParam Long id) {
|
||||||
return success(machineInfoService.connect(machineInfoDO));
|
return success(machineInfoService.connect(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/disconnect/{sessionId}")
|
@GetMapping("/fileTreeNode")
|
||||||
@Operation(summary = "断开机器连接")
|
@Operation(summary = "获得文件树")
|
||||||
public CommonResult<Boolean> disconnect(@PathVariable String sessionId) {
|
public CommonResult<Map<String, Object>> fileTreeNode(
|
||||||
return success(machineInfoService.disconnect(sessionId));
|
@RequestParam Long machineId,
|
||||||
}
|
@RequestParam(required = false, defaultValue = "/") String path
|
||||||
|
) {
|
||||||
@GetMapping("/execute/{sessionId}")
|
return CommonResult.success(machineInfoService.fileTreeNode(machineId, path));
|
||||||
@Operation(summary = "执行远程命令")
|
|
||||||
public CommonResult<String> executeCommand(
|
|
||||||
@PathVariable String sessionId,
|
|
||||||
@RequestBody String command) {
|
|
||||||
|
|
||||||
return success(machineInfoService.executeCommand(sessionId, command));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/upload/{sessionId}")
|
|
||||||
@Operation(summary = "上传文件到远程机器")
|
|
||||||
public CommonResult<Boolean> uploadFile(
|
|
||||||
@PathVariable String sessionId,
|
|
||||||
@RequestParam String localFilePath,
|
|
||||||
@RequestParam String remoteFilePath) {
|
|
||||||
return success(machineInfoService.uploadFile(sessionId, localFilePath, remoteFilePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/download/{sessionId}")
|
|
||||||
@Operation(summary = "从远程机器下载文件")
|
|
||||||
public CommonResult<Boolean> downloadFile(
|
|
||||||
@PathVariable String sessionId,
|
|
||||||
@RequestParam String remoteFilePath,
|
|
||||||
@RequestParam String localFilePath) {
|
|
||||||
return success(machineInfoService.downloadFile(sessionId, remoteFilePath, localFilePath));
|
|
||||||
}
|
}
|
||||||
|
// @GetMapping("/upload")
|
||||||
|
// @Operation(summary = "上传文件到远程机器")
|
||||||
|
// public CommonResult<Boolean> uploadFile(
|
||||||
|
// @RequestParam String sessionId,
|
||||||
|
// @RequestParam String localFilePath,
|
||||||
|
// @RequestParam String remoteFilePath
|
||||||
|
// ) {
|
||||||
|
// return success(machineInfoService.uploadFile(sessionId, localFilePath, remoteFilePath));
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @GetMapping("/download")
|
||||||
|
// @Operation(summary = "从远程机器下载文件")
|
||||||
|
// public CommonResult<Boolean> downloadFile(
|
||||||
|
// @RequestParam String sessionId,
|
||||||
|
// @RequestParam String remoteFilePath,
|
||||||
|
// @RequestParam String localFilePath) {
|
||||||
|
// return success(machineInfoService.downloadFile(sessionId, remoteFilePath, localFilePath));
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package cd.casic.module.machine.controller;
|
|||||||
import cd.casic.framework.commons.pojo.CommonResult;
|
import cd.casic.framework.commons.pojo.CommonResult;
|
||||||
import cd.casic.framework.commons.pojo.PageResult;
|
import cd.casic.framework.commons.pojo.PageResult;
|
||||||
import cd.casic.framework.commons.util.object.BeanUtils;
|
import cd.casic.framework.commons.util.object.BeanUtils;
|
||||||
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
||||||
import cd.casic.module.machine.service.SecretKeyService;
|
import cd.casic.module.machine.service.SecretKeyService;
|
||||||
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
||||||
@ -11,6 +12,8 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -51,20 +54,32 @@ public class SecretKeyController {
|
|||||||
return success(true);
|
return success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("unbindMachine")
|
@PutMapping("/unbindMachine")
|
||||||
@Operation(summary = "解绑机器")
|
@Operation(summary = "解绑机器")
|
||||||
public CommonResult<Boolean> unbindMachine(@Valid @RequestBody SecretKeyVO secretKeyVO) {
|
public CommonResult<Boolean> unbindMachine(@Valid @RequestBody SecretKeyVO secretKeyVO) {
|
||||||
secretKeyService.unbindMachine(secretKeyVO);
|
secretKeyService.unbindMachine(secretKeyVO);
|
||||||
return success(true);
|
return success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/getBindMachine")
|
||||||
|
@Operation(summary = "获取密钥绑定的机器列表")
|
||||||
|
public CommonResult<List<MachineInfoDO>> getBindMachine(@RequestParam Long secretKeyId) {
|
||||||
|
return success(secretKeyService.getBindMachine(secretKeyId));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/getSecretKey")
|
@GetMapping("/getSecretKey")
|
||||||
@Operation(summary = "获取机器的环境变量")
|
@Operation(summary = "获取机器的密钥")
|
||||||
public CommonResult<SecretKeyVO> getSecretKey(@RequestParam("id") Long id) {
|
public CommonResult<SecretKeyVO> getSecretKey(@RequestParam("id") Long id) {
|
||||||
SecretKeyVO secretKeyVO = secretKeyService.getSecretKey(id);
|
SecretKeyVO secretKeyVO = secretKeyService.getSecretKey(id);
|
||||||
return success(secretKeyVO);
|
return success(secretKeyVO);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/delete")
|
||||||
|
@Operation(summary = "密钥信息删除")
|
||||||
|
public CommonResult<Boolean> delete(@RequestParam("id") Long id) {
|
||||||
|
secretKeyService.deleteSecretKey(id);
|
||||||
|
return success(true);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/deleteList")
|
@DeleteMapping("/deleteList")
|
||||||
@Operation(summary = "批量删除密钥")
|
@Operation(summary = "批量删除密钥")
|
||||||
@ -76,11 +91,17 @@ public class SecretKeyController {
|
|||||||
|
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
@Operation(summary = "获取密钥信息列表")
|
@Operation(summary = "获取密钥信息列表")
|
||||||
public CommonResult<PageResult<SecretKeyVO>> getSecretKeypage(@Valid @RequestBody SecretKeyVO secretKeyVO) {
|
public CommonResult<PageResult<SecretKeyVO>> getSecretKeyPage(@Valid @RequestBody SecretKeyVO secretKeyVO) {
|
||||||
PageResult<SecretKeyDO> pageResult = secretKeyService.getSecretKeypage(secretKeyVO);
|
PageResult<SecretKeyDO> pageResult = secretKeyService.getSecretKeypage(secretKeyVO);
|
||||||
if (CollUtil.isEmpty(pageResult.getList())) {
|
if (CollUtil.isEmpty(pageResult.getList())) {
|
||||||
return success(new PageResult<>(pageResult.getTotal()));
|
return success(new PageResult<>(pageResult.getTotal()));
|
||||||
}
|
}
|
||||||
return success(BeanUtils.toBean(pageResult, SecretKeyVO.class));
|
return success(BeanUtils.toBean(pageResult, SecretKeyVO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download")
|
||||||
|
@Operation(summary = "下载密钥文件")
|
||||||
|
public ResponseEntity<InputStreamResource> downloadSecretFile(@RequestParam("id") Long id) {
|
||||||
|
return secretKeyService.downloadSecretFile(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,9 @@ import cd.casic.framework.commons.pojo.PageParam;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import lombok.experimental.Accessors;
|
import lombok.experimental.Accessors;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
package cd.casic.module.machine.convert;
|
|
||||||
|
|
||||||
import cd.casic.module.machine.controller.vo.MachineEnvVO;
|
|
||||||
import cd.casic.module.machine.dal.dataobject.MachineEnvDO;
|
|
||||||
import org.mapstruct.Mapper;
|
|
||||||
import org.mapstruct.factory.Mappers;
|
|
||||||
|
|
||||||
@Mapper
|
|
||||||
public interface MachineEnvConvert {
|
|
||||||
|
|
||||||
MachineEnvConvert INSTANCE = Mappers.getMapper(MachineEnvConvert.class);
|
|
||||||
|
|
||||||
// 转换实体为VO
|
|
||||||
default MachineEnvVO convertToVO(MachineEnvDO machineEnvDO) {
|
|
||||||
MachineEnvVO VO = new MachineEnvVO();
|
|
||||||
VO.setId(machineEnvDO.getId());
|
|
||||||
VO.setEnvKey(machineEnvDO.getEnvKey());
|
|
||||||
VO.setEnvValue(machineEnvDO.getEnvValue());
|
|
||||||
VO.setDescription(machineEnvDO.getDescription());
|
|
||||||
VO.setCreateTime(machineEnvDO.getCreateTime());
|
|
||||||
VO.setUpdateTime(machineEnvDO.getUpdateTime());
|
|
||||||
return VO;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,56 @@
|
|||||||
|
package cd.casic.module.machine.dal.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
//文件节点类
|
||||||
|
@Data
|
||||||
|
/*
|
||||||
|
* 远程文件系统的节点信息,用于构建文件树结构
|
||||||
|
*/
|
||||||
|
@Schema(description = "远程文件系统节点")
|
||||||
|
public class FileNode {
|
||||||
|
@Schema(description = "文件名或目录名")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "是否为目录")
|
||||||
|
private boolean isDirectory;
|
||||||
|
|
||||||
|
@Schema(description = "文件大小(字节)")
|
||||||
|
private long size;
|
||||||
|
|
||||||
|
@Schema(description = "文件权限字符串(如 rwxr-xr-x)")
|
||||||
|
private String permissions;
|
||||||
|
|
||||||
|
@Schema(description = "最后修改时间(时间戳,毫秒)")
|
||||||
|
private long modifiedTime;
|
||||||
|
|
||||||
|
@Schema(description = "子节点列表(仅目录有此属性)")
|
||||||
|
private List<FileNode> children;
|
||||||
|
|
||||||
|
public FileNode(String name, boolean isDirectory, long size, String permissions, long modifiedTime) {
|
||||||
|
this.name = name;
|
||||||
|
this.isDirectory = isDirectory;
|
||||||
|
this.size = size;
|
||||||
|
this.permissions = permissions;
|
||||||
|
this.modifiedTime = modifiedTime;
|
||||||
|
|
||||||
|
if (isDirectory) {
|
||||||
|
this.children = new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addChild(FileNode child) {
|
||||||
|
if (this.children == null) {
|
||||||
|
throw exception(NOT_DIRECTORY_NODE);
|
||||||
|
}
|
||||||
|
this.children.add(child);
|
||||||
|
}
|
||||||
|
}
|
@ -48,5 +48,10 @@ public interface MachineInfoMapper extends BaseMapperX<MachineInfoDO> {
|
|||||||
return selectPage(machineInfoVO, machineInfoDOLambdaQueryWrapperX);
|
return selectPage(machineInfoVO, machineInfoDOLambdaQueryWrapperX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default List<MachineInfoDO> selectBindMachineBySecretKey(Long secretKeyId) {
|
||||||
|
LambdaQueryWrapperX<MachineInfoDO> lambdaQueryWrapperX = new LambdaQueryWrapperX<MachineInfoDO>()
|
||||||
|
.eq(MachineInfoDO::getSecretKeyId, secretKeyId);
|
||||||
|
return selectList(lambdaQueryWrapperX);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,6 @@ public interface SecretKeyMapper extends BaseMapperX<SecretKeyDO> {
|
|||||||
return selectPage(secretKeyVO, new LambdaQueryWrapperX<SecretKeyDO>()
|
return selectPage(secretKeyVO, new LambdaQueryWrapperX<SecretKeyDO>()
|
||||||
.likeIfPresent(SecretKeyDO::getName, secretKeyVO.getName())
|
.likeIfPresent(SecretKeyDO::getName, secretKeyVO.getName())
|
||||||
.likeIfPresent(SecretKeyDO::getDescription, secretKeyVO.getDescription()));
|
.likeIfPresent(SecretKeyDO::getDescription, secretKeyVO.getDescription()));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default void bindingMachine(Long machineInfoId, List<Long> secretKeyId) {
|
default void bindingMachine(Long machineInfoId, List<Long> secretKeyId) {
|
||||||
|
@ -1,22 +1,33 @@
|
|||||||
package cd.casic.module.machine.enums;
|
package cd.casic.module.machine.enums;
|
||||||
|
|
||||||
|
import cd.casic.framework.commons.core.IntArrayValuable;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 连接状态枚举
|
* 连接状态枚举
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public enum ConnectionStatus {
|
public enum ConnectionStatus implements IntArrayValuable {
|
||||||
DISCONNECTED("断开连接"),
|
DISCONNECTED(1, "断开连接"),
|
||||||
CONNECTING("正在连接"),
|
CONNECTING(2, "正在连接"),
|
||||||
CONNECTED("已连接"),
|
CONNECTED(3, "已连接"),
|
||||||
AUTH_FAILED("认证失败"),
|
AUTH_FAILED(4, "认证失败"),
|
||||||
CONNECTION_TIMEOUT("连接超时"),
|
CONNECTION_TIMEOUT(5, "连接超时"),
|
||||||
CONNECTION_ERROR("连接错误"),
|
CONNECTION_ERROR(6, "连接错误"),
|
||||||
CLOSED("已关闭");
|
CLOSED(7, "已关闭");
|
||||||
|
|
||||||
private final String description;
|
private final int code;
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(ConnectionStatus::getCode).toArray();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int[] array() {
|
||||||
|
return ARRAYS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,35 @@
|
|||||||
|
package cd.casic.module.machine.enums;
|
||||||
|
|
||||||
|
import cd.casic.framework.commons.core.IntArrayValuable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
//ssh远程连接管道类型
|
||||||
|
public enum SSHChanelType implements IntArrayValuable {
|
||||||
|
EXEC(1, "单条命令返回结果"),
|
||||||
|
SHELL(2, "多行命令持续交互"),
|
||||||
|
SUBSYSTEM(3, "文件传输或其它特点服务"),
|
||||||
|
DIRECT_TCP_IP(4, "TCP 端口转发"),
|
||||||
|
X11(5, "X Window 系统转发");
|
||||||
|
|
||||||
|
public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(SSHChanelType::getCode).toArray();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态值
|
||||||
|
*/
|
||||||
|
private final Integer code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态名
|
||||||
|
*/
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int[] array() {
|
||||||
|
return ARRAYS;
|
||||||
|
}
|
||||||
|
}
|
@ -1,642 +0,0 @@
|
|||||||
package cd.casic.module.machine.handler;
|
|
||||||
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
|
||||||
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
|
||||||
import cd.casic.module.machine.enums.AuthenticationType;
|
|
||||||
import cd.casic.module.machine.utils.AliYunOssClient;
|
|
||||||
import cd.casic.module.machine.enums.ConnectionStatus;
|
|
||||||
import cd.casic.module.machine.service.SecretKeyService;
|
|
||||||
import com.jcraft.jsch.*;
|
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.util.StreamUtils;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
|
||||||
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
|
||||||
/**
|
|
||||||
* 优化后的SSH连接会话类
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class ConnectionSession implements AutoCloseable {
|
|
||||||
@Resource
|
|
||||||
SecretKeyService secretKeyService;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
AliYunOssClient aliYunOssClient;
|
|
||||||
|
|
||||||
private MachineInfoDO machineInfo;
|
|
||||||
private Session sshSession;
|
|
||||||
private ConnectionStatus status = ConnectionStatus.DISCONNECTED;
|
|
||||||
private final AtomicBoolean isExecuting = new AtomicBoolean(false);
|
|
||||||
|
|
||||||
// todo连接配置常量
|
|
||||||
private static final int CONNECTION_TIMEOUT = 5000; // 连接超时时间(毫秒)
|
|
||||||
private static final int COMMAND_TIMEOUT = 30000; // 命令执行超时时间(毫秒)
|
|
||||||
private static final int RETRY_COUNT = 3; // 重试次数
|
|
||||||
private static final int RETRY_DELAY = 1000; // 重试间隔(毫秒)
|
|
||||||
|
|
||||||
public ConnectionSession() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用setter注入MachineInfo
|
|
||||||
public void setMachineInfo(MachineInfoDO machineInfo) {
|
|
||||||
this.machineInfo = Objects.requireNonNull(machineInfo, "MachineInfo cannot be null");
|
|
||||||
log.debug("MachineInfo 已注入: {}", machineInfo.getHostIp());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 建立SSH连接,支持重试机制
|
|
||||||
*/
|
|
||||||
public synchronized void connect() throws JSchException {
|
|
||||||
if (status == ConnectionStatus.CONNECTED) {
|
|
||||||
log.debug("已经连接到 {}", machineInfo.getHostIp());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
status = ConnectionStatus.CONNECTING;
|
|
||||||
JSchException lastException = null;
|
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
||||||
try {
|
|
||||||
doConnect();
|
|
||||||
status = ConnectionStatus.CONNECTED;
|
|
||||||
log.info("SSH connection established successfully to {} (attempt {}/{})",
|
|
||||||
machineInfo.getHostIp(), attempt, RETRY_COUNT);
|
|
||||||
return;
|
|
||||||
} catch (JSchException e) {
|
|
||||||
lastException = e;
|
|
||||||
status = ConnectionStatus.CONNECTION_ERROR;
|
|
||||||
log.error("SSH connection attempt {}/{} failed: {}",
|
|
||||||
attempt, RETRY_COUNT, e.getMessage());
|
|
||||||
|
|
||||||
// 认证失败直接退出,无需重试
|
|
||||||
if (e.getMessage().contains("Auth fail")) {
|
|
||||||
status = ConnectionStatus.AUTH_FAILED;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试前等待
|
|
||||||
if (attempt < RETRY_COUNT) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(RETRY_DELAY);
|
|
||||||
} catch (InterruptedException ie) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new JSchException("Connection attempt interrupted", ie);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所有重试都失败
|
|
||||||
throw new JSchException("Failed to connect after " + RETRY_COUNT + " attempts", lastException);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 实际执行连接逻辑
|
|
||||||
*/
|
|
||||||
private void doConnect() throws JSchException, IOException {
|
|
||||||
JSch jsch = new JSch();
|
|
||||||
|
|
||||||
// 配置认证方式
|
|
||||||
configureAuthentication(jsch);
|
|
||||||
|
|
||||||
// 创建SSH会话
|
|
||||||
sshSession = jsch.getSession(
|
|
||||||
machineInfo.getUsername(),
|
|
||||||
machineInfo.getHostIp(),
|
|
||||||
machineInfo.getSshPort() != null ? machineInfo.getSshPort() : 22
|
|
||||||
);
|
|
||||||
|
|
||||||
// 配置连接参数
|
|
||||||
configureSession(sshSession);
|
|
||||||
|
|
||||||
// 建立连接
|
|
||||||
sshSession.connect(CONNECTION_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 配置认证方式(密码或密钥)
|
|
||||||
*/
|
|
||||||
private void configureAuthentication(JSch jsch) throws JSchException {
|
|
||||||
if (machineInfo.getAuthenticationType() == AuthenticationType.SECRET_KEY.getCode()) {
|
|
||||||
// 密钥认证
|
|
||||||
if (machineInfo.getSecretKeyId() == null) {
|
|
||||||
throw exception(SECRET_KEY_NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
String privateKeyContent = getPrivateKeyContent(machineInfo.getSecretKeyId());
|
|
||||||
// 验证私钥格式
|
|
||||||
if (!privateKeyContent.startsWith("-----BEGIN")) {
|
|
||||||
throw new JSchException("Invalid private key format. Expected OpenSSH format.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 尝试加载私钥
|
|
||||||
jsch.addIdentity(
|
|
||||||
machineInfo.getName(),
|
|
||||||
privateKeyContent.getBytes(StandardCharsets.UTF_8),
|
|
||||||
null,
|
|
||||||
null
|
|
||||||
);
|
|
||||||
log.info("Private key loaded successfully for {}", machineInfo.getHostIp());
|
|
||||||
} catch (JSchException e) {
|
|
||||||
log.error("Failed to load private key: {}", e.getMessage());
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
} else if (machineInfo.getAuthenticationType() == AuthenticationType.PASSWORD.getCode()) {
|
|
||||||
// 密码认证
|
|
||||||
if (StringUtils.isEmpty(machineInfo.getPassword())) {
|
|
||||||
throw new JSchException("Password is required for password-based authentication");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new JSchException("Unsupported authentication type: " + machineInfo.getAuthenticationType());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 配置SSH会话参数(安全增强)
|
|
||||||
*/
|
|
||||||
private void configureSession(Session session) {
|
|
||||||
Properties config = new Properties();
|
|
||||||
|
|
||||||
// 安全增强:默认验证主机密钥
|
|
||||||
if (isTrustedEnvironment()) {
|
|
||||||
log.warn("Running in trusted environment - disabling strict host key checking for {}",
|
|
||||||
machineInfo.getHostIp());
|
|
||||||
config.put("StrictHostKeyChecking", "no");
|
|
||||||
} else {
|
|
||||||
config.put("StrictHostKeyChecking", "yes");
|
|
||||||
// 可选:配置已知主机文件路径
|
|
||||||
//直接配置阿里云密钥地址
|
|
||||||
config.put("UserKnownHostsFile", secretKeyService.getSecretKey(machineInfo.getSecretKeyId()).getPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他安全配置
|
|
||||||
config.put("PreferredAuthentications", "publicKey,password,keyboard-interactive");
|
|
||||||
config.put("ServerAliveInterval", "30"); // 每30秒发送一次心跳
|
|
||||||
config.put("ServerAliveCountMax", "3"); // 允许3次心跳失败
|
|
||||||
|
|
||||||
session.setConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断是否为可信环境(生产环境应返回false)
|
|
||||||
*/
|
|
||||||
private boolean isTrustedEnvironment() {
|
|
||||||
// todo实际项目中应基于配置或环境变量判断
|
|
||||||
return System.getProperty("environment", "production").equalsIgnoreCase("development");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public synchronized void close() {
|
|
||||||
disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void disconnect() {
|
|
||||||
if (sshSession != null && sshSession.isConnected()) {
|
|
||||||
try {
|
|
||||||
sshSession.disconnect();
|
|
||||||
log.info("SSH connection closed: {}", machineInfo.getHostIp());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error closing SSH session: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status = ConnectionStatus.DISCONNECTED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行远程命令,支持超时和中断处理
|
|
||||||
*/
|
|
||||||
public String executeCommand(String command) throws JSchException, IOException {
|
|
||||||
if (!isConnected()) {
|
|
||||||
throw new IllegalStateException("Session is not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isExecuting.compareAndSet(false, true)) {
|
|
||||||
throw new IllegalStateException("Another command is already executing");
|
|
||||||
}
|
|
||||||
|
|
||||||
Channel channel = null;
|
|
||||||
InputStream inputStream = null;
|
|
||||||
ByteArrayOutputStream outputStream = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
channel = createExecChannel(command);
|
|
||||||
inputStream = channel.getInputStream();
|
|
||||||
outputStream = new ByteArrayOutputStream();
|
|
||||||
|
|
||||||
// 连接通道并设置超时
|
|
||||||
channel.connect(COMMAND_TIMEOUT);
|
|
||||||
|
|
||||||
// 读取命令输出
|
|
||||||
return readCommandOutput(inputStream, outputStream, channel);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new IOException("Command execution interrupted", e);
|
|
||||||
} finally {
|
|
||||||
// 释放资源
|
|
||||||
closeResources(channel, inputStream, outputStream);
|
|
||||||
isExecuting.set(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建并配置命令执行通道
|
|
||||||
*/
|
|
||||||
private Channel createExecChannel(String command) throws JSchException {
|
|
||||||
Channel channel = sshSession.openChannel("exec");
|
|
||||||
((ChannelExec) channel).setCommand(command);
|
|
||||||
|
|
||||||
// 配置通道
|
|
||||||
channel.setInputStream(null);
|
|
||||||
((ChannelExec) channel).setErrStream(new ByteArrayOutputStream()); // 捕获错误输出
|
|
||||||
|
|
||||||
return channel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取命令输出
|
|
||||||
*/
|
|
||||||
private String readCommandOutput(InputStream inputStream,
|
|
||||||
ByteArrayOutputStream outputStream,
|
|
||||||
Channel channel) throws IOException, InterruptedException {
|
|
||||||
byte[] buffer = new byte[1024];
|
|
||||||
|
|
||||||
|
|
||||||
// 使用线程中断机制实现超时控制
|
|
||||||
Thread readerThread = new Thread(() -> {
|
|
||||||
int bytesRead;
|
|
||||||
try {
|
|
||||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
|
||||||
outputStream.write(buffer, 0, bytesRead);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
// 通道关闭或读取异常
|
|
||||||
if (channel.isConnected()) {
|
|
||||||
log.warn("Error reading command output: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
readerThread.start();
|
|
||||||
|
|
||||||
// 等待命令执行完成或超时
|
|
||||||
readerThread.join(COMMAND_TIMEOUT);
|
|
||||||
|
|
||||||
// 如果线程仍在运行,中断并关闭通道
|
|
||||||
if (readerThread.isAlive()) {
|
|
||||||
readerThread.interrupt();
|
|
||||||
channel.disconnect();
|
|
||||||
throw new IOException("Command execution timed out after " + COMMAND_TIMEOUT + "ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待通道完全关闭
|
|
||||||
while (channel.isConnected()) {
|
|
||||||
Thread.sleep(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputStream.toString(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭资源
|
|
||||||
*/
|
|
||||||
private void closeResources(Channel channel, InputStream inputStream, OutputStream outputStream) {
|
|
||||||
if (outputStream != null) {
|
|
||||||
try {
|
|
||||||
outputStream.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.warn("Error closing output stream: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inputStream != null) {
|
|
||||||
try {
|
|
||||||
inputStream.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.warn("Error closing input stream: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel != null && channel.isConnected()) {
|
|
||||||
channel.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件到远程服务器
|
|
||||||
*/
|
|
||||||
public boolean uploadFile(String localFilePath, String remoteFilePath) throws IOException {
|
|
||||||
if (!isConnected()) {
|
|
||||||
throw new IllegalStateException("Cannot upload file: SSH session is not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查本地文件是否存在且可读
|
|
||||||
File localFile = new File(localFilePath);
|
|
||||||
if (!localFile.exists()) {
|
|
||||||
throw new FileNotFoundException("Local file not found: " + localFilePath);
|
|
||||||
}
|
|
||||||
if (!localFile.canRead()) {
|
|
||||||
throw new IOException("Cannot read local file: " + localFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChannelSftp channel = null;
|
|
||||||
boolean uploadSuccess = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建并连接SFTP通道,设置超时
|
|
||||||
channel = (ChannelSftp) sshSession.openChannel("sftp");
|
|
||||||
channel.connect(CONNECTION_TIMEOUT);
|
|
||||||
|
|
||||||
// 确保目标目录存在
|
|
||||||
createRemoteDirectoryIfNotExists(channel, getParentDirectory(remoteFilePath));
|
|
||||||
|
|
||||||
// 使用更健壮的上传方式
|
|
||||||
channel.put(
|
|
||||||
new FileInputStream(localFile),
|
|
||||||
remoteFilePath,
|
|
||||||
new ProgressMonitorAdapter(localFile.length()),
|
|
||||||
ChannelSftp.OVERWRITE
|
|
||||||
);
|
|
||||||
|
|
||||||
uploadSuccess = true;
|
|
||||||
log.info("File uploaded successfully: {} -> {}", localFilePath, remoteFilePath);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (SftpException e) {
|
|
||||||
log.error("SFTP error during file upload ({} -> {}): {}",
|
|
||||||
localFilePath, remoteFilePath, e.getMessage(), e);
|
|
||||||
throw new IOException("SFTP error: " + e.getMessage(), e);
|
|
||||||
} catch (FileNotFoundException e) {
|
|
||||||
log.error("Local file not found during upload: {}", localFilePath, e);
|
|
||||||
throw e;
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("IO error during file upload: {}", e.getMessage(), e);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Unexpected error during file upload: {}", e.getMessage(), e);
|
|
||||||
throw new IOException("Unexpected error: " + e.getMessage(), e);
|
|
||||||
} finally {
|
|
||||||
// 确保通道始终被关闭
|
|
||||||
disconnectChannel(channel);
|
|
||||||
|
|
||||||
// // 如果上传失败,尝试删除不完整的文件
|
|
||||||
// if (!uploadSuccess && remoteFilePath != null && !remoteFilePath.isEmpty()) {
|
|
||||||
// tryDeleteIncompleteFile(remoteFilePath);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean downloadFile(String remoteFilePath, String localFilePath) throws IOException {
|
|
||||||
if (!isConnected()) {
|
|
||||||
throw new IllegalStateException("Cannot download file: SSH session is not connected");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查本地目录是否存在且可写
|
|
||||||
File localFile = new File(localFilePath);
|
|
||||||
File parentDir = localFile.getParentFile();
|
|
||||||
if (parentDir != null && !parentDir.exists()) {
|
|
||||||
if (!parentDir.mkdirs()) {
|
|
||||||
throw new IOException("Failed to create local directory: " + parentDir.getAbsolutePath());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parentDir != null && !parentDir.canWrite()) {
|
|
||||||
throw new IOException("Cannot write to local directory: " + parentDir.getAbsolutePath());
|
|
||||||
}
|
|
||||||
|
|
||||||
ChannelSftp channel = null;
|
|
||||||
boolean downloadSuccess = false;
|
|
||||||
File tempFile = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建并连接SFTP通道,设置超时
|
|
||||||
channel = (ChannelSftp) sshSession.openChannel("sftp");
|
|
||||||
channel.connect(CONNECTION_TIMEOUT);
|
|
||||||
|
|
||||||
// 检查远程文件是否存在
|
|
||||||
SftpATTRS attrs = channel.stat(remoteFilePath);
|
|
||||||
long fileSize = attrs.getSize();
|
|
||||||
|
|
||||||
// 使用临时文件避免部分下载覆盖完整文件
|
|
||||||
tempFile = new File(localFilePath + ".part");
|
|
||||||
|
|
||||||
// 执行下载并监控进度
|
|
||||||
channel.get(
|
|
||||||
remoteFilePath,
|
|
||||||
new FileOutputStream(tempFile).toString(),
|
|
||||||
new ProgressMonitorAdapter(fileSize),
|
|
||||||
ChannelSftp.OVERWRITE
|
|
||||||
);
|
|
||||||
|
|
||||||
// 验证下载完整性
|
|
||||||
if (tempFile.length() != fileSize) {
|
|
||||||
throw new IOException("Download incomplete: expected " + fileSize +
|
|
||||||
" bytes, but got " + tempFile.length() + " bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重命名临时文件为目标文件(原子操作)
|
|
||||||
if (!tempFile.renameTo(localFile)) {
|
|
||||||
throw new IOException("Failed to rename temporary file to: " + localFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSuccess = true;
|
|
||||||
log.info("File downloaded successfully: {} -> {}", remoteFilePath, localFilePath);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (SftpException e) {
|
|
||||||
log.error("SFTP error during file download ({} -> {}): {}",
|
|
||||||
remoteFilePath, localFilePath, e.getMessage(), e);
|
|
||||||
if (e.id == ChannelSftp.SSH_FX_NO_SUCH_FILE) {
|
|
||||||
throw new FileNotFoundException("Remote file not found: " + remoteFilePath);
|
|
||||||
}
|
|
||||||
throw new IOException("SFTP error: " + e.getMessage(), e);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("IO error during file download: {}", e.getMessage(), e);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Unexpected error during file download: {}", e.getMessage(), e);
|
|
||||||
throw new IOException("Unexpected error: " + e.getMessage(), e);
|
|
||||||
} finally {
|
|
||||||
// 确保通道始终被关闭
|
|
||||||
disconnectChannel(channel);
|
|
||||||
|
|
||||||
// 如果下载失败,清理临时文件
|
|
||||||
if (!downloadSuccess && tempFile != null && tempFile.exists()) {
|
|
||||||
if (tempFile.delete()) {
|
|
||||||
log.debug("Deleted incomplete temporary file: {}", tempFile.getAbsolutePath());
|
|
||||||
} else {
|
|
||||||
log.warn("Failed to delete incomplete temporary file: {}", tempFile.getAbsolutePath());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建远程目录(如果不存在)
|
|
||||||
private void createRemoteDirectoryIfNotExists(ChannelSftp channel, String directory) throws SftpException {
|
|
||||||
if (directory == null || directory.isEmpty() || directory.equals("/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
channel.stat(directory);
|
|
||||||
} catch (SftpException e) {
|
|
||||||
// 目录不存在,尝试创建
|
|
||||||
createRemoteDirectoryIfNotExists(channel, getParentDirectory(directory));
|
|
||||||
channel.mkdir(directory);
|
|
||||||
log.debug("Created remote directory: {}", directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取路径的父目录
|
|
||||||
private String getParentDirectory(String path) {
|
|
||||||
int lastSlash = path.lastIndexOf('/');
|
|
||||||
return lastSlash > 0 ? path.substring(0, lastSlash) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 断开SFTP通道
|
|
||||||
private void disconnectChannel(Channel channel) {
|
|
||||||
if (channel != null && channel.isConnected()) {
|
|
||||||
try {
|
|
||||||
channel.disconnect();
|
|
||||||
log.debug("SFTP channel disconnected");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Error disconnecting SFTP channel: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 尝试删除不完整的文件
|
|
||||||
private void tryDeleteIncompleteFile(String remoteFilePath) {
|
|
||||||
ChannelSftp channel = null;
|
|
||||||
try {
|
|
||||||
channel = (ChannelSftp) sshSession.openChannel("sftp");
|
|
||||||
channel.connect(CONNECTION_TIMEOUT);
|
|
||||||
channel.rm(remoteFilePath);
|
|
||||||
log.info("Deleted incomplete file: {}", remoteFilePath);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to delete incomplete file {}: {}", remoteFilePath, e.getMessage());
|
|
||||||
} finally {
|
|
||||||
disconnectChannel(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 增强的进度监控器
|
|
||||||
private static class ProgressMonitorAdapter implements SftpProgressMonitor {
|
|
||||||
private final long totalBytes;
|
|
||||||
private long bytesWritten = 0;
|
|
||||||
private int lastProgress = 0;
|
|
||||||
private final long startTime = System.currentTimeMillis();
|
|
||||||
|
|
||||||
public ProgressMonitorAdapter(long totalBytes) {
|
|
||||||
this.totalBytes = totalBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean count(long count) {
|
|
||||||
bytesWritten += count;
|
|
||||||
|
|
||||||
// 计算进度百分比
|
|
||||||
int progress = (int) ((bytesWritten * 100) / totalBytes);
|
|
||||||
|
|
||||||
// 每10%或每秒更新一次日志
|
|
||||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
|
||||||
if (progress - lastProgress >= 10 || elapsedTime >= 1000) {
|
|
||||||
double speed = bytesWritten / (elapsedTime / 1000.0);
|
|
||||||
String speedStr = formatTransferSpeed(speed);
|
|
||||||
|
|
||||||
log.debug("Upload progress: {}% ({}/{} bytes, {})",
|
|
||||||
progress, bytesWritten, totalBytes, speedStr);
|
|
||||||
lastProgress = progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true; // 返回true继续传输,返回false中断传输
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void end() {
|
|
||||||
long elapsedTime = System.currentTimeMillis() - startTime;
|
|
||||||
double speed = totalBytes / (elapsedTime / 1000.0);
|
|
||||||
String speedStr = formatTransferSpeed(speed);
|
|
||||||
|
|
||||||
log.info("Upload completed: {} bytes in {} ms (avg speed: {})",
|
|
||||||
totalBytes, elapsedTime, speedStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void init(int op, String src, String dest, long max) {
|
|
||||||
log.info("Starting upload: {} -> {} ({} bytes)", src, dest, max);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化传输速度
|
|
||||||
private String formatTransferSpeed(double bytesPerSecond) {
|
|
||||||
String[] units = {"B/s", "KB/s", "MB/s", "GB/s"};
|
|
||||||
int unitIndex = 0;
|
|
||||||
|
|
||||||
while (bytesPerSecond >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
bytesPerSecond /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format("%.2f %s", bytesPerSecond, units[unitIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查连接状态
|
|
||||||
*/
|
|
||||||
public ConnectionStatus getStatus() {
|
|
||||||
if (status == ConnectionStatus.CONNECTED &&
|
|
||||||
(sshSession == null || !sshSession.isConnected())) {
|
|
||||||
status = ConnectionStatus.DISCONNECTED;
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否已连接
|
|
||||||
*/
|
|
||||||
public boolean isConnected() {
|
|
||||||
return status == ConnectionStatus.CONNECTED &&
|
|
||||||
sshSession != null &&
|
|
||||||
sshSession.isConnected();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private String getPrivateKeyContent(Long secretKeyId) {
|
|
||||||
if (secretKeyId == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
SecretKeyVO secretKey = secretKeyService.getSecretKey(secretKeyId);
|
|
||||||
byte[] content;
|
|
||||||
try {
|
|
||||||
content = aliYunOssClient.getContent(secretKey.getPath().substring(secretKey.getPath().lastIndexOf("/") + 1));
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
//改为S3FileClient读取
|
|
||||||
InputStream read = new ByteArrayInputStream(content);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return StreamUtils.copyToString(read, StandardCharsets.UTF_8);
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("读取私钥文件失败", e);
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,67 @@
|
|||||||
|
package cd.casic.module.machine.handler;
|
||||||
|
|
||||||
|
import cd.casic.module.machine.component.WebSocketConnection;
|
||||||
|
import cd.casic.module.machine.component.WebSocketSessionManager;
|
||||||
|
import cd.casic.module.machine.enums.ConnectionStatus;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
|
|
||||||
|
//WebSocket处理器
|
||||||
|
@Component("machineWebSocketHandler")
|
||||||
|
public class MachineWebSocketHandler extends TextWebSocketHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionEstablished(@NotNull WebSocketSession webSocketSession) {
|
||||||
|
Long machineId = (Long) webSocketSession.getAttributes().get("machineId");
|
||||||
|
//保存webSocketSession信息
|
||||||
|
WebSocketSessionManager.addWebSocketSession(webSocketSession.getId(), webSocketSession);
|
||||||
|
WebSocketSessionManager.addWebSocketConnection(webSocketSession.getId(), WebSocketSessionManager.getWebSocketConnectionByMachineId(machineId));
|
||||||
|
try {
|
||||||
|
webSocketSession.sendMessage(new TextMessage("欢迎连接到 WebSocket 服务器!"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw exception(WEBSOCKET_SEND_MESSAGE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文本消息
|
||||||
|
@Override
|
||||||
|
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
String sessionId = webSocketSession.getId();
|
||||||
|
// 从管理器获取连接
|
||||||
|
WebSocketConnection webSocketConnection = WebSocketSessionManager.getWebSocketConnection(sessionId);
|
||||||
|
|
||||||
|
if (webSocketConnection != null && ConnectionStatus.CONNECTING.equals(webSocketConnection.getConnectionStatus())) {
|
||||||
|
// 转发消息到远程机器
|
||||||
|
webSocketConnection.executeCommand(webSocketSession, payload);
|
||||||
|
} else if (webSocketConnection != null) {
|
||||||
|
webSocketConnection.sendErrorMessage(webSocketSession, "连接已断开,无法发送消息");
|
||||||
|
} else {
|
||||||
|
throw exception(WEBSOCKET_SEND_MESSAGE_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterConnectionClosed(WebSocketSession webSocketSession, @NotNull CloseStatus status) {
|
||||||
|
String sessionId = webSocketSession.getId();
|
||||||
|
// 获取并关闭相关的 WebSocketConnection
|
||||||
|
WebSocketConnection webSocketConnection = WebSocketSessionManager.getWebSocketConnection(sessionId);
|
||||||
|
if (webSocketConnection != null) {
|
||||||
|
webSocketConnection.disconnect();
|
||||||
|
}
|
||||||
|
// 从管理器中移除会话和连接
|
||||||
|
WebSocketSessionManager.removeWebSocketSession(sessionId);
|
||||||
|
WebSocketSessionManager.removeWebSocketConnection(sessionId);
|
||||||
|
Long machineInfoId = (Long) webSocketSession.getAttributes().get("machineId");
|
||||||
|
WebSocketSessionManager.removeWebSocketConnectionByMachineId(machineInfoId);
|
||||||
|
}
|
||||||
|
}
|
@ -5,15 +5,21 @@ import cd.casic.module.machine.controller.vo.MachineInfoVO;
|
|||||||
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
import cd.casic.module.machine.enums.ConnectionStatus;
|
import cd.casic.module.machine.enums.ConnectionStatus;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public interface MachineInfoService {
|
public interface MachineInfoService {
|
||||||
|
/**
|
||||||
|
* 新增机器
|
||||||
|
* @return 新增机器的id
|
||||||
|
*/
|
||||||
Long createMachine(MachineInfoVO MachineInfoVO);
|
Long createMachine(MachineInfoVO MachineInfoVO);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查看机器列表
|
||||||
|
* @return 分院
|
||||||
|
*/
|
||||||
PageResult<MachineInfoDO> listMachineInfo(@Valid MachineInfoVO MachineInfoVO);
|
PageResult<MachineInfoDO> listMachineInfo(@Valid MachineInfoVO MachineInfoVO);
|
||||||
|
|
||||||
void updateMachineInfo(@Valid MachineInfoVO machineInfoVO);
|
void updateMachineInfo(@Valid MachineInfoVO machineInfoVO);
|
||||||
@ -35,63 +41,61 @@ public interface MachineInfoService {
|
|||||||
*/
|
*/
|
||||||
boolean testConnection(Long id);
|
boolean testConnection(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接远程机器
|
||||||
|
*
|
||||||
|
* @param id 机器id
|
||||||
|
* @return 连接后端文件树
|
||||||
|
*/
|
||||||
|
Map<String, Object> connect(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取机器连接状态
|
* 获取机器连接状态
|
||||||
*
|
*
|
||||||
* @param machineName 机器名称
|
|
||||||
* @return 连接状态
|
* @return 连接状态
|
||||||
*/
|
*/
|
||||||
ConnectionStatus getConnectionStatus(String machineName);
|
ConnectionStatus getConnectionStatus(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有连接状态
|
* 获取所有连接状态
|
||||||
*
|
*
|
||||||
* @return 机器名称到连接状态的映射
|
* @return 机器名称到连接状态的映射
|
||||||
*/
|
*/
|
||||||
Map<String, ConnectionStatus> getAllConnectionStatus();
|
Map<Long, ConnectionStatus> getAllConnectionStatus();
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * 上传文件到远程机器
|
||||||
|
// *
|
||||||
|
// * @param sessionId 会话ID
|
||||||
|
// * @param localFilePath 本地文件路径
|
||||||
|
// * @param remoteFilePath 远程文件路径
|
||||||
|
// * @return 操作结果
|
||||||
|
// */
|
||||||
|
// boolean uploadFile(String sessionId, String localFilePath, String remoteFilePath);
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * 从远程机器下载文件
|
||||||
|
// *
|
||||||
|
// * @param sessionId 会话ID
|
||||||
|
// * @param remoteFilePath 远程文件路径
|
||||||
|
// * @param localFilePath 本地文件路径
|
||||||
|
// * @return 操作结果
|
||||||
|
// */
|
||||||
|
// boolean downloadFile(String sessionId, String remoteFilePath, String localFilePath);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 建立机器连接
|
* 校验机器是否存在
|
||||||
*
|
*
|
||||||
* @param machineInfoDO 机器信息
|
* @param id 机器id
|
||||||
* @return 连接会话ID
|
|
||||||
*/
|
*/
|
||||||
String connect(MachineInfoDO machineInfoDO);
|
MachineInfoDO validateMachineInfoExists(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开机器连接
|
* 根据路径获得远程文件树
|
||||||
*
|
*
|
||||||
* @param sessionId 会话ID
|
* @param machineId 机器id
|
||||||
* @return 操作结果
|
* @param path 文件夹路径
|
||||||
|
* @return 远程文件树
|
||||||
*/
|
*/
|
||||||
boolean disconnect(String sessionId);
|
Map<String, Object> fileTreeNode(Long machineId, String path);
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行远程命令
|
|
||||||
*
|
|
||||||
* @param sessionId 会话ID
|
|
||||||
* @param command 命令
|
|
||||||
* @return 命令执行结果
|
|
||||||
*/
|
|
||||||
String executeCommand(String sessionId, String command);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件到远程机器
|
|
||||||
*
|
|
||||||
* @param sessionId 会话ID
|
|
||||||
* @param localFilePath 本地文件路径
|
|
||||||
* @param remoteFilePath 远程文件路径
|
|
||||||
* @return 操作结果
|
|
||||||
*/
|
|
||||||
boolean uploadFile(String sessionId, String localFilePath, String remoteFilePath);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从远程机器下载文件
|
|
||||||
*
|
|
||||||
* @param sessionId 会话ID
|
|
||||||
* @param remoteFilePath 远程文件路径
|
|
||||||
* @param localFilePath 本地文件路径
|
|
||||||
* @return 操作结果
|
|
||||||
*/
|
|
||||||
boolean downloadFile(String sessionId, String remoteFilePath, String localFilePath);
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
package cd.casic.module.machine.service;
|
package cd.casic.module.machine.service;
|
||||||
|
|
||||||
import cd.casic.framework.commons.pojo.PageResult;
|
import cd.casic.framework.commons.pojo.PageResult;
|
||||||
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
||||||
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -20,6 +23,14 @@ public interface SecretKeyService {
|
|||||||
|
|
||||||
SecretKeyVO getSecretKey(Long id);
|
SecretKeyVO getSecretKey(Long id);
|
||||||
|
|
||||||
|
void deleteSecretKey(Long id);
|
||||||
|
|
||||||
void unbindMachine(@Valid SecretKeyVO secretKeyVO);
|
void unbindMachine(@Valid SecretKeyVO secretKeyVO);
|
||||||
|
|
||||||
|
ResponseEntity<InputStreamResource> downloadSecretFile(Long id);
|
||||||
|
|
||||||
|
List<MachineInfoDO> getBindMachine(Long secretKeyId);
|
||||||
|
|
||||||
|
String getKeyContent(Long secretKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
package cd.casic.module.machine.service.impl;
|
package cd.casic.module.machine.service.impl;
|
||||||
|
|
||||||
import cd.casic.framework.commons.pojo.PageResult;
|
import cd.casic.framework.commons.pojo.PageResult;
|
||||||
|
import cd.casic.module.machine.component.FileTreeComponent;
|
||||||
|
import cd.casic.module.machine.component.WebSocketSessionManager;
|
||||||
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
||||||
import cd.casic.module.machine.enums.AuthenticationType;
|
import cd.casic.module.machine.enums.AuthenticationType;
|
||||||
import cd.casic.module.machine.enums.MachineInfoType;
|
import cd.casic.module.machine.enums.MachineInfoType;
|
||||||
import cd.casic.module.machine.handler.ConnectionSession;
|
|
||||||
import cd.casic.module.machine.dal.mysql.MachineInfoMapper;
|
import cd.casic.module.machine.dal.mysql.MachineInfoMapper;
|
||||||
import cd.casic.module.machine.controller.vo.MachineInfoVO;
|
import cd.casic.module.machine.controller.vo.MachineInfoVO;
|
||||||
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
import cd.casic.module.machine.enums.ConnectionStatus;
|
import cd.casic.module.machine.enums.ConnectionStatus;
|
||||||
import cd.casic.module.machine.enums.MachineInfoStatus;
|
import cd.casic.module.machine.enums.MachineInfoStatus;
|
||||||
|
import cd.casic.module.machine.component.WebSocketConnection;
|
||||||
import cd.casic.module.machine.service.MachineInfoService;
|
import cd.casic.module.machine.service.MachineInfoService;
|
||||||
import cd.casic.module.machine.service.SecretKeyService;
|
import cd.casic.module.machine.service.SecretKeyService;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import cd.casic.framework.commons.util.object.BeanUtils;
|
import cd.casic.framework.commons.util.object.BeanUtils;
|
||||||
@ -20,8 +23,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.stream.Collectors;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
||||||
@ -35,24 +37,12 @@ public class MachineInfoServiceImpl implements MachineInfoService {
|
|||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SecretKeyService secretKeyService;
|
private SecretKeyService secretKeyService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private MachineInfoMapper machineInfoMapper;
|
private MachineInfoMapper machineInfoMapper;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ConnectionSession connectionSession;
|
private FileTreeComponent fileTreeComponent;
|
||||||
/**
|
|
||||||
* 会话ID生成器
|
|
||||||
*/
|
|
||||||
private final AtomicInteger sessionIdGenerator = new AtomicInteger(1000);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话管理:会话ID -> 连接会话
|
|
||||||
*/
|
|
||||||
private final Map<String, ConnectionSession> sessions = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 机器名称 -> 会话ID
|
|
||||||
*/
|
|
||||||
private final Map<String, String> machineSessionMapping = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Long createMachine(MachineInfoVO machineInfoVO) {
|
public Long createMachine(MachineInfoVO machineInfoVO) {
|
||||||
@ -124,162 +114,68 @@ public class MachineInfoServiceImpl implements MachineInfoService {
|
|||||||
MachineInfoDO machineInfoDO = validateMachineInfoExists(id);
|
MachineInfoDO machineInfoDO = validateMachineInfoExists(id);
|
||||||
validateMachineUnEnable(machineInfoDO);
|
validateMachineUnEnable(machineInfoDO);
|
||||||
log.info("测试机器连接: {}", machineInfoDO.getHostIp());
|
log.info("测试机器连接: {}", machineInfoDO.getHostIp());
|
||||||
connectionSession.setMachineInfo(machineInfoDO);
|
WebSocketConnection webSocketConnection = createWebSocketConnection(machineInfoDO);
|
||||||
try {
|
webSocketConnection.initConnection(machineInfoDO);
|
||||||
connectionSession.connect();
|
return true;
|
||||||
return true;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("机器连接测试失败: {}", e.getMessage(), e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ConnectionStatus getConnectionStatus(String machineName) {
|
public Map<String, Object> connect(Long id) {
|
||||||
String sessionId = machineSessionMapping.get(machineName);
|
//todo使用代理机器的情况还未完成
|
||||||
if (sessionId == null) {
|
WebSocketConnection webSocketConnection = new WebSocketConnection(this.secretKeyService);
|
||||||
return ConnectionStatus.DISCONNECTED;
|
MachineInfoDO machineInfoDO = validateMachineInfoExists(id);
|
||||||
}
|
//初始化连接
|
||||||
|
webSocketConnection.initConnection(machineInfoDO);
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
WebSocketSessionManager.addWebSocketConnectionByMachineId(id, webSocketConnection);
|
||||||
return session != null ? session.getStatus() : ConnectionStatus.DISCONNECTED;
|
return fileTreeComponent.getRemoteFileTree(webSocketConnection.getSshSession(), "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Map<String, ConnectionStatus> getAllConnectionStatus() {
|
public ConnectionStatus getConnectionStatus(Long id) {
|
||||||
Map<String, ConnectionStatus> result = new HashMap<>();
|
validateMachineInfoExists(id);
|
||||||
|
WebSocketConnection webSocketConnection = WebSocketSessionManager.getWebSocketConnectionByMachineId(id);
|
||||||
machineSessionMapping.forEach((machineName, sessionId) -> {
|
return webSocketConnection == null ? ConnectionStatus.DISCONNECTED : webSocketConnection.getConnectionStatus();
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
|
||||||
result.put(machineName, session != null ? session.getStatus() : ConnectionStatus.DISCONNECTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String connect(MachineInfoDO machineInfoDO) {
|
public Map<Long, ConnectionStatus> getAllConnectionStatus() {
|
||||||
//先查询机器是否存在,在判断机器可用性
|
return WebSocketSessionManager.getAllWebSocketConnections().entrySet().stream()
|
||||||
validateMachineUnEnable(machineInfoDO);
|
.collect(Collectors.toMap(
|
||||||
log.info("建立机器连接: {}", machineInfoDO.getHostIp());
|
Map.Entry::getKey,
|
||||||
|
entry -> entry.getValue().getConnectionStatus()
|
||||||
// 检查是否已连接
|
));
|
||||||
String existingSessionId = machineSessionMapping.get(machineInfoDO.getName());
|
|
||||||
if (existingSessionId != null) {
|
|
||||||
ConnectionSession existingSession = sessions.get(existingSessionId);
|
|
||||||
if (existingSession != null && existingSession.getStatus() == ConnectionStatus.CONNECTED) {
|
|
||||||
log.info("机器已连接,返回现有会话: {}", machineInfoDO.getHostIp());
|
|
||||||
return existingSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
connectionSession.setMachineInfo(machineInfoDO);
|
|
||||||
|
|
||||||
connectionSession.connect();
|
|
||||||
|
|
||||||
// 生成会话ID
|
|
||||||
String sessionId = generateSessionId();
|
|
||||||
|
|
||||||
// 保存会话
|
|
||||||
sessions.put(sessionId, connectionSession);
|
|
||||||
machineSessionMapping.put(machineInfoDO.getName(), sessionId);
|
|
||||||
|
|
||||||
log.info("机器连接成功: {}, 会话ID: {}", machineInfoDO.getHostIp(), sessionId);
|
|
||||||
return sessionId;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("机器连接失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("机器连接失败: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
// @Override
|
||||||
public boolean disconnect(String sessionId) {
|
// public boolean uploadFile(String sessionId, String localFilePath, String remoteFilePath) {
|
||||||
log.info("断开机器连接: {}", sessionId);
|
// log.info("上传文件: {} -> {}, 会话ID: {}", localFilePath, remoteFilePath, sessionId);
|
||||||
|
// WebSocketConnection webSocketConnection = sessionConnectionMap.get(sessionId);
|
||||||
|
// if (webSocketConnection == null||webSocketConnection.getConnectionStatus() != ConnectionStatus.CONNECTED) {
|
||||||
|
// throw exception(SESSION_NOT_CONNECT);
|
||||||
|
// }
|
||||||
|
// try {
|
||||||
|
// return webSocketConnection.uploadFile(localFilePath, remoteFilePath);
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// log.error("文件上传失败: {}", e.getMessage(), e);
|
||||||
|
// throw exception(FILE_UPLOAD_ERROR);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
// @Override
|
||||||
if (session == null) {
|
// public boolean downloadFile(String sessionId, String remoteFilePath, String localFilePath) {
|
||||||
log.warn("会话不存在: {}", sessionId);
|
// log.info("下载文件: {} -> {}, 会话ID: {}", remoteFilePath, localFilePath, sessionId);
|
||||||
return false;
|
//
|
||||||
}
|
// WebSocketConnection webSocketConnection = sessionConnectionMap.get(sessionId);
|
||||||
|
// if (webSocketConnection == null||webSocketConnection.getConnectionStatus() != ConnectionStatus.CONNECTED) {
|
||||||
try {
|
// throw new RuntimeException("会话不存在: " + sessionId);
|
||||||
session.disconnect();
|
// }
|
||||||
|
// try {
|
||||||
// 清理会话
|
// return webSocketConnection.downloadFile(remoteFilePath, localFilePath);
|
||||||
sessions.remove(sessionId);
|
// } catch (Exception e) {
|
||||||
machineSessionMapping.entrySet().removeIf(entry -> entry.getValue().equals(sessionId));
|
// log.error("文件下载失败: {}", e.getMessage(), e);
|
||||||
|
// throw exception(FILE_DOWNLOAD_ERROR);
|
||||||
log.info("机器连接已断开: {}", sessionId);
|
// }
|
||||||
return true;
|
// }
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("断开连接失败: {}", e.getMessage(), e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String executeCommand(String sessionId, String command) {
|
|
||||||
log.info("执行命令: {}, 会话ID: {}", command, sessionId);
|
|
||||||
|
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
|
||||||
if (session == null) {
|
|
||||||
throw new RuntimeException("会话不存在: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.getStatus() != ConnectionStatus.CONNECTED) {
|
|
||||||
throw new RuntimeException("会话未连接: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return session.executeCommand(command);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("命令执行失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("命令执行失败: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean uploadFile(String sessionId, String localFilePath, String remoteFilePath) {
|
|
||||||
log.info("上传文件: {} -> {}, 会话ID: {}", localFilePath, remoteFilePath, sessionId);
|
|
||||||
|
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
|
||||||
if (session == null) {
|
|
||||||
throw new RuntimeException("会话不存在: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.getStatus() != ConnectionStatus.CONNECTED) {
|
|
||||||
throw new RuntimeException("会话未连接: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return session.uploadFile(localFilePath, remoteFilePath);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("文件上传失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean downloadFile(String sessionId, String remoteFilePath, String localFilePath) {
|
|
||||||
log.info("下载文件: {} -> {}, 会话ID: {}", remoteFilePath, localFilePath, sessionId);
|
|
||||||
|
|
||||||
ConnectionSession session = sessions.get(sessionId);
|
|
||||||
if (session == null) {
|
|
||||||
throw new RuntimeException("会话不存在: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.getStatus() != ConnectionStatus.CONNECTED) {
|
|
||||||
throw new RuntimeException("会话未连接: " + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return session.downloadFile(remoteFilePath, localFilePath);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("文件下载失败: {}", e.getMessage(), e);
|
|
||||||
throw new RuntimeException("文件下载失败: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void validateMachineEnvAdd(MachineInfoVO machineInfoVO) {
|
void validateMachineEnvAdd(MachineInfoVO machineInfoVO) {
|
||||||
@ -333,8 +229,8 @@ public class MachineInfoServiceImpl implements MachineInfoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@Override
|
||||||
MachineInfoDO validateMachineInfoExists(Long id) {
|
public MachineInfoDO validateMachineInfoExists(Long id) {
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
throw exception(MACHINE_INFO_NULL);
|
throw exception(MACHINE_INFO_NULL);
|
||||||
}
|
}
|
||||||
@ -345,6 +241,13 @@ public class MachineInfoServiceImpl implements MachineInfoService {
|
|||||||
return machineInfoDO;
|
return machineInfoDO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> fileTreeNode(Long machineId, String path) {
|
||||||
|
validateMachineInfoExists(machineId);
|
||||||
|
Session sshSession = WebSocketSessionManager.getWebSocketConnectionByMachineId(machineId).getSshSession();
|
||||||
|
return fileTreeComponent.getRemoteFileTree(sshSession, path);
|
||||||
|
}
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
void validateMachineEnable(MachineInfoDO machineInfoDO) {
|
void validateMachineEnable(MachineInfoDO machineInfoDO) {
|
||||||
if (machineInfoDO.getStatus() == MachineInfoStatus.ENABLE.getCode()) {
|
if (machineInfoDO.getStatus() == MachineInfoStatus.ENABLE.getCode()) {
|
||||||
@ -360,10 +263,12 @@ public class MachineInfoServiceImpl implements MachineInfoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@VisibleForTesting
|
||||||
* 生成会话ID
|
WebSocketConnection createWebSocketConnection(MachineInfoDO machineInfoDO) {
|
||||||
*/
|
if (WebSocketSessionManager.containsMachineId(machineInfoDO.getId())) {
|
||||||
private String generateSessionId() {
|
return WebSocketSessionManager.getWebSocketConnectionByMachineId((machineInfoDO.getId()));
|
||||||
return "session-" + sessionIdGenerator.incrementAndGet();
|
} else {
|
||||||
|
return new WebSocketConnection(this.secretKeyService);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ import cd.casic.framework.commons.util.object.BeanUtils;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
@ -38,7 +37,6 @@ public class MachineProxyServiceImpl implements MachineProxyService {
|
|||||||
validateMachineProxyAdd(machineProxyVO);
|
validateMachineProxyAdd(machineProxyVO);
|
||||||
// 创建代理记录
|
// 创建代理记录
|
||||||
MachineProxyDO machineProxyDO = BeanUtils.toBean(machineProxyVO, MachineProxyDO.class);
|
MachineProxyDO machineProxyDO = BeanUtils.toBean(machineProxyVO, MachineProxyDO.class);
|
||||||
;
|
|
||||||
save(machineProxyDO);
|
save(machineProxyDO);
|
||||||
return machineProxyDO.getId();
|
return machineProxyDO.getId();
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,9 @@ package cd.casic.module.machine.service.impl;
|
|||||||
import cd.casic.framework.commons.pojo.PageResult;
|
import cd.casic.framework.commons.pojo.PageResult;
|
||||||
import cd.casic.framework.commons.util.object.BeanUtils;
|
import cd.casic.framework.commons.util.object.BeanUtils;
|
||||||
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
import cd.casic.module.machine.controller.vo.SecretKeyVO;
|
||||||
|
import cd.casic.module.machine.dal.dataobject.MachineInfoDO;
|
||||||
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
import cd.casic.module.machine.dal.dataobject.SecretKeyDO;
|
||||||
|
import cd.casic.module.machine.dal.mysql.MachineInfoMapper;
|
||||||
import cd.casic.module.machine.dal.mysql.SecretKeyMapper;
|
import cd.casic.module.machine.dal.mysql.SecretKeyMapper;
|
||||||
import cd.casic.module.machine.service.MachineInfoService;
|
import cd.casic.module.machine.service.MachineInfoService;
|
||||||
import cn.hutool.core.io.resource.ResourceUtil;
|
import cn.hutool.core.io.resource.ResourceUtil;
|
||||||
@ -12,9 +14,18 @@ import cd.casic.module.machine.utils.AliYunOssClient;
|
|||||||
import cd.casic.module.machine.service.SecretKeyService;
|
import cd.casic.module.machine.service.SecretKeyService;
|
||||||
import com.google.common.annotations.VisibleForTesting;
|
import com.google.common.annotations.VisibleForTesting;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
import static cd.casic.framework.commons.exception.util.ServiceExceptionUtil.exception;
|
||||||
@ -23,6 +34,7 @@ import static cd.casic.module.machine.contants.MachineErrorCodeConstants.*;
|
|||||||
/**
|
/**
|
||||||
* 密钥服务实现类
|
* 密钥服务实现类
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Service("secretKeyService")
|
@Service("secretKeyService")
|
||||||
public class SecretKeyServiceImpl implements SecretKeyService {
|
public class SecretKeyServiceImpl implements SecretKeyService {
|
||||||
|
|
||||||
@ -35,6 +47,8 @@ public class SecretKeyServiceImpl implements SecretKeyService {
|
|||||||
@Resource
|
@Resource
|
||||||
private SecretKeyMapper secretKeyMapper;
|
private SecretKeyMapper secretKeyMapper;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private MachineInfoMapper machineInfoMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SecretKeyVO getSecretKey(Long id) {
|
public SecretKeyVO getSecretKey(Long id) {
|
||||||
@ -42,33 +56,62 @@ public class SecretKeyServiceImpl implements SecretKeyService {
|
|||||||
return BeanUtils.toBean(secretKeyDO, SecretKeyVO.class);
|
return BeanUtils.toBean(secretKeyDO, SecretKeyVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteSecretKey(Long id) {
|
||||||
|
validateSecretKeyExists(id);
|
||||||
|
secretKeyMapper.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void unbindMachine(SecretKeyVO secretKeyVO) {
|
public void unbindMachine(SecretKeyVO secretKeyVO) {
|
||||||
machineInfoService.bindingSecretKey(secretKeyVO.getMachineInfoIds(),null);
|
machineInfoService.bindingSecretKey(secretKeyVO.getMachineInfoIds(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ResponseEntity<InputStreamResource> downloadSecretFile(Long id) {
|
||||||
|
// 获取私钥内容
|
||||||
|
String keyContent = getKeyContent(id);
|
||||||
|
if (keyContent == null || keyContent.isEmpty()) {
|
||||||
|
throw exception(SECRET_KEY_NULL);
|
||||||
|
}
|
||||||
|
// 将字符串转换为输入流
|
||||||
|
ByteArrayInputStream inputStream = new ByteArrayInputStream(keyContent.getBytes(StandardCharsets.UTF_8));
|
||||||
|
// 设置响应头
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=private_key.pem");
|
||||||
|
headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");
|
||||||
|
headers.add(HttpHeaders.CONTENT_LENGTH, String.valueOf(keyContent.getBytes(StandardCharsets.UTF_8).length));
|
||||||
|
// 返回带有文件流的响应实体
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.headers(headers)
|
||||||
|
.body(new InputStreamResource(inputStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<MachineInfoDO> getBindMachine(Long secretKeyId) {
|
||||||
|
return machineInfoMapper.selectBindMachineBySecretKey(secretKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Long createSecretKey(SecretKeyVO secretKeyVO) {
|
public Long createSecretKey(SecretKeyVO secretKeyVO) {
|
||||||
validateSecretKeyAdd(secretKeyVO);
|
validateSecretKeyAdd(secretKeyVO);
|
||||||
String ossPath = upLoadSecretKey(secretKeyVO.getPath());
|
String localPath = secretKeyVO.getPath();
|
||||||
|
//将反斜杠替换为双反斜杠(Windows路径转义)
|
||||||
|
localPath = localPath.replace("\\", "\\\\");
|
||||||
|
String ossPath = upLoadSecretKey(localPath);
|
||||||
//检查得到的oss路径是否为空
|
//检查得到的oss路径是否为空
|
||||||
validateSecretKeyPath(ossPath);
|
validateSecretKeyPath(ossPath);
|
||||||
secretKeyVO.setPath(ossPath);
|
secretKeyVO.setPath(ossPath);
|
||||||
SecretKeyDO secretKeyDO = BeanUtils.toBean(secretKeyVO, SecretKeyDO.class);
|
SecretKeyDO secretKeyDO = BeanUtils.toBean(secretKeyVO, SecretKeyDO.class);
|
||||||
//todo检查密钥合法
|
|
||||||
|
|
||||||
secretKeyMapper.insert(secretKeyDO);
|
secretKeyMapper.insert(secretKeyDO);
|
||||||
return secretKeyDO.getId();
|
return secretKeyDO.getId();
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void updateSecretKey(SecretKeyVO secretKeyVO) {
|
public void updateSecretKey(SecretKeyVO secretKeyVO) {
|
||||||
SecretKeyDO secretKeyDO = validateSecretKeyExists(secretKeyVO.getId());
|
SecretKeyDO secretKeyDO = validateSecretKeyExists(secretKeyVO.getId());
|
||||||
//如果路径改变==改变密钥
|
//路径改变==改变密钥
|
||||||
if (!secretKeyDO.getPath().equals(secretKeyVO.getPath())) {
|
if (!secretKeyDO.getPath().equals(secretKeyVO.getPath())) {
|
||||||
//todo检查密钥合法
|
|
||||||
String ossPath = upLoadSecretKey(secretKeyVO.getPath());
|
String ossPath = upLoadSecretKey(secretKeyVO.getPath());
|
||||||
BeanUtils.copyProperties(secretKeyVO, secretKeyDO);
|
BeanUtils.copyProperties(secretKeyVO, secretKeyDO);
|
||||||
secretKeyDO.setPath(ossPath);
|
secretKeyDO.setPath(ossPath);
|
||||||
@ -104,8 +147,7 @@ public class SecretKeyServiceImpl implements SecretKeyService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
//绑定的机器全部设置为空
|
//绑定的机器全部设置为空
|
||||||
machineInfoService.bindingSecretKey(ids,null);
|
machineInfoService.bindingSecretKey(ids, null);
|
||||||
|
|
||||||
secretKeyMapper.deleteBatchIds(ids);
|
secretKeyMapper.deleteBatchIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,4 +202,25 @@ public class SecretKeyServiceImpl implements SecretKeyService {
|
|||||||
return secretKeyDO;
|
return secretKeyDO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getKeyContent(Long secretKeyId) {
|
||||||
|
if (secretKeyId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
SecretKeyVO secretKey = getSecretKey(secretKeyId);
|
||||||
|
byte[] content;
|
||||||
|
try {
|
||||||
|
content = aliYunOssClient.getContent(secretKey.getPath().substring(secretKey.getPath().lastIndexOf("/") + 1));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("读取密钥文件失败", e);
|
||||||
|
throw exception(READ_SECRET_CONTENT_ERROR);
|
||||||
|
}
|
||||||
|
//改为S3FileClient读取
|
||||||
|
InputStream read = new ByteArrayInputStream(content);
|
||||||
|
try {
|
||||||
|
return StreamUtils.copyToString(read, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,9 +4,8 @@ import cd.casic.module.infra.framework.file.core.client.s3.S3FileClient;
|
|||||||
import cd.casic.module.infra.framework.file.core.client.s3.S3FileClientConfig;
|
import cd.casic.module.infra.framework.file.core.client.s3.S3FileClientConfig;
|
||||||
|
|
||||||
|
|
||||||
public class AliYunOssClient extends S3FileClient{
|
public class AliYunOssClient extends S3FileClient {
|
||||||
public AliYunOssClient(Long id, S3FileClientConfig config) {
|
public AliYunOssClient(Long id, S3FileClientConfig config) {
|
||||||
super(id, config);
|
super(id, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
package cd.casic.module.machine.utils;
|
|
||||||
|
|
||||||
import io.micrometer.common.util.StringUtils;
|
|
||||||
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性操作工具类
|
|
||||||
*/
|
|
||||||
public class PropertyUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 如果不为空则设置值
|
|
||||||
*
|
|
||||||
* @param value 值
|
|
||||||
* @param setter 具体set操作
|
|
||||||
*/
|
|
||||||
public static void setIfNotBlank(String value, Consumer<String> setter) {
|
|
||||||
if (StringUtils.isNotBlank(value)) {
|
|
||||||
setter.accept(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 如果不为空则设置值
|
|
||||||
*
|
|
||||||
* @param value 值
|
|
||||||
* @param setter 具体set操作
|
|
||||||
*/
|
|
||||||
public static <T> void setIfNotNull(T value, Consumer<T> setter) {
|
|
||||||
if (value != null) {
|
|
||||||
setter.accept(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user