# 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`.
