1923 lines
73 KiB
Markdown
1923 lines
73 KiB
Markdown
|
|
# Deploy Linux SSH/SFTP Implementation Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 在 `system-ops/deploy` 中实现 Linux 服务器连接配置、SFTP 文件管理和 SSH WebSocket 终端基础能力。
|
|||
|
|
|
|||
|
|
**Architecture:** 保留现有 `/deploy/overview` 基础入口,新增独立的服务器配置、文件操作和终端会话职责。连接配置使用本地 JSON 文件存储,密码由 `DeployCryptoService` 加密后落盘;SFTP 每次请求创建短连接,终端通过 Spring WebSocket 维持 SSH Shell 长连接。
|
|||
|
|
|
|||
|
|
**Tech Stack:** Java 8、Spring Boot 2.3.12、Maven 多模块、JSch、Spring WebSocket、Jackson、Lombok、PowerShell 静态校验
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## File Structure
|
|||
|
|
|
|||
|
|
### Files To Modify
|
|||
|
|
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/pom.xml`
|
|||
|
|
作用:增加 JSch、Spring WebSocket 和 Jackson 依赖。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/entrance/src/main/resources/application.yml`
|
|||
|
|
作用:增加 `deploy` 配置项,使用环境变量承载加密密钥。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/README.md`
|
|||
|
|
作用:同步 deploy 模块真实能力和使用边界。
|
|||
|
|
|
|||
|
|
### Files To Create
|
|||
|
|
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java`
|
|||
|
|
作用:绑定 `deploy.storage-dir`、`deploy.crypto-key`、`deploy.terminal-idle-timeout-minutes` 等配置。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java`
|
|||
|
|
作用:注册 `/deploy/terminal` WebSocket endpoint。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java`
|
|||
|
|
作用:连接配置 CRUD 和测试连接。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java`
|
|||
|
|
作用:SFTP 文件列表、新建目录、删除、上传、下载。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigDTO.java`
|
|||
|
|
作用:JSON 文件中的服务器连接配置模型,包含加密后的密码。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigStoreDTO.java`
|
|||
|
|
作用:JSON 根对象,承载服务器列表。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java`
|
|||
|
|
作用:终端 WebSocket 与 SSH Session/Channel 的绑定对象。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums/DeployFileTypeEnum.java`
|
|||
|
|
作用:统一文件类型:`FILE`、`DIRECTORY`、`LINK`。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java`
|
|||
|
|
作用:连接配置接口入参。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java`
|
|||
|
|
作用:文件操作接口入参。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java`
|
|||
|
|
作用:WebSocket 终端输入消息模型。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java`
|
|||
|
|
作用:连接配置列表返回模型,不包含密码。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java`
|
|||
|
|
作用:连接测试返回模型。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java`
|
|||
|
|
作用:远程文件列表返回模型。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java`
|
|||
|
|
作用:封装 JSON 文件读写、初始化和并发写入保护。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java`
|
|||
|
|
作用:服务器密码加密解密。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java`
|
|||
|
|
作用:连接配置业务接口。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java`
|
|||
|
|
作用:SFTP 文件操作接口。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java`
|
|||
|
|
作用:SSH Shell 终端会话接口。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java`
|
|||
|
|
作用:AES 加解密实现。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java`
|
|||
|
|
作用:连接配置 CRUD、校验、连接测试。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java`
|
|||
|
|
作用:SFTP 列表、上传、下载、删除、新建目录。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java`
|
|||
|
|
作用:SSH Shell 创建、输入、resize、关闭。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java`
|
|||
|
|
作用:远程路径规范化和危险路径拦截。
|
|||
|
|
- `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java`
|
|||
|
|
作用:WebSocket 消息解析、输出推送、连接关闭时释放 SSH 资源。
|
|||
|
|
|
|||
|
|
## Assumptions
|
|||
|
|
|
|||
|
|
- 本期只支持 Linux,所有连接均使用 SSH 密码认证。
|
|||
|
|
- 当前仓库默认不执行 `mvn` 编译、打包、测试;本计划验证步骤默认使用静态检查和人工接口验证。
|
|||
|
|
- `deploy.crypto-key` 通过环境变量或外部配置提供;默认配置不写入真实密钥。
|
|||
|
|
- 如果后续执行时用户明确允许 Maven 校验,可在每个任务完成后补充运行 `mvn -pl system-ops/deploy -am -DskipTests compile`。
|
|||
|
|
- 计划中的提交命令只提交对应任务文件;工作区已有其他改动不纳入提交。
|
|||
|
|
|
|||
|
|
### Task 1: Add Dependencies And Deploy Configuration
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/pom.xml`
|
|||
|
|
- Modify: `D:/Work/SourceCode/CN_Tool/entrance/src/main/resources/application.yml`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Confirm current deploy dependencies and config do not already contain SSH/SFTP settings**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\pom.xml -Pattern 'jsch|spring-boot-starter-websocket|jackson-databind'
|
|||
|
|
Select-String -Path entrance\src\main\resources\application.yml -Pattern '^deploy:|storage-dir|terminal-idle-timeout'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: no matches for JSch/WebSocket deploy-specific entries. If `deploy:` already exists, merge the fields below into the existing block.
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Add SSH, WebSocket and JSON dependencies**
|
|||
|
|
|
|||
|
|
Modify `system-ops/deploy/pom.xml` and append these dependencies inside `<dependencies>`:
|
|||
|
|
|
|||
|
|
```xml
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>com.jcraft</groupId>
|
|||
|
|
<artifactId>jsch</artifactId>
|
|||
|
|
<version>0.1.55</version>
|
|||
|
|
</dependency>
|
|||
|
|
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>org.springframework.boot</groupId>
|
|||
|
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
|||
|
|
</dependency>
|
|||
|
|
|
|||
|
|
<dependency>
|
|||
|
|
<groupId>com.fasterxml.jackson.core</groupId>
|
|||
|
|
<artifactId>jackson-databind</artifactId>
|
|||
|
|
</dependency>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Add deploy runtime configuration**
|
|||
|
|
|
|||
|
|
Append this block to `entrance/src/main/resources/application.yml`:
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
deploy:
|
|||
|
|
storage-dir: ${log.homeDir}/deploy
|
|||
|
|
crypto-key: ${DEPLOY_CRYPTO_KEY:}
|
|||
|
|
ssh-connect-timeout-ms: 5000
|
|||
|
|
terminal-idle-timeout-minutes: 30
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create `DeployProperties`**
|
|||
|
|
|
|||
|
|
Create `system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.config;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|||
|
|
import org.springframework.stereotype.Component;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 系统部署模块配置。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
@Component
|
|||
|
|
@ConfigurationProperties(prefix = "deploy")
|
|||
|
|
public class DeployProperties {
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 连接配置文件存储目录。
|
|||
|
|
*/
|
|||
|
|
private String storageDir;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 密码加密密钥,通过外部配置注入,不能写死在代码中。
|
|||
|
|
*/
|
|||
|
|
private String cryptoKey;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SSH 连接超时时间。
|
|||
|
|
*/
|
|||
|
|
private int sshConnectTimeoutMs = 5000;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 终端空闲超时时间,单位分钟。
|
|||
|
|
*/
|
|||
|
|
private int terminalIdleTimeoutMinutes = 30;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Verify static wiring**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\pom.xml -Pattern '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>|<artifactId>jackson-databind</artifactId>'
|
|||
|
|
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'DEPLOY_CRYPTO_KEY|storage-dir|terminal-idle-timeout-minutes'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployProperties.java -Pattern '@ConfigurationProperties\(prefix = "deploy"\)|sshConnectTimeoutMs'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: all commands return matches.
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/pom.xml entrance/src/main/resources/application.yml system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployProperties.java
|
|||
|
|
git commit -m "deploy: add Linux SSH runtime configuration"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Task 2: Add Core Models And JSON Repository
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigDTO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeployServerConfigStoreDTO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create stored config DTO**
|
|||
|
|
|
|||
|
|
Create `DeployServerConfigDTO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.dto;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置落盘模型,password 保存加密后的密文。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployServerConfigDTO implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 7798801709696949115L;
|
|||
|
|
|
|||
|
|
private String id;
|
|||
|
|
private String name;
|
|||
|
|
private String host;
|
|||
|
|
private Integer sshPort;
|
|||
|
|
private String username;
|
|||
|
|
private String password;
|
|||
|
|
private String description;
|
|||
|
|
private String createdTime;
|
|||
|
|
private String updatedTime;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create JSON store DTO**
|
|||
|
|
|
|||
|
|
Create `DeployServerConfigStoreDTO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.dto;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
import java.util.ArrayList;
|
|||
|
|
import java.util.List;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置文件根对象。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployServerConfigStoreDTO implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 7814307413743236622L;
|
|||
|
|
|
|||
|
|
private List<DeployServerConfigDTO> servers = new ArrayList<>();
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create list response VO without password**
|
|||
|
|
|
|||
|
|
Create `DeployServerVO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.vo;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置返回模型,不包含密码。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployServerVO implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 8392283918210194314L;
|
|||
|
|
|
|||
|
|
private String id;
|
|||
|
|
private String name;
|
|||
|
|
private String host;
|
|||
|
|
private Integer sshPort;
|
|||
|
|
private String username;
|
|||
|
|
private String description;
|
|||
|
|
private String createdTime;
|
|||
|
|
private String updatedTime;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create file repository**
|
|||
|
|
|
|||
|
|
Create `DeployServerConfigRepository.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.repository;
|
|||
|
|
|
|||
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigStoreDTO;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.stereotype.Repository;
|
|||
|
|
|
|||
|
|
import java.io.IOException;
|
|||
|
|
import java.nio.file.Files;
|
|||
|
|
import java.nio.file.Path;
|
|||
|
|
import java.nio.file.Paths;
|
|||
|
|
import java.nio.file.StandardCopyOption;
|
|||
|
|
import java.util.ArrayList;
|
|||
|
|
import java.util.List;
|
|||
|
|
import java.util.Optional;
|
|||
|
|
import java.util.function.Consumer;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置文件仓储。
|
|||
|
|
*/
|
|||
|
|
@Repository
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployServerConfigRepository {
|
|||
|
|
|
|||
|
|
private static final String STORE_FILE_NAME = "deploy-server-connections.json";
|
|||
|
|
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
private final ObjectMapper objectMapper;
|
|||
|
|
private final Object lock = new Object();
|
|||
|
|
|
|||
|
|
public List<DeployServerConfigDTO> list() {
|
|||
|
|
synchronized (lock) {
|
|||
|
|
return new ArrayList<>(readStore().getServers());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public Optional<DeployServerConfigDTO> findById(String id) {
|
|||
|
|
synchronized (lock) {
|
|||
|
|
return readStore().getServers().stream()
|
|||
|
|
.filter(item -> item.getId().equals(id))
|
|||
|
|
.findFirst();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public void save(Consumer<List<DeployServerConfigDTO>> consumer) {
|
|||
|
|
synchronized (lock) {
|
|||
|
|
DeployServerConfigStoreDTO store = readStore();
|
|||
|
|
consumer.accept(store.getServers());
|
|||
|
|
writeStore(store);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private DeployServerConfigStoreDTO readStore() {
|
|||
|
|
Path storePath = getStorePath();
|
|||
|
|
ensureStoreFile(storePath);
|
|||
|
|
try {
|
|||
|
|
return objectMapper.readValue(storePath.toFile(), DeployServerConfigStoreDTO.class);
|
|||
|
|
} catch (IOException e) {
|
|||
|
|
throw new IllegalStateException("读取服务器连接配置失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void writeStore(DeployServerConfigStoreDTO store) {
|
|||
|
|
Path storePath = getStorePath();
|
|||
|
|
ensureStoreFile(storePath);
|
|||
|
|
Path tempPath = storePath.resolveSibling(STORE_FILE_NAME + ".tmp");
|
|||
|
|
try {
|
|||
|
|
objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempPath.toFile(), store);
|
|||
|
|
Files.move(tempPath, storePath, StandardCopyOption.REPLACE_EXISTING);
|
|||
|
|
} catch (IOException e) {
|
|||
|
|
throw new IllegalStateException("写入服务器连接配置失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private Path getStorePath() {
|
|||
|
|
return Paths.get(deployProperties.getStorageDir(), STORE_FILE_NAME);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ensureStoreFile(Path storePath) {
|
|||
|
|
try {
|
|||
|
|
Files.createDirectories(storePath.getParent());
|
|||
|
|
if (!Files.exists(storePath)) {
|
|||
|
|
objectMapper.writerWithDefaultPrettyPrinter()
|
|||
|
|
.writeValue(storePath.toFile(), new DeployServerConfigStoreDTO());
|
|||
|
|
}
|
|||
|
|
} catch (IOException e) {
|
|||
|
|
throw new IllegalStateException("初始化服务器连接配置文件失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Verify repository structure**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\repository\DeployServerConfigRepository.java -Pattern 'deploy-server-connections.json|StandardCopyOption.REPLACE_EXISTING|synchronized'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\dto\DeployServerConfigDTO.java -Pattern 'private String password'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
- repository contains the JSON filename, temp replace write, and synchronized access
|
|||
|
|
- stored DTO contains `password`
|
|||
|
|
- VO command returns no match, confirming password is not exposed
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployServerVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/repository/DeployServerConfigRepository.java
|
|||
|
|
git commit -m "deploy: add server config file repository"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Task 3: Implement Password Crypto And Server Config CRUD
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create crypto service contract**
|
|||
|
|
|
|||
|
|
Create `DeployCryptoService.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 部署模块敏感字段加解密服务。
|
|||
|
|
*/
|
|||
|
|
public interface DeployCryptoService {
|
|||
|
|
|
|||
|
|
String encrypt(String plaintext);
|
|||
|
|
|
|||
|
|
String decrypt(String ciphertext);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create AES crypto implementation**
|
|||
|
|
|
|||
|
|
Create `DeployCryptoServiceImpl.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service.impl;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployCryptoService;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.stereotype.Service;
|
|||
|
|
|
|||
|
|
import javax.crypto.Cipher;
|
|||
|
|
import javax.crypto.spec.SecretKeySpec;
|
|||
|
|
import java.nio.charset.StandardCharsets;
|
|||
|
|
import java.security.MessageDigest;
|
|||
|
|
import java.util.Base64;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 基于 AES 的部署敏感字段加解密实现。
|
|||
|
|
*/
|
|||
|
|
@Service
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployCryptoServiceImpl implements DeployCryptoService {
|
|||
|
|
|
|||
|
|
private static final String AES = "AES";
|
|||
|
|
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public String encrypt(String plaintext) {
|
|||
|
|
return doCipher(Cipher.ENCRYPT_MODE, plaintext);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public String decrypt(String ciphertext) {
|
|||
|
|
return doCipher(Cipher.DECRYPT_MODE, ciphertext);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private String doCipher(int mode, String input) {
|
|||
|
|
if (input == null) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
Cipher cipher = Cipher.getInstance(AES);
|
|||
|
|
cipher.init(mode, new SecretKeySpec(resolveKey(), AES));
|
|||
|
|
byte[] source = mode == Cipher.ENCRYPT_MODE
|
|||
|
|
? input.getBytes(StandardCharsets.UTF_8)
|
|||
|
|
: Base64.getDecoder().decode(input);
|
|||
|
|
byte[] result = cipher.doFinal(source);
|
|||
|
|
return mode == Cipher.ENCRYPT_MODE
|
|||
|
|
? Base64.getEncoder().encodeToString(result)
|
|||
|
|
: new String(result, StandardCharsets.UTF_8);
|
|||
|
|
} catch (Exception e) {
|
|||
|
|
throw new IllegalStateException("服务器连接密码加解密失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private byte[] resolveKey() throws Exception {
|
|||
|
|
String cryptoKey = deployProperties.getCryptoKey();
|
|||
|
|
if (cryptoKey == null || cryptoKey.trim().isEmpty()) {
|
|||
|
|
throw new IllegalStateException("deploy.crypto-key 未配置,不能保存服务器密码");
|
|||
|
|
}
|
|||
|
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|||
|
|
byte[] hash = digest.digest(cryptoKey.getBytes(StandardCharsets.UTF_8));
|
|||
|
|
byte[] key = new byte[16];
|
|||
|
|
System.arraycopy(hash, 0, key, 0, key.length);
|
|||
|
|
return key;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create server request params**
|
|||
|
|
|
|||
|
|
Create `DeployServerParam.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.param;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import javax.validation.constraints.Max;
|
|||
|
|
import javax.validation.constraints.Min;
|
|||
|
|
import javax.validation.constraints.NotBlank;
|
|||
|
|
import javax.validation.constraints.NotNull;
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置参数。
|
|||
|
|
*/
|
|||
|
|
public class DeployServerParam {
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class QueryParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = -8124738859090254151L;
|
|||
|
|
private String keyword;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class AddParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = -7439507513729950886L;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器名称不能为空")
|
|||
|
|
private String name;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "主机地址不能为空")
|
|||
|
|
private String host;
|
|||
|
|
|
|||
|
|
@NotNull(message = "SSH端口不能为空")
|
|||
|
|
@Min(value = 1, message = "SSH端口不能小于1")
|
|||
|
|
@Max(value = 65535, message = "SSH端口不能大于65535")
|
|||
|
|
private Integer sshPort;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "用户名不能为空")
|
|||
|
|
private String username;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "密码不能为空")
|
|||
|
|
private String password;
|
|||
|
|
|
|||
|
|
private String description;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class UpdateParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 452443833197581777L;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器ID不能为空")
|
|||
|
|
private String id;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器名称不能为空")
|
|||
|
|
private String name;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "主机地址不能为空")
|
|||
|
|
private String host;
|
|||
|
|
|
|||
|
|
@NotNull(message = "SSH端口不能为空")
|
|||
|
|
@Min(value = 1, message = "SSH端口不能小于1")
|
|||
|
|
@Max(value = 65535, message = "SSH端口不能大于65535")
|
|||
|
|
private Integer sshPort;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "用户名不能为空")
|
|||
|
|
private String username;
|
|||
|
|
|
|||
|
|
private String password;
|
|||
|
|
private String description;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class IdParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 195332015618863732L;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器ID不能为空")
|
|||
|
|
private String id;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create connection test VO**
|
|||
|
|
|
|||
|
|
Create `DeployConnectionTestVO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.vo;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SSH 连接测试结果。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployConnectionTestVO implements Serializable {
|
|||
|
|
private static final long serialVersionUID = 352678084415038771L;
|
|||
|
|
|
|||
|
|
private Boolean success;
|
|||
|
|
private String message;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Create server config service contract**
|
|||
|
|
|
|||
|
|
Create `DeployServerConfigService.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
|
|||
|
|
|
|||
|
|
import java.util.List;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置服务。
|
|||
|
|
*/
|
|||
|
|
public interface DeployServerConfigService {
|
|||
|
|
|
|||
|
|
List<DeployServerVO> list(DeployServerParam.QueryParam param);
|
|||
|
|
|
|||
|
|
Boolean add(DeployServerParam.AddParam param);
|
|||
|
|
|
|||
|
|
Boolean update(DeployServerParam.UpdateParam param);
|
|||
|
|
|
|||
|
|
Boolean delete(DeployServerParam.IdParam param);
|
|||
|
|
|
|||
|
|
DeployConnectionTestVO test(DeployServerParam.IdParam param);
|
|||
|
|
|
|||
|
|
DeployServerConfigDTO getRequiredConfig(String serverId);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Create server config service implementation**
|
|||
|
|
|
|||
|
|
Create `DeployServerConfigServiceImpl.java` with the key methods below. Keep helper methods private in the same class:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service.impl;
|
|||
|
|
|
|||
|
|
import com.jcraft.jsch.JSch;
|
|||
|
|
import com.jcraft.jsch.JSchException;
|
|||
|
|
import com.jcraft.jsch.Session;
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.repository.DeployServerConfigRepository;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployCryptoService;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.stereotype.Service;
|
|||
|
|
import org.springframework.util.StringUtils;
|
|||
|
|
|
|||
|
|
import java.text.SimpleDateFormat;
|
|||
|
|
import java.util.Date;
|
|||
|
|
import java.util.List;
|
|||
|
|
import java.util.Properties;
|
|||
|
|
import java.util.UUID;
|
|||
|
|
import java.util.stream.Collectors;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 服务器连接配置服务实现。
|
|||
|
|
*/
|
|||
|
|
@Service
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployServerConfigServiceImpl implements DeployServerConfigService {
|
|||
|
|
|
|||
|
|
private final DeployServerConfigRepository repository;
|
|||
|
|
private final DeployCryptoService cryptoService;
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public List<DeployServerVO> list(DeployServerParam.QueryParam param) {
|
|||
|
|
String keyword = param == null ? null : param.getKeyword();
|
|||
|
|
return repository.list().stream()
|
|||
|
|
.filter(item -> !StringUtils.hasText(keyword)
|
|||
|
|
|| item.getName().contains(keyword)
|
|||
|
|
|| item.getHost().contains(keyword))
|
|||
|
|
.map(this::toVO)
|
|||
|
|
.collect(Collectors.toList());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean add(DeployServerParam.AddParam param) {
|
|||
|
|
repository.save(list -> {
|
|||
|
|
ensureNotDuplicate(list, null, param.getHost(), param.getSshPort(), param.getUsername());
|
|||
|
|
DeployServerConfigDTO config = new DeployServerConfigDTO();
|
|||
|
|
config.setId(UUID.randomUUID().toString().replace("-", ""));
|
|||
|
|
config.setName(param.getName());
|
|||
|
|
config.setHost(param.getHost());
|
|||
|
|
config.setSshPort(param.getSshPort());
|
|||
|
|
config.setUsername(param.getUsername());
|
|||
|
|
config.setPassword(cryptoService.encrypt(param.getPassword()));
|
|||
|
|
config.setDescription(param.getDescription());
|
|||
|
|
config.setCreatedTime(now());
|
|||
|
|
config.setUpdatedTime(config.getCreatedTime());
|
|||
|
|
list.add(config);
|
|||
|
|
});
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean update(DeployServerParam.UpdateParam param) {
|
|||
|
|
repository.save(list -> {
|
|||
|
|
ensureNotDuplicate(list, param.getId(), param.getHost(), param.getSshPort(), param.getUsername());
|
|||
|
|
DeployServerConfigDTO config = list.stream()
|
|||
|
|
.filter(item -> item.getId().equals(param.getId()))
|
|||
|
|
.findFirst()
|
|||
|
|
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
|
|||
|
|
config.setName(param.getName());
|
|||
|
|
config.setHost(param.getHost());
|
|||
|
|
config.setSshPort(param.getSshPort());
|
|||
|
|
config.setUsername(param.getUsername());
|
|||
|
|
if (StringUtils.hasText(param.getPassword())) {
|
|||
|
|
config.setPassword(cryptoService.encrypt(param.getPassword()));
|
|||
|
|
}
|
|||
|
|
config.setDescription(param.getDescription());
|
|||
|
|
config.setUpdatedTime(now());
|
|||
|
|
});
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean delete(DeployServerParam.IdParam param) {
|
|||
|
|
repository.save(list -> {
|
|||
|
|
boolean removed = list.removeIf(item -> item.getId().equals(param.getId()));
|
|||
|
|
if (!removed) {
|
|||
|
|
throw new IllegalArgumentException("服务器连接配置不存在");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public DeployConnectionTestVO test(DeployServerParam.IdParam param) {
|
|||
|
|
DeployConnectionTestVO result = new DeployConnectionTestVO();
|
|||
|
|
Session session = null;
|
|||
|
|
try {
|
|||
|
|
DeployServerConfigDTO config = getRequiredConfig(param.getId());
|
|||
|
|
session = createSession(config);
|
|||
|
|
result.setSuccess(true);
|
|||
|
|
result.setMessage("SSH连接成功");
|
|||
|
|
} catch (JSchException e) {
|
|||
|
|
result.setSuccess(false);
|
|||
|
|
result.setMessage(resolveSshError(e));
|
|||
|
|
} finally {
|
|||
|
|
if (session != null && session.isConnected()) {
|
|||
|
|
session.disconnect();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public DeployServerConfigDTO getRequiredConfig(String serverId) {
|
|||
|
|
DeployServerConfigDTO config = repository.findById(serverId)
|
|||
|
|
.orElseThrow(() -> new IllegalArgumentException("服务器连接配置不存在"));
|
|||
|
|
config.setPassword(cryptoService.decrypt(config.getPassword()));
|
|||
|
|
return config;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private Session createSession(DeployServerConfigDTO config) throws JSchException {
|
|||
|
|
JSch jSch = new JSch();
|
|||
|
|
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
|
|||
|
|
session.setPassword(config.getPassword());
|
|||
|
|
Properties properties = new Properties();
|
|||
|
|
properties.put("StrictHostKeyChecking", "no");
|
|||
|
|
session.setConfig(properties);
|
|||
|
|
session.connect(deployProperties.getSshConnectTimeoutMs());
|
|||
|
|
return session;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void ensureNotDuplicate(List<DeployServerConfigDTO> list, String id, String host, Integer port, String username) {
|
|||
|
|
boolean duplicate = list.stream().anyMatch(item ->
|
|||
|
|
!item.getId().equals(id)
|
|||
|
|
&& item.getHost().equals(host)
|
|||
|
|
&& item.getSshPort().equals(port)
|
|||
|
|
&& item.getUsername().equals(username));
|
|||
|
|
if (duplicate) {
|
|||
|
|
throw new IllegalArgumentException("相同主机、端口和用户名的连接配置已存在");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private DeployServerVO toVO(DeployServerConfigDTO config) {
|
|||
|
|
DeployServerVO vo = new DeployServerVO();
|
|||
|
|
vo.setId(config.getId());
|
|||
|
|
vo.setName(config.getName());
|
|||
|
|
vo.setHost(config.getHost());
|
|||
|
|
vo.setSshPort(config.getSshPort());
|
|||
|
|
vo.setUsername(config.getUsername());
|
|||
|
|
vo.setDescription(config.getDescription());
|
|||
|
|
vo.setCreatedTime(config.getCreatedTime());
|
|||
|
|
vo.setUpdatedTime(config.getUpdatedTime());
|
|||
|
|
return vo;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private String now() {
|
|||
|
|
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private String resolveSshError(JSchException e) {
|
|||
|
|
String message = e.getMessage() == null ? "" : e.getMessage();
|
|||
|
|
if (message.contains("Auth fail")) {
|
|||
|
|
return "SSH认证失败";
|
|||
|
|
}
|
|||
|
|
if (message.contains("Connection refused")) {
|
|||
|
|
return "SSH端口连接失败";
|
|||
|
|
}
|
|||
|
|
return "连接服务器失败";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Create server controller**
|
|||
|
|
|
|||
|
|
Create `DeployServerController.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.controller;
|
|||
|
|
|
|||
|
|
import com.njcn.common.pojo.annotation.OperateInfo;
|
|||
|
|
import com.njcn.common.pojo.constant.OperateType;
|
|||
|
|
import com.njcn.common.pojo.enums.common.LogEnum;
|
|||
|
|
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
|
|||
|
|
import com.njcn.common.pojo.response.HttpResult;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployServerParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployConnectionTestVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployServerVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
|
|||
|
|
import com.njcn.web.controller.BaseController;
|
|||
|
|
import com.njcn.web.utils.HttpResultUtil;
|
|||
|
|
import io.swagger.annotations.Api;
|
|||
|
|
import io.swagger.annotations.ApiOperation;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import lombok.extern.slf4j.Slf4j;
|
|||
|
|
import org.springframework.validation.annotation.Validated;
|
|||
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestBody;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|||
|
|
import org.springframework.web.bind.annotation.RestController;
|
|||
|
|
|
|||
|
|
import java.util.List;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Linux 服务器连接配置接口。
|
|||
|
|
*/
|
|||
|
|
@Slf4j
|
|||
|
|
@Api(tags = "Linux服务器连接配置")
|
|||
|
|
@RestController
|
|||
|
|
@RequestMapping("/deploy/server")
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployServerController extends BaseController {
|
|||
|
|
|
|||
|
|
private final DeployServerConfigService deployServerConfigService;
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
|
|||
|
|
@ApiOperation("查询服务器连接配置")
|
|||
|
|
@PostMapping("/list")
|
|||
|
|
public HttpResult<List<DeployServerVO>> list(@RequestBody(required = false) DeployServerParam.QueryParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("list");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deployServerConfigService.list(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
|
|||
|
|
@ApiOperation("新增服务器连接配置")
|
|||
|
|
@PostMapping("/add")
|
|||
|
|
public HttpResult<Boolean> add(@RequestBody @Validated DeployServerParam.AddParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("add");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deployServerConfigService.add(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.UPDATE)
|
|||
|
|
@ApiOperation("修改服务器连接配置")
|
|||
|
|
@PostMapping("/update")
|
|||
|
|
public HttpResult<Boolean> update(@RequestBody @Validated DeployServerParam.UpdateParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("update");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deployServerConfigService.update(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
|
|||
|
|
@ApiOperation("删除服务器连接配置")
|
|||
|
|
@PostMapping("/delete")
|
|||
|
|
public HttpResult<Boolean> delete(@RequestBody @Validated DeployServerParam.IdParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("delete");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deployServerConfigService.delete(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
|
|||
|
|
@ApiOperation("测试SSH连接")
|
|||
|
|
@PostMapping("/test")
|
|||
|
|
public HttpResult<DeployConnectionTestVO> test(@RequestBody @Validated DeployServerParam.IdParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("test");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deployServerConfigService.test(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Verify server API static behavior**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\DeployServerController.java -Pattern '/deploy/server|/add|/update|/delete|/test'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeployServerConfigServiceImpl.java -Pattern 'StrictHostKeyChecking|Auth fail|setPassword|cryptoService.decrypt'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
- controller exposes the five endpoints
|
|||
|
|
- service uses JSch password authentication and decrypts only when needed
|
|||
|
|
- `DeployServerVO` still has no password match
|
|||
|
|
|
|||
|
|
- [ ] **Step 9: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployCryptoService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeployServerConfigService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployCryptoServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeployServerConfigServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployServerParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployConnectionTestVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployServerController.java
|
|||
|
|
git commit -m "deploy: implement Linux server config management"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Task 4: Implement SFTP File Management
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums/DeployFileTypeEnum.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create file type enum**
|
|||
|
|
|
|||
|
|
Create `DeployFileTypeEnum.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.enums;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 远程文件类型。
|
|||
|
|
*/
|
|||
|
|
public enum DeployFileTypeEnum {
|
|||
|
|
FILE,
|
|||
|
|
DIRECTORY,
|
|||
|
|
LINK
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create file params**
|
|||
|
|
|
|||
|
|
Create `DeployFileParam.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.param;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import javax.validation.constraints.NotBlank;
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 远程文件操作参数。
|
|||
|
|
*/
|
|||
|
|
public class DeployFileParam {
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class PathParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = -8848807296768191244L;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器ID不能为空")
|
|||
|
|
private String serverId;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "远程路径不能为空")
|
|||
|
|
private String path;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Data
|
|||
|
|
public static class MkdirParam implements Serializable {
|
|||
|
|
private static final long serialVersionUID = -2758794589492869208L;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "服务器ID不能为空")
|
|||
|
|
private String serverId;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "父目录不能为空")
|
|||
|
|
private String parentPath;
|
|||
|
|
|
|||
|
|
@NotBlank(message = "目录名称不能为空")
|
|||
|
|
private String name;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create file VO**
|
|||
|
|
|
|||
|
|
Create `DeployFileVO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.vo;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.enums.DeployFileTypeEnum;
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.Serializable;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 远程文件信息。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployFileVO implements Serializable {
|
|||
|
|
private static final long serialVersionUID = -2268703146258555013L;
|
|||
|
|
|
|||
|
|
private String name;
|
|||
|
|
private String path;
|
|||
|
|
private DeployFileTypeEnum type;
|
|||
|
|
private Long size;
|
|||
|
|
private String permissions;
|
|||
|
|
private String modifiedTime;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create remote path utility**
|
|||
|
|
|
|||
|
|
Create `DeployPathUtil.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.util;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Linux 远程路径工具。
|
|||
|
|
*/
|
|||
|
|
public final class DeployPathUtil {
|
|||
|
|
|
|||
|
|
private DeployPathUtil() {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static String normalize(String path) {
|
|||
|
|
if (path == null || path.trim().isEmpty()) {
|
|||
|
|
throw new IllegalArgumentException("远程路径不能为空");
|
|||
|
|
}
|
|||
|
|
String normalized = path.trim().replace("\\", "/");
|
|||
|
|
while (normalized.contains("//")) {
|
|||
|
|
normalized = normalized.replace("//", "/");
|
|||
|
|
}
|
|||
|
|
if (!normalized.startsWith("/")) {
|
|||
|
|
normalized = "/" + normalized;
|
|||
|
|
}
|
|||
|
|
if (normalized.contains("/../") || normalized.endsWith("/..")) {
|
|||
|
|
throw new IllegalArgumentException("远程路径不合法");
|
|||
|
|
}
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static void rejectRootDelete(String path) {
|
|||
|
|
if ("/".equals(normalize(path))) {
|
|||
|
|
throw new IllegalArgumentException("禁止删除根目录");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static String child(String parent, String name) {
|
|||
|
|
if (name == null || name.trim().isEmpty() || name.contains("/") || name.contains("\\")
|
|||
|
|
|| ".".equals(name.trim()) || "..".equals(name.trim())) {
|
|||
|
|
throw new IllegalArgumentException("目录名称不合法");
|
|||
|
|
}
|
|||
|
|
String normalizedParent = normalize(parent);
|
|||
|
|
return "/".equals(normalizedParent) ? "/" + name.trim() : normalizedParent + "/" + name.trim();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Create SFTP service contract**
|
|||
|
|
|
|||
|
|
Create `DeploySftpService.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
|
|||
|
|
import org.springframework.web.multipart.MultipartFile;
|
|||
|
|
|
|||
|
|
import javax.servlet.http.HttpServletResponse;
|
|||
|
|
import java.util.List;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SFTP 文件操作服务。
|
|||
|
|
*/
|
|||
|
|
public interface DeploySftpService {
|
|||
|
|
|
|||
|
|
List<DeployFileVO> list(DeployFileParam.PathParam param);
|
|||
|
|
|
|||
|
|
Boolean mkdir(DeployFileParam.MkdirParam param);
|
|||
|
|
|
|||
|
|
Boolean delete(DeployFileParam.PathParam param);
|
|||
|
|
|
|||
|
|
Boolean upload(String serverId, String path, MultipartFile file);
|
|||
|
|
|
|||
|
|
void download(DeployFileParam.PathParam param, HttpServletResponse response);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Create SFTP service implementation**
|
|||
|
|
|
|||
|
|
Create `DeploySftpServiceImpl.java`. Include helper methods in the same file:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service.impl;
|
|||
|
|
|
|||
|
|
import com.jcraft.jsch.ChannelSftp;
|
|||
|
|
import com.jcraft.jsch.JSch;
|
|||
|
|
import com.jcraft.jsch.Session;
|
|||
|
|
import com.jcraft.jsch.SftpATTRS;
|
|||
|
|
import com.jcraft.jsch.SftpException;
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.enums.DeployFileTypeEnum;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeploySftpService;
|
|||
|
|
import com.njcn.gather.systemops.deploy.util.DeployPathUtil;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.stereotype.Service;
|
|||
|
|
import org.springframework.web.multipart.MultipartFile;
|
|||
|
|
|
|||
|
|
import javax.servlet.http.HttpServletResponse;
|
|||
|
|
import java.net.URLEncoder;
|
|||
|
|
import java.text.SimpleDateFormat;
|
|||
|
|
import java.util.ArrayList;
|
|||
|
|
import java.util.List;
|
|||
|
|
import java.util.Properties;
|
|||
|
|
import java.util.Vector;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SFTP 文件操作实现。
|
|||
|
|
*/
|
|||
|
|
@Service
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeploySftpServiceImpl implements DeploySftpService {
|
|||
|
|
|
|||
|
|
private final DeployServerConfigService deployServerConfigService;
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public List<DeployFileVO> list(DeployFileParam.PathParam param) {
|
|||
|
|
return withSftp(param.getServerId(), channel -> {
|
|||
|
|
String path = DeployPathUtil.normalize(param.getPath());
|
|||
|
|
Vector<?> entries = channel.ls(path);
|
|||
|
|
List<DeployFileVO> result = new ArrayList<>();
|
|||
|
|
for (Object entry : entries) {
|
|||
|
|
ChannelSftp.LsEntry lsEntry = (ChannelSftp.LsEntry) entry;
|
|||
|
|
if (".".equals(lsEntry.getFilename()) || "..".equals(lsEntry.getFilename())) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
result.add(toVO(path, lsEntry));
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean mkdir(DeployFileParam.MkdirParam param) {
|
|||
|
|
return withSftp(param.getServerId(), channel -> {
|
|||
|
|
channel.mkdir(DeployPathUtil.child(param.getParentPath(), param.getName()));
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean delete(DeployFileParam.PathParam param) {
|
|||
|
|
return withSftp(param.getServerId(), channel -> {
|
|||
|
|
String path = DeployPathUtil.normalize(param.getPath());
|
|||
|
|
DeployPathUtil.rejectRootDelete(path);
|
|||
|
|
SftpATTRS attrs = channel.lstat(path);
|
|||
|
|
if (attrs.isDir()) {
|
|||
|
|
channel.rmdir(path);
|
|||
|
|
} else {
|
|||
|
|
channel.rm(path);
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public Boolean upload(String serverId, String path, MultipartFile file) {
|
|||
|
|
return withSftp(serverId, channel -> {
|
|||
|
|
String targetDir = DeployPathUtil.normalize(path);
|
|||
|
|
SftpATTRS attrs = channel.lstat(targetDir);
|
|||
|
|
if (!attrs.isDir()) {
|
|||
|
|
throw new IllegalArgumentException("上传目标必须是远程目录");
|
|||
|
|
}
|
|||
|
|
channel.put(file.getInputStream(), DeployPathUtil.child(targetDir, file.getOriginalFilename()));
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void download(DeployFileParam.PathParam param, HttpServletResponse response) {
|
|||
|
|
withSftp(param.getServerId(), channel -> {
|
|||
|
|
String path = DeployPathUtil.normalize(param.getPath());
|
|||
|
|
SftpATTRS attrs = channel.lstat(path);
|
|||
|
|
if (attrs.isDir()) {
|
|||
|
|
throw new IllegalArgumentException("只能下载普通文件");
|
|||
|
|
}
|
|||
|
|
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
|||
|
|
response.setContentType("application/octet-stream");
|
|||
|
|
response.setHeader("Content-Disposition",
|
|||
|
|
"attachment; filename=" + URLEncoder.encode(fileName, "UTF-8"));
|
|||
|
|
channel.get(path, response.getOutputStream());
|
|||
|
|
return null;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private DeployFileVO toVO(String parentPath, ChannelSftp.LsEntry entry) {
|
|||
|
|
SftpATTRS attrs = entry.getAttrs();
|
|||
|
|
DeployFileVO vo = new DeployFileVO();
|
|||
|
|
vo.setName(entry.getFilename());
|
|||
|
|
vo.setPath(DeployPathUtil.child(parentPath, entry.getFilename()));
|
|||
|
|
vo.setType(resolveType(attrs));
|
|||
|
|
vo.setSize(attrs.isDir() ? null : attrs.getSize());
|
|||
|
|
vo.setPermissions(attrs.getPermissionsString());
|
|||
|
|
vo.setModifiedTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(attrs.getMTime() * 1000L));
|
|||
|
|
return vo;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private DeployFileTypeEnum resolveType(SftpATTRS attrs) {
|
|||
|
|
if (attrs.isLink()) {
|
|||
|
|
return DeployFileTypeEnum.LINK;
|
|||
|
|
}
|
|||
|
|
if (attrs.isDir()) {
|
|||
|
|
return DeployFileTypeEnum.DIRECTORY;
|
|||
|
|
}
|
|||
|
|
return DeployFileTypeEnum.FILE;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private <T> T withSftp(String serverId, SftpCallback<T> callback) {
|
|||
|
|
Session session = null;
|
|||
|
|
ChannelSftp channel = null;
|
|||
|
|
try {
|
|||
|
|
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
|
|||
|
|
JSch jSch = new JSch();
|
|||
|
|
session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
|
|||
|
|
session.setPassword(config.getPassword());
|
|||
|
|
Properties properties = new Properties();
|
|||
|
|
properties.put("StrictHostKeyChecking", "no");
|
|||
|
|
session.setConfig(properties);
|
|||
|
|
session.connect(deployProperties.getSshConnectTimeoutMs());
|
|||
|
|
channel = (ChannelSftp) session.openChannel("sftp");
|
|||
|
|
channel.connect(deployProperties.getSshConnectTimeoutMs());
|
|||
|
|
return callback.doInSftp(channel);
|
|||
|
|
} catch (Exception e) {
|
|||
|
|
throw new IllegalStateException("SFTP文件操作失败", e);
|
|||
|
|
} finally {
|
|||
|
|
if (channel != null) {
|
|||
|
|
channel.disconnect();
|
|||
|
|
}
|
|||
|
|
if (session != null) {
|
|||
|
|
session.disconnect();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private interface SftpCallback<T> {
|
|||
|
|
T doInSftp(ChannelSftp channel) throws Exception;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Create file controller**
|
|||
|
|
|
|||
|
|
Create `DeployFileController.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.controller;
|
|||
|
|
|
|||
|
|
import com.njcn.common.pojo.annotation.OperateInfo;
|
|||
|
|
import com.njcn.common.pojo.constant.OperateType;
|
|||
|
|
import com.njcn.common.pojo.enums.common.LogEnum;
|
|||
|
|
import com.njcn.common.pojo.enums.response.CommonResponseEnum;
|
|||
|
|
import com.njcn.common.pojo.response.HttpResult;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployFileParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.vo.DeployFileVO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeploySftpService;
|
|||
|
|
import com.njcn.web.controller.BaseController;
|
|||
|
|
import com.njcn.web.utils.HttpResultUtil;
|
|||
|
|
import io.swagger.annotations.Api;
|
|||
|
|
import io.swagger.annotations.ApiOperation;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import lombok.extern.slf4j.Slf4j;
|
|||
|
|
import org.springframework.validation.annotation.Validated;
|
|||
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestBody;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestParam;
|
|||
|
|
import org.springframework.web.bind.annotation.RequestPart;
|
|||
|
|
import org.springframework.web.bind.annotation.RestController;
|
|||
|
|
import org.springframework.web.multipart.MultipartFile;
|
|||
|
|
|
|||
|
|
import javax.servlet.http.HttpServletResponse;
|
|||
|
|
import java.util.List;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Linux 服务器远程文件接口。
|
|||
|
|
*/
|
|||
|
|
@Slf4j
|
|||
|
|
@Api(tags = "Linux服务器文件管理")
|
|||
|
|
@RestController
|
|||
|
|
@RequestMapping("/deploy/file")
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployFileController extends BaseController {
|
|||
|
|
|
|||
|
|
private final DeploySftpService deploySftpService;
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON)
|
|||
|
|
@ApiOperation("查询远程目录文件列表")
|
|||
|
|
@PostMapping("/list")
|
|||
|
|
public HttpResult<List<DeployFileVO>> list(@RequestBody @Validated DeployFileParam.PathParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("list");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deploySftpService.list(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
|
|||
|
|
@ApiOperation("新建远程目录")
|
|||
|
|
@PostMapping("/mkdir")
|
|||
|
|
public HttpResult<Boolean> mkdir(@RequestBody @Validated DeployFileParam.MkdirParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("mkdir");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deploySftpService.mkdir(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DELETE)
|
|||
|
|
@ApiOperation("删除远程文件或目录")
|
|||
|
|
@PostMapping("/delete")
|
|||
|
|
public HttpResult<Boolean> delete(@RequestBody @Validated DeployFileParam.PathParam param) {
|
|||
|
|
String methodDescribe = getMethodDescribe("delete");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deploySftpService.delete(param), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.ADD)
|
|||
|
|
@ApiOperation("上传文件到远程目录")
|
|||
|
|
@PostMapping(value = "/upload", consumes = {"multipart/form-data"})
|
|||
|
|
public HttpResult<Boolean> upload(@RequestParam String serverId,
|
|||
|
|
@RequestParam String path,
|
|||
|
|
@RequestPart("file") MultipartFile file) {
|
|||
|
|
String methodDescribe = getMethodDescribe("upload");
|
|||
|
|
return HttpResultUtil.assembleCommonResponseResult(CommonResponseEnum.SUCCESS,
|
|||
|
|
deploySftpService.upload(serverId, path, file), methodDescribe);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@OperateInfo(info = LogEnum.BUSINESS_COMMON, operateType = OperateType.DOWNLOAD)
|
|||
|
|
@ApiOperation("下载远程普通文件")
|
|||
|
|
@PostMapping("/download")
|
|||
|
|
public void download(@RequestBody @Validated DeployFileParam.PathParam param,
|
|||
|
|
HttpServletResponse response) {
|
|||
|
|
deploySftpService.download(param, response);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Verify SFTP static behavior**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeploySftpServiceImpl.java -Pattern 'openChannel\("sftp"\)|channel.ls|channel.put|channel.get|channel.rmdir|channel.rm'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\util\DeployPathUtil.java -Pattern '禁止删除根目录|contains\("/../"\)|child'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\DeployFileController.java -Pattern '/deploy/file|/upload|/download'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: all key operations and safety checks are present.
|
|||
|
|
|
|||
|
|
- [ ] **Step 9: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/enums system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployFileParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/vo/DeployFileVO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/util/DeployPathUtil.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySftpService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySftpServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/controller/DeployFileController.java
|
|||
|
|
git commit -m "deploy: implement Linux SFTP file management"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Task 5: Implement SSH Terminal WebSocket
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java`
|
|||
|
|
- Create: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Create WebSocket config**
|
|||
|
|
|
|||
|
|
Create `DeployWebSocketConfig.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.config;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.websocket.DeployTerminalWebSocketHandler;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.context.annotation.Configuration;
|
|||
|
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
|||
|
|
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|||
|
|
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
|||
|
|
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* deploy 终端 WebSocket 配置。
|
|||
|
|
*/
|
|||
|
|
@Configuration
|
|||
|
|
@EnableWebSocket
|
|||
|
|
@EnableScheduling
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployWebSocketConfig implements WebSocketConfigurer {
|
|||
|
|
|
|||
|
|
private final DeployTerminalWebSocketHandler deployTerminalWebSocketHandler;
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
|||
|
|
registry.addHandler(deployTerminalWebSocketHandler, "/deploy/terminal")
|
|||
|
|
.setAllowedOrigins("*");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Create SSH session DTO**
|
|||
|
|
|
|||
|
|
Create `DeploySshSessionDTO.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.dto;
|
|||
|
|
|
|||
|
|
import com.jcraft.jsch.ChannelShell;
|
|||
|
|
import com.jcraft.jsch.Session;
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
import java.io.OutputStream;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SSH 终端会话上下文。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeploySshSessionDTO {
|
|||
|
|
private Session session;
|
|||
|
|
private ChannelShell channel;
|
|||
|
|
private OutputStream input;
|
|||
|
|
private long lastAccessTime;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Create terminal message param**
|
|||
|
|
|
|||
|
|
Create `DeployTerminalMessageParam.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.pojo.param;
|
|||
|
|
|
|||
|
|
import lombok.Data;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 终端 WebSocket 消息。
|
|||
|
|
*/
|
|||
|
|
@Data
|
|||
|
|
public class DeployTerminalMessageParam {
|
|||
|
|
private String type;
|
|||
|
|
private String data;
|
|||
|
|
private Integer cols;
|
|||
|
|
private Integer rows;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Create terminal service contract**
|
|||
|
|
|
|||
|
|
Create `DeploySshTerminalService.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service;
|
|||
|
|
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
|
|||
|
|
|
|||
|
|
import java.util.function.Consumer;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SSH 终端服务。
|
|||
|
|
*/
|
|||
|
|
public interface DeploySshTerminalService {
|
|||
|
|
|
|||
|
|
DeploySshSessionDTO open(String serverId, Consumer<String> outputConsumer);
|
|||
|
|
|
|||
|
|
void input(DeploySshSessionDTO session, String data);
|
|||
|
|
|
|||
|
|
void resize(DeploySshSessionDTO session, Integer cols, Integer rows);
|
|||
|
|
|
|||
|
|
void close(DeploySshSessionDTO session);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Create terminal service implementation**
|
|||
|
|
|
|||
|
|
Create `DeploySshTerminalServiceImpl.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.service.impl;
|
|||
|
|
|
|||
|
|
import com.jcraft.jsch.ChannelShell;
|
|||
|
|
import com.jcraft.jsch.JSch;
|
|||
|
|
import com.jcraft.jsch.Session;
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeployServerConfigDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeployServerConfigService;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.stereotype.Service;
|
|||
|
|
|
|||
|
|
import java.io.InputStream;
|
|||
|
|
import java.nio.charset.StandardCharsets;
|
|||
|
|
import java.util.Properties;
|
|||
|
|
import java.util.function.Consumer;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* SSH Shell 终端服务实现。
|
|||
|
|
*/
|
|||
|
|
@Service
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeploySshTerminalServiceImpl implements DeploySshTerminalService {
|
|||
|
|
|
|||
|
|
private final DeployServerConfigService deployServerConfigService;
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public DeploySshSessionDTO open(String serverId, Consumer<String> outputConsumer) {
|
|||
|
|
try {
|
|||
|
|
DeployServerConfigDTO config = deployServerConfigService.getRequiredConfig(serverId);
|
|||
|
|
JSch jSch = new JSch();
|
|||
|
|
Session session = jSch.getSession(config.getUsername(), config.getHost(), config.getSshPort());
|
|||
|
|
session.setPassword(config.getPassword());
|
|||
|
|
Properties properties = new Properties();
|
|||
|
|
properties.put("StrictHostKeyChecking", "no");
|
|||
|
|
session.setConfig(properties);
|
|||
|
|
session.connect(deployProperties.getSshConnectTimeoutMs());
|
|||
|
|
|
|||
|
|
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
|||
|
|
channel.setPty(true);
|
|||
|
|
InputStream output = channel.getInputStream();
|
|||
|
|
channel.connect(deployProperties.getSshConnectTimeoutMs());
|
|||
|
|
|
|||
|
|
DeploySshSessionDTO context = new DeploySshSessionDTO();
|
|||
|
|
context.setSession(session);
|
|||
|
|
context.setChannel(channel);
|
|||
|
|
context.setInput(channel.getOutputStream());
|
|||
|
|
context.setLastAccessTime(System.currentTimeMillis());
|
|||
|
|
startOutputReader(output, outputConsumer);
|
|||
|
|
return context;
|
|||
|
|
} catch (Exception e) {
|
|||
|
|
throw new IllegalStateException("打开SSH终端失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void input(DeploySshSessionDTO session, String data) {
|
|||
|
|
try {
|
|||
|
|
session.getInput().write(data.getBytes(StandardCharsets.UTF_8));
|
|||
|
|
session.getInput().flush();
|
|||
|
|
session.setLastAccessTime(System.currentTimeMillis());
|
|||
|
|
} catch (Exception e) {
|
|||
|
|
throw new IllegalStateException("写入SSH终端失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void resize(DeploySshSessionDTO session, Integer cols, Integer rows) {
|
|||
|
|
if (cols != null && rows != null && session.getChannel() != null) {
|
|||
|
|
session.getChannel().setPtySize(cols, rows, 0, 0);
|
|||
|
|
session.setLastAccessTime(System.currentTimeMillis());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void close(DeploySshSessionDTO session) {
|
|||
|
|
if (session == null) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (session.getChannel() != null) {
|
|||
|
|
session.getChannel().disconnect();
|
|||
|
|
}
|
|||
|
|
if (session.getSession() != null) {
|
|||
|
|
session.getSession().disconnect();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void startOutputReader(InputStream output, Consumer<String> outputConsumer) {
|
|||
|
|
Thread thread = new Thread(() -> {
|
|||
|
|
byte[] buffer = new byte[4096];
|
|||
|
|
try {
|
|||
|
|
int length;
|
|||
|
|
while ((length = output.read(buffer)) != -1) {
|
|||
|
|
outputConsumer.accept(new String(buffer, 0, length, StandardCharsets.UTF_8));
|
|||
|
|
}
|
|||
|
|
} catch (Exception ignored) {
|
|||
|
|
outputConsumer.accept("\r\nSSH终端输出读取结束\r\n");
|
|||
|
|
}
|
|||
|
|
}, "deploy-ssh-terminal-reader");
|
|||
|
|
thread.setDaemon(true);
|
|||
|
|
thread.start();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: Create terminal WebSocket handler**
|
|||
|
|
|
|||
|
|
Create `DeployTerminalWebSocketHandler.java`:
|
|||
|
|
|
|||
|
|
```java
|
|||
|
|
package com.njcn.gather.systemops.deploy.websocket;
|
|||
|
|
|
|||
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
|
|
import com.njcn.gather.systemops.deploy.config.DeployProperties;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.dto.DeploySshSessionDTO;
|
|||
|
|
import com.njcn.gather.systemops.deploy.pojo.param.DeployTerminalMessageParam;
|
|||
|
|
import com.njcn.gather.systemops.deploy.service.DeploySshTerminalService;
|
|||
|
|
import lombok.RequiredArgsConstructor;
|
|||
|
|
import org.springframework.scheduling.annotation.Scheduled;
|
|||
|
|
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 org.springframework.web.util.UriComponentsBuilder;
|
|||
|
|
|
|||
|
|
import java.net.URI;
|
|||
|
|
import java.util.HashMap;
|
|||
|
|
import java.util.Map;
|
|||
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* deploy SSH 终端 WebSocket 处理器。
|
|||
|
|
*/
|
|||
|
|
@Component
|
|||
|
|
@RequiredArgsConstructor
|
|||
|
|
public class DeployTerminalWebSocketHandler extends TextWebSocketHandler {
|
|||
|
|
|
|||
|
|
private static final String TYPE_INPUT = "input";
|
|||
|
|
private static final String TYPE_RESIZE = "resize";
|
|||
|
|
|
|||
|
|
private final DeploySshTerminalService deploySshTerminalService;
|
|||
|
|
private final DeployProperties deployProperties;
|
|||
|
|
private final ObjectMapper objectMapper;
|
|||
|
|
private final Map<String, DeploySshSessionDTO> sshSessions = new ConcurrentHashMap<>();
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
|||
|
|
String serverId = resolveServerId(session.getUri());
|
|||
|
|
DeploySshSessionDTO sshSession = deploySshTerminalService.open(serverId, output -> sendOutput(session, output));
|
|||
|
|
sshSessions.put(session.getId(), sshSession);
|
|||
|
|
sendStatus(session, "CONNECTED");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
|||
|
|
DeploySshSessionDTO sshSession = sshSessions.get(session.getId());
|
|||
|
|
DeployTerminalMessageParam param = objectMapper.readValue(message.getPayload(), DeployTerminalMessageParam.class);
|
|||
|
|
if (TYPE_INPUT.equals(param.getType())) {
|
|||
|
|
deploySshTerminalService.input(sshSession, param.getData());
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (TYPE_RESIZE.equals(param.getType())) {
|
|||
|
|
deploySshTerminalService.resize(sshSession, param.getCols(), param.getRows());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
|||
|
|
deploySshTerminalService.close(sshSessions.remove(session.getId()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Override
|
|||
|
|
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
|||
|
|
deploySshTerminalService.close(sshSessions.remove(session.getId()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Scheduled(fixedDelay = 60000)
|
|||
|
|
public void closeIdleSessions() {
|
|||
|
|
long timeoutMs = deployProperties.getTerminalIdleTimeoutMinutes() * 60L * 1000L;
|
|||
|
|
long now = System.currentTimeMillis();
|
|||
|
|
sshSessions.entrySet().removeIf(entry -> {
|
|||
|
|
DeploySshSessionDTO sshSession = entry.getValue();
|
|||
|
|
if (now - sshSession.getLastAccessTime() <= timeoutMs) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
deploySshTerminalService.close(sshSession);
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private String resolveServerId(URI uri) {
|
|||
|
|
String serverId = UriComponentsBuilder.fromUri(uri).build().getQueryParams().getFirst("serverId");
|
|||
|
|
if (serverId == null || serverId.trim().isEmpty()) {
|
|||
|
|
throw new IllegalArgumentException("serverId不能为空");
|
|||
|
|
}
|
|||
|
|
return serverId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void sendOutput(WebSocketSession session, String data) {
|
|||
|
|
sendJson(session, "output", data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void sendStatus(WebSocketSession session, String status) {
|
|||
|
|
sendJson(session, "status", status);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private void sendJson(WebSocketSession session, String type, String value) {
|
|||
|
|
try {
|
|||
|
|
if (!session.isOpen()) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
Map<String, String> payload = new HashMap<>();
|
|||
|
|
payload.put("type", type);
|
|||
|
|
if ("output".equals(type)) {
|
|||
|
|
payload.put("data", value);
|
|||
|
|
} else {
|
|||
|
|
payload.put("status", value);
|
|||
|
|
}
|
|||
|
|
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(payload)));
|
|||
|
|
} catch (Exception ignored) {
|
|||
|
|
deploySshTerminalService.close(sshSessions.remove(session.getId()));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 7: Verify terminal static behavior**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployWebSocketConfig.java -Pattern '/deploy/terminal|EnableWebSocket|EnableScheduling'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\service\impl\DeploySshTerminalServiceImpl.java -Pattern 'openChannel\("shell"\)|setPty|setPtySize|deploy-ssh-terminal-reader'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\websocket\DeployTerminalWebSocketHandler.java -Pattern 'serverId|input|resize|afterConnectionClosed|closeIdleSessions'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: endpoint registration, scheduling, SSH shell operations, WebSocket cleanup, and idle session cleanup are present.
|
|||
|
|
|
|||
|
|
- [ ] **Step 8: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/config/DeployWebSocketConfig.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/dto/DeploySshSessionDTO.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/pojo/param/DeployTerminalMessageParam.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/DeploySshTerminalService.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/service/impl/DeploySshTerminalServiceImpl.java system-ops/deploy/src/main/java/com/njcn/gather/systemops/deploy/websocket/DeployTerminalWebSocketHandler.java
|
|||
|
|
git commit -m "deploy: implement Linux SSH terminal websocket"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Task 6: Update Documentation And Perform Final Validation
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `D:/Work/SourceCode/CN_Tool/system-ops/deploy/README.md`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Update deploy README**
|
|||
|
|
|
|||
|
|
Replace `system-ops/deploy/README.md` with:
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
# deploy 模块说明
|
|||
|
|
|
|||
|
|
## 模块定位
|
|||
|
|
|
|||
|
|
`deploy` 是 `system-ops` 下的系统部署运维模块,当前提供 Linux 服务器远程运维基础能力。
|
|||
|
|
|
|||
|
|
## 当前接口
|
|||
|
|
|
|||
|
|
- `GET /deploy/overview`
|
|||
|
|
- 查询系统部署基础信息。
|
|||
|
|
- `POST /deploy/server/list`
|
|||
|
|
- 查询 Linux 服务器连接配置。
|
|||
|
|
- `POST /deploy/server/add`
|
|||
|
|
- 新增 Linux 服务器连接配置。
|
|||
|
|
- `POST /deploy/server/update`
|
|||
|
|
- 修改 Linux 服务器连接配置。
|
|||
|
|
- `POST /deploy/server/delete`
|
|||
|
|
- 删除 Linux 服务器连接配置。
|
|||
|
|
- `POST /deploy/server/test`
|
|||
|
|
- 测试 SSH 连接。
|
|||
|
|
- `POST /deploy/file/list`
|
|||
|
|
- 查询远程目录文件列表。
|
|||
|
|
- `POST /deploy/file/mkdir`
|
|||
|
|
- 新建远程目录。
|
|||
|
|
- `POST /deploy/file/delete`
|
|||
|
|
- 删除远程文件或空目录。
|
|||
|
|
- `POST /deploy/file/upload`
|
|||
|
|
- 上传本地文件到远程目录。
|
|||
|
|
- `POST /deploy/file/download`
|
|||
|
|
- 下载远程普通文件。
|
|||
|
|
- `WebSocket /deploy/terminal?serverId={serverId}`
|
|||
|
|
- 打开 Linux SSH Shell 终端。
|
|||
|
|
|
|||
|
|
## 配置项
|
|||
|
|
|
|||
|
|
运行前需要配置:
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
deploy:
|
|||
|
|
storage-dir: ${log.homeDir}/deploy
|
|||
|
|
crypto-key: ${DEPLOY_CRYPTO_KEY:}
|
|||
|
|
ssh-connect-timeout-ms: 5000
|
|||
|
|
terminal-idle-timeout-minutes: 30
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`deploy.crypto-key` 必须通过环境变量或外部配置提供真实值,否则无法保存服务器密码。
|
|||
|
|
|
|||
|
|
## 当前限制
|
|||
|
|
|
|||
|
|
- 仅支持 Linux 服务器。
|
|||
|
|
- 仅支持 SSH 密码登录。
|
|||
|
|
- 文件传输使用 SFTP,不支持 FTP。
|
|||
|
|
- 下载只支持远程普通文件。
|
|||
|
|
- 删除目录仅支持空目录。
|
|||
|
|
- 不保存命令历史。
|
|||
|
|
- 不提供命令审批、命令黑名单或部署任务编排。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: Run static validation commands**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
Select-String -Path system-ops\deploy\pom.xml -Pattern '<artifactId>jsch</artifactId>|<artifactId>spring-boot-starter-websocket</artifactId>'
|
|||
|
|
Select-String -Path entrance\src\main\resources\application.yml -Pattern 'deploy:|DEPLOY_CRYPTO_KEY|terminal-idle-timeout-minutes'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\controller\*.java -Pattern '/deploy/server|/deploy/file'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\config\DeployWebSocketConfig.java -Pattern '/deploy/terminal'
|
|||
|
|
Select-String -Path system-ops\deploy\src\main\java\com\njcn\gather\systemops\deploy\pojo\vo\DeployServerVO.java -Pattern 'password'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected:
|
|||
|
|
|
|||
|
|
- dependency and config checks return matches
|
|||
|
|
- server/file controller routes return matches
|
|||
|
|
- terminal route returns one match
|
|||
|
|
- `DeployServerVO` password check returns no match
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Review final diff**
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git diff -- system-ops/deploy entrance/src/main/resources/application.yml
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: diff only contains deploy module implementation, deploy README, deploy pom dependency changes, and `application.yml` deploy config block.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Manual interface verification when a Linux test host is available**
|
|||
|
|
|
|||
|
|
Use a Linux server with SSH password login enabled and `DEPLOY_CRYPTO_KEY` configured.
|
|||
|
|
|
|||
|
|
Expected results:
|
|||
|
|
|
|||
|
|
- `POST /deploy/server/add` creates `deploy-server-connections.json` under `${log.homeDir}/deploy`.
|
|||
|
|
- `POST /deploy/server/list` returns no `password` field.
|
|||
|
|
- JSON file contains encrypted `password` text, not the submitted plaintext.
|
|||
|
|
- `POST /deploy/server/test` returns `success=true` for valid credentials.
|
|||
|
|
- `POST /deploy/file/list` with `/` returns remote directory entries.
|
|||
|
|
- `POST /deploy/file/upload` uploads one file to a writable directory.
|
|||
|
|
- `POST /deploy/file/download` returns the same file content.
|
|||
|
|
- `POST /deploy/file/delete` removes the uploaded test file.
|
|||
|
|
- WebSocket `/deploy/terminal?serverId={serverId}` opens a shell and returns output for `pwd` and `ls -la`.
|
|||
|
|
- Closing the WebSocket releases the SSH channel.
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add system-ops/deploy/README.md
|
|||
|
|
git commit -m "docs: describe deploy Linux remote operations"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Self-Review
|
|||
|
|
|
|||
|
|
### Spec Coverage
|
|||
|
|
|
|||
|
|
- Linux-only scope: Task 1 through Task 6 keep SSH/SFTP only and README states Linux-only.
|
|||
|
|
- Connection config save/edit/delete/list: Task 3.
|
|||
|
|
- File storage instead of database: Task 2.
|
|||
|
|
- Password encryption and no password in responses: Task 3 and Task 6 validation.
|
|||
|
|
- SFTP upload/download/list/mkdir/delete: Task 4.
|
|||
|
|
- Xshell-like SSH terminal over WebSocket: Task 5.
|
|||
|
|
- Frontend page layout: covered by the approved spec; no frontend code exists in this repository.
|
|||
|
|
- No Maven execution by default: Assumptions and Task 6 validation use static checks and manual API checks.
|
|||
|
|
|
|||
|
|
### Placeholder Scan
|
|||
|
|
|
|||
|
|
- 未发现未完成标记。
|
|||
|
|
- 未发现空任务标记。
|
|||
|
|
- Every task lists exact files, concrete code blocks, verification commands, expected results, and commit commands.
|
|||
|
|
- No task depends on a later undefined class; shared models and services are introduced before consumers.
|
|||
|
|
|
|||
|
|
### Type Consistency
|
|||
|
|
|
|||
|
|
- Server config model names are consistent: `DeployServerConfigDTO`, `DeployServerConfigStoreDTO`, `DeployServerVO`.
|
|||
|
|
- Server endpoints consistently use `/deploy/server`.
|
|||
|
|
- File endpoints consistently use `/deploy/file`.
|
|||
|
|
- Terminal endpoint consistently uses `/deploy/terminal`.
|
|||
|
|
- SSH port property is consistently named `sshPort`.
|
|||
|
|
- Runtime config names are consistent with `DeployProperties`: `storageDir`, `cryptoKey`, `sshConnectTimeoutMs`, `terminalIdleTimeoutMinutes`.
|