From 5c77f5ce14bc5a9e2188decb7f2b12437ca161ba Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sun, 30 Aug 2026 13:19:51 +0200 Subject: [PATCH] Feature: Add download_file MCP tool (V2 protocol, SHA-256 verified) download_file pulls a remote file or directory back to the controller over the existing V2 file-transfer protocol. It reuses COMMAND_LIST_DRIVE to open the file-manager sub-link, then sends CMD_DOWN_FILES_V2 so the Windows client streams COMMAND_SEND_FILE_V2 chunks and a per-file COMMAND_FILE_COMPLETE_V2 SHA-256 checksum over its own authenticated sub-connection. The server routes those two packet types into a new per-device FileTransferSession whenever a download is pending, so the headless path never opens the GUI progress dialog; the C2C and existing GUI branches are left untouched. Files are written under a normalized local_dir, and every chunk filename is resolved and verified to stay inside local_dir (directories included) to block .. traversal; overwrite=false skips existing files and counts them as skipped. One transfer per host (mutually exclusive with the one-shot pending registry), a dedicated McpFileTransfer=0-by-default gate surfaced in the settings dialog, and audit logging complete the change. upload_file remains future work. Co-Authored-By: deepseek-v4-pro --- docs/Mcp_FileTransfer_Design.md | 284 ++++++++++++++++ server/2015Remote/2015RemoteDlg.cpp | 24 ++ server/2015Remote/McpServer.cpp | 485 +++++++++++++++++++++++++++ server/2015Remote/McpServer.h | 67 ++++ server/2015Remote/McpSettingsDlg.cpp | 15 +- server/2015Remote/McpSettingsDlg.h | 2 + 6 files changed, 875 insertions(+), 2 deletions(-) create mode 100644 docs/Mcp_FileTransfer_Design.md diff --git a/docs/Mcp_FileTransfer_Design.md b/docs/Mcp_FileTransfer_Design.md new file mode 100644 index 0000000..7b0141b --- /dev/null +++ b/docs/Mcp_FileTransfer_Design.md @@ -0,0 +1,284 @@ +# YAMA MCP 文件传输功能设计(download_file / upload_file) + +> **状态**:设计定稿(经专家审查修订,结论见 §13)。`download_file` 采用 **V2 文件传输协议**(`CMD_DOWN_FILES_V2` + 流式子连接 + SHA-256 校验),`upload_file` 复用服务端既有 `FileBatchTransferWorkerV2`。两者共享同一套「流式文件会话」注册表。 +> **读者**:MCP 后续功能研发/评审人员。 +> **关联文档**:[Mcp_Phase2_Design.md](./Mcp_Phase2_Design.md)(双模式无头驱动机制)、[FILE_TRANSFER_V2.md](./FILE_TRANSFER_V2.md)(V2 协议清单)、[Mcp_Design.md](./Mcp_Design.md)(Phase 1 协议/架构/配置)。 + +--- + +## 1. 背景与目标 + +MCP 目前已暴露 `list_files`(只读列目录,`McpServer.cpp:1741`),但**没有文件传输工具**:把远程主机的文件/目录拉回主控本机(下载),或把本机文件推到远程(上传),只能通过 GUI 文件管理器手工操作。 + +本技术书解决: + +1. **`download_file`**(本期 P1)——远程文件/目录 → 主控本机指定目录,复用 V2 推送协议,带 SHA-256 完整性校验、支持大文件。 +2. **`upload_file`**(后续 P2)——主控本机文件/目录 → 远程指定目录,复用服务端 V2 发送器。 +3. **一套共享的「流式文件会话」基础设施**——一次投入,下载/上传两处受益,镜像现有终端(`TermSession`)与远程控制(`ScreenCtrlSession`)会话模式。 + +--- + +## 2. 现状回顾 + +| 能力 | 现有协议/命令 | 服务端现有实现 | 是否暴露给 MCP | +|---|---|---|---| +| 远程列目录 | `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST`;`COMMAND_LIST_FILES`→`TOKEN_FILE_LIST` | `McpServer.cpp:1741`(`list_files`) | ✅ | +| 远程→本地下载(V1) | `COMMAND_DOWN_FILES`(`commands.h:166`) + `TOKEN_FILE_SIZE/DATA/FINISH` | `FileManagerDlg.cpp:2164` / `file/CFileManagerDlg.cpp:1136` | ❌ 仅 GUI | +| 远程→本地下载(V2) | `CMD_DOWN_FILES_V2`(94, `commands.h:260`) + `COMMAND_SEND_FILE_V2`(85) + `COMMAND_FILE_COMPLETE_V2`(91) | `FileManagerDlg.cpp:3337-3384`(下发)、`2756`(`RecvFileChunkV2` 落盘) | ❌ 仅 GUI | +| 本地→远程上传(V2) | `FileBatchTransferWorkerV2`(服务端作 sender) | `2015RemoteDlg.cpp:7572` | ❌ 仅 GUI | + +### 2.1 关键事实 + +1. **V2 下载的客户端会另开一条新子连接**:`client/FileManager.cpp:1101-1190` `UploadToRemoteV2()` 解析完文件列表后,`new IOCPClient(...)` → `EnableSubConnAuth()` → `ConnectServer(...)`(`FileManager.cpp:1170-1172`),在**独立鉴权子连接**上推 `COMMAND_SEND_FILE_V2` 分块 + `COMMAND_FILE_COMPLETE_V2` 校验包。即下载 ≠ 复用文件管理器那条子链接,而是一条**持续流子链接**——正属 `Mcp_Phase2_Design.md §4.3` 当初判定「本期不做」的流式能力。 + +2. **接收端 `RecvFileChunkV2` 无 UI 可调**:`CDlgFileSend.cpp:128` 已 `RecvFileChunkV2(buf, len, nullptr, nullptr, hash, hmac, 0)` 无头落盘;落盘状态在 `SimplePlugins/file_upload.cpp` 内部按 `transferID` 自维护,天然适合 MCP 无头复用。 + +3. **V2 落盘路径由客户端回填**:`CMD_DOWN_FILES_V2` 的 `targetDir` 是**主控本机**保存目录(`FileManagerDlg.cpp:3370` `m_Local_Path`);客户端把它拼进每个 chunk 的 `filename` 发回,服务端 `RecvFileChunkV2` 直接按该完整路径写盘。因此「下载保存到哪」由服务端在命令里指定、客户端原样回填。 + +--- + +## 3. 协议选型(已定:V2) + +| 维度 | **V2 推送(选定)** | V1 拉取(回退) | +|---|---|---| +| 命令 | `CMD_DOWN_FILES_V2` + `COMMAND_SEND_FILE_V2` + `COMMAND_FILE_COMPLETE_V2` | `COMMAND_DOWN_FILES` + `TOKEN_FILE_SIZE/DATA/FINISH` | +| 连接 | 客户端新开一条鉴权流式子连接 | 复用文件管理器子链接 | +| 完整性 | ✅ SHA-256(`COMMAND_FILE_COMPLETE_V2`) | ❌ 无 | +| 大文件 | ✅ 64 位偏移,>4GB | 64 位偏移但无校验 | +| 客户端代码 | ✅ 已存在,是当前 GUI 正规路径 | ⚠️ 遗留路径 | +| MCP 侧复杂度 | 需路由一条**新流式子连接**(新增会话注册表) | 需在 `MessageHandle` 维护「发 CONTINUE/收 DATA」状态机,逐块 ACK 慢 | + +**结论:采用 V2。** 代价是服务端要新增「流式文件会话」注册与路由;但这是 `upload_file` 同样要复用的基础设施,一次投入两处受益。V1 仅作「不想引入新子连接路由」时的降级备案,不在本期实现。 + +--- + +## 4. 设计原则 + +1. **复用现有协议,不新造命令**:`CMD_DOWN_FILES_V2`、`COMMAND_SEND_FILE_V2`、`COMMAND_FILE_COMPLETE_V2`、`COMMAND_LIST_DRIVE` 全部为 `common/commands.h` 既有;客户端零改动。 +2. **无头接管复用会话模式**:镜像 `McpServer.h` 的 `TermSession` / `ScreenCtrlSession`,新增 `FileTransferSession`;`MessageHandle` 用 `IsFileTransferContext(context*)` 判定是否路由到 MCP 无头落盘,否则回落 GUI 进度框。 +3. **单设备单传输会话**:与终端/远程控制一致,避免同 host 并发传输的归属歧义;并发返回 `-32003 Device busy`。 +4. **超时与清理是硬约束**:大文件传输是长任务,超时需独立于 `kMcpToolTimeoutMs`(20s);断线/超时必须清会话 + 关子链接 + 删半成品文件。 +5. **输出 schema 先行**:两个工具先在 `tools/list` 声明完整 `inputSchema`/`outputSchema`。 + +--- + +## 5. 核心机制:流式文件会话 + +### 5.1 会话注册表(`McpServer.h/.cpp`) + +```cpp +struct FileTransferSession { + std::string tool; // "download_file" / "upload_file" + std::string localDir; // download: 本机落盘目录(规范化后绝对路径,用于穿越校验) + std::string remotePath; // 审计用原始远程路径(UTF-8) + uint64_t transferID = 0; // 绑定后从首包填充(download);upload 由本端 GenerateTransferID() + uint32_t totalFiles = 0; // 完成判定:首个 COMMAND_SEND_FILE_V2 chunk 的 totalFiles(§13 F4) + uint32_t filesDone = 0; // 完成判定:累计收到的 COMMAND_FILE_COMPLETE_V2 数 + bool done = false; + int error = 0; // FEV2_* / 自定义 + std::vector files; // {path, size} 已落盘/已发送文件 + std::vector skipped; // overwrite=false 时跳过的同名文件 + time_t startAt = 0; +}; +std::mutex m_FileXferMutex; +std::map m_FileXferSessions; // device_id → 会话 +``` + +> **取消 `m_FileXferContextToDevice` 映射与 `IsFileTransferContext(context*)`**:流式子连接经 `TOKEN_CONN_AUTH` 已把 `clientID` 钉在 `ContextObject` 上(`2015RemoteDlg.cpp:6411 SetID`),分派时直接用 `ContextObject->GetClientID()` 定位 `device_id`,无需 context 路由表(§13 F1)。 + +配套接口(与 `BeginTermPending` / `OnTerminalData` / `WaitTerminalDone` 同构): + +```cpp +bool BeginFileTransferPending(uint64_t device_id, const std::string& tool, + const std::string& localDir, const std::string& remotePath); +bool IsFileTransferPending(uint64_t device_id); // 分支顶部守卫 +void OnFileChunkV2(uint64_t device_id, const BYTE* buf, ULONG len); // 路径校验 + 无头 RecvFileChunkV2 +void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);// SHA-256 + filesDone 计数 +bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, std::vector& out); +void ClearFileTransfer(uint64_t device_id); // 失败/超时/断线收尾(删半成品) +``` + +### 5.2 流式子连接的路由(device_id 键,镜像 `TOKEN_DRIVE_LIST` 守卫) + +V2 下载的数据包 `COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 本就由 `MessageHandle` 按 `ContextObject->GetClientID()` 分派(`2015RemoteDlg.cpp:5842` / `6101`)。MCP 只需在这两个 case 的 **`dstClientID==0`(M2C)分支顶部**加守卫: + +```cpp +case COMMAND_SEND_FILE_V2: { + FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer; + if (pkt->dstClientID == 0) { + uint64_t devId = ContextObject->GetClientID(); + if (McpServer().IsFileTransferPending(devId)) { // ← 新增守卫:无头接管 + McpServer().OnFileChunkV2(devId, szBuffer, len); + break; // 不建 CDlgFileSend、不设 hDlg + } + // ... 原 GUI 逻辑(建 CDlgFileSend + OnReceiveComplete)不变 + } + // ... C2C 分支(dstClientID != 0)完全不动 +} +``` + +- `TOKEN_CONN_AUTH`(`6378`) **完全不动**——它只负责把 `clientID` 钉在 ctx 上,下载复用既有行为。 +- `TOKEN_DRIVE_LIST`(`6430`) 既有 `IsPending(devId)` 守卫**扩展复用**:`OnDriveList` 识别当前挂起工具为 `download_file` 时,改发 `CMD_DOWN_FILES_V2` 而非 `COMMAND_LIST_FILES`,发完即 `CancelIO` 文件管理器子链接(数据走新流式子连接,§13 F3)。 +- 守卫为假时全部回落原 GUI/C2C 路径——对既有功能零影响(原则 #1,§13 F7)。 + +--- + +## 6. `download_file` 设计(P1) + +### 6.1 工具 Schema + +``` +input: { + id: string (必填, 主机 id) + remote_path: string (必填, 远程文件或目录绝对路径, 如 C:\Users\shaun\Pictures) + local_dir: string (必填, 主控本机保存目录; 不存在会自动创建) + overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 则跳过) + timeout_ms: integer (可选, 默认 600000, 上限 3600000) +} +output: { + files: [{ path: string, size: integer, sha256: string }] // 实际落盘文件 + total_bytes: integer + skipped: integer +} +``` + +### 6.2 时序 + +``` +MCP工具线程 服务端 MessageHandle 客户端 + │ 1. BeginFileTransferPending(id,"download_file",localDir,remotePath) │ + │ 2. 主连接发 COMMAND_LIST_DRIVE ────────────────────────────────────────► 开文件管理器子链接 + │ 3. ◄── TOKEN_DRIVE_LIST ─────────────────────┤ + │ OnDriveList → 锁外下发 CMD_DOWN_FILES_V2[targetDir\0][remote_path\0]\0 │ + │ 4. ────────────────────────────────────────► UploadToRemoteV2() + │ (客户端另开一条鉴权流式子连接 ConnectServer) + │ 5. 新子连接 TOKEN_CONN_AUTH 钉 clientID → 后续分块按 device_id 守卫接管 │ + │ 6. ◄── COMMAND_SEND_FILE_V2 分块流 ──────────┤ + │ → OnFileChunkV2 → RecvFileChunkV2() 无头落盘 + │ 7. ◄── COMMAND_FILE_COMPLETE_V2(SHA-256) ────┤ + │ 8. 校验通过 → done → 唤醒工具线程 → CancelIO 两条子连接 → 返回 │ +``` + +### 6.3 服务端改动 + +1. **`McpServer.h`**:新增 `FileTransferSession` 结构 + 注册表 + 接口声明(§5.1)。 +2. **`McpServer.cpp`**: + - `BuildDownloadFileInputSchema/OutputSchema` + `BuildDownloadFile(...)`(`tools/call` 分派); + - `OnFileChunkV2`:**先路径校验**(chunk `filename` 规范化后仍在 `local_dir` 内,防 `..` 穿越,§13 F5)→ 调 `RecvFileChunkV2(buf,len,nullptr,nullptr,hash,hmac,0)` 无头落盘,记 `totalFiles`/进度; + - `OnFileCompleteV2`:`HandleFileCompleteV2` 校验 SHA-256 → `filesDone++`;`filesDone==totalFiles` 时置 `done` + `notify`(§13 F4); + - `hash/hmac` 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`,兼容客户端 M2C `hmac` 为空(`client/FileManager.cpp:1167`,§13 F6); + - `ClearFileTransfer`(发送失败/超时/断线):`CancelIO` 子链接 + 删除半成品。 +3. **`2015RemoteDlg.cpp`(`MessageHandle`)**:`COMMAND_SEND_FILE_V2`(85)、`COMMAND_FILE_COMPLETE_V2`(91) 的 `dstClientID==0` 分支顶部加 `IsFileTransferPending(devId)` 守卫;`TOKEN_DRIVE_LIST` 处扩展 `OnDriveList` 识别 `download_file` 改发 `CMD_DOWN_FILES_V2`。`TOKEN_CONN_AUTH` 与 C2C 分支**完全不动**。 +4. **`McpSettingsDlg.cpp/.h`**:新增 `McpFileTransfer` 配置项(默认 0)。 + +### 6.4 落盘与路径安全 + +- 发 `CMD_DOWN_FILES_V2` 前把 `local_dir` 规范化为绝对路径并 `CreateDirectory`;`targetDir` 用 ANSI(MBCS 构建本机路径)。 +- 每收到一个 chunk,校验其 `filename` 规范化后**前缀必须在 `local_dir` 内**(防 `..` 穿越,呼应 `FILE_TRANSFER_V2.md §8.3` 已识别的风险);逃逸则置 `error` 并 `CancelIO`。 +- 编码:`remote_path` 走 UTF-8→ANSI(936)(Windows 客户端,复用 `McpServer.cpp` 既有 `ToAnsi`,与 `list_files` 一致)。 + +### 6.5 超时与并发 + +- **P1 范围:同步 + 单文件/已打包目录**。一次 `tools/call` 阻塞到底,不引入 job/task 异步模型(决策见 §12)。核心场景「先 `terminal_exec` 压缩成单 zip,再 `download_file` 拉回」天然是单文件,同步足够。 +- 默认 `timeout_ms=600000`、上限 3600000,因大文件远超 `kMcpToolTimeoutMs`(20s)。 +- **单飞互斥(§13 F2)**:`BeginFileTransferPending` 与既有一次性 `m_Pending` **双向互斥**——下载登记时占用 `m_Pending`,反之 `BeginPending` 也检查 `m_FileXferSessions`,保证同 host 任意时刻只一个 MCP 工具在飞;并发返回 `-32003 Device busy`。 +- 断线(`OfflineProc` 擦会话)+ 超时(删半成品)双收尾。 + +--- + +## 7. `upload_file` 设计(P2,未来) + +- **复用服务端 sender**:`2015RemoteDlg.cpp:7572` 已有 `FileBatchTransferWorkerV2(files, targetDir, ..., SendFileChunkToClientV2, ...)`,服务端读本机文件分块 `COMMAND_SEND_FILE_V2` 推给客户端;客户端 `RecvFileChunkV2` 落盘并回 `COMMAND_FILE_COMPLETE_V2`。 +- **比 download 更简单**:服务端是发送方,工具线程直接驱动 `FileBatchTransferWorkerV2`,无需等待外来流;只需等客户端回 `COMMAND_FILE_COMPLETE_V2`(复用同一文件会话注册表)。 +- **Schema**(§3.2 已列):`id` + `local_path` + `remote_dir` + `overwrite` + `timeout_ms`。 +- **安全**:写远程盘,独立评审;`remote_dir` 路径规范化,可选系统目录黑名单。 + +--- + +## 8. 安全门 + +| 工具 | 定性 | 门槛 | +|---|---|---| +| `download_file` | 不改远程状态,但**数据外带** | `McpFileTransfer=1`(仅此一开关;**不依赖** `McpReadonly`,与 `list_files`/`get_screenshot` 同权) | +| `upload_file` | **写远程盘** | `McpFileTransfer=1` **且** `McpReadonly=0`(复用既有「允许写」主开关) | + +- **只新增一个开关 `McpFileTransfer`(默认 0)**:download 只看它;upload 额外要求 `McpReadonly=0`。决策依据见 §12。 +- `tools/list` 据此隐藏两个工具(与 `exec_command`/`terminal_*`/`remote_*` 同款开关判断)。 +- 审计:`host_id + tool + remote_path + local_dir + 结果`,写 `Mprintf` + 审计日志。 + +--- + +## 9. 改动文件清单 + +| 文件 | 改动 | +|---|---| +| `server/2015Remote/McpServer.h` | `FileTransferSession` + 注册表 + 8 个接口声明 | +| `server/2015Remote/McpServer.cpp` | `download_file` schema/实现 + 会话状态机 + 路由分支 | +| `server/2015Remote/2015RemoteDlg.cpp` | `MessageHandle`:`COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 两处守卫分支 + `TOKEN_DRIVE_LIST` 处 `OnDriveList` 扩展;`TOKEN_CONN_AUTH` 不动 | +| `server/2015Remote/McpSettingsDlg.cpp/.h` | `McpFileTransfer` 配置项 | +| (P2)`McpServer.cpp` | `upload_file` 实现(复用 `FileBatchTransferWorkerV2`) | + +**客户端零改动**(`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在)。 + +--- + +## 10. 分阶段实施与回滚 + +- **P1**:`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收(真实主机拖回一个目录,SHA-256 与 `certutil -hashfile` 比对一致)。 +- **P2**:`upload_file`。 +- **P3**(可选):断点续传(需先验证服务端续传状态落盘;文件管理器侧现 `enableResume=false`,`client/FileManager.cpp:1164`)、大文件进度流式上报。 +- **回滚**:改动集中在 `McpServer.*` + `MessageHandle` 三个 `if` 分支,revert 当期 commit 即可,不影响既有 GUI 文件管理器。 + +--- + +## 11. 测试点 + +- 单文件 / 目录(含中文名、深层嵌套)下载;SHA-256 与 `certutil -hashfile` 比对一致。 +- `overwrite=false` 同名跳过;`local_dir` 不存在自动创建。 +- 路径穿越:`remote_path` 含 `..` 或 chunk 文件名逃逸 `local_dir` 时被拒。 +- 大文件(>2GB)超时与断线;并发下载同主机返回 `-32003`。 +- 编码:GBK 中文文件名往返无乱码(与 `list_files` 同规则)。 +- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。 + +--- + +## 12. 决策记录(以「简单易用」为原则) + +原则:最少开关、最少认知负担、复用既有机制、不为低频场景预留复杂度;只有威胁模型确实需要时才加复杂度。 + +### 12.1 download 数据外带 → **不加目录白名单,信任 `McpFileTransfer` + 审计** + +- **威胁模型**:MCP 默认 `127.0.0.1` + token;「数据外带」的实际顾虑是 AI 误操作或提示注入。但一旦开启 `terminal_exec`(全 shell、无白名单),外带通道远大于 download,目录白名单对已开终端者是冗余防御。 +- **一致性**:`terminal_*` / `remote_*` 均只有开关 + 审计、无路径白名单;给 download 单独加白名单是特例,增加解释负担。 +- **简单**:工具契约保持 `id + remote_path + local_dir`,不引入「允许根目录」概念。 +- **逃生舱**:若未来确有需要,复用 `McpCmdWhitelist` 那种**单字符串**配置作可选收紧项,进 P3,不进 P1。 + +### 12.2 大文件模型 → **P1 同步 + 大超时,收窄到单文件/已打包目录** + +- MCP 是 request/response;为一次性传输引入「提交任务 + 轮询进度 + 任务清理」违背「简单 + 每阶段独立可交付」。 +- 核心场景(先压缩成单 zip 再拉回)天然是单文件,同步足够;`get_screenshot` 已先例式地内联返回字节。 +- 异步任务模型进 P3,且**只在确有超大流式需求时**才做,不预建。 + +### 12.3 upload 开关 → **单一 `McpFileTransfer`,upload 复用 `McpReadonly=0`** + +- `download_file` = `McpFileTransfer=1`(不依赖 `McpReadonly`):它只读远程,与 `list_files`/`get_screenshot` 同权,readonly 语义本就只拦「改远程」。 +- `upload_file` = `McpFileTransfer=1 && McpReadonly=0`:upload 改远程,复用既有「允许写」主开关,与 `terminal_*`/`remote_*` 的 `X && !Readonly` 模式一致。 +- 只新增 **1 个**开关;代价是「不能只开 upload 不开 download」——这是可接受的简化(无人有此诉求)。 + +--- + +## 13. 专家审查记录(实施前) + +原则优先级:① 对既有功能影响最小 → ② 简单易用 → ③ 优先 V2。逐条核对了 `2015RemoteDlg.cpp` 的 `MessageHandle` 分派、`client/FileManager.cpp` 与 `SimplePlugins/file_upload.cpp` 的实现,结论如下: + +| # | 审查发现 | 结论 | +|---|---|---| +| F1 | 路由机制比草案更简单:`COMMAND_SEND_FILE_V2`(`5842`)/`COMMAND_FILE_COMPLETE_V2`(`6101`) 本就按 `ContextObject->GetClientID()` 分派,流式子连接经 `TOKEN_CONN_AUTH`(`6411 SetID`) 钉住 clientID | **无需 context 路由表**。改为 `device_id` 键 + 两个 case 顶部的 `IsFileTransferPending(devId)` 守卫,`TOKEN_CONN_AUTH` 完全不动 | +| F2 | 一次性 `m_Pending` 与新增文件会话可能并发冲突(同 host 双工具) | `BeginFileTransferPending` 与 `m_Pending` **双向互斥**,同 host 全 MCP 工具单飞 | +| F3 | 文件管理器子链接发完 `CMD_DOWN_FILES_V2` 后即无用(数据走新流式子连接) | `OnDriveList` 发完命令即 `CancelIO` 该子链接;客户端 `UploadToRemoteV2` 用独立 `IOCPClient`,不依赖它 | +| F4 | 完成信号是**每文件一个** `COMMAND_FILE_COMPLETE_V2`(`FileCompletePacketV2.fileIndex`) | 会话记 `totalFiles`(首 chunk)/`filesDone`(complete 计数),`filesDone==totalFiles` 才算 done | +| F5 | `RecvFileChunkV2` 内部直接按 chunk `filename` 落盘 | MCP 在调用**前**校验 `filename` 规范化仍在 `local_dir` 内,逃逸即取消传输 | +| F6 | `RecvFileChunkV2` 需正确 `hash/hmac` | 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`;客户端 M2C `hmac` 为空(`FileManager.cpp:1167`) | +| F7 | 三处改动均为「加 if 守卫 + 现有逻辑作 else」,C2C 分支不动 | 结构性满足原则 #1,对既有 GUI/C2C 零影响 | + +**判定**:7 项问题均已在正文对应章节修正,无阻塞项,**定稿**。 diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index fd2f562..b04c789 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -2201,6 +2201,8 @@ BOOL CMy2015RemoteDlg::OnInitDialog() McpServer().SetTerminalEnabled(THIS_CFG.GetInt("settings", "McpTerminal", 0) != 0); // 远程控制开关:默认关;要求只读关(McpReadonly=0)才生效(工具列表/分派双重门控)。 McpServer().SetRemoteControlEnabled(THIS_CFG.GetInt("settings", "McpRemoteControl", 0) != 0); + // 文件传输开关:默认关;仅 download_file 门控(不要求只读关)。 + McpServer().SetFileTransferEnabled(THIS_CFG.GetInt("settings", "McpFileTransfer", 0) != 0); if (!McpServer().Start(mcpBind, mcpPort)) { Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); } else { @@ -5843,6 +5845,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) // V2 文件传输(支持 C2C) FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer; + // P6:MCP download_file 挂起时接管流式子连接(dstClientID==0 → 主控端)。 + // OnFileChunkV2 自身会做 len 校验,这里先判长度再读 dstClientID,避免越界。 + if (len >= sizeof(FileChunkPacketV2) && pkt->dstClientID == 0 && + McpServer().IsFileTransferPending(ContextObject->GetClientID())) { + McpServer().OnFileChunkV2(ContextObject->GetClientID(), ContextObject, szBuffer, len); + break; + } + if (pkt->dstClientID == 0) { // 目标是主控端:本地接收 if (ContextObject->hDlg == NULL) { @@ -6103,6 +6113,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) if (len < sizeof(FileCompletePacketV2)) break; FileCompletePacketV2* pkt = (FileCompletePacketV2*)szBuffer; + // P6:MCP download_file 挂起时接管完成校验包(dstClientID==0 → 主控端)。 + if (pkt->dstClientID == 0 && + McpServer().IsFileTransferPending(ContextObject->GetClientID())) { + McpServer().OnFileCompleteV2(ContextObject->GetClientID(), szBuffer, len); + break; + } + if (pkt->dstClientID == 0) { // 目标是主控端:本地校验 bool verifyOk = HandleFileCompleteV2((char*)szBuffer, len, 0); @@ -6436,6 +6453,13 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) ContextObject->CancelIO(); // 只列盘 → 用完即关一次性子链接 break; } + // P6:MCP download_file 挂起时接管文件管理器子链接,下发 CMD_DOWN_FILES_V2。 + // true=已接管(保持子链接,ClearFileTransfer 收尾);false=会话已清理(迟到包)→ 关孤儿子链接。 + if (McpServer().IsFileTransferPending(devId)) { + if (!McpServer().OnDownloadDriveList(devId, ContextObject)) + ContextObject->CancelIO(); + break; + } ContextObject->EnableZstdContext(6); g_2015RemoteDlg->SendMessage(WM_OPENFILEMANAGERDIALOG, 0, (LPARAM)ContextObject); break; diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index c38b251..e37720d 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -7,6 +7,7 @@ #include "WebService.h" // 远程控制复用屏幕子连接(StartRemoteDesktop / GetScreenContext / GetScreenSize) #include "Server.h" // CONTEXT_OBJECT 定义(GetScreenContext 返回 CONTEXT_OBJECT* → context* 上转型) #include "LangManager.h" // _TR(审计日志标题语言映射) +#include "common/file_upload.h" // V2 文件传输(RecvFileChunkV2 / HandleFileCompleteV2 / 包结构) #include @@ -24,6 +25,11 @@ #endif #endif +// P6 download_file 复用 V2 落盘接口,需 FileManagerDlg 同款密码哈希 / HMAC 参数 +// (声明见 CPasswordDlg.h,避免在此引入重量级 MFC 头)。 +std::string GetPwdHash(); +std::string GetHMAC(int offset); + namespace { // P2b 工具等待响应的超时(ms)。MCP 一次性请求:等待子连接回传进程/窗口列表。 @@ -1147,6 +1153,11 @@ std::string BuildRemoteOpen(const Json::Value& id, const Json::Value& args, CMy2 std::string BuildRemoteClose(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); std::string BuildRemoteKeyboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); std::string BuildRemoteMouse(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); + +// ===== P6 前置声明(定义见下方「tools/call 分派」前)===== +Json::Value BuildDownloadFileInputSchema(); +Json::Value BuildDownloadFileOutputSchema(); +std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent); // tools/list @@ -1397,6 +1408,17 @@ std::string BuildToolsListResult(const Json::Value& id) { } } + // 16) download_file(P6:MCP 文件传输,仅 Windows,安全门:McpFileTransfer=1) + // 独立开关(不要求 McpReadonly=0):下载是只读方向(主控落盘),风险可控。 + if (CMcpServer::Instance().IsFileTransferEnabled()) { + Json::Value tool(Json::objectValue); + tool["name"] = "download_file"; + tool["description"] = u8"从指定在线 Windows 主机下载文件或目录到主控本机(V2 协议,逐文件 SHA-256 校验)。remote_path 为远程绝对路径(文件或目录,目录递归下载);local_dir 为本机保存目录;overwrite=false 时跳过已存在文件。返回 files[{path,size,sha256}] 与 total_bytes/skipped。"; + tool["inputSchema"] = BuildDownloadFileInputSchema(); + tool["outputSchema"] = BuildDownloadFileOutputSchema(); + tools.append(tool); + } + result["tools"] = tools; return BuildResult(id, result); } @@ -3516,6 +3538,264 @@ std::string BuildRemoteClipboard(const Json::Value& id, const Json::Value& args, return BuildResult(id, result); } +// ===== P6:download_file 辅助 ===== + +// SHA-256 摘要转小写 hex(32 字节 → 64 字符) +std::string Sha256Hex(const uint8_t* b, size_t n) { + static const char hex[] = "0123456789abcdef"; + std::string out; + out.reserve(n * 2); + for (size_t i = 0; i < n; ++i) { + out += hex[b[i] >> 4]; + out += hex[b[i] & 0xF]; + } + return out; +} + +// 逐层创建目录(Windows 盘符路径;失败返回 false)。 +bool EnsureDirA(const std::string& path) { + std::string current; + for (size_t i = 0; i < path.size(); ++i) { + current += path[i]; + if (path[i] == '\\' || path[i] == '/' || i + 1 == path.size()) { + if (current.size() == 2 && current[1] == ':') continue; // 跳过盘符 "C:" + if (GetFileAttributesA(current.c_str()) == INVALID_FILE_ATTRIBUTES) { + if (!CreateDirectoryA(current.c_str(), nullptr) && + GetLastError() != ERROR_ALREADY_EXISTS) { + return false; + } + } + } + } + return GetFileAttributesA(path.c_str()) != INVALID_FILE_ATTRIBUTES; +} + +// 把 UTF-8 local_dir 转为 ANSI 绝对路径(结尾 '\'),并创建目录。失败返回 false 并写 err。 +bool NormalizeLocalDir(const std::string& utf8, std::string& ansiOut, std::string& err) { + if (utf8.empty()) { err = "Missing required parameter: local_dir"; return false; } + std::string ansi = ToAnsi(utf8, CP_ACP); + if (ansi.empty()) { err = "Invalid local_dir encoding"; return false; } + + char full[MAX_PATH]; + DWORD n = GetFullPathNameA(ansi.c_str(), MAX_PATH, full, nullptr); + if (n == 0 || n >= MAX_PATH) { err = "Invalid local_dir: " + utf8; return false; } + + std::string dir = full; + while (!dir.empty() && (dir.back() == '\\' || dir.back() == '/')) dir.pop_back(); + if (dir.size() == 2 && dir[1] == ':') { err = "Invalid local_dir: " + utf8; return false; } + + if (!EnsureDirA(dir)) { err = "Failed to create local_dir: " + utf8; return false; } + ansiOut = dir + "\\"; + return true; +} + +// 防路径穿越:fullPath 规范化后必须位于 baseDir 内(含 baseDir 本身)。 +bool PathWithinDirA(const std::string& baseDir, const std::string& fullPath) { + char baseFull[MAX_PATH], pathFull[MAX_PATH]; + if (!GetFullPathNameA(baseDir.c_str(), MAX_PATH, baseFull, nullptr)) return false; + if (!GetFullPathNameA(fullPath.c_str(), MAX_PATH, pathFull, nullptr)) return false; + + for (char* p = baseFull; *p; ++p) { if (*p == '/') *p = '\\'; else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p - 'A' + 'a'); } + for (char* p = pathFull; *p; ++p) { if (*p == '/') *p = '\\'; else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p - 'A' + 'a'); } + + size_t bl = strlen(baseFull); + while (bl > 0 && baseFull[bl - 1] == '\\') --bl; + if (bl == 0) return true; // 根目录 + if (strncmp(pathFull, baseFull, bl) != 0) return false; + return pathFull[bl] == '\\' || pathFull[bl] == '\0'; +} + +Json::Value BuildDownloadFileInputSchema() { + Json::Value props(Json::objectValue); + Json::Value idProp(Json::objectValue); + idProp["type"] = "string"; + idProp["description"] = u8"主机 id,取 list_online_hosts / search_hosts 返回的 id 字段"; + props["id"] = idProp; + + Json::Value rp(Json::objectValue); + rp["type"] = "string"; + rp["description"] = u8"远程绝对路径(文件或目录;目录递归下载),如 C:\\Users\\foo\\Pictures"; + props["remote_path"] = rp; + + Json::Value ld(Json::objectValue); + ld["type"] = "string"; + ld["description"] = u8"主控本机保存目录(不存在则自动创建),如 C:\\Downloads"; + props["local_dir"] = ld; + + Json::Value ow(Json::objectValue); + ow["type"] = "boolean"; + ow["description"] = u8"是否覆盖已存在的同名文件;默认 false(跳过并计入 skipped)"; + props["overwrite"] = ow; + + Json::Value to(Json::objectValue); + to["type"] = "integer"; + to["description"] = u8"超时毫秒(默认 600000=10 分钟,上限 3600000=1 小时)"; + props["timeout_ms"] = to; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + Json::Value required(Json::arrayValue); + required.append("id"); + required.append("remote_path"); + required.append("local_dir"); + schema["required"] = required; + return schema; +} + +Json::Value BuildDownloadFileOutputSchema() { + Json::Value props(Json::objectValue); + + Json::Value filesProp(Json::objectValue); + filesProp["type"] = "array"; + Json::Value items(Json::objectValue); + items["type"] = "object"; + Json::Value itemProps(Json::objectValue); + Json::Value pathProp(Json::objectValue); pathProp["type"] = "string"; itemProps["path"] = pathProp; + Json::Value sizeProp(Json::objectValue); sizeProp["type"] = "integer"; itemProps["size"] = sizeProp; + Json::Value shaProp(Json::objectValue); shaProp["type"] = "string"; itemProps["sha256"] = shaProp; + items["properties"] = itemProps; + filesProp["items"] = items; + props["files"] = filesProp; + + Json::Value tb(Json::objectValue); tb["type"] = "integer"; props["total_bytes"] = tb; + Json::Value sk(Json::objectValue); sk["type"] = "integer"; props["skipped"] = sk; + + Json::Value schema(Json::objectValue); + schema["type"] = "object"; + schema["properties"] = props; + return schema; +} + +// tools/call:download_file(下载远程文件/目录到本机,V2 协议) +std::string BuildDownloadFile(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { + // 分派门控(独立开关 McpFileTransfer,不要求 McpReadonly=0):即便绕过 tools/list 直调也拒绝。 + if (!CMcpServer::Instance().IsFileTransferEnabled()) + return BuildError(id, -32006, "File transfer is disabled: requires McpFileTransfer=1"); + + uint64_t devId = 0; + std::string err; + if (!ParseHostIdArg(args, devId, err)) + return BuildError(id, -32602, err); + + context* ctx = FindMainContext(parent, devId); + if (!ctx) + return BuildError(id, -32002, "Host not found or offline: " + std::to_string(devId)); + + // 仅 Windows 客户端实现 CMD_DOWN_FILES_V2(client/FileManager.cpp),LNX/MAC 提前拒绝。 + CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE); + if (clientType == "LNX" || clientType == "MAC") + return BuildError(id, -32005, "download_file is only supported on Windows hosts"); + + // 客户端须支持 V2 文件传输(否则 CMD_DOWN_FILES_V2 会被静默忽略 → 超时)。 + if (!ctx->SupportsFileV2()) + return BuildError(id, -32006, "Host does not support V2 file transfer"); + + std::string remotePathUtf8 = GetStringArg(args, "remote_path"); + if (remotePathUtf8.empty()) + return BuildError(id, -32602, "Missing required parameter: remote_path"); + + std::string localDirUtf8 = GetStringArg(args, "local_dir"); + if (localDirUtf8.empty()) + return BuildError(id, -32602, "Missing required parameter: local_dir"); + + bool overwrite = false; + if (args.isMember("overwrite") && args["overwrite"].isBool()) + overwrite = args["overwrite"].asBool(); + + int timeoutMs = 600000; + if (args.isMember("timeout_ms")) { + int v = 0; + if (!GetIntArg(args, "timeout_ms", v) || v <= 0 || v > 3600000) + return BuildError(id, -32602, "timeout_ms must be in range 1..3600000"); + timeoutMs = v; + } + + // 本机保存目录:UTF-8 → ANSI 绝对路径(结尾 '\'),并创建。 + std::string localDirAnsi; + if (!NormalizeLocalDir(localDirUtf8, localDirAnsi, err)) + return BuildError(id, -32602, err); + + // 远程路径:UTF-8 → 客户端 ANSI(Windows=936,与 list_files 一致)。 + std::string remotePathAnsi = ToAnsi(remotePathUtf8, 936); + if (remotePathAnsi.empty()) + return BuildError(id, -32602, "Invalid remote_path encoding"); + + CMcpServer& mcp = CMcpServer::Instance(); + if (!mcp.BeginFileTransferPending(devId, "download_file", localDirAnsi, remotePathAnsi, overwrite)) + return BuildError(id, -32003, "Device busy: another request is pending for this host"); + + BYTE cmd = COMMAND_LIST_DRIVE; + if (!ctx->Send2Client(&cmd, 1)) { + mcp.ClearFileTransfer(devId); + return BuildError(id, -32004, "Failed to send command to host"); + } + + std::vector files; + int skipped = 0, error = 0; + bool ok = mcp.WaitFileTransferDone(devId, timeoutMs, files, skipped, error); + mcp.ClearFileTransfer(devId); // 擦会话 + CancelIO 子链接 + + if (ok) { + uint64_t totalBytes = 0; + for (auto& f : files) totalBytes += f.size; + + // 审计(不可关闭;只记路径与字节数,不落文件内容) + if (parent) { + std::string audit = "host " + std::to_string(devId) + " download_file: " + + remotePathUtf8 + " -> " + localDirUtf8 + + " (" + std::to_string(files.size()) + " files, " + + std::to_string(totalBytes) + " bytes, skipped " + std::to_string(skipped) + ")"; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(audit, 936).c_str()), + (LPARAM)new CString(_TR("MCP文件传输"))); + } + + Json::Value result(Json::objectValue); + Json::Value structuredContent(Json::objectValue); + Json::Value filesArr(Json::arrayValue); + for (auto& f : files) { + Json::Value fo(Json::objectValue); + fo["path"] = ToUtf8(f.path.c_str(), CP_ACP); + fo["size"] = (Json::UInt64)f.size; + fo["sha256"] = f.sha256; + filesArr.append(fo); + } + structuredContent["files"] = filesArr; + structuredContent["total_bytes"] = (Json::UInt64)totalBytes; + structuredContent["skipped"] = skipped; + result["structuredContent"] = structuredContent; + + Json::Value content(Json::arrayValue); + Json::Value item(Json::objectValue); + item["type"] = "text"; + item["text"] = std::string(u8"下载完成:") + std::to_string(files.size()) + u8" 个文件(" + + std::to_string(totalBytes) + u8" 字节),跳过 " + std::to_string(skipped) + u8" 个。"; + content.append(item); + result["content"] = content; + result["isError"] = false; + return BuildResult(id, result); + } + + // 失败:删除已落盘的半成品文件(跳过的文件是既有的,不删)。 + for (auto& f : files) { + if (!f.path.empty()) DeleteFileA(f.path.c_str()); + } + if (parent) { + std::string audit = "host " + std::to_string(devId) + " download_file FAILED (error " + std::to_string(error) + + "): " + remotePathUtf8 + " -> " + localDirUtf8; + parent->PostMessageA(WM_SHOWERRORMSG, + (WPARAM)new CString(ToAnsi(audit, 936).c_str()), + (LPARAM)new CString(_TR("MCP文件传输"))); + } + + const char* msg = (error == 1002) ? "Timeout waiting for file transfer" + : (error == 1001) ? "Path traversal detected in received path" + : (error == FEV2_HASH_MISMATCH) ? "File SHA-256 verification failed" + : "File transfer failed"; + return BuildError(id, -32001, msg); +} + // tools/call 分派 std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { const Json::Value& id = root["id"]; @@ -3549,6 +3829,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { if (toolName == "remote_keyboard") return BuildRemoteKeyboard(id, args, parent); if (toolName == "remote_mouse") return BuildRemoteMouse(id, args, parent); if (toolName == "remote_clipboard") return BuildRemoteClipboard(id, args, parent); + if (toolName == "download_file") return BuildDownloadFile(id, args, parent); return BuildError(id, -32602, "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); @@ -4073,6 +4354,7 @@ void CMcpServer::TakeMainResponse(uint64_t device_id, const BYTE* data, ULONG le } bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool) { + if (IsFileTransferPending(device_id)) return false; // 该 host 有文件传输会话在飞(互斥,见 F2) std::lock_guard lk(m_PendingMutex); if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙 PendingRequest r; @@ -4105,6 +4387,7 @@ void CMcpServer::ClearPending(uint64_t device_id) { // ===== P2c:list_files / get_screenshot 扩展 ===== bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool, const std::string& path) { + if (IsFileTransferPending(device_id)) return false; // 该 host 有文件传输会话在飞(互斥,见 F2) std::lock_guard lk(m_PendingMutex); if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙 PendingRequest r; @@ -4258,3 +4541,205 @@ std::string GenerateRandomToken() { } return out; } + +// ===== P6:download_file 文件传输会话(状态机方法)===== + +bool CMcpServer::IsFileTransferPending(uint64_t device_id) { + std::lock_guard lk(m_FileXferMutex); + return m_FileXferSessions.find(device_id) != m_FileXferSessions.end(); +} + +bool CMcpServer::BeginFileTransferPending(uint64_t device_id, const std::string& tool, + const std::string& localDir, const std::string& remotePath, + bool overwrite) { + // 与一次性挂起请求互斥(F2):先查对方注册表再查自己的,避免跨锁嵌套死锁。 + { + std::lock_guard lk(m_PendingMutex); + if (m_Pending.find(device_id) != m_Pending.end()) return false; + } + std::lock_guard lk(m_FileXferMutex); + if (m_FileXferSessions.find(device_id) != m_FileXferSessions.end()) return false; // 单设备单传输 + FileTransferSession s; + s.tool = tool; + s.localDir = localDir; + s.remotePath = remotePath; + s.overwrite = overwrite; + s.startAt = time(nullptr); + m_FileXferSessions[device_id] = std::move(s); + return true; +} + +bool CMcpServer::OnDownloadDriveList(uint64_t device_id, context* fmSubCtx) { + std::string localDir, remotePath; + { + std::lock_guard lk(m_FileXferMutex); + auto it = m_FileXferSessions.find(device_id); + if (it == m_FileXferSessions.end() || it->second.tool != "download_file") + return false; // 会话已清理 → 调用方 CancelIO 收尾 + FileTransferSession& s = it->second; + if (s.fmSubCtx != nullptr) return true; // 已下发过,防重复 + s.fmSubCtx = fmSubCtx; + localDir = s.localDir; + remotePath = s.remotePath; + } + + // 锁外组包下发(Send2Client 异步投递,子链接保持到 ClearFileTransfer 收尾,见 §13 F3)。 + // 布局与 FileManagerDlg::OnTransferV2ToLocal 一致:[cmd][targetDir\0][remotePath\0][\0] + std::vector pkt; + pkt.reserve(1 + localDir.size() + 1 + remotePath.size() + 2); + pkt.push_back((BYTE)CMD_DOWN_FILES_V2); + pkt.insert(pkt.end(), localDir.begin(), localDir.end()); + pkt.push_back(0); + pkt.insert(pkt.end(), remotePath.begin(), remotePath.end()); + pkt.push_back(0); + pkt.push_back(0); + fmSubCtx->Send2Client(pkt.data(), (ULONG)pkt.size()); + Mprintf("[MCP] download_file: 下发 CMD_DOWN_FILES_V2 remote=%s\n", remotePath.c_str()); + return true; +} + +void CMcpServer::OnFileChunkV2(uint64_t device_id, context* streamSubCtx, const BYTE* buf, ULONG len) { + if (len < (ULONG)sizeof(FileChunkPacketV2)) return; + const FileChunkPacketV2* pkt = (const FileChunkPacketV2*)buf; + if (pkt->nameLength > (uint64_t)(len - sizeof(FileChunkPacketV2))) return; // 畸形包:名字越界 + + // 与 FileManagerDlg.cpp 落盘同款参数(镜像其 GetPwdHash()/GetHMAC(100))。 + std::string hash = GetPwdHash(), hmac = GetHMAC(100); + + std::lock_guard lk(m_FileXferMutex); + auto it = m_FileXferSessions.find(device_id); + if (it == m_FileXferSessions.end()) return; + FileTransferSession& s = it->second; + if (s.tool != "download_file") return; + if (s.done) return; // 已出错/完成,丢弃迟到包 + + if (s.streamSubCtx == nullptr) s.streamSubCtx = streamSubCtx; + if (s.transferID == 0) { + s.transferID = pkt->transferID; + s.totalFiles = pkt->totalFiles; + } + + uint32_t fileIndex = pkt->fileIndex; + std::string fileName((char*)(pkt + 1), pkt->nameLength); // ANSI = localDir + relPath + + // 目录项:只建目录(RecvFileChunkV2 会 mkdir),不参与 files 输出(见 §13 F4)。 + // 与文件项一样先做路径穿越校验(F5):目录项的 filename 同样可能越界(如远程目录名含 ..)。 + if (pkt->flags & FFV2_DIRECTORY) { + if (!PathWithinDirA(s.localDir, fileName)) { + s.error = 1001; + s.done = true; + m_FileXferCv.notify_one(); + return; + } + s.directoryCount++; + RecvFileChunkV2((char*)buf, len, nullptr, nullptr, hash, hmac, 0); + return; + } + + if (pkt->offset == 0) { + // 路径穿越校验(F5):规范化后必须在 localDir 内。 + if (!PathWithinDirA(s.localDir, fileName)) { + s.error = 1001; + s.done = true; + m_FileXferCv.notify_one(); + return; + } + // overwrite=false 且同名已存在 → 跳过该文件(丢弃其后续分块,不落盘)。 + if (!s.overwrite && GetFileAttributesA(fileName.c_str()) != INVALID_FILE_ATTRIBUTES) { + s.skipIndexes.insert(fileIndex); + FileTransferEntry e; + e.path = fileName; + e.size = pkt->fileSize; + s.skipped.push_back(std::move(e)); + return; + } + // 记录文件条目(size 已知;sha256 待 COMPLETE 包回填)。 + FileTransferEntry e; + e.path = fileName; + e.size = pkt->fileSize; + s.fileEntries[fileIndex] = std::move(e); + } else { + if (s.skipIndexes.count(fileIndex)) return; // 已跳过的文件,丢弃后续分块 + } + + int n = RecvFileChunkV2((char*)buf, len, nullptr, nullptr, hash, hmac, 0); + if (n) { + s.error = n; + s.done = true; + m_FileXferCv.notify_one(); + } +} + +void CMcpServer::OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len) { + if (len < (ULONG)sizeof(FileCompletePacketV2)) return; + const FileCompletePacketV2* pkt = (const FileCompletePacketV2*)buf; + + std::lock_guard lk(m_FileXferMutex); + auto it = m_FileXferSessions.find(device_id); + if (it == m_FileXferSessions.end()) return; + FileTransferSession& s = it->second; + if (s.tool != "download_file") return; + if (s.done) return; + + uint32_t fileIndex = pkt->fileIndex; + + if (s.skipIndexes.count(fileIndex)) { + s.filesDone++; // 跳过的文件:不校验,直接计数 + } else { + bool verifyOk = HandleFileCompleteV2((const char*)buf, len, 0); + auto fe = s.fileEntries.find(fileIndex); + if (fe != s.fileEntries.end()) fe->second.sha256 = Sha256Hex(pkt->sha256, 32); + if (!verifyOk) { + s.error = FEV2_HASH_MISMATCH; + s.done = true; + m_FileXferCv.notify_one(); + return; + } + s.filesDone++; + } + + // 完成判定:目录不发 COMPLETE 包,故 filesDone 达 totalFiles - directoryCount 即完成(F4)。 + uint32_t expectedFiles = (s.totalFiles > s.directoryCount) ? (s.totalFiles - s.directoryCount) : 0; + if (s.filesDone >= expectedFiles) { + s.done = true; + m_FileXferCv.notify_one(); + } +} + +bool CMcpServer::WaitFileTransferDone(uint64_t device_id, int timeoutMs, + std::vector& files, int& skipped, int& error) { + std::unique_lock lk(m_FileXferMutex); + auto it = m_FileXferSessions.find(device_id); + if (it == m_FileXferSessions.end()) { error = 1002; return false; } + + bool signaled = m_FileXferCv.wait_for(lk, std::chrono::milliseconds(timeoutMs), + [&] { return it->second.done; }); + + FileTransferSession& s = it->second; + if (!signaled) s.error = 1002; // 超时 + + error = s.error; + // 复制已见文件(成功=完整文件;失败=供调用方删半成品)。跳过的既有文件不在此列。 + for (auto& kv : s.fileEntries) files.push_back(kv.second); + skipped = (int)s.skipped.size(); + + return signaled && s.error == 0; +} + +void CMcpServer::ClearFileTransfer(uint64_t device_id) { + FileTransferSession s; + bool found = false; + { + std::lock_guard lk(m_FileXferMutex); + auto it = m_FileXferSessions.find(device_id); + if (it != m_FileXferSessions.end()) { + s = std::move(it->second); + m_FileXferSessions.erase(it); + found = true; + } + } + if (!found) return; + // 锁外关闭两条子链接(CancelIO 触发客户端断开,停掉仍在飞的流式数据)。 + if (s.fmSubCtx) s.fmSubCtx->CancelIO(); + if (s.streamSubCtx) s.streamSubCtx->CancelIO(); +} diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h index 57c27c7..42fd74b 100644 --- a/server/2015Remote/McpServer.h +++ b/server/2015Remote/McpServer.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,14 @@ class CMy2015RemoteDlg; class context; +// 文件传输结果条目(download_file 输出 files[] 的一项)。 +// path 为本机落盘绝对路径(ANSI,输出 JSON 时再转 UTF-8);sha256 为小写 hex。 +struct FileTransferEntry { + std::string path; + uint64_t size = 0; + std::string sha256; +}; + // MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。 // 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听 // (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。 @@ -194,6 +203,40 @@ public: // 屏幕子连接断开(OfflineProc 调用):擦除该子连接对应的会话 + 路由(幂等)。 void OnScreenControlClosed(context* subCtx); + // ===== P6:MCP 文件传输(download_file,V2 协议)===== + // 下载走 CMD_DOWN_FILES_V2 → 客户端新开流式子连接回传 COMMAND_SEND_FILE_V2 / + // COMMAND_FILE_COMPLETE_V2(dstClientID==0)。会话按 device_id 键控,单设备单传输; + // 复用 list_files 的 COMMAND_LIST_DRIVE → TOKEN_DRIVE_LIST 开文件管理器子链接下发命令。 + void SetFileTransferEnabled(bool enabled) { m_fileTransferEnabled = enabled; } + bool IsFileTransferEnabled() const { return m_fileTransferEnabled; } + + // 该 host 是否有进行中的文件传输会话(MessageHandle 分派守卫)。 + bool IsFileTransferPending(uint64_t device_id); + + // 登记文件传输会话(false = 该 host 已有文件会话或一次性挂起请求)。 + bool BeginFileTransferPending(uint64_t device_id, const std::string& tool, + const std::string& localDir, const std::string& remotePath, + bool overwrite); + + // TOKEN_DRIVE_LIST:识别 download_file,存文件管理器子链接并下发 CMD_DOWN_FILES_V2。 + // 返回 true=已接管(保持子链接,收尾由 ClearFileTransfer);false=会话已清理(调用方 CancelIO)。 + bool OnDownloadDriveList(uint64_t device_id, context* fmSubCtx); + + // COMMAND_SEND_FILE_V2:解析 chunk,路径校验 + overwrite 判定 + 落盘(RecvFileChunkV2)。 + void OnFileChunkV2(uint64_t device_id, context* streamSubCtx, const BYTE* buf, ULONG len); + + // COMMAND_FILE_COMPLETE_V2:SHA-256 校验(HandleFileCompleteV2)+ 完成判定。 + void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len); + + // 工具线程:等待传输完成;true=成功(files 已填,error=0),false=失败/超时(error 填原因)。 + // 无论成败,files 都会返回已见文件的落盘路径(成功=完整文件,失败=供调用方删半成品)。 + // 调用方随后必须调 ClearFileTransfer 收尾(擦会话 + CancelIO 子链接)。 + bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, + std::vector& files, int& skipped, int& error); + + // 收尾:擦会话 + 锁外 CancelIO 文件管理器/流式两条子链接(幂等)。 + void ClearFileTransfer(uint64_t device_id); + // 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。 void SetReadonly(bool readonly) { m_readonly = readonly; } void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; } @@ -272,10 +315,34 @@ private: std::map m_ScreenCtrlSessions; // device_id → 会话 std::map m_ScreenCtrlContextToDevice; // subCtx → device_id(OfflineProc 反查) + // ===== P6:文件传输会话(受 m_FileXferMutex 保护;单设备单传输)===== + struct FileTransferSession { + std::string tool; // 恒为 "download_file" + std::string localDir; // 本机保存目录(ANSI,结尾 '\') + std::string remotePath; // 客户端远程路径(ANSI,发 CMD_DOWN_FILES_V2 用) + bool overwrite = false; // 是否覆盖已存在文件 + context* fmSubCtx = nullptr; // 文件管理器子链接(下发 CMD_DOWN_FILES_V2) + context* streamSubCtx = nullptr; // 流式子链接(首个 chunk 填,收尾 CancelIO) + uint64_t transferID = 0; // 客户端回填的传输会话 ID + uint32_t totalFiles = 0; // 客户端声明的文件总数(含目录项,见 F4) + uint32_t directoryCount = 0; // 目录项计数(目录不发 COMPLETE 包) + uint32_t filesDone = 0; // 已完成的文件数(含跳过的) + bool done = false; // 全部完成或出错 + int error = 0; // 0=ok;8=哈希不匹配;1001=路径逃逸;1002=超时 + std::map fileEntries; // fileIndex → {path,size,sha256} + std::set skipIndexes; // overwrite=false 跳过的 fileIndex + std::vector skipped; // 跳过的文件(供输出) + time_t startAt = 0; + }; + std::mutex m_FileXferMutex; + std::condition_variable m_FileXferCv; + std::map m_FileXferSessions; // device_id → 会话 + bool m_readonly = true; std::string m_cmdWhitelist; bool m_terminalEnabled = false; // 持久终端开关(默认关;要求 m_readonly=false) bool m_remoteControlEnabled = false; // 远程控制开关(默认关;要求 m_readonly=false) + bool m_fileTransferEnabled = false; // 文件传输开关(默认关;download_file) }; // 全局访问器(仿 WebService(),见 WebService.h 末尾) diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp index 34bd92b..f186055 100644 --- a/server/2015Remote/McpSettingsDlg.cpp +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -106,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal() { USES_CONVERSION; CString title = _TR("MCP设置"); - BuildDialogTemplate(m_Template, T2CW(title), 320, 390); + BuildDialogTemplate(m_Template, T2CW(title), 320, 420); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); return CDialog::DoModal(); } @@ -142,6 +142,9 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnRemoteControl.Create(_TR("启用远程控制(AI 操控桌面)"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, r0, this, IDC_MCP_REMOTECONTROL); + m_btnFileTransfer.Create(_TR("启用文件传输(download_file)"), + WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX, + r0, this, IDC_MCP_FILETRANSFER); m_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL, @@ -164,6 +167,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnTerminal.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnRemoteControl.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); + m_btnFileTransfer.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); @@ -179,6 +183,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1); int terminal = THIS_CFG.GetInt("settings", "McpTerminal", 0); int remoteControl = THIS_CFG.GetInt("settings", "McpRemoteControl", 0); + int fileTransfer = THIS_CFG.GetInt("settings", "McpFileTransfer", 0); std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""); m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); @@ -188,6 +193,7 @@ BOOL CMcpSettingsDlg::OnInitDialog() m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED); m_btnTerminal.SetCheck(terminal ? BST_CHECKED : BST_UNCHECKED); m_btnRemoteControl.SetCheck(remoteControl ? BST_CHECKED : BST_UNCHECKED); + m_btnFileTransfer.SetCheck(fileTransfer ? BST_CHECKED : BST_UNCHECKED); // 白名单存储为逗号分隔,展示为每行一条。 m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str())); @@ -206,6 +212,7 @@ void CMcpSettingsDlg::OnOK() bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED); bool terminal = (m_btnTerminal.GetCheck() == BST_CHECKED); bool remoteControl = (m_btnRemoteControl.GetCheck() == BST_CHECKED); + bool fileTransfer = (m_btnFileTransfer.GetCheck() == BST_CHECKED); // 端口校验:1-65535 int port = atoi(CT2A(sPort)); @@ -231,13 +238,14 @@ void CMcpSettingsDlg::OnOK() THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0); THIS_CFG.SetInt("settings", "McpTerminal", terminal ? 1 : 0); THIS_CFG.SetInt("settings", "McpRemoteControl", remoteControl ? 1 : 0); + THIS_CFG.SetInt("settings", "McpFileTransfer", fileTransfer ? 1 : 0); std::string whitelist = CT2A(sWhitelist); whitelist = NormalizeWhitelist(whitelist); THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist); // 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示) MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + - _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制的改动需重启程序生效。") + _T("\r\n") + + _TR("启用/端口/绑定地址/Token/只读/白名单/持久终端/远程控制/文件传输的改动需重启程序生效。") + _T("\r\n") + _TR("持久终端与远程控制仅在只读模式关闭时生效。"), _TR("提示"), MB_ICONINFORMATION); @@ -278,6 +286,9 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy) m_btnRemoteControl.MoveWindow(margin, y, cx - margin * 2, 22); y += 30; + m_btnFileTransfer.MoveWindow(margin, y, cx - margin * 2, 22); + y += 30; + const int whitelistH = 90; m_lblWhitelist.MoveWindow(margin, y, labelW, rowH); m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH); diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h index 54b5623..3ace154 100644 --- a/server/2015Remote/McpSettingsDlg.h +++ b/server/2015Remote/McpSettingsDlg.h @@ -30,6 +30,7 @@ private: IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀) IDC_MCP_TERMINAL = 1007, // 「启用持久终端」复选框(全命令,无白名单,要求只读关) IDC_MCP_REMOTECONTROL = 1008, // 「启用远程控制」复选框(AI 操控桌面,要求只读关) + IDC_MCP_FILETRANSFER = 1009, // 「启用文件传输」复选框(download_file,不要求只读关) }; CButton m_btnEnable; @@ -38,6 +39,7 @@ private: CButton m_btnReadonly; CButton m_btnTerminal; CButton m_btnRemoteControl; + CButton m_btnFileTransfer; CStatic m_lblWhitelist; CEdit m_editWhitelist; CButton m_btnOK, m_btnCancel;