13 Commits
Author SHA1 Message Date
yuanyuanxianganddeepseek-v4-pro bcde74368d Fix: Resolve window titles by clientID instead of peer IP
Window list (WSLIST) sub-connections carry no capability bits, so
GetClientEncoding fell back to FindHostByIP(peer IP) to locate the main
connection. Under FRP/NAT reverse proxy the sub-connection's socket peer
is 127.0.0.1 or an internal relay address, which does not match the main
connection's recorded public IP; the lookup returned NULL and the client's
UTF-8 window titles were decoded as CP936 (GBK), producing mojibake on
hosts with Chinese window titles.

Resolve the main connection by clientID first (pinned on the sub-connection
after TOKEN_CONN_AUTH via SetID), falling back to the peer-IP lookup only
for legacy clients that do not send TOKEN_CONN_AUTH.

Co-Authored-By: deepseek-v4-pro
2026-08-19 11:05:35 +02:00
yuanyuanxianganddeepseek-v4-pro 6ba6b0289d Feature: Add list_processes, list_windows and get_activity_history MCP tools
Add three read-only P2b MCP tools over the existing protocol. list_processes and list_windows trigger the client via COMMAND_SYSTEM / COMMAND_WSLIST on the main connection and receive TOKEN_PSLIST / TOKEN_WSLIST on a one-shot sub-link; get_activity_history uses the main-connection RPC COMMAND_QUERY_ACTIVITY -> TOKEN_REPORT_ACTIVITY. A per-host single-flight pending registry (m_Pending) with a 20s timeout correlates each response to its request and rejects a concurrent request for the same host with -32003. Parsers stop on the first empty record to ignore the client's LocalSize trailing zero padding, and window titles are decoded per the client UTF-8 capability bit. MessageHandle only adds if-guarded branches, so the MFC dialogs are untouched. Sync Mcp_Phase2_Design.md with the mode A'/A architecture, the verification notes, and the registry-backed config location.

Co-Authored-By: deepseek-v4-pro
2026-08-19 09:58:58 +02:00
yuanyuanxianganddeepseek-v4-pro 86c4f14cf4 Feature: Add search_hosts and get_host_detail MCP tools
Add two pure in-memory MCP tools over the online host list. search_hosts filters by name/remark, IP, group and OS (all optional, AND-combined, ASCII case-insensitive substring match); get_host_detail returns one online host by id and answers -32602 for a missing/non-numeric id and -32002 for an unknown or offline id. Sync Mcp_Phase2_Design.md with the second-review corrections and the P2a implementation.

Co-Authored-By: deepseek-v4-pro
2026-08-18 16:53:28 +02:00
yuanyuanxianganddeepseek-v4-pro 13052b7cae Feature: Add MCP (Model Context Protocol) server integration
Add an optional MCP server (JSON-RPC 2.0 over Streamable HTTP) exposing
an online-host listing tool, protected by a Bearer token. Disabled by
default; configured via a new "Extensions > MCP Settings" dialog.

- McpServer: httplib + JSON-RPC 2.0 dispatch (initialize/ping/tools/list/tools/call)
- McpSettingsDlg: runtime-created dialog for enable/port/bind/token
- HostJson: extract single-host JSON serialization shared with WebService
- FRP: expose MCP port (union with listening/Web ports) when bound to 0.0.0.0
- i18n: en_US / zh_TW translations

Co-Authored-By: deepseek-v4-pro
2026-08-16 14:37:11 +02:00
yuanyuanxianganddeepseek-v4-pro 43398e405e Feature: Add icons to online host context menu items
Add 16x16 icons for the three context-menu items that previously had
none: "Activity History" (Client Management), "Browse Window" (Remote
Control), and "Proxy Port - Auto Start" (Client Proxy). Register three
new bitmap resources and extend the main dialog's bitmap array from 63
to 66 entries so each menu item renders an intuitive glyph.

Co-Authored-By: deepseek-v4-pro
2026-08-15 13:42:57 +02:00
yuanyuanxianganddeepseek-v4-pro 29929e48b2 Feature: Add client activity history recording and display
Record the client's active-window history (start time, window title, and
dwell duration), newest first, skipping dwellings under 5 seconds and
breaking the timing on idle, capped at 500 entries. Add an "Activity
History" item inside the host's Client Management submenu that requests
the snapshot over the main connection and shows it in a new dialog. The
dialog rebuilds its edit control as a Unicode window so UTF-8 titles
render correctly instead of degrading to "?" through the MBCS ANSI
boundary. The menu item and dialog title go through _TR and are
localized in en_US and zh_TW.

Co-Authored-By: deepseek-v4-pro
2026-08-15 11:09:14 +02:00
yuanyuanxianganddeepseek-v4-pro d0bad75284 Feature: Remember host list sort preference in main dialog
Persist the sort column and direction chosen via the online host list
header (list\OnlineListSort registry key) so the next launch defaults to
the same sort. Re-apply the sort when hosts come online/offline so newly
connected hosts land in their sorted position instead of appending at the
end.

Co-Authored-By: deepseek-v4-pro
2026-08-13 13:12:29 +02:00
yuanyuanxianganddeepseek-v4-pro 9d2e9ec8ca Feature: Remember process/window list sort preference
Persist the sort column and direction chosen via the list header so the
next time the process or window management dialog opens it defaults to
the same sort. Process and window lists are remembered separately
(list\ProcessListSort and list\WindowListSort registry keys) since their
columns have different meanings.

Co-Authored-By: deepseek-v4-pro
2026-08-13 12:59:29 +02:00
yuanyuanxianganddeepseek-v4-pro 2d7f224a22 Fix: process list architecture column sorting was a no-op
The process list stored the architecture string only in ItemData::Arch,
leaving Data[3] (the architecture column) empty. Sorting by the
architecture column therefore compared empty strings and did nothing.
Populate Data[3] with the architecture value so the sort works.

Co-Authored-By: deepseek-v4-pro
2026-08-13 12:44:14 +02:00
yuanyuanxiangandDeepSeek V4 Pro c8973282cb Feature: Add "Unban IP" to log message context menu in main dialog
- Add "Unban IP" menu item to the log message list (m_CList_Message)
  right-click context menu, performing two actions on the extracted IP:
  1. Remove the IP from the IPBlacklist singleton and persist to config
  2. Scan the entire history host list (m_ClientMap->GetAll()) for all
     clients matching the IP with AUTH_FORBIDDEN status, and restore
     them to UNAUTHORIZED (0) — requiring re-authorization for safety
- Reuses ExtractFirstIPFromMessage() for dependency-free IPv4 parsing
- Handles the case where an IP is not in the blacklist and has no
  matching forbidden clients with a clear "no action needed" message
- Menu item is grayed out when no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-07 11:45:18 +02:00
yuanyuanxiangandDeepSeek V4 Pro 7bbc47a0ee Feature: Add "Ban IP" to log message context menu in main dialog
- Add "Ban IP" menu item to the log message list (m_CList_Message)
  right-click context menu, allowing users to extract IPv4 addresses from
  selected log entries and add them directly to the IP blacklist
- Reuses ExtractFirstIPFromMessage() from the previous commit for
  dependency-free IPv4 parsing with per-segment 0-255 validation
- Checks against whitelist before banning (whitelisted IPs cannot be
  banned) and skips IPs already in blacklist with clear feedback
- Immediately persists via THIS_CFG.SetStr to the config ini file;
  IPBlacklist singleton takes effect instantly — subsequent connections
  from banned IPs are rejected in IOCPServer::OnAccept() which already
  calls IsIPBlacklisted() on every new connection
- Menu item is grayed out when no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-06 20:44:33 +02:00
yuanyuanxiangandDeepSeek V4 Pro eeacbe5d61 Feature: Add "Online Notify" to log message context menu in main dialog
- Add "Online Notify" menu item to the log message list (m_CList_Message)
  right-click context menu, allowing users to extract IPv4 addresses from
  selected log entries and add them to notification keywords
- Implement ExtractFirstIPFromMessage() for dependency-free IPv4 parsing
  with per-segment 0-255 validation, handling diverse log message formats
- Extend NotifyManager::ShouldNotify with IP column (ONLINELIST_IP)
  fallback check, ensuring IP keywords match regardless of the user's
  configured ColumnIndex setting
- OnMsglogLoginNotify only appends keywords and enables the rule without
  overwriting the user's existing ColumnIndex or TriggerType settings
- Menu item is automatically grayed out when PowerShell is unavailable
  or no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-06 18:13:14 +02:00
yuanyuanxiangandDeepSeek V4 Pro 7c09e9dd8c Fix: Move ONLINELIST_PRIVILEGE to end of enum to fix history host column data corruption
Commit 6d0b1bb (Feat: Add privilege column) inserted ONLINELIST_PRIVILEGE
at enum value 9, shifting INSTALLTIME from 9→10 and PATH from 12→13.
However, FileUpload_Lib (SimplePlugins) is compiled against a separate
copy of context.h that still uses the old enum values. Since
HostInfo.cpp's SaveClientMapData uses OLD enum values to read
sClientInfo[], it was reading privilegeStr for InstallTime and client
type for ProgramPath — corrupting the history host database.

Fix: move ONLINELIST_PRIVILEGE to value 16 (just before MAX), restoring
all original enum values (INSTALLTIME=9, PATH=12, etc.) so the
pre-built library can continue reading correct data without recompilation.

The 'permission' column now appears as the rightmost column in the
online host list instead of between 'version' and 'install time'.

Co-Authored-By: DeepSeek V4 Pro
2026-08-05 20:07:21 +02:00
38 changed files with 3387 additions and 149 deletions

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
// ActivityHistory.cpp: 客户端历史活动记录采集模块
#include "stdafx.h"
#include "ActivityHistory.h"
#include "KernelManager.h" // ActivityWindow
ActivityHistory& ActivityHistory::Instance()
{
static ActivityHistory inst;
return inst;
}
void ActivityHistory::Start()
{
if (m_started.exchange(true))
return;
m_running = true;
m_thread = std::thread(&ActivityHistory::Loop, this);
}
void ActivityHistory::Stop()
{
m_running = false;
if (m_thread.joinable())
m_thread.join();
}
std::string ActivityHistory::Dump() const
{
std::lock_guard<std::mutex> lock(m_mutex);
std::string out;
// 当前进行中的窗口:若已持续达到阈值,作为最新一条返回
if (!m_curTitle.empty() && m_curDwellSec >= ACTIVITY_MIN_DWELL_SEC)
out += FormatRecord(m_curStartTime, m_curTitle, m_curDwellSec) + "\n";
for (const auto& r : m_records)
out += r + "\n";
return out;
}
void ActivityHistory::Loop()
{
// 空闲打断阈值:远大于 ACTIVITY_MIN_DWELL_SEC,避免“停在一个窗口上看几秒”被过早结账。
const DWORD IDLE_THRESHOLD_MS = ACTIVITY_IDLE_BREAK_SEC * 1000;
ActivityWindow aw;
HWND lastHwnd = NULL;
std::string lastTitle;
std::string startTime;
int dwellSec = 0;
while (m_running) {
// 取前台窗口句柄 + 标题;空闲/锁定/取不到标题时 hwnd==NULL 或 title 为空
HWND hwnd = aw.GetActiveWindowHandle(IDLE_THRESHOLD_MS);
std::string title = aw.GetActiveTitleOrEmpty(IDLE_THRESHOLD_MS);
{
std::lock_guard<std::mutex> lock(m_mutex);
if (hwnd == NULL || title.empty()) {
// 空闲/锁定/无标题:结账上一个窗口
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
lastHwnd = NULL;
lastTitle.clear();
dwellSec = 0;
} else if (hwnd == lastHwnd) {
// 同一窗口:即使标题动态变化也连续累计,标题取最新值
++dwellSec;
lastTitle = title;
} else {
// 窗口切换:结账上一个,开始累计新窗口
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
lastHwnd = hwnd;
lastTitle = title;
startTime = NowStr();
dwellSec = 1;
}
while (m_records.size() > ACTIVITY_HISTORY_MAX)
m_records.pop_back();
m_curTitle = lastTitle;
m_curStartTime = startTime;
m_curDwellSec = dwellSec;
}
Sleep(1000);
}
}
std::string ActivityHistory::FormatRecord(const std::string& startTime, const std::string& title, int dwellSec)
{
// 标题里的换行会破坏“每行一条”,做最小清洗
std::string t;
t.reserve(title.size());
for (char c : title) {
if (c == '\r' || c == '\n') c = ' ';
t += c;
}
return "[" + startTime + "] [" + t + "] " + FormatDuration(dwellSec);
}
std::string ActivityHistory::FormatDuration(int sec)
{
if (sec < 0) sec = 0;
if (sec < 60)
return std::to_string(sec) + "s";
if (sec < 3600) {
int m = sec / 60, s = sec % 60;
return s ? std::to_string(m) + "m" + std::to_string(s) + "s"
: std::to_string(m) + "m";
}
int h = sec / 3600, m = (sec % 3600) / 60;
return std::to_string(h) + "h" + std::to_string(m) + "m";
}
std::string ActivityHistory::NowStr()
{
SYSTEMTIME st;
GetLocalTime(&st);
char buf[32];
sprintf_s(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
return buf;
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <string>
#include <deque>
#include <mutex>
#include <thread>
#include <atomic>
// 历史活动记录:
// 每条格式:[YYYY-MM-DD HH:MM:SS] [窗口标题] <时长>
// 倒序存储(最新在前)。后台线程每 1 秒采样一次前台窗口。
#define ACTIVITY_MIN_DWELL_SEC 5 // 一闪而过阈值(秒):驻留低于此值不记录
#define ACTIVITY_IDLE_BREAK_SEC 300 // 空闲打断阈值(秒):空闲超过此值才结账当前窗口
// 注意:与心跳“活动窗口”列的 6s 判定不同,这里要远大于 MIN_DWELL,
// 否则“停在一个窗口上看 10 秒”会被 6s 空闲判定过早结账/漏记
#define ACTIVITY_HISTORY_MAX 500 // 内存保留的最大记录条数
class ActivityHistory
{
public:
static ActivityHistory& Instance();
// 启动常驻采样线程(幂等,重复调用无副作用)
void Start();
// 停止采样线程
void Stop();
// 返回全部历史记录(最新在前,多行文本,每行一条)
std::string Dump() const;
private:
ActivityHistory() = default;
~ActivityHistory() { Stop(); }
ActivityHistory(const ActivityHistory&) = delete;
ActivityHistory& operator=(const ActivityHistory&) = delete;
void Loop();
// 拼一条记录字符串
static std::string FormatRecord(const std::string& startTime, const std::string& title, int dwellSec);
// 时长格式化:Ns / Nm / NmNs / NhNm
static std::string FormatDuration(int sec);
// 当前时间戳 "YYYY-MM-DD HH:MM:SS"
static std::string NowStr();
mutable std::mutex m_mutex;
std::deque<std::string> m_records; // 已完成记录,最新在前
std::string m_curTitle; // 当前进行中的窗口标题
std::string m_curStartTime; // 当前窗口的起始时间
int m_curDwellSec = 0; // 当前窗口已连续活跃秒数
std::atomic<bool> m_running{ false };
std::atomic<bool> m_started{ false };
std::thread m_thread;
};
+2
View File
@@ -3,6 +3,7 @@
#include "stdafx.h"
#include "ClientDll.h"
#include "ActivityHistory.h"
#include <common/iniFile.h>
#include <common/LANChecker.h>
#include <common/VerifyV2.h>
@@ -588,6 +589,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
std::string ip = settings.ServerIP();
int port = settings.ServerPort();
Mprintf("StartClient begin[%s:%d]\n", ip.c_str(), port);
ActivityHistory::Instance().Start();
if (!app.m_bShared) {
auto now = time(0);
valid_to = atof(cfg.GetStr("settings", "valid_to").c_str());
+2
View File
@@ -178,6 +178,7 @@
<ClCompile Include="Audio.cpp" />
<ClCompile Include="AudioManager.cpp" />
<ClCompile Include="Buffer.cpp" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="CaptureVideo.cpp" />
<ClCompile Include="clang_rt_compat.c" />
<ClCompile Include="ClientDll.cpp" />
@@ -225,6 +226,7 @@
<ClInclude Include="..\common\zstd_wrapper.h" />
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
<ClInclude Include="Audio.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="AudioManager.h" />
<ClInclude Include="Buffer.h" />
<ClInclude Include="CaptureVideo.h" />
+11
View File
@@ -10,6 +10,7 @@
#include <fstream>
#include <corecrt_io.h>
#include "ClientDll.h"
#include "ActivityHistory.h"
#include "MemoryModule.h"
#include "common/dllRunner.h"
#include "server/2015Remote/pwd_gen.h"
@@ -822,6 +823,16 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopClientLogManager, &m_hThread[m_ulThreadCount], 0, NULL);
break;
}
case COMMAND_QUERY_ACTIVITY: {
// 主连接一次性快照:直接 dump 历史活动并回传
std::string text = ActivityHistory::Instance().Dump();
std::vector<BYTE> pkt(1 + text.size());
pkt[0] = TOKEN_REPORT_ACTIVITY;
if (!text.empty())
memcpy(pkt.data() + 1, text.data(), text.size());
m_ClientObject->Send2Server((char*)pkt.data(), (ULONG)pkt.size());
break;
}
case CMD_SET_GROUP: {
std::string group = std::string((char*)szBuffer + 1);
m_cfg->SetStr("settings", "group_name", group);
+18
View File
@@ -40,6 +40,24 @@ public:
return (!IsWorkstationLocked() ? "Inactive: " : "Locked: ") + FormatMilliseconds(idle);
}
// 返回当前活跃窗口标题;若空闲(idle ≥ threshold_ms)或取不到标题则返回空串。
// 供历史活动采集使用,避免“判定活跃”与“取标题”之间的竞态窗口。
std::string GetActiveTitleOrEmpty(DWORD threshold_ms = 6000)
{
if (GetUserIdleTime() >= threshold_ms)
return std::string();
return GetActiveWindowTitle();
}
// 返回当前前台窗口句柄;空闲(idle ≥ threshold_ms)返回 NULL。
// 历史活动按窗口(HWND)而非标题字符串累计:标题动态变化时不会反复归零。
HWND GetActiveWindowHandle(DWORD threshold_ms = 6000)
{
if (GetUserIdleTime() >= threshold_ms)
return NULL;
return GetForegroundWindow();
}
private:
std::string FormatMilliseconds(DWORD ms)
{
+2
View File
@@ -185,6 +185,7 @@
<ClCompile Include="..\common\ikcp.c" />
<ClCompile Include="..\common\zstd_wrapper.c" />
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="Audio.cpp" />
<ClCompile Include="AudioManager.cpp" />
<ClCompile Include="Buffer.cpp" />
@@ -234,6 +235,7 @@
<ClInclude Include="..\common\wallet.h" />
<ClInclude Include="..\common\zstd_wrapper.h" />
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="Audio.h" />
<ClInclude Include="AudioManager.h" />
<ClInclude Include="auto_start.h" />
+2
View File
@@ -317,6 +317,8 @@ enum {
TOKEN_REPORT_LOG = 156,
COMMAND_FORBIDDEN = 157,
CMD_CUSTOM_CURSOR = 158,
COMMAND_QUERY_ACTIVITY = 159, // 服务端 → 客户端:索取历史活动
TOKEN_REPORT_ACTIVITY = 160, // 客户端 → 服务端:上报历史活动
TOKEN_DECRYPT = 199,
TOKEN_REGEDIT = 200, // 注册表
+443
View File
@@ -0,0 +1,443 @@
# YAMA 服务端 MCP 支持方案
> 版本:1.0
> 状态:Final(定稿)
> 适用范围:`server/2015Remote/`(C++/MFC 服务端,生产主流)
---
## 1. 背景与目标
### 1.1 背景
MCP(Model Context Protocol)是 Anthropic 提出的开放协议,让 AI 助手(Claude Code、Claude Desktop 等)通过标准化的工具调用接口访问外部系统的能力。
YAMA 服务端维护了「在线主机列表」这一核心数据,如果能通过 MCP 暴露出去,用户就能在 Claude Code 里直接问「现在有多少台机器在线?」「列出所有在线的 Windows 主机」等,由 AI 调用工具获取实时数据。
### 1.2 本期范围(MVP)
**只跑通 MCP,只实现一个工具**,把可扩展架构搭起来:
- 传输方式:Streamable HTTP(`POST /mcp`,JSON-RPC 2.0 over HTTP)
- 工具数量:1 个 —— `list_online_hosts`(获取在线主机列表)
- 暴露范围:启用开关(默认关闭,经「扩展 → MCP设置」对话框开启)+ 绑定地址可配置(默认 `127.0.0.1` 仅本机)+ token 认证(env / THIS_CFG / 随机)
- 后续:逐步添加工具(进程列表、命令执行、屏幕截图等),架构不动
### 1.3 非目标(明确不做)
- 不做 SSE 服务器推送(本期工具调用是 request/response 即可)
- 不做多用户/角色体系(复用现有 WebService 认证或静态 token)
- 不做写操作工具(操控主机的工具属于后续阶段,需单独评审安全边界)
- 不碰现有 Web 控制台的 WebSocket 服务
---
## 2. 现状分析
C++ 服务端已有大量可直接复用的资产,MCP 的落地成本因此大幅降低。
| 现成资产 | 位置 | 在 MCP 中的角色 |
|---|---|---|
| HTTP 服务器 | `httplib.h`(header-only,`file_server.h:34` 已用) | MCP 的 HTTP 传输层 |
| JSON 库 | `jsoncpp/json.h`(`WebService.cpp` 已用) | JSON-RPC 编解码 |
| Token 认证 | `WebServiceAuth.h`(`GenerateToken` / `ValidateToken` / `ComputeSHA256`) | 可选升级路径;MVP 用简单静态 token 比对(见 §3.5) |
| 在线主机列表 → JSON | `CWebService::BuildDeviceListJson()`(`WebService.cpp:1506`) | **工具 `list_online_hosts` 的核心逻辑** |
| 在线主机数据源 | `CMy2015RemoteDlg::m_HostList`(`2015RemoteDlg.h:345`,`m_cs` 保护)+ `context::IsLogin()` + `context::GetClientData()` | 取数入口 |
| 配置系统 | `THIS_CFG.GetInt/GetStr("settings", ...)` | MCP 端口/token 配置 |
### 2.1 关键结论
- **HTTP、JSON、认证、主机列表 JSON 全部现成**,MCP 侧不需要重新造轮子。
- **唯一需要手写的是 MCP 的 JSON-RPC 2.0 协议层**,因为 C++ 没有官方 MCP SDK(官方仅有 TS/Python/Java/Kotlin/C#/Go)。
### 2.2 `BuildDeviceListJson` 现状
`WebService.cpp:1506` 的 `CWebService::BuildDeviceListJson(const std::string& username)` 已实现:
- 遍历 `m_pParentDlg->m_HostList`,过滤 `ctx->IsLogin()`
- 输出字段:`id` / `name` / `remark` / `ip` / `os` / `location` / `rtt` / `version` / `activeWindow` / `online` / `group` / `screen` / `clientType`
- 已处理 GBK/UTF-8 编码分叉(`activeWindow` 字段按客户端能力位选择编码页,见 `WebService.cpp:1581` 附近的 `GetClientEncoding`)
**该方法是 `private`**(`WebService.h:160`,位于 private 段)。MCP 侧需通过公开入口复用(见 §3.4)。
---
## 3. 方案设计
### 3.1 总体架构
```
2015Remote.exe(MFC 服务端进程)
├─ IOCP TCP 服务(被控端连接,现有)
├─ WebService(ws::Server,Web 控制台,现有,8080)
└─ McpServer(httplib,新增,默认禁用,经「MCP设置」开启后监听 127.0.0.1:6544)
POST /mcp → JSON-RPC 分发
├─ initialize
├─ tools/list
└─ tools/call
└─ list_online_hosts → 复用 BuildHostJson(公共函数,§3.4)
```
**核心决策:独立起一个 httplib server,不挂到现有 `ws::Server`。**
理由:
- 现有 `ws::Server` 的 `HttpHandler` 只有 `path` 参数、无 POST body(`SimpleWebSocket.h:345`),强挂 MCP 需改动其底层,侵入大、有回归风险。
- 独立 server 零耦合、零风险,且天然满足「仅 127.0.0.1」的绑定需求。
### 3.2 新增文件清单
| 文件 | 职责 | 估算量 |
|---|---|---|
| `McpServer.h` / `McpServer.cpp` | httplib 封装、`POST /mcp` 路由、token 校验、生命周期 | ~120 行 |
| `McpProtocol.h` / `McpProtocol.cpp` | JSON-RPC 2.0 协议层:`initialize` / `tools/list` / `tools/call` 分发 | ~250 行 |
| `McpTools.h` / `McpTools.cpp` | 工具注册表 + `list_online_hosts` 实现 | ~80 行 |
| `McpSettingsDlg.h` / `McpSettingsDlg.cpp` | 「MCP设置」配置对话框(启用/端口/绑定/token + 重启提醒) | ~150 行 |
| 修改 `2015RemoteDlg.cpp` / `.h` | 启动/停止 McpServer + 菜单项接线(`ID_MCP_SETTINGS` → `OnMcpSettings`) | ~30 行 |
| 修改 `2015Remote.rc` / `resource.h` / `UIBranding.h` / `FeatureFlags.h` | `ID_MCP_SETTINGS` 菜单项 + `IDD_DIALOG_MCP_SETTINGS` 对话框资源 + `HIDE_MENU_MCP_SETTINGS` + `MF_MCP_SETTINGS` 许可位 | 少量 |
> 头文件/实现可合并精简,实际以「一个 `McpServer` 类 + 一个工具注册表 + 一个协议分发函数」为最小形态,文件拆分仅为可读性。
### 3.3 协议层设计(JSON-RPC 2.0)
MCP 是 JSON-RPC 2.0。MVP 只需实现以下方法:
#### 3.3.1 `initialize`(握手)
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"..."}}}
```
响应:
```json
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{}},
"serverInfo":{"name":"yama","version":"<VERSION_STR>"}
}}
```
#### 3.3.2 `tools/list`
请求:`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`
响应:
```json
{"jsonrpc":"2.0","id":2,"result":{"tools":[{
"name":"list_online_hosts",
"description":"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。",
"inputSchema":{"type":"object","properties":{},"required":[]},
"outputSchema":{
"type":"object",
"properties":{
"hosts":{
"type":"array",
"items":{
"type":"object",
"properties":{
"id":{"type":"string"},
"name":{"type":"string"},
"remark":{"type":"string"},
"ip":{"type":"string"},
"os":{"type":"string"},
"location":{"type":"string"},
"rtt":{"type":"string"},
"version":{"type":"string"},
"activeWindow":{"type":"string"},
"online":{"type":"boolean"},
"group":{"type":"string"},
"screen":{"type":"string"},
"clientType":{"type":"string"}
}
}
}
},
"required":["hosts"]
}
}]}}
```
#### 3.3.3 `tools/call`
请求:
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_online_hosts","arguments":{}}}
```
响应(结构化输出:`structuredContent` 承载主机数组,`content` 附带可读摘要):
```json
{"jsonrpc":"2.0","id":3,"result":{
"structuredContent":{
"hosts":[
{"id":"123","name":"DESKTOP-ABC","remark":"我的Windows","ip":"192.168.0.92",
"os":"Windows 10","location":"上海市","rtt":"12","version":"...",
"activeWindow":"WeChat","online":true,"group":"default","screen":"1:1920x1080","clientType":"EXE"}
]
},
"content":[{"type":"text","text":"共 1 台主机在线。"}],
"isError":false
}}
```
#### 3.3.4 可选实现
- `ping`:返回空 result(便于健康检查)
- `notifications/initialized`:客户端通知,可忽略
#### 3.3.5 错误响应(JSON-RPC 2.0 标准)
协议层必须处理以下错误,返回标准 `error` 结构(`id` 回填请求 id):
| 场景 | code | message |
|---|---|---|
| 请求体不是合法 JSON | `-32700` | Parse error |
| 请求结构不合法(缺 jsonrpc/method 等) | `-32600` | Invalid Request |
| 方法未实现 | `-32601` | Method not found |
| 参数非法 | `-32602` | Invalid params |
| 内部错误 | `-32603` | Internal error |
示例(方法未实现):
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
```
#### 3.3.6 传输层约定(Streamable HTTP 最小子集)
- 客户端 `POST /mcp`,请求头 `Content-Type: application/json`、`Accept: application/json, text/event-stream`。
- 服务端返回 `Content-Type: application/json`(MVP 工具调用为 request/response,无需 SSE 流式)。
- `initialize` 响应可回填 `Mcp-Session-Id` 头用于会话管理;MVP 单会话可省略,实现时对照最新 MCP 规范确认。
- 仅接受 `POST /mcp`,其余路径/方法返回 404/405。
### 3.4 工具设计:`list_online_hosts`
#### 取数方案(已定:抽公共函数)
`BuildDeviceListJson` 目前是 `CWebService` 的 private 方法,且依赖 `m_pParentDlg`。为满足「MCP 与 WebService 是平级数据消费者、不互相依赖」的原则,采用**方案 C:抽公共函数**。
```cpp
// 公共函数:提取单台在线主机的完整数据(含 GBK/UTF-8 编码处理),返回 UTF-8 的 Json::Value
Json::Value BuildHostJson(context* ctx, _ClientList* clientMap);
```
- `CWebService::BuildDeviceListJson` 内部改调该公共函数,Web 侧签名与输出不变(零回归)。
- `McpServer` 侧持 `CMy2015RemoteDlg*`(`SetParentDlg`),在 `m_cs` 锁内遍历 `m_HostList`、`IsLogin()` 过滤,对每台主机调 `BuildHostJson`,组装为结构化输出。
- **职责边界**:用户名/分组过滤(`username` → `allowed_groups`)、`m_cs` 加锁、以及外层结构(Web 的 `{"cmd","devices"}` vs MCP 的 `{"hosts"}`)属于**调用方**,不进公共函数;`BuildHostJson` 只负责「单台主机 → `Json::Value`」的字段序列化与编码转换。其中 `remark` 依赖 `m_ClientMap->GetClientMapData(id, MAP_NOTE)`,故 `clientMap` 作为参数传入。
选型说明:方案 A(MCP 依赖 `CWebService` 单例)依赖方向错误,且 `WebSvrPort=0` 时 `m_pParentDlg` 为空导致取不到数;方案 B(MCP 自持父对话框复制逻辑)违反 DRY、重复易错的编码处理。故取 C。
#### 返回内容(全量,含实时信息)
**保留 `BuildDeviceListJson` 的全部字段,不做精简**,以支持「AI 协助监督客户端运行状态」的场景:
- 基础标识:`id` / `name` / `remark` / `ip` / `os` / `location` / `group` / `version` / `clientType` / `screen`
- 实时信息:`rtt`(延迟)、`activeWindow`(当前活动窗口,即被控端正在做什么)
输出结构 `{"hosts": [...]}`,每台主机一个对象,字段与 §3.3.2 的 `outputSchema` 一致。
### 3.5 安全设计
- **绑定地址**:默认 `127.0.0.1`(仅本机回环,其他机器无法访问);可经 `McpBind` 配置为 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程调用。
- 非回环绑定时 token 经明文 HTTP 传输,需强 token,并建议配合防火墙限制源 IP 或启用 HTTPS(项目已有 WebHTTPS 实践)。
- **静态 token 校验**:每个 `POST /mcp` 请求校验 `Authorization: Bearer <token>` header;不匹配返回 HTTP 401。
- **token 来源**(优先顺序):环境变量 `YAMA_MCP_TOKEN` → `THIS_CFG` 的 `McpToken` → 两者皆空则**随机生成**。
- 前两者任一非空即作为固定 token;仅当两者皆空时才随机生成(仅本次进程运行期间有效,进程重启后重新生成不同的 token)。
- 随机生成时**必须将 token 输出到日志**(`Mprintf`),否则用户无从获知、无法在 Claude Code 侧配置。
- 环境变量名 `YAMA_MCP_TOKEN` 建议同 `BRAND_WEB_ENV_VAR` 一样在 `UIBranding.h` 定义为宏(如 `BRAND_MCP_ENV_VAR`),避免散落硬编码字符串。
- **只读边界**:MVP 仅一个只读工具,不暴露任何写/操控能力。
- **HTTP 方法限制**:仅接受 `POST /mcp`,其余返回 404/405。
### 3.6 配置设计
沿用现有 `THIS_CFG`(`settings` 节):
| 配置键 | 默认值 | 说明 |
|---|---|---|
| `McpEnabled` | `0` | MCP 总开关:`0` = 禁用(默认),`1` = 启用。经「扩展 → MCP设置」对话框设置 |
| `McpPort` | `6544` | MCP 监听端口;仅当 `McpEnabled=1` 时生效;未配置用默认 `6544` |
| `McpBind` | `127.0.0.1` | 监听地址;默认仅本机;可设 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程 |
| `McpToken` | 空(UI 默认随机值) | 静态 token;对话框内「token 必填、默认随机值」——首次打开若为空则预填随机并随保存持久化。运行时优先级:env `YAMA_MCP_TOKEN` → `McpToken` → 皆空则随机生成(见 §3.5) |
> 与旧版「`McpPort=0` 即禁用」不同:启用与否改由独立的 `McpEnabled` 开关控制,`McpPort` 回归纯端口语义,二者解耦。
### 3.7 生命周期接入
在 `2015RemoteDlg.cpp` Web 服务启动块(约 `2048-2081`)之后,新增 MCP 启动逻辑。`McpServer` 与 `CWebService` 同为单例:提供 `static McpServer& Instance()` 与全局 `inline McpServer& McpServer()` 访问器(仿 `WebService()`,见 `WebService.h` 末尾的全局访问器写法)。
```cpp
auto mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); // 默认禁用
auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544);
auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
if (mcpEnabled) {
// token 优先顺序:env YAMA_MCP_TOKEN → THIS_CFG McpToken → 皆空则随机(仅本次进程有效)
const char* envTok = getenv("YAMA_MCP_TOKEN");
std::string mcpToken;
if (envTok && *envTok) {
mcpToken = envTok;
} else {
mcpToken = THIS_CFG.GetStr("settings", "McpToken", "");
}
if (mcpToken.empty()) {
mcpToken = GenerateRandomToken(); // 32 hex 随机串
Mprintf("[McpServer] YAMA_MCP_TOKEN / McpToken 均未设置,本次进程随机 token:%s\n", mcpToken.c_str());
}
McpServer().SetParentDlg(this); // 方案 C:MCP 需遍历 m_HostList,须持父对话框指针
McpServer().SetToken(mcpToken);
if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
}
}
```
并在退出路径(`OnDestroy` / `ExitInstance` 对应位置)调用 `McpServer().Stop()`。
### 3.8 「MCP设置」配置对话框
MCP 默认不启用,通过主对话框「扩展 → MCP设置」菜单项打开配置对话框来开启。菜单项位置:`2015Remote.rc` 扩展菜单(`POPUP "扩展(&X)"`)内、「地理信息(&L)」子菜单(纯真数据库 / IP2Region)的 `END` 之后、「插件设置」之前。
#### 3.8.1 菜单与资源接线
1. **`resource.h`**:新增命令 ID(取当前空闲段,经查 `33077` 空闲):
```cpp
#define ID_MCP_SETTINGS 33077
```
2. **`2015Remote.rc`**:在「地理信息(&L)」子菜单 `END` 之后、`插件设置` 之前插入:
```
MENUITEM "MCP设置(&M)...", ID_MCP_SETTINGS
```
并新增对话框资源 `IDD_DIALOG_MCP_SETTINGS`(「启用 MCP」复选框 + 端口/绑定地址/token 三个编辑框 + 确定/取消按钮)。
3. **`UIBranding.h`**:扩展菜单隐藏开关新增一行(默认 `0` 显示):
```cpp
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
```
4. **`FeatureFlags.h`**:新增运行时许可位,占用 MenuFlags 保留段 `[43-63]` 的首位(紧跟 `MF_REQUEST_AUTH` 之后):
```cpp
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
```
5. **`2015RemoteDlg.cpp`**:`#include "McpSettingsDlg.h"`,然后消息映射 + 处理器 + 剪枝:
```cpp
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
// ...
void CMy2015RemoteDlg::OnMcpSettings()
{
CMcpSettingsDlg dlg(this);
dlg.DoModal();
}
```
并在扩展菜单剪枝块(`pExtMenu`,约 `2015RemoteDlg.cpp:1225` 之后)追加:
```cpp
if (SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS))
pExtMenu->DeleteMenu(ID_MCP_SETTINGS, MF_BYCOMMAND);
```
> **实现注意**:`2015Remote.rc` 为 UTF-16 编码,菜单与对话框资源须由 VS 资源编辑器维护(手工改文本易破坏编码与资源 ID 引用)。`IDD_DIALOG_MCP_SETTINGS` 需在 `resource.h` 分配独立空闲的 `IDD_` 数值(与 `ID_MCP_SETTINGS` 分开);对话框内控件 ID(如 `IDC_CHK_ENABLE_MCP` / `IDC_EDIT_MCP_PORT` / `IDC_EDIT_MCP_BIND` / `IDC_EDIT_MCP_TOKEN`)同理。
#### 3.8.2 对话框字段与默认值
| 控件 | 绑定配置键 | 默认值 | 说明 |
|---|---|---|---|
| 「启用 MCP」复选框 | `McpEnabled` | 未勾选(`0`) | 勾选后持久化 `McpEnabled=1` |
| 端口 | `McpPort` | `6544` | 数字编辑框,仅当启用时有效 |
| 绑定地址 | `McpBind` | `127.0.0.1` | 默认仅本机;可改 `0.0.0.0` / 内网 IP |
| Token | `McpToken` | 随机值 | 必填;为空时预填随机 token |
#### 3.8.3 初始化与保存
打开时读 `THIS_CFG` 回填;token 为空则当场生成随机值预填(32 hex)。保存时落盘并弹重启提醒:
```cpp
BOOL CMcpSettingsDlg::OnInitDialog()
{
CDialogLangEx::OnInitDialog();
int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0);
int port = THIS_CFG.GetInt("settings", "McpPort", 6544);
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
if (tok.empty()) tok = GenerateRandomToken(); // 预填随机,随保存持久化
// 回填到控件:m_bEnabled / m_nPort / m_strBind / m_strToken
return TRUE;
}
void CMcpSettingsDlg::OnBnClickedBtnSave()
{
// 从控件收集:enabled / port / bind / token
if (enabled && token.empty()) {
MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING);
return;
}
THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0);
THIS_CFG.SetInt("settings", "McpPort", port);
THIS_CFG.SetStr("settings", "McpBind", bind);
THIS_CFG.SetStr("settings", "McpToken", token);
MessageBoxL(_TR("MCP 设置已保存。\n启用/端口/绑定地址/Token 的改动需重启程序生效。"),
_TR("提示"), MB_ICONINFORMATION);
CDialogLangEx::OnOK();
}
```
#### 3.8.4 与运行时 token 逻辑的衔接
对话框的「默认随机 token」是把随机值**写入 `McpToken` 并持久化**,于是下次启动走 `env → McpToken` 的固定 token 分支;运行时「两者皆空则随机」分支仅在用户从未通过对话框设置过(或手动清空 token)时兜底,两者不冲突。`GenerateRandomToken()` 为 MCP 侧新增的小工具函数(32 hex 随机串,可用 `rand_s` / `BCryptGenRandom` 实现;如需可复用 `WebServiceAuth.h` 的 `ComputeSHA256` 派生)。
---
## 4. 实施步骤
| 步骤 | 内容 | 验收 |
|---|---|---|
| 1 | 新建 `McpProtocol`:JSON-RPC 分发 + `initialize`/`tools/list`/`tools/call`(含 jsoncpp 解析/序列化) | 单元自测:手写请求串,验证响应 |
| 2 | 新建 `McpServer`:httplib 起 `127.0.0.1:6544`,`POST /mcp` 路由 + token 校验(含随机生成) | curl 请求返回 401/正常 |
| 3 | 抽公共函数 `BuildHostJson`,`CWebService::BuildDeviceListJson` 改调之 | Web 输出不变,零回归 |
| 4 | 注册 `list_online_hosts` 工具:handler 持父对话框、`m_cs` 锁内遍历、调 `BuildHostJson` 组装 | `tools/call` 返回主机列表 |
| 5 | 新增 `McpSettingsDlg` + 菜单项接线(`ID_MCP_SETTINGS` / `OnMcpSettings` / `HIDE_MENU_MCP_SETTINGS` / `IDD_DIALOG_MCP_SETTINGS` 资源) | 对话框可启用/禁用、配置端口/绑定/token,保存落盘并提示重启 |
| 6 | 接入 `2015RemoteDlg` 启动/停止(按 `McpEnabled` 门控)+ 配置 | 服务端启动后端口监听正常 |
| 7 | Claude Code 接入验证(见 §5) | 见验收标准 |
---
## 5. 验收标准
1. 在「MCP设置」对话框启用并重启服务端后,`127.0.0.1:6544` 监听正常,外部地址无法访问;未启用时该端口不监听。
2. 无 token 的请求返回 HTTP 401;错误 token 返回 401;正确 token 通过。
3. `curl` 模拟 `initialize` → `tools/list` → `tools/call` 全流程,返回符合 JSON-RPC 2.0 与 MCP 规范。
4. Claude Code 侧:
```bash
claude mcp add yama --transport http http://127.0.0.1:6544/mcp -H "Authorization: Bearer <token>"
```
`claude mcp list` 能看到 `yama`,在对话中问「列出在线主机」能正确触发工具并返回列表。
5. 现有 Web 控制台、IOCP 主服务功能无回归。
---
## 6. 风险与约束
| 风险 | 说明 | 应对 |
|---|---|---|
| 无官方 C++ MCP SDK | 需手写 JSON-RPC 协议层 | 仅实现 3 个方法,量小可控;协议稳定后再考虑抽库 |
| MCP 规范版本演进 | protocolVersion 字符串需与客户端匹配 | 实现时对照最新规范;`initialize` 返回可协商版本 |
| 并发安全 | 遍历 `m_HostList` 需持 `m_cs` | MCP 与 WebService 采用相同的「`m_cs` 锁内遍历」模式 |
| 字符编码 | GBK/UTF-8 分叉 | 复用公共函数 `BuildHostJson` 的编码处理,不自造 |
| token 存储 | `THIS_CFG` 的 token 会明文落盘;随机 token 会打印到日志 | 本地回环 + 只读,风险可控;敏感场景建议仅用 env 并妥善保管 |
---
## 7. 后续扩展规划(本期不做)
- 工具:`get_host_processes`(进程列表)、`execute_command`(命令执行)、`capture_screen`(截图)等
- 写操作工具的安全边界评审(操作对象、权限、审计)
- 认证升级:复用 `WebServiceAuth` 的签名 token / 过期机制
- 分组过滤:`list_online_hosts` 加 `group` 参数
---
## 8. 决策记录
**决策记录(全部已定):**
- ✅ **启用开关**:新增 `McpEnabled`,默认 `0`(禁用)。MCP 默认不启动,须用户在「扩展 → MCP设置」对话框手动开启。
- ✅ **端口**:`McpPort` 可配置,未配置默认 `6544`(纯端口语义,不再兼任启用开关)。
- ✅ **token**:优先顺序 env `YAMA_MCP_TOKEN` → `THIS_CFG.McpToken` → 皆空则随机生成(仅本次进程有效,打印到日志)。对话框内 token 必填、默认随机值(预填并随保存持久化到 `McpToken`)。
- ✅ **绑定地址**:默认 `127.0.0.1`(仅本机);可配置为 `0.0.0.0` 或内网 IP(如 `192.168.0.92`)以支持多人协作跨机调用。
- ✅ **配置入口**:主对话框「扩展」菜单,位于「地理信息(&L)」子菜单下方新增「MCP设置」菜单项,打开独立配置对话框。
- ✅ **生效方式**:MCP 启用/端口/绑定/token 的改动均需重启服务端生效,对话框保存后弹窗提醒「重启程序生效」。
- ✅ **取数方案**:C(抽公共函数 `BuildHostJson`,MCP 与 WebService 平级复用)。
- ✅ **返回字段**:全量,含实时信息(`rtt` / `activeWindow`),支持 AI 监督场景。
- ✅ **序列化**:`structuredContent` + `outputSchema`,`content` 附带可读摘要。
+405
View File
@@ -0,0 +1,405 @@
# YAMA MCP 功能开发技术书(Phase 2 及后续)
> **状态**:设计定稿(已按两轮评审修订);P2a、P2b 已实现并经真实主机验证(`search_hosts` / `get_host_detail` / `list_processes` / `list_windows` / `get_activity_history`)。
> **读者**:MCP 后续功能的研发/评审人员。
> **关联文档**:[Mcp_Design.md](./Mcp_Design.md)(Phase 1 的协议、架构、配置与菜单设计)。
> **核心目标**:让 MCP 从「单个只读工具」平滑演进为「分阶段、可回滚、影响面可控」的工具集,**不一次性大改现有功能**。
---
## 1. 背景与目标
Phase 1 已跑通 MCP 最小闭环:`httplib` + JSON-RPC 2.0,单工具 `list_online_hosts`,静态 Bearer token,经「扩展 → MCP设置」对话框开启。目标是把 YAMA 服务端已有的主机管理能力(进程、窗口、终端、截图、文件等)**逐步**暴露为 MCP 工具,供 Claude Code 等 AI 助手以工具调用方式使用。
本技术书解决三个问题:
1. **做什么**——工具路线图与优先级(只读 → 安全写 → 会话)。
2. **怎么做**——复用现有协议,无头驱动、不弹对话框;**区分「主连接 RPC」与「子链接流式」两种模式**。
3. **怎么稳**——每个阶段独立可交付、可回滚,影响面小、可验收。
---
## 2. 现状回顾(Phase 1 已交付)
| 模块 | 文件 | 职责 |
|------|------|------|
| HTTP/JSON-RPC 分发 | `McpServer.h` / `McpServer.cpp` | `POST /mcp`;token 校验;`initialize`/`ping`/`tools/list`/`tools/call` |
| 工具序列化复用 | `HostJson.h` / `HostJson.cpp` | `BuildHostJson(ctx, clientMap)`,单主机 JSON(与 WebService 共用) |
| 配置对话框 | `McpSettingsDlg.h` / `.cpp` | 运行时构造对话框:enable/port/bind/token |
| 生命周期接入 | `2015RemoteDlg.cpp:2136-2162`(启动)、`3900-3906`(停止) | 随主对话框启停 |
| FRP 暴露 | `2015RemoteDlg.cpp:3269-3304` | MCP 端口仅在「启用 + 绑 `0.0.0.0`」时纳入 FRP |
关键结论:**MCP 未引入任何新第三方库**——`httplib`(HTTP)与 `jsoncpp`(JSON)均为项目既有依赖。
当前工具实现只有一个 dispatch 分支(`McpServer.cpp` 的 `BuildToolsCall`),新增工具的边际成本很低:**注册 `outputSchema` + 加一个 `tools/call` 分支**。
---
## 3. 设计原则(贯穿所有后续开发)
1. **只读优先,写操作单独评审**:延续 `Mcp_Design.md §1.3`。只读工具直接做;任何「改变被控端状态」的工具(命令执行、杀进程、传文件、控制)必须经安全评审 + 独立授权。
2. **复用现有协议,不新造命令**:一切数据获取/操控都走 `common/commands.h` 里既有的 `COMMAND_*`(服务端→客户端)与 `TOKEN_*`(客户端→服务端),不新增自定义命令。
3. **无头接管复用 Web 终端模式**:对话框只是 UI 外壳;MCP 复用 `WebService` 的挂起标记/接管模式,把数据接到 MCP 侧,**不弹框**。
4. **每阶段独立可交付、可回滚**:一个阶段 = 一批工具 + 对应验收;出错可单独 revert,不连坐。
5. **超时与清理是硬约束**:MCP 是 request/response,一次 `tools/call` 不能无限阻塞等数据;必须有超时、迟到回收、用完即关。
6. **输出 schema 先行**:每个工具先在 `tools/list` 里声明完整 `inputSchema`/`outputSchema`,AI 客户端据此决定何时调用、如何解析。
---
## 4. 核心机制:无头驱动(双模式)
> **本节是本技术书的关键**。此前设计把「进程/窗口/截图」误当作「子链接」,评审后纠正为:这些是**主连接 request/response**;真正走子链接的只有终端、完整屏幕、文件传输等大流量/持续流能力。两者实现机制不同,必须分开建模。
### 4.1 连接模型:主连接 vs 子链接
`common/commands.h` 定义了完整协议,分两层:
- **主连接**:客户端上线后与主控保持的长连接。承载心跳(`TOKEN_HEARTBEAT`)、命令下发,以及**轻量 request/response**——屏幕预览缩略图(`TOKEN_SCREEN_PREVIEW_RSP`)、历史活动(`TOKEN_REPORT_ACTIVITY`)等。响应以 `TOKEN_*` 回到主连接(捕获点见 §4.2)。
- **子链接**:屏幕、终端、文件传输、**进程/窗口列表**等功能,服务端下 `COMMAND_*` 后客户端**新建一条 TCP 子链接**回连,数据以 `TOKEN_*` 开头,作为一个**新的 `CONTEXT_OBJECT`** 进入分派。其中进程/窗口列表是**一次性子链接**(回一条 `TOKEN_PSLIST`/`TOKEN_WSLIST` 即关,见 §4.2 模式 A′),终端/屏幕/文件传输则是**持续流子链接**。
两者都由同一个 `MessageHandle(CONTEXT_OBJECT*)` 分派,靠 `ContextObject->GetClientID()` 关联到同一台主机。**判断一个 `TOKEN_*` 属于哪层,看它的数据落在哪个 context 上**:
| 证据 | 结论 |
|------|------|
| 客户端 `KernelManager.cpp:1156` `COMMAND_SYSTEM` → `new IOCPClient` 子链接 + `LoopProcessManager`;`SystemManager.cpp:75` `GetProcessList()` 里 `szBuffer[0]=TOKEN_PSLIST`,经子链接 `Send2Server` 回传 | 进程/窗口列表 = **一次性子链接**(主连接下 `COMMAND_SYSTEM`/`COMMAND_WSLIST`,子连接回 `TOKEN_PSLIST`/`TOKEN_WSLIST`) |
| `2015RemoteDlg.cpp:8527-8536`:`SendScreenPreviewRequest` 用 `ctx->Send2Client(...)`;响应 `TOKEN_SCREEN_PREVIEW_RSP` 在 `MessageHandle:6173` | 屏幕预览 = **主连接 RPC** |
| `WebService.cpp:1836-1900` + `2015RemoteDlg.cpp:6312-6351`:`COMMAND_SHELL` → 客户端建 shell 子上下文 → `TOKEN_SHELL_START`/`TOKEN_TERMINAL_START` | 终端 = **子链接** |
### 4.2 模式 A:主连接 RPC(截图 / 历史活动)
适用于:`get_screenshot`(屏幕预览)、`get_activity_history`(历史活动)。
```
MCP 工具 tools/call
└─► 主 context ctx = FindHost(device_id)
└─► ctx->Send2Client(COMMAND_*) // 主连接发 1 字节命令
客户端处理后在主连接回 TOKEN_*
└─► MessageHandle 收到 TOKEN_*(数据已在 ctx->InDeCompressedBuffer)
├─ if (McpServer().IsPending(devId))
│ McpServer().TakeMainResponse(devId, ctx->GetBuffer(0), ctx->GetBufferLength()); // IO 线程同步拷贝
│ break; // 跳过 MFC 弹框(不 CancelIO,主连接不可关)
└─ 否则走原 MFC 路径(如打开历史活动对话框)
```
要点:
- **无子链接、无 subCtx**:数据就在主 context 缓冲里,MCP 只需在 `MessageHandle` 的对应 `TOKEN_*` 分支里**同步拷贝**到结果缓冲,再唤醒等待者。
- **不 CancelIO**:响应落在主连接上,关闭会断开整个主机会话;故拦截后仅 `TakeMainResponse` + `break`,主连接保持。
- **捕获点因工具而异(必须在 IO 线程同步拷贝)**:屏幕预览的 `TOKEN_SCREEN_PREVIEW_RSP` **直接读本消息的 `szBuffer`/`len`**(`2015RemoteDlg.cpp:6173-6185` 已在 case 内把 `szBuffer` 拷贝到堆消息再转主线程);历史活动的 `TOKEN_REPORT_ACTIVITY` 读 `GetBuffer(0)`(= `InDeCompressedBuffer`)。两者都是每 context 单消息缓冲,主连接 recv 循环处理完本条消息后即被覆盖,故拷贝必须发生在 `MessageHandle` 内部(IO 线程),不能异步等 UI 线程。
- **请求关联**:`get_screenshot` 有 `reqId`(`ScreenPreviewReq.reqId`)可丢弃过期响应;`get_activity_history` **无请求 id**,靠「每 host 单飞行」(见 §4.4)保证正确性。
### 4.2′ 模式 A′:一次性子链接(进程 / 窗口列表)
适用于:`list_processes`、`list_windows`。**这是本技术书相对原设计的重要纠正**:进程/窗口列表**不是**主连接 RPC,而是一次性子链接——`COMMAND_PSLIST` 在主连接上是空操作(客户端 `KernelManager::OnReceive` 无此 case),正确的触发命令是 `COMMAND_SYSTEM`(进程)/`COMMAND_WSLIST`(窗口)。
```
MCP 工具 tools/call
└─► 主 context ctx = FindHost(device_id)
└─► ctx->Send2Client(COMMAND_SYSTEM / COMMAND_WSLIST) // 主连接发 1 字节命令
客户端 KernelManager::OnReceive 收到后新建一条子链接(new IOCPClient,EnableSubConnAuth)
└─► 子链接 ConnectServer 后先发 TOKEN_CONN_AUTH(服务端 SetID 把 clientID 钉在子 context 上)
└─► CSystemManager 构造函数立即 Send2Server(TOKEN_PSLIST / TOKEN_WSLIST)
└─► MessageHandle 在【子 context】上收到 TOKEN_PSLIST / TOKEN_WSLIST(GetClientID()==主 host id)
├─ if (McpServer().IsPending(devId))
│ McpServer().TakeMainResponse(devId, GetBuffer(0), GetBufferLength());
│ ContextObject->CancelIO(); // 用完即关一次性子链接(不关会泄漏)
│ break; // 跳过 WM_OPENSYSTEMDIALOG
└─ 否则 g_2015RemoteDlg->SendMessage(WM_OPENSYSTEMDIALOG, ...) // MFC 路径
```
要点:
- **有子链接、有 subCtx**:数据落在子 context 上,非主 context。子 context 的 `GetClientID()` 已由 `TOKEN_CONN_AUTH`(`2015RemoteDlg.cpp:6291` 的 `SetID`)钉成主连接 clientID,故 `IsPending(devId)` 能与主 context 的 `device_id` 对上。
- **用完即关**:MCP 拦截后 `CancelIO()` 关闭子链接(客户端 IOCPClient 以 `exit_while_disconnect=true` 构造,会随断开退出);不关会泄漏子链接。
- **与 MFC 对话框可并存**:MCP 与 MFC 对话框**各自使用独立的子链接**(客户端每次 `COMMAND_SYSTEM`/`COMMAND_WSLIST` 都新建一条),因此两者互不阻塞——MCP 的一次性调用不会导致 MFC 无法查看进程/窗口,反之亦然。无请求 id 的串扰靠「每 host 单飞行」(§4.4)规避,而非「与 MFC 对话框互斥」。
- **编码**:进程名/路径为客户端 ANSI(CP_ACP);窗口标题为客户端 UTF-8(按能力位 `CLIENT_CAP_UTF8` 判别,老客户端回落 CP_ACP)。
- **请求关联**:无请求 id → 每 host 单飞行(§4.4)。
> **模式 A0(主连接单向命令,无响应)**:`kill_process`(`COMMAND_KILLPROCESS`)、`send_message`(`COMMAND_TALK`)这类「发完即走」的命令没有 MCP 需要的响应数据,**不需要** §4.4 的挂起注册表/超时/单飞行——`Send2Client` 后直接返回成功。它是模式 A 的退化情形,实现最简单。
### 4.3 模式 B:子链接流式(终端 / 完整屏幕 / 完整文件)
适用于:`exec_command`(终端),以及未来的实时远程桌面、完整文件传输。
```
MCP 工具 tools/call
└─► 登记挂起请求 m_Pending[device_id](标记 mode=B)
└─► ctx = FindHost(device_id); ctx->Send2Client(COMMAND_SHELL) // 主连接下命令
客户端建 shell 子链接
└─► MessageHandle 收到 TOKEN_SHELL_START / TOKEN_TERMINAL_START
├─ if (McpServer().IsPending(devId))
│ McpServer().TakeSubContext(devId, ContextObject); // 接管子上下文
│ break; // 跳过弹框
└─ 否则弹 WM_OPENSHELLDIALOG
后续 shell 输出
└─► MessageHandle 顶部 IsTerminalContext 分支 → McpServer().OnShellData(...) 泵数据
```
完全复刻 `WebService` 的 `IsTermPending`/`RegisterTerminalContext`/`OnTerminalData` 模式(`WebService.h:283-287`),但**不复用其长会话状态**——MCP 是「一次性:建链 → 收发 → 关链」。
### 4.4 统一挂起请求注册表
模式 A(主连接 RPC)与模式 A′(一次性子链接)共用一个按 `device_id`(= `context::GetClientID()`)索引的注册表。**当前实现(P2b)只覆盖 A/A′ 两种「一次性响应」**,故结构里只有 `data` + `done`,无需 `mode`/`subCtx`/`timedOut`;未来 P3a 的 `exec_command`(模式 B 流式)再扩展 `subCtx` 字段。
```cpp
struct PendingRequest {
std::string tool;
std::vector<BYTE> data; // 结果缓冲(MessageHandle 内同步拷贝的字节)
bool done = false;
};
std::mutex m_PendingMutex;
std::condition_variable m_PendingCv;
std::map<uint64_t, PendingRequest> m_Pending; // 受 m_PendingMutex 保护
```
暴露给 `MessageHandle` 的钩子:
- `bool IsPending(uint64_t device_id)` —— 该 `TOKEN_*` 是否为 MCP 触发。
- `void TakeMainResponse(uint64_t device_id, const BYTE* data, ULONG len)` —— 模式 A/A′:拷贝缓冲、`done=true`、唤醒 `cv`(在 IO 线程阻塞期间同步调用,拷贝安全)。
工具线程侧的方法:
- `bool BeginPending(uint64_t device_id, const std::string& tool)` —— 登记挂起(`false` = 该 host 已有挂起,返回「设备忙」)。
- `bool WaitPending(uint64_t device_id, std::vector<BYTE>& out, int timeoutMs)` —— 等待响应(超时/失败自动清理并返回 `false`)。
- `void ClearPending(uint64_t device_id)` —— 提前退出路径的清理。
**每 host 单飞行**:同一 `device_id` 同一时刻只允许一个挂起请求。这是「无请求 id」工具的正确性前提——进程/窗口/历史活动响应都不带请求 id,若同 host 并发两个请求,无法区分响应归属,故并发第二个请求返回「设备忙」(`-32003`)而非串扰。
> 说明:单飞行只约束「MCP 侧同 host 并发」,**不**约束「MCP 与 MFC 对话框并存」——二者各用独立子链接(模式 A′)或各自触发(模式 A),互不占位。详见 §4.2′。
### 4.5 超时与清理(MCP 与 Web 的关键差异)
Web 是长连接,子链接可长驻;MCP 是**一次性 request/response**。因此:
- **超时**:`wait_for(timeout)` 等数据;当前 P2b 用编译期常量 `kMcpToolTimeoutMs = 20000`(暂未接配置,见 §9)。超时返回 JSON-RPC 错误(`-32001`),并清理注册表。
- **迟到回收(已知边界)**:数据在超时后才到达(`IsPending` 已清)时,拦截分支不成立,响应会**回落到原 MFC 路径**(进程/窗口弹 `WM_OPENSYSTEMDIALOG`,历史活动弹对话框)——属「客户端在 20s 内未响应」这一病态场景的罕见副作用,不会串扰或崩溃。正常路径(<1s 内响应)不受影响。若未来要彻底消除,可给注册表加「超时墓碑 + 宽限期丢弃」。
- **用完即关**:模式 A′(进程/窗口)拦截后 `CancelIO()` 关一次性子链接;模式 A(主连接 RPC)不关主连接。未来模式 B(终端)收集完 stdout 后主动关子上下文。
- **阻塞 httplib worker**:`wait_for` 会阻塞 `HandleMcp` 所在的 httplib worker 线程。httplib 默认线程池约 8 线程,阻塞一个 20–30s 会占住一个 worker。MCP 属低频串行调用(AI 逐条调用),**本阶段可接受**;但需在 §12 显式声明,若未来需并发改用「提交任务 + 轮询结果」的异步模型。
---
## 5. 工具路线图(分阶段)
### 5.1 工具总表
> 「模式」标注实现路径(A=主连接 RPC,A′=一次性子链接,A0=主连接单向命令[无响应],B=子链接流式,—=纯内存)。
| 阶段 | 工具 | 功能 | 复用协议/函数 | 模式 | 风险 |
|------|------|------|---------------|------|------|
| P1(已交付) | `list_online_hosts` | 在线主机列表 | `BuildHostJson` | — | 只读 |
| P2a(已实现并验证) | `search_hosts` | 按名称/IP/分组/OS/在线态过滤 | `list_online_hosts` 内存过滤 | — | 只读 |
| P2a(已实现并验证) | `get_host_detail` | 单机详情(不含活动历史) | 现成 device 字段(`BuildHostJson`) | — | 只读 |
| P2b(已实现并验证) | `list_processes` | 主机进程列表 | `COMMAND_SYSTEM`→(子链接)`TOKEN_PSLIST` | **A′** | 只读 |
| P2b(已实现并验证) | `list_windows` | 主机窗口列表 | `COMMAND_WSLIST`→(子链接)`TOKEN_WSLIST` | **A′** | 只读 |
| P2b(已实现并验证) | `get_activity_history` | 单机历史活动(文本快照) | `COMMAND_QUERY_ACTIVITY`(159)→`TOKEN_REPORT_ACTIVITY`(160) | **A** | 只读 |
| P2c | `get_screenshot` | 单帧屏幕截图(JPEG) | `COMMAND_SCREEN_PREVIEW_REQ`→`TOKEN_SCREEN_PREVIEW_RSP` | **A** | 只读 |
| P2c | `list_files` | 目录列举 | `COMMAND_LIST_DRIVE`/`COMMAND_LIST_FILES`→`TOKEN_DRIVE_LIST`/`TOKEN_FILE_LIST` | **A** | 只读 |
| P3 | `exec_command` | 执行命令、返回 stdout | `COMMAND_SHELL`→`TOKEN_SHELL_START`/`TOKEN_SHELL_DATA` | **B** | **写** |
| P3 | `send_message` | 给被控端弹消息 | `COMMAND_TALK`(220) | **A0** | 写(低危) |
| P3 | `kill_process` | 结束进程 | `COMMAND_KILLPROCESS` | **A0** | **写** |
| P4(暂缓) | 实时远程桌面 / 交互终端 | 持续流 | 屏幕/终端子链接 + SSE/轮询 | **B** | 需会话模型 |
### 5.2 Phase 2:只读观测(低风险,无写操作)
全部只读,不改变被控端任何状态,token 模型不变(仍单一静态 token)。P2a 纯内存、零改动;P2b/P2c 引入模式 A 无头接管,但只读、可回滚。
### 5.3 Phase 3:安全写操作(需单独评审)
进入 `Mcp_Design.md §1.3` 明确划出的「写操作」区。每个工具交付前需评审三点(见 §8):命令白名单、审计日志、**分组授权 + token 分级**。
### 5.4 Phase 4:会话/流式(暂缓)
实时远程桌面、交互终端等需要「持续流」的能力,与当前 request/response 模型不匹配。需先决定传输方案(SSE / 提交任务 + 轮询),本期**不做**,避免过早引入复杂会话状态。
---
## 6. 分阶段实施计划
> 每期都给出:改动文件、接口、测试点、回滚方式。原则是**每期可独立合入、独立验收、独立回滚**。
### 6.1 P2a:`search_hosts` + `get_host_detail`(纯内存,零子链接)✅ 已实现并验证
**改动**:仅 `McpServer.cpp`。
- `BuildToolsListResult` 增两个工具描述 + schema。
- `BuildToolsCall` 增两个分支,复用 `list_online_hosts` 已遍历出的主机数据做内存过滤/单机抽取(抽出 `CollectOnlineHosts` 收集 + `BuildHostItemSchema` 复用)。
- 无任何 `MessageHandle`/`WebService`/`context` 改动。
**影响面**:极小,只加不改。**回滚**:revert 单文件即可。
**验收**:`tools/list` 返回 3 个工具;`tools/call search_hosts` 按 filter 正确过滤;`get_host_detail` 对在线主机返回正确字段、对离线/非法 id 返回明确错误。
> **范围修正**:原计划 `get_host_detail` 附带「活动历史」,但活动历史是 `COMMAND_QUERY_ACTIVITY`(159)→`TOKEN_REPORT_ACTIVITY`(160) 的**主连接往返**(客户端上报、服务端不落库),非纯内存字段,违反 P2a「纯内存」约束。故 P2a 的 `get_host_detail` 只返回主机实时字段,活动历史单列为 P2b 的 `get_activity_history`(模式 A)。
### 6.2 P2b:`list_processes` + `list_windows` + `get_activity_history`(模式 A′ / 模式 A,首次无头接管)✅ 已实现并验证
> 本节已按实际实现修订:进程/窗口是**一次性子链接**(模式 A′),历史活动是**主连接 RPC**(模式 A),见 §4.2/§4.2′。
**改动**:
- `McpServer.h/.cpp`:
- 加 §4.4 的 `m_Pending` 注册表 + `IsPending`/`TakeMainResponse`/`BeginPending`/`WaitPending`/`ClearPending`,每 host 单飞行检查。
- 加解析器 `ParseProcessList`(PID/name/arch/path,CP_ACP 转 UTF-8)、`ParseWindowList`(hwnd/title/status/pid,标题按能力位解码);两者都用 `BoundedStrlen` 有界读 + 「空记录 = 尾部零填充」终止,规避客户端 `LocalSize` 对齐引入的尾部零字节。
- 加三个工具:`list_processes`(发 `COMMAND_SYSTEM`)、`list_windows`(发 `COMMAND_WSLIST`)、`get_activity_history`(发 `COMMAND_QUERY_ACTIVITY`)。
- `2015RemoteDlg.cpp` 的 `MessageHandle`:
- `TOKEN_PSLIST`/`TOKEN_WSLIST` 分支(子 context):`if (McpServer().IsPending(devId)) { TakeMainResponse(devId, GetBuffer(0), GetBufferLength()); ContextObject->CancelIO(); break; }`(**在 UI 线程、IO 线程阻塞期间同步拷贝**;用完即关子链接)。
- `TOKEN_REPORT_ACTIVITY` 分支(主 context):`if (McpServer().IsPending(devId)) { TakeMainResponse(...); break; }`(**不 CancelIO**,主连接不可关)。
**影响面**:`MessageHandle` 只新增 `if` 分支,不影响现有 MFC 弹框路径(非 MCP 触发时 `IsPending` 为假,行为不变)。
**验收**:
- MFC 双击进程/窗口管理弹框仍正常(回归点);MFC 历史活动对话框仍正常(回归点)。
- MCP `list_processes` 返回 `[{pid,name,arch,path}]`;`list_windows` 返回 `[{hwnd,title,status,pid}]`;`get_activity_history` 返回 `{records:[...], activityHistory}`(UTF-8 文本,逐行拆记录)。
- 超时(如对离线主机)返回 `-32001` 而非挂死;同 host 并发第二次调用返回 `-32003`「设备忙」;发送失败返回 `-32004`。
- 中文进程名/窗口标题无乱码(进程名 CP_ACP、窗口标题按能力位 UTF-8)。
**验证记录**(2026-08-19,真实在线主机实测):
- `list_processes`(Windows 11 主机):`pid`/`name`/`arch`/`path` 完整;`pid==0` 仅 1 条(合法 `[System Process]`),无尾部零填充空记录——padding 防护生效。
- `list_windows`:`hwnd`/`title`/`status`/`pid` 完整,无空标题记录;中文标题(「搭建 MCP 架构…」「微信」)UTF-8 正确。
- `get_activity_history`:返回 `[时间] [标题] 时长` 记录,UTF-8 正确。
- 错误路径实测:缺 `id` → `-32602`;非数字 `id` → `-32602`;未知/离线主机 → `-32002`;错误 token → HTTP 401。
- **客户端版本门槛**:`get_activity_history` 依赖客户端 `2026-08-15`(提交 `29929e4`)加入的 `COMMAND_QUERY_ACTIVITY` 处理;更早的客户端(如 `Jul 14 2026`)不识别该命令、无响应,服务端 20s 超时返回 `-32001`——属预期行为,非 bug。测试须选 `Aug 15 2026` 及之后的客户端。
**回滚**:revert `McpServer.h` + `McpServer.cpp` + `MessageHandle` 两处 `if` 分支。
### 6.3 P2c:`get_screenshot` + `list_files`(模式 A)
**`get_screenshot`(屏幕预览链路,主连接 RPC)**:
- 复用**现有双击预览机制** `COMMAND_SCREEN_PREVIEW_REQ`(247)→`TOKEN_SCREEN_PREVIEW_RSP`(248)(`common/commands.h:416-455`,单帧 JPEG,`ScreenPreviewReq`/`ScreenPreviewRspHeader`),`ctx->Send2Client` 发请求、主连接回响应,**不建子链接、不弹框**。
- `ScreenPreviewReq.reqId` 提供请求关联,可丢弃过期响应。
- 客户端能力位 `CLIENT_CAP_SCREEN_PREVIEW`(0x0004,仅 Windows 客户端声明):非 Windows 主机返回「不支持」错误。
- 若后续需要更高清/控制,再升级为完整屏幕子链接(模式 B,需 `RegisterScreenContext` 接管)。
**`list_files`**:复用经典文件链路 `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST` 与 `COMMAND_LIST_FILES`→`TOKEN_FILE_LIST`(模式 A)。**明确范围**:本阶段只做经典链路;项目另有 `COMMAND_GET_FOLDER`(66)、插件版 `TOKEN_DRIVE_LIST_PLUGIN`(150)、V2 文件传输,均列为非目标。文件列表可能较大,需**分页/上限**(首期限 `path` 一层、条目上限 500),避免 JSON 响应过大。
**验收**:`get_screenshot` 返回 JPEG(base64 或经 MCP 图片内容类型);Windows 主机正常、非 Windows 返回明确「不支持」;`list_files` 正确列目录且有大列表保护。
### 6.4 P3a:`exec_command`(模式 B,写操作,评审后实施)
**改动**:复刻 Web 终端链路(§4.3),把 `m_TermPending` 换成 MCP 自己的挂起标记:
- 下 `COMMAND_SHELL` → `TOKEN_SHELL_START`/`TOKEN_SHELL_DATA` 接管 → 发送命令文本 → 收集 stdout → 关子链接 → 返回。
**完成判据(本工具最难的半截,必须定死)**:交互 shell 没有「命令结束」信号,采用**哨兵方案**:
```
实际下发: cmd ; echo __MCP_DONE_<nonce>__ ; echo __MCP_EXIT_$?__
收集端: 持续累积 TOKEN_SHELL_DATA,直到同时匹配 __MCP_DONE_<nonce>__ 与 __MCP_EXIT_<code>__
取出退出码,剥离哨兵行,返回 stdout
```
`<nonce>` 随机,避免命令自身输出误命中哨兵;整体受 `McpToolTimeoutMs` 兜底(超时强制关链 + 返回错误)。
**前置安全评审**(见 §8)通过后才实施。
**验收**:`exec_command(id, "tasklist")` 返回进程列表文本 + 退出码;白名单外命令被拒绝并审计;超时/离线返回明确错误;MFC 终端弹框不受影响。
---
## 7. 「新增工具」标准配方(可复用流程)
后续任何人新增一个工具,按以下 6 步走:
1. **定协议与模式**:确认复用哪个 `COMMAND_*`→`TOKEN_*` 链路,判定是模式 A(主连接 RPC)、A0(单向命令)还是 B(子链接流式),写进 §5.1 表格。
2. **定 schema**:在 `BuildToolsListResult` 注册 `name`/`description`/`inputSchema`/`outputSchema`(description 用 `u8"..."`,见 §10)。
3. **加 dispatch**:在 `BuildToolsCall` 里加一个 `toolName == "..."` 分支。
4. **实现数据通路**:
- 纯内存/现成字段 → 直接实现,不进 `MessageHandle`。
- 模式 A0(单向命令)→ 直接 `Send2Client` 后返回,无需挂起/超时。
- 模式 A → 加 `m_Pending` 挂起 + `MessageHandle` 对应 `TOKEN_*` 加 `if (IsPending) TakeMainResponse`(IO 线程同步拷贝,**不 CancelIO**)。
- 模式 A′ → 同模式 A,但拦截后额外 `ContextObject->CancelIO()` 关一次性子链接。
- 模式 B → 加 `m_Pending` 挂起 + `MessageHandle` 对应 `TOKEN_*` 加 `if (IsPending) TakeSubContext` + `OnShellData` 泵数据。
5. **超时与清理**:带超时、迟到回收、用完即关(模式 B);每 host 单飞行(§4.5)。
6. **验收与回滚**:写 §11 的 DoD 清单,确认回归点(对应 MFC 弹框路径不变)。
---
## 8. 安全与权限模型演进
| 阶段 | 鉴权 | 授权 |
|------|------|------|
| P1/P2 | 单一静态 Bearer token | 全部只读,token 即授权 |
| P3 | 引入 **token 分级 + 分组授权** | 只读 token / 完整 token 两档;写操作要求完整 token 且限定可访问分组 |
| P4 | (如需)会话级授权 | 会话内临时授权 |
**P3 进入前必须评审的三条边界:**
1. **命令白名单**:`exec_command` 默认只放行只读命令(`systeminfo`/`tasklist`/`ipconfig`/`netstat` 等);危险命令(`del`/`format`/下载执行/`reg add`)需显式放行或直接拒绝。
2. **审计日志**:每次写操作记录 `host_id + tool + 参数 + token 来源(env/CFG)+ 时间`,写 `Mprintf` + 日志文件。
3. **分组授权(新增)**:写工具必须限定「token 能操作哪些主机/分组」,**复用 Web 远程桌面已有的 `WebUser.allowed_groups` + admin/viewer 角色模型**(`WebService.cpp:1852-1877`),而非仅区分读/写。否则一个完整 token 就能对**所有**主机执行命令,越过了 Web 侧已有的分组隔离。
> 设计底线:MCP 的写能力与 Web 远程桌面同等级敏感,任何写工具都要按「可控、可审、可关、可分组隔离」交付,不允许「开了 MCP 就能无脑执行任意命令」。
---
## 9. 配置与开关扩展
延续 Phase 1 的配置风格(`settings` 段 + env 覆盖 + `UIBranding` 开关):
> **配置存储位置(重要)**:`THIS_CFG` 在 **Release 版恒走注册表** `HKCU\Software\YAMA\settings`(`2015Remote.cpp:238` 的 `#else` 分支 `new iniFile`);`settings.ini` 仅在 **Debug 版**(`#ifdef _DEBUG` 且 `GetPwdHash()==masterHash`)才读,否则同样走注册表。故 Release 版改 MCP 配置应走「扩展 → MCP设置」对话框(写注册表)或直接改注册表,手改 `settings.ini` 对 Release 无效。
| 配置键 | 默认 | 说明 |
|--------|------|------|
| `McpEnabled` | 0 | 总开关(已有) |
| `McpPort` | 6544 | 端口(已有) |
| `McpBind` | 127.0.0.1 | 绑定(已有) |
| `McpToken` | 空 | 静态 token(已有) |
| `McpReadonly` | 1 | **P3 新增**:1=仅只读工具,0=开放写工具(配合完整 token) |
| `McpCmdWhitelist` | 内置只读命令集 | **P3 新增**:`exec_command` 白名单(分号分隔) |
| `McpToolTimeoutMs` | 20000 | **P2b 新增**:数据等待超时(当前为 `McpServer.cpp` 内编译期常量 `kMcpToolTimeoutMs`,未接配置) |
新增写能力时,`UIBranding.h`/`FeatureFlags.h` 加对应隐藏/特性位,保持「品牌定制可裁剪」的既有约定。
---
## 10. 编码 / 兼容性规范
- **UTF-8 输出**:所有进入 JSON 的字符串用 `u8"..."` 前缀(项目 `/execution-charset:.936` 会把普通窄字面量编成 GBK)。已在 `list_online_hosts` 的 `description` 中验证,服务端输出正确 UTF-8。
- **JSON 转义**:jsoncpp 会把非 ASCII 输出为 `\uXXXX`,这是合法 JSON,客户端解析即还原,无需处理。
- **客户端能力位**:依赖客户端能力的工具(如 `get_screenshot` 的 `CLIENT_CAP_SCREEN_PREVIEW`、UTF-8 字段的 `CLIENT_CAP_UTF8`)必须先查能力位,不支持则返回明确错误而非空结果。
- **id 一致性**:工具入参 `id` 必须与 `list_online_hosts` 返回的 `id` 同源(`context::GetClientID()` 的 uint64),可直接回填 `FindHost(uint64_t)`;仅接受**在线**主机的 id,离线返回明确错误。
- **版本兼容**:子链接命令/结构体(`common/commands.h`)**禁止改动既有字段**;MCP 只消费既有协议,不新增命令(原则 2)。
- **MBCS 构建**:新增 UI 文案走 `_TR()` + lang ini(GBK),与 Phase 1 一致。
---
## 11. 验收标准(Definition of Done)
每个工具交付时,以下全部满足才算完成:
- [ ] `tools/list` 返回该工具及完整 schema。
- [ ] 正常输入返回正确结构化结果。
- [ ] 非法输入(错 id / 缺参数 / 设备离线 / 不支持能力)返回**明确的 JSON-RPC 错误码**,不崩溃、不挂死。
- [ ] 数据获取有超时、迟到回收;模式 B 用完即关(无会话泄漏)。
- [ ] 每 host 单飞行:同 host 并发请求被拒绝而非串扰。
- [ ] **回归点**:对应 MFC 弹框/Web 路径行为不变(非 MCP 触发时不受影响)。
- [ ] 编码正确(UTF-8,中文无乱码)。
- [ ] 写操作工具附带白名单/审计/分组授权(仅 P3)。
- [ ] 无新增第三方库。
---
## 12. 风险与回滚
| 风险 | 缓解 |
|------|------|
| 无头接管串扰 MFC/Web 路径 | 挂起标记按 `device_id` 唯一;`if (IsPending)` 仅在 MCP 触发时成立,其余路径原样 |
| 共享 `InDeCompressedBuffer` 被覆盖 | 模式 A 在 IO 线程 `MessageHandle` 内同步拷贝,不等异步 |
| 无请求 id 的工具响应串扰 | 每 host 单飞行(§4.4);MCP 与 MFC 各用独立子链接,互不占位(§4.2′) |
| 阻塞 httplib worker 饿死后续请求 | 低频串行可接受;文档显式声明,未来并发改「任务+轮询」(§4.5) |
| 超时/挂死 | 所有工具强制 `wait_for(timeout)` + 迟到回收 |
| 写操作越权 | P3 前三道评审边界(白名单/审计/分组授权)+ `McpReadonly` 默认 1 |
| 一次性大改影响现有功能 | 分 P2a→P2b→P2c→P3a 四期,每期独立合入/验收/回滚 |
| 输出过大拖垮 JSON | `list_files`/进程列表等大结果设上限/分页 |
**回滚策略**:每期改动集中在一两个文件(`McpServer.*` + 可选 `MessageHandle` 若干 `if` 分支),出错 revert 当期 commit 即可,不影响已交付的 P1。
---
## 附:建议执行顺序
1. **P2a**(`search_hosts` + `get_host_detail`)✅ ——纯内存、零风险,已实现,验证「多工具 dispatch」模式。
2. **P2b**(`list_processes` + `list_windows` + `get_activity_history`)✅ ——首次引入**模式 A′ 一次性子链接**(进程/窗口)与**模式 A 主连接 RPC**(历史活动)无头接管,把 §4.2/§4.2′ 的机制跑通并沉淀成 §7 配方。
3. **P2c**(`get_screenshot` 走屏幕预览链路 + `list_files`)——补上「看」的能力,仍是只读、模式 A。
4. **P3a**(`exec_command`)——首次引入**模式 B 子链接流式**,在 P2 的挂起机制成熟后,走完安全评审再落地写操作。
每完成一期即提交、验收,再进入下一期,保持「有序、平稳、循序渐进」。
Binary file not shown.
+547 -23
View File
@@ -15,6 +15,7 @@
#include "TerminalDlg.h"
#include "SystemDlg.h"
#include "CClientLog.h"
#include "CActivityDialog.h"
#include "BuildDlg.h"
#include "AudioDlg.h"
#include "RegisterDlg.h"
@@ -42,6 +43,8 @@
#include <sstream>
#include "common/skCrypter.h"
#include "common/commands.h"
#include "McpServer.h"
#include "McpSettingsDlg.h"
#include "common/md5.h"
#include <algorithm>
#include <set>
@@ -148,10 +151,14 @@ COLUMNSTRUCT g_Column_Data_Online[g_Column_Count_Online] = {
{"摄像头", 60 },
{"RTT", 60 },
{"版本", 90 },
{"权限", 60 },
{"安装时间", 130 },
{"活动窗口", 130 },
{"类型", 50 },
{"", 0 },
{"", 0 },
{"", 0 },
{"", 0 },
{"权限", 60 },
};
// 缩略图列号常量 + 列号→数据槽映射。负返回值代表"该列无数据槽"。
@@ -196,10 +203,10 @@ bool SupportsFileTransferV2(context* ctx) {
}
// 获取客户端协议字符串编码:优先看自身能力位,若是子连接(CAPABILITIES 为空)
// 则通过 peer IP 查主连接。Linux/macOS 客户端文件系统路径与 locale 现代发行版
// 默认就是 UTF-8——即便客户端二进制是早于 CLIENT_CAP_UTF8 引入(commit 0aa7588)
// 之前编译的,没声明 cap 位,事实上仍发 UTF-8 字节,按 client type 兜底走 UTF-8。
// 找不到则默认 CP936。
// 则先按 clientID、再按 peer IP 查主连接。Linux/macOS 客户端文件系统路径与 locale
// 现代发行版默认就是 UTF-8——即便客户端二进制是早于 CLIENT_CAP_UTF8 引入
// (commit 0aa7588)之前编译的,没声明 cap 位,事实上仍发 UTF-8 字节,按 client
// type 兜底走 UTF-8。找不到则默认 CP936。
UINT GetClientEncoding(context* ctx) {
if (!ctx) return 936;
// 主连接情形:CAPABILITIES 已由 LOGIN_INFOR 处理流程填好
@@ -207,9 +214,17 @@ UINT GetClientEncoding(context* ctx) {
// 客户端类型兜底:LNX / MAC 默认 UTF-8(兼容老二进制无 UTF-8 cap 位的情形)
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
if (clientType == "LNX" || clientType == "MAC") return CP_UTF8;
// 子连接情形:CAPABILITIES 为空 -> 通过 IP 找主连接
// 子连接情形:CAPABILITIES 为空 -> 找主连接。
// 优先按 clientID 精确查:子连接经 TOKEN_CONN_AUTH 校验后 SetID(pkt->clientID)
// 已把 clientID 钉成主连接 ID,FRP/NAT 反代下 socket peer 是 127.0.0.1/内网地址
// 也能命中,不再依赖 peer IP 反查。
if (g_2015RemoteDlg) {
context* mainCtx = g_2015RemoteDlg->FindHostByIP(ctx->GetPeerName());
context* mainCtx = g_2015RemoteDlg->FindHostByClientID(ctx->GetClientID());
if (!mainCtx) {
// 老客户端子连接不发 TOKEN_CONN_AUTH(clientID 未钉,为 0),
// 回退到 peer IP 反查(直连场景仍可命中)。
mainCtx = g_2015RemoteDlg->FindHostByIP(ctx->GetPeerName());
}
if (mainCtx) {
if (mainCtx->SupportsUtf8()) return CP_UTF8;
CString mainType = mainCtx->GetAdditionalData(RES_CLIENT_TYPE);
@@ -648,6 +663,9 @@ CMy2015RemoteDlg::CMy2015RemoteDlg(CWnd* pParent): CDialogLangEx(CMy2015RemoteDl
m_bmOnline[60].LoadBitmap(IDB_BITMAP_DEBUG);
m_bmOnline[61].LoadBitmap(IDB_BITMAP_CLIENTLOG);
m_bmOnline[62].LoadBitmap(IDB_BITMAP_FORBIDDEN);
m_bmOnline[63].LoadBitmap(IDB_BITMAP_HISTORY_ACTIVITY); // 历史活动
m_bmOnline[64].LoadBitmap(IDB_BITMAP_VIEW_WND); // 浏览窗口
m_bmOnline[65].LoadBitmap(IDB_BITMAP_PROXY_AUTORUN); // 代理端口-自启
for (int i = 0; i < PAYLOAD_MAXTYPE; i++) {
m_ServerDLL[i] = nullptr;
m_ServerBin[i] = nullptr;
@@ -938,6 +956,9 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_MSGLOG_COPY, &CMy2015RemoteDlg::OnMsglogCopy)
ON_COMMAND(ID_MSGLOG_CLEAR, &CMy2015RemoteDlg::OnMsglogClear)
ON_COMMAND(ID_MSGLOG_SEARCH, &CMy2015RemoteDlg::OnMsglogSearch)
ON_COMMAND(ID_MSGLOG_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnMsglogLoginNotify)
ON_COMMAND(ID_MSGLOG_BAN_IP, &CMy2015RemoteDlg::OnMsglogBanIP)
ON_COMMAND(ID_MSGLOG_UNBAN_IP, &CMy2015RemoteDlg::OnMsglogUnbanIP)
ON_COMMAND(ID_ONLINE_ADD_WATCH, &CMy2015RemoteDlg::OnOnlineAddWatch)
ON_COMMAND(ID_ONLINE_LOGIN_NOTIFY, &CMy2015RemoteDlg::OnOnlineLoginNotify)
ON_NOTIFY(NM_CUSTOMDRAW, IDC_ONLINE, &CMy2015RemoteDlg::OnNMCustomdrawOnline)
@@ -965,6 +986,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_SHELLCODE_TEST_AES_BIN, &CMy2015RemoteDlg::OnShellcodeTestAesBin)
ON_COMMAND(ID_TOOL_RELOAD_PLUGINS, &CMy2015RemoteDlg::OnToolReloadPlugins)
ON_COMMAND(ID_TOOL_PLUGIN_SETTINGS, &CMy2015RemoteDlg::OnToolPluginSettings)
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
ON_COMMAND(ID_TRIGGER_SETTINGS, &CMy2015RemoteDlg::OnTriggerSettings)
ON_COMMAND(ID_SHELLCODE_AES_C_ARRAY, &CMy2015RemoteDlg::OnShellcodeAesCArray)
ON_COMMAND(ID_PARAM_KBLOGGER, &CMy2015RemoteDlg::OnParamKblogger)
@@ -1010,6 +1032,7 @@ BEGIN_MESSAGE_MAP(CMy2015RemoteDlg, CDialogEx)
ON_COMMAND(ID_ONLINE_VIEW_WND, &CMy2015RemoteDlg::OnOnlineWindowManager)
ON_COMMAND(ID_ENABLE_DEV_DEBUG, &CMy2015RemoteDlg::OnEnableDevDebug)
ON_COMMAND(ID_ONLINE_CLIENT_LOG, &CMy2015RemoteDlg::OnOnlineClientLog)
ON_COMMAND(ID_ONLINE_HISTORY_ACTIVITY, &CMy2015RemoteDlg::OnOnlineHistoryActivity)
ON_COMMAND(ID_ONLINE_FORBIDDEN, &CMy2015RemoteDlg::OnOnlineForbidden)
END_MESSAGE_MAP()
@@ -1205,13 +1228,63 @@ VOID CMy2015RemoteDlg::CreateSolidMenu()
pExtMenu->DeleteMenu(ID_BACKUP_DATA, MF_BYCOMMAND);
if (SHOULD_HIDE_MENU(HIDE_MENU_IMPORT_DATA, MF_IMPORT_DATA))
pExtMenu->DeleteMenu(ID_IMPORT_DATA, MF_BYCOMMAND);
if (SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS))
pExtMenu->DeleteMenu(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND);
if (SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST))
pExtMenu->DeleteMenu(ID_PLUGIN_REQUEST, MF_BYCOMMAND);
if (SHOULD_HIDE_MENU(HIDE_MENU_FRPS_FOR_SUB, MF_FRPS_FOR_SUB))
pExtMenu->DeleteMenu(ID_FRPS_FOR_SUB, MF_BYCOMMAND);
// 动态插入「MCP设置」菜单项(2015Remote.rc 为 UTF-16 不便编辑,改运行时插入,
// 位于「地理信息」之后、「插件设置」之前,见 docs/Mcp_Design.md §3.8.1)
if (!SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS)) {
UINT mcpFlags = MF_BYCOMMAND;
if (THIS_CFG.GetInt("settings", "McpEnabled", 0)) {
mcpFlags |= MF_CHECKED; // 已启用 MCP 则菜单打勾
}
pExtMenu->InsertMenu(ID_TOOL_PLUGIN_SETTINGS, mcpFlags,
ID_MCP_SETTINGS, _TR("MCP设置(&M)..."));
}
// 将 3 个插件相关菜单项收拢到「插件」二级菜单(位于「MCP设置」之下、原「插件设置」之前,
// 顺序不变:插件设置 / 刷新插件 / 获取插件)。
// 注意:先删除顶层项、再按位置插入子菜单,避免同一命令 ID 同时存在于子菜单与顶层时,
// MF_BYCOMMAND 的 DeleteMenu/InsertMenu 因重复 ID 而误删/误定位子菜单项。
{
// 记录「插件设置」的索引,作为「插件」子菜单的插入点
int pluginPos = -1;
for (UINT i = 0; i < pExtMenu->GetMenuItemCount(); i++) {
if (pExtMenu->GetMenuItemID(i) == ID_TOOL_PLUGIN_SETTINGS) {
pluginPos = (int)i;
break;
}
}
if (pluginPos >= 0) {
// 先删除 3 个顶层项
pExtMenu->DeleteMenu(ID_TOOL_PLUGIN_SETTINGS, MF_BYCOMMAND);
pExtMenu->DeleteMenu(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND);
pExtMenu->DeleteMenu(ID_PLUGIN_REQUEST, MF_BYCOMMAND);
// 再创建「插件」子菜单
CMenu pluginMenu;
pluginMenu.CreatePopupMenu();
pluginMenu.AppendMenu(MF_STRING, ID_TOOL_PLUGIN_SETTINGS, _TR("插件设置(&S)"));
if (!SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS))
pluginMenu.AppendMenu(MF_STRING, ID_TOOL_RELOAD_PLUGINS, _TR("刷新插件(&P)"));
if (!SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST))
pluginMenu.AppendMenu(MF_STRING, ID_PLUGIN_REQUEST, _TR("获取插件(&P)"));
// 继承原 3 项的菜单图标(见上方 SetMenuItemBitmaps 区)
pluginMenu.SetMenuItemBitmaps(ID_TOOL_PLUGIN_SETTINGS, MF_BYCOMMAND, &m_bmOnline[53], &m_bmOnline[53]);
if (!SHOULD_HIDE_MENU(HIDE_MENU_RELOAD_PLUGINS, MF_RELOAD_PLUGINS))
pluginMenu.SetMenuItemBitmaps(ID_TOOL_RELOAD_PLUGINS, MF_BYCOMMAND, &m_bmOnline[43], &m_bmOnline[43]);
if (!SHOULD_HIDE_MENU(HIDE_MENU_PLUGIN_REQUEST, MF_PLUGIN_REQUEST))
pluginMenu.SetMenuItemBitmaps(ID_PLUGIN_REQUEST, MF_BYCOMMAND, &m_bmOnline[44], &m_bmOnline[44]);
// 插入到原「插件设置」位置(MF_BYPOSITION)
pExtMenu->InsertMenu((UINT)pluginPos, MF_BYPOSITION | MF_POPUP,
(UINT_PTR)pluginMenu.GetSafeHmenu(), _TR("插件(&P)"));
pluginMenu.Detach(); // HMENU 所有权转交 pExtMenu,避免局部析构销毁子菜单
}
}
// --- 扩展菜单 - 语言设置子菜单 ---
CMenu* pLangMenu = FindSubMenuByCommand(pExtMenu, ID_CHANGE_LANG);
if (pLangMenu) {
@@ -1412,6 +1485,13 @@ VOID CMy2015RemoteDlg::InitControl()
g_Column_Online_Width+=g_Column_Data_Online[i].nWidth;
}
m_CList_Online.InitColumns();
LoadSortPreference(); // 加载上次记忆的排序偏好
// 隐藏空标题列(内部数据槽,不允许用户拖拽显示)
for (int i = 0; i < g_Column_Count_Online; ++i) {
if (g_Column_Data_Online[i].szTitle[0] == '\0') {
m_CList_Online.SetColumnVisible(i, FALSE);
}
}
// 让虚拟列表用 Unicode 通知(LVN_GETDISPINFOW),从而绕开 MBCS 工程里
// 列表 → ANSI → CP_ACP 的回转,这样在德语/日语等非中文 ACP 服务端上也能显示中文。
m_CList_Online.SendMessage(LVM_SETUNICODEFORMAT, TRUE, 0);
@@ -1487,8 +1567,8 @@ VOID CMy2015RemoteDlg::AddList(CString strIP, CString strAddr, CString strPCName
CString privilegeStr = privUser == "SYSTEM" ? "SYSTEM" : privAdmin == "1" ? "Admin" : "User";
CString data[ONLINELIST_MAX] = { strIP, strAddr, "", strPCName, strOS, strCPU, strVideo, strPing,
verDisplay, privilegeStr, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr,
verDisplay, install, startTime, v[RES_CLIENT_TYPE].empty() ? "?" : v[RES_CLIENT_TYPE].c_str(), path,
v[RES_CLIENT_PUBIP].empty() ? strIP : v[RES_CLIENT_PUBIP].c_str(), startTime, capStr, privilegeStr,
};
// 优先采用客户端自报的 ID(新客户端用 V2 算法 = MachineGuid + 归一化路径,
// 比服务端按老算法 IP+PC+OS+CPU+PATH 重算更稳定)。
@@ -2061,6 +2141,34 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
}
}
// Start MCP service(默认禁用;经「扩展 → MCP设置」开启后监听,见 docs/Mcp_Design.md §3.7)
if (THIS_CFG.GetInt("settings", "McpEnabled", 0)) {
auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544);
auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
// token 优先顺序:env BRAND_MCP_ENV_VAR → THIS_CFG McpToken → 皆空则随机(仅本次进程有效)
const char* envTok = getenv(BRAND_MCP_ENV_VAR);
std::string mcpToken;
if (envTok && *envTok) {
mcpToken = envTok;
} else {
mcpToken = THIS_CFG.GetStr("settings", "McpToken", "");
}
if (mcpToken.empty()) {
mcpToken = GenerateRandomToken();
Mprintf("[McpServer] %s / McpToken 均未设置,本次进程随机 token:%s\n",
BRAND_MCP_ENV_VAR, mcpToken.c_str());
}
McpServer().SetParentDlg(this);
McpServer().SetToken(mcpToken);
if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
} else {
Mprintf("McpServer started on %s:%d\n", mcpBind.c_str(), mcpPort);
}
}
UPDATE_SPLASH(18, "正在初始化文件上传模块...");
int ret = InitFileUpload({}, {}, GetHMAC(), 64, 50, Logf, GetUpperHash());
g_hKeyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, AfxGetInstanceHandle(), 0);
@@ -3166,6 +3274,12 @@ void CMy2015RemoteDlg::ApplyFrpSettings()
auto arr = StringToVector(ports, ';');
int fileServerPort = THIS_CFG.GetInt("settings", "WebSvrPort", 8080);
// MCP 端口:仅当「启用 MCP 且绑定 0.0.0.0」时纳入 FRP 暴露(与监听/Web 取并集,不覆盖)。
int mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0);
std::string mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
int mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544);
bool exposeMcp = (mcpEnabled != 0) && (mcpBind == "0.0.0.0") && (mcpPort > 0);
// 为每个服务端生成独立配置文件 (index=0 用 frpc.ini 保持兼容)
for (size_t idx = 0; idx < servers.size(); ++idx) {
std::string path = (idx == 0) ? basePath : baseNoExt + "." + std::to_string(idx) + ".ini";
@@ -3195,6 +3309,14 @@ void CMy2015RemoteDlg::ApplyFrpSettings()
cfg.SetInt(name, "remote_port", fileServerPort);
cfg.SetStr(name, "proxy_protocol_version", "v2"); // 传递真实客户端 IP
}
if (exposeMcp) {
std::string name = BRAND_NET_PREFIX "-MCP-" + std::to_string(mcpPort);
cfg.SetStr(name, "type", "tcp");
cfg.SetInt(name, "local_port", mcpPort);
cfg.SetInt(name, "remote_port", mcpPort);
// 不设置 proxy_protocol_version:MCP 由 httplib 提供、不解析 Proxy Protocol,
// 且 MCP 以 Bearer token 鉴权、无需透传真实 IP(监听/Web 端口才需要 PP2)。
}
}
}
@@ -3473,6 +3595,7 @@ void CMy2015RemoteDlg::OnTimer(UINT_PTR nIDEvent)
// 虚拟列表:重建过滤索引并更新项目计数
if (hasListChange) {
ApplySort(); // 有排序偏好时重排,让新上线主机落到排序位置
RebuildFilteredIndices();
m_CList_Online.SetItemCountExV((int)m_FilteredIndices.size(), LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL);
}
@@ -3785,6 +3908,12 @@ void CMy2015RemoteDlg::Release()
WebService().Stop();
}
// Stop MCP service
if (McpServer().IsRunning()) {
Mprintf("Stopping McpServer...\n");
McpServer().Stop();
}
UninitFileUpload();
DeletePopupWindow(TRUE);
CloseAllLoopTips(); // 关闭所有"播放快照"循环窗口
@@ -3867,8 +3996,6 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
// 缩略图列点击表头不参与排序 —— 没有可比较的数据维度,避免误触发
if (ColumnToDataSlot(nColumn) < 0) return;
static int m_nSortColumn = 0;
static bool m_bSortAscending = false;
if (nColumn == m_nSortColumn) {
// 如果点击的是同一列,切换排序顺序
m_bSortAscending = !m_bSortAscending;
@@ -3878,8 +4005,25 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
m_bSortAscending = true;
}
// 虚拟列表:对数据源进行排序(列号映射到底层数据槽)
EnterCriticalSection(&m_cs);
ApplySort();
RebuildFilteredIndices();
LeaveCriticalSection(&m_cs);
// 刷新列表
m_CList_Online.Invalidate();
SaveSortPreference();
}
void CMy2015RemoteDlg::ApplySort()
{
// 无有效排序偏好或列不合法(缩略图/越界)时保持列表原始顺序
if (m_nSortColumn <= COL_THUMBNAIL || m_nSortColumn >= g_Column_Count_Online) {
return;
}
// 虚拟列表:对数据源进行排序(列号映射到底层数据槽),调用方需持有 m_cs
int slot = ColumnToDataSlot(m_nSortColumn);
bool asc = m_bSortAscending;
std::sort(m_HostList.begin(), m_HostList.end(),
@@ -3890,19 +4034,38 @@ void CMy2015RemoteDlg::SortByColumn(int nColumn)
return asc ? (result < 0) : (result > 0);
});
// 重建索引映射
// 排序后 clientID -> 索引映射已改变,需重建
m_ClientIndex.clear();
for (size_t i = 0; i < m_HostList.size(); ++i) {
if (m_HostList[i]) {
m_ClientIndex[m_HostList[i]->GetClientID()] = i;
}
}
// 重建过滤索引
RebuildFilteredIndices();
LeaveCriticalSection(&m_cs);
}
// 刷新列表
m_CList_Online.Invalidate();
void CMy2015RemoteDlg::LoadSortPreference()
{
std::string val = THIS_CFG.GetStr("list", "OnlineListSort", "");
if (val.empty()) {
m_nSortColumn = -1; // 无偏好:保持列表原始顺序
m_bSortAscending = true;
return;
}
auto arr = StringToVector(val, ',');
if (arr.size() >= 2) {
m_nSortColumn = atoi(arr[0].c_str());
m_bSortAscending = (atoi(arr[1].c_str()) != 0);
} else {
m_nSortColumn = -1;
m_bSortAscending = true;
}
}
void CMy2015RemoteDlg::SaveSortPreference()
{
std::string val = std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0");
THIS_CFG.SetStr("list", "OnlineListSort", val);
}
void CMy2015RemoteDlg::OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult)
@@ -4109,6 +4272,8 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult)
Menu.SetMenuItemBitmaps(ID_ONLINE_ACTIVE_WND, MF_BYCOMMAND, &m_bmOnline[59], &m_bmOnline[59]);
Menu.SetMenuItemBitmaps(ID_ONLINE_CLIENT_LOG, MF_BYCOMMAND, &m_bmOnline[61], &m_bmOnline[61]);
Menu.SetMenuItemBitmaps(ID_ONLINE_FORBIDDEN, MF_BYCOMMAND, &m_bmOnline[62], &m_bmOnline[62]);
Menu.SetMenuItemBitmaps(ID_ONLINE_VIEW_WND, MF_BYCOMMAND, &m_bmOnline[64], &m_bmOnline[64]);
Menu.SetMenuItemBitmaps(ID_PROXY_PORT_AUTORUN, MF_BYCOMMAND, &m_bmOnline[65], &m_bmOnline[65]);
Menu.ModifyMenuL(ID_ONLINE_AUTHORIZE, MF_BYCOMMAND | MF_STRING, ID_ONLINE_AUTHORIZE, _T("发送授权"));
@@ -4200,6 +4365,13 @@ void CMy2015RemoteDlg::OnNMRClickOnline(NMHDR *pNMHDR, LRESULT *pResult)
}
}
// 客户管理子菜单:新增“历史活动”
CMenu* pClientMenu = FindSubMenuByCommand(SubMenu, ID_ONLINE_ASSIGN_TO);
if (pClientMenu) {
pClientMenu->InsertMenu(0, MF_BYPOSITION | MF_STRING, ID_ONLINE_HISTORY_ACTIVITY, _TR("历史活动"));
pClientMenu->SetMenuItemBitmaps(ID_ONLINE_HISTORY_ACTIVITY, MF_BYCOMMAND, &m_bmOnline[63], &m_bmOnline[63]);
}
// 创建一个新的子菜单
CMenu newMenu;
if (!newMenu.CreatePopupMenu()) {
@@ -5308,6 +5480,23 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
}
break;
}
case TOKEN_REPORT_ACTIVITY: {
// P2b:MCP 挂起时接管。历史活动为「主连接 RPC」(无子链接),响应回主 context,
// 不能 CancelIO(会断开主连接),仅同步拷贝后 break。
uint64_t devId = ContextObject->GetClientID();
if (McpServer().IsPending(devId)) {
McpServer().TakeMainResponse(devId, ContextObject->GetBuffer(0), ContextObject->GetBufferLength());
break;
}
// 一次性快照:直接打开“历史活动”对话框展示,不占用 hDlg(避免与运行日志对话框冲突)
std::string text((char*)(szBuffer + 1), len > 1 ? len - 1 : 0);
CActivityDialog* dlg = new CActivityDialog(this, ContextObject->GetServer(), ContextObject);
dlg->Create(IDD_DIALOG_CLIENT_LOG, GetDesktopWindow());
dlg->ShowWindow(SW_SHOW);
if (!text.empty())
dlg->AppendLog(text);
break;
}
case TOKEN_CLIENT_MSG: {
ClientMsg *msg =(ClientMsg*)ContextObject->InDeCompressedBuffer.GetBuffer(0);
PostMessageA(WM_SHOWERRORMSG, (WPARAM)new CString(_L(msg->text)), (LPARAM)new CString(_L(msg->title)));
@@ -6177,6 +6366,14 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
}
case TOKEN_WSLIST: // 窗口管理【x】
case TOKEN_PSLIST: { // 进程管理【x】
// P2b:MCP 挂起时接管。进程/窗口列表无请求 id,按 device_id 匹配并同步拷贝
// (此时 IO 线程阻塞在 NotifyProc,缓冲尚未被覆盖,拷贝安全)。
uint64_t devId = ContextObject->GetClientID();
if (McpServer().IsPending(devId)) {
McpServer().TakeMainResponse(devId, ContextObject->GetBuffer(0), ContextObject->GetBufferLength());
ContextObject->CancelIO(); // 用完即关,避免子链接泄漏(仿 WebService 终端关闭)
break;
}
g_2015RemoteDlg->SendMessage(WM_OPENSYSTEMDIALOG, 0, (LPARAM)ContextObject);
break;
}
@@ -9293,11 +9490,22 @@ void CMy2015RemoteDlg::OnRClickMessage(NMHDR* pNMHDR, LRESULT* pResult)
menu.AppendMenu(MF_SEPARATOR);
menu.AppendMenu(MF_STRING, ID_MSGLOG_SEARCH,
m_bLogSearchVisible ? _TR("隐藏搜索") : _TR("搜索日志"));
menu.AppendMenu(MF_SEPARATOR);
menu.AppendMenu(MF_STRING, ID_MSGLOG_LOGIN_NOTIFY, _TR("上线提醒"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_BAN_IP, _TR("封禁IP"));
menu.AppendMenu(MF_STRING, ID_MSGLOG_UNBAN_IP, _TR("解除封禁"));
// 没有选中项时禁用"删除选中"
// 没有选中项时禁用"删除选中"、"复制选中"、"上线提醒"、"封禁IP"和"解除封禁"
if (m_CList_Message.GetSelectedCount() == 0) {
menu.EnableMenuItem(ID_MSGLOG_DELETE, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_COPY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_BAN_IP, MF_GRAYED);
menu.EnableMenuItem(ID_MSGLOG_UNBAN_IP, MF_GRAYED);
}
// PowerShell不可用时禁用"上线提醒"
if (!GetNotifyManager().IsPowerShellAvailable()) {
menu.EnableMenuItem(ID_MSGLOG_LOGIN_NOTIFY, MF_GRAYED);
}
// 列表为空时禁用"清空日志"
if (m_CList_Message.GetItemCount() == 0) {
@@ -9369,6 +9577,306 @@ void CMy2015RemoteDlg::OnMsglogSearch()
OnSize(SIZE_RESTORED, rc.Width(), rc.Height());
}
// Extract the first valid IPv4 address from a log message text.
// Returns true and sets outIP if a valid IPv4 address is found.
static bool ExtractFirstIPFromMessage(const CString& msg, CString& outIP)
{
// Scan the string for patterns like d.d.d.d and validate each segment
int len = msg.GetLength();
for (int i = 0; i < len; i++) {
TCHAR ch = msg[i];
// Look for start of a potential IP: digit
if (ch >= _T('0') && ch <= _T('9')) {
int segments[4] = { -1, -1, -1, -1 };
int segIdx = 0;
int numStart = i;
for (int j = i; j < len && segIdx < 4; j++) {
TCHAR c = msg[j];
if (c >= _T('0') && c <= _T('9')) {
if (numStart < 0) numStart = j;
} else if (c == _T('.')) {
if (numStart < 0) break; // consecutive dots or leading dot
// Parse the number
CString numStr = msg.Mid(numStart, j - numStart);
if (numStr.GetLength() > 3) break; // too many digits
int val = _tstoi(numStr);
if (val > 255) break;
segments[segIdx] = val;
segIdx++;
numStart = -1;
if (segIdx >= 4) break; // too many segments
} else {
break; // non-IP character
}
}
// Check if we have exactly 4 valid segments and the last char ends a number
if (segIdx == 3 && numStart >= 0) {
// Find where the number ends
int numEnd = numStart;
while (numEnd < len && msg[numEnd] >= _T('0') && msg[numEnd] <= _T('9'))
numEnd++;
CString numStr = msg.Mid(numStart, numEnd - numStart);
if (numStr.GetLength() <= 3) {
int val = _tstoi(numStr);
if (val >= 0 && val <= 255) {
segments[3] = val;
// Success: build the IP string
outIP.Format(_T("%d.%d.%d.%d"), segments[0], segments[1], segments[2], segments[3]);
return true;
}
}
}
// Skip past the partial match to continue scanning
// (the outer loop will increment i, which is fine)
}
}
return false;
}
void CMy2015RemoteDlg::OnMsglogLoginNotify()
{
// Get selected log messages and extract IP addresses from them
std::vector<std::string> ipsToAdd;
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
while (pos) {
int row = m_CList_Message.GetNextSelectedItem(pos);
// Column 2 is the message content ("信息内容")
CString msgText = m_CList_Message.GetItemText(row, 2);
CString extractedIP;
if (ExtractFirstIPFromMessage(msgText, extractedIP)) {
std::string ipUtf8 = CT2A(extractedIP, CP_UTF8);
ipsToAdd.push_back(ipUtf8);
}
}
if (ipsToAdd.empty()) {
MessageBoxL("所选日志消息中未检测到有效的 IP 地址", "提示", MB_ICONINFORMATION);
return;
}
// Helper to convert string to lowercase
auto toLower = [](const std::string& str) -> std::string {
std::string result = str;
std::transform(result.begin(), result.end(), result.begin(), ::tolower);
return result;
};
// Use the same deduplication logic as OnOnlineLoginNotify
NotifyConfig config = GetNotifyManager().GetConfig();
NotifyRule& rule = config.GetRule();
// Parse existing keywords into a set for deduplication (case-insensitive)
std::set<std::string> existingKeywordsLower;
std::vector<std::string> existingKeywords;
std::string pattern = rule.matchPattern;
size_t patternPos = 0;
while ((patternPos = pattern.find(';')) != std::string::npos) {
std::string kw = pattern.substr(0, patternPos);
size_t start = kw.find_first_not_of(" \t");
size_t end = kw.find_last_not_of(" \t");
if (start != std::string::npos) {
std::string trimmed = kw.substr(start, end - start + 1);
existingKeywordsLower.insert(toLower(trimmed));
existingKeywords.push_back(trimmed);
}
pattern.erase(0, patternPos + 1);
}
if (!pattern.empty()) {
size_t start = pattern.find_first_not_of(" \t");
size_t end = pattern.find_last_not_of(" \t");
if (start != std::string::npos) {
std::string trimmed = pattern.substr(start, end - start + 1);
existingKeywordsLower.insert(toLower(trimmed));
existingKeywords.push_back(trimmed);
}
}
// Add new IPs if not already present
int addedCount = 0;
for (const auto& ip : ipsToAdd) {
if (existingKeywordsLower.find(toLower(ip)) == existingKeywordsLower.end()) {
existingKeywordsLower.insert(toLower(ip));
existingKeywords.push_back(ip);
addedCount++;
}
}
if (addedCount == 0) {
MessageBoxL("所有解析出的 IP 已在上线提醒列表中", "提示", MB_ICONINFORMATION);
return;
}
// Rebuild match pattern
std::string newPattern;
for (const auto& kw : existingKeywords) {
if (!newPattern.empty()) {
newPattern += ";";
}
newPattern += kw;
}
// Update config (preserve existing columnIndex and triggerType)
rule.matchPattern = newPattern;
rule.enabled = true;
if (rule.triggerType == NOTIFY_TRIGGER_NONE) {
rule.triggerType = NOTIFY_TRIGGER_HOST_ONLINE;
}
GetNotifyManager().SetConfig(config);
GetNotifyManager().SaveConfig();
// Build message with SMTP warning if not configured
CString msg;
msg.Format(_TR("已添加 %d 个 IP 到上线提醒列表"), addedCount);
if (!config.smtp.IsValid()) {
msg += _T("\n\n");
msg += _TR("注意: SMTP 未配置,请先在通知设置中配置邮箱");
}
MessageBoxL(msg, _TR("上线提醒"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnMsglogBanIP()
{
// Get selected log messages and extract IP addresses from them
std::vector<std::string> ipsToAdd;
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
while (pos) {
int row = m_CList_Message.GetNextSelectedItem(pos);
// Column 2 is the message content ("信息内容")
CString msgText = m_CList_Message.GetItemText(row, 2);
CString extractedIP;
if (ExtractFirstIPFromMessage(msgText, extractedIP)) {
std::string ipUtf8 = CT2A(extractedIP, CP_UTF8);
ipsToAdd.push_back(ipUtf8);
}
}
if (ipsToAdd.empty()) {
MessageBoxL("所选日志消息中未检测到有效的 IP 地址", "提示", MB_ICONINFORMATION);
return;
}
// Process each extracted IP
int bannedCount = 0;
int alreadyCount = 0;
int whitelistCount = 0;
for (const auto& ip : ipsToAdd) {
// Cannot ban whitelisted IPs
if (IPWhitelist::getInstance().IsWhitelisted(ip)) {
whitelistCount++;
continue;
}
// Already in blacklist
if (IPBlacklist::getInstance().IsBlacklisted(ip)) {
alreadyCount++;
continue;
}
IPBlacklist::getInstance().AddIP(ip);
bannedCount++;
}
// Persist to config
if (bannedCount > 0) {
std::string blacklistStr = IPBlacklist::getInstance().Export();
THIS_CFG.SetStr("settings", "IPBlacklist", blacklistStr.c_str());
}
// Build result message
CString details;
if (bannedCount > 0) {
details.AppendFormat(_TR("已封禁 %d 个 IP"), bannedCount);
}
if (alreadyCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 已在黑名单中"), alreadyCount);
}
if (whitelistCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("%d 个 IP 在白名单中,已跳过"), whitelistCount);
}
if (details.IsEmpty()) {
details = _TR("未执行任何操作");
}
MessageBoxL(details, _TR("封禁IP"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnMsglogUnbanIP()
{
// Get selected log messages and extract IP addresses from them
std::vector<std::string> ipsToProcess;
POSITION pos = m_CList_Message.GetFirstSelectedItemPosition();
while (pos) {
int row = m_CList_Message.GetNextSelectedItem(pos);
CString msgText = m_CList_Message.GetItemText(row, 2);
CString extractedIP;
if (ExtractFirstIPFromMessage(msgText, extractedIP)) {
std::string ipUtf8 = CT2A(extractedIP, CP_UTF8);
ipsToProcess.push_back(ipUtf8);
}
}
if (ipsToProcess.empty()) {
MessageBoxL("所选日志消息中未检测到有效的 IP 地址", "提示", MB_ICONINFORMATION);
return;
}
int unblacklistedCount = 0;
int clientRestoredCount = 0;
for (const auto& ip : ipsToProcess) {
// 1. Remove from IP blacklist
if (IPBlacklist::getInstance().IsBlacklisted(ip)) {
IPBlacklist::getInstance().RemoveIP(ip);
unblacklistedCount++;
}
// 2. Find all clients matching this IP and restore AUTH_FORBIDDEN to UNAUTHORIZED
for (const auto& kv : m_ClientMap->GetAll()) {
if (strcmp(kv.second.IP, ip.c_str()) == 0
&& kv.second.Authorized == AUTH_FORBIDDEN) {
m_ClientMap->SetClientMapInteger(kv.first, MAP_AUTH, UNAUTHORIZED);
clientRestoredCount++;
}
}
}
// Persist
if (unblacklistedCount > 0) {
std::string blacklistStr = IPBlacklist::getInstance().Export();
THIS_CFG.SetStr("settings", "IPBlacklist", blacklistStr.c_str());
}
if (clientRestoredCount > 0) {
m_ClientMap->SaveToFile(GetDbPath());
}
// Build result message
CString details;
if (unblacklistedCount > 0) {
details.AppendFormat(_TR("已将 %d 个 IP 从黑名单移除"), unblacklistedCount);
}
if (clientRestoredCount > 0) {
if (!details.IsEmpty()) details += _T("\n");
details.AppendFormat(_TR("已恢复 %d 个客户端的授权状态"), clientRestoredCount);
}
if (unblacklistedCount == 0 && clientRestoredCount == 0) {
details = _TR("未找到需要解除封禁的 IP");
}
MessageBoxL(details, _TR("解除封禁"), MB_ICONINFORMATION);
}
void CMy2015RemoteDlg::OnOnlineAddWatch()
{
EnterCriticalSection(&m_cs);
@@ -10372,6 +10880,16 @@ void CMy2015RemoteDlg::OnToolPluginSettings()
dlg.DoModal();
}
void CMy2015RemoteDlg::OnMcpSettings()
{
CMcpSettingsDlg dlg(this);
if (dlg.DoModal() != IDOK) return;
// 保存后重生成 frpc.ini:若已启用 MCP 且绑定 0.0.0.0,则把 MCP 端口纳入 FRP 暴露列表
// (与监听/Web 端口取并集,互不覆盖)。MCP 生效仍需重启,此处仅为下次启动准备配置。
ApplyFrpSettings();
}
void CMy2015RemoteDlg::OnTriggerSettings()
{
if (m_DllList.empty()) {
@@ -11655,6 +12173,12 @@ void CMy2015RemoteDlg::OnOnlineClientLog()
SendSelectedCommand(&cmd, sizeof(BYTE));
}
void CMy2015RemoteDlg::OnOnlineHistoryActivity()
{
BYTE cmd = COMMAND_QUERY_ACTIVITY;
SendSelectedCommand(&cmd, sizeof(BYTE));
}
LRESULT CMy2015RemoteDlg::OnOpenClientLogDialog(WPARAM wParam, LPARAM lParam)
{
std::string* pInitLog = reinterpret_cast<std::string*>(wParam);
+11 -1
View File
@@ -173,6 +173,9 @@ protected:
DECLARE_MESSAGE_MAP()
public:
void SortByColumn(int nColumn);
void ApplySort(); // 按偏好重排 m_HostList(调用方需持有 m_cs)
void LoadSortPreference();
void SaveSortPreference();
afx_msg VOID OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult);
static int CALLBACK CompareFunction(LPARAM lParam1, LPARAM lParam2, LPARAM lParamSort);
template<class T, int id, int Show = SW_SHOW> LRESULT OpenDialog(WPARAM wParam, LPARAM lParam)
@@ -342,6 +345,8 @@ public:
std::vector<context*> m_HostList; // 虚拟列表数据源(全部客户端)
std::unordered_map<uint64_t, size_t> m_ClientIndex; // clientID -> m_HostList 索引映射
std::vector<size_t> m_FilteredIndices; // 当前分组过滤后的索引列表
int m_nSortColumn = -1; // 当前排序列,-1 表示未排序
bool m_bSortAscending = true; // 排序方向(true=升序)
std::set<std::string> m_GroupList;
std::string m_selectedGroup;
std::string m_v2KeyPath; // V2 密钥文件路径
@@ -385,7 +390,7 @@ public:
bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu;
CBitmap m_bmOnline[63];
CBitmap m_bmOnline[66];
uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList;
@@ -564,6 +569,9 @@ public:
afx_msg void OnMsglogCopy();
afx_msg void OnMsglogClear();
afx_msg void OnMsglogSearch();
afx_msg void OnMsglogLoginNotify();
afx_msg void OnMsglogBanIP();
afx_msg void OnMsglogUnbanIP();
afx_msg void OnOnlineAddWatch();
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin();
@@ -630,6 +638,7 @@ public:
afx_msg void OnMasterTrail();
afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl();
afx_msg void OnMcpSettings();
afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
afx_msg void OnMenuCompress();
@@ -642,6 +651,7 @@ public:
afx_msg void OnOnlineActiveWnd();
afx_msg void OnEnableDevDebug();
afx_msg void OnOnlineClientLog();
afx_msg void OnOnlineHistoryActivity();
afx_msg LRESULT OnOpenClientLogDialog(WPARAM wParam, LPARAM lParam);
afx_msg void OnOnlineForbidden();
};
@@ -291,6 +291,7 @@
<ClInclude Include="BuildDlg.h" />
<ClInclude Include="CClientListDlg.h" />
<ClInclude Include="CClientLog.h" />
<ClInclude Include="CActivityDialog.h" />
<ClInclude Include="LogSearchBar.h" />
<ClInclude Include="CDlgFileSend.h" />
<ClInclude Include="CDrawingBoard.h" />
@@ -316,6 +317,7 @@
<ClInclude Include="file\CFileTransferModeDlg.h" />
<ClInclude Include="HideScreenSpyDlg.h" />
<ClInclude Include="HostInfo.h" />
<ClInclude Include="HostJson.h" />
<ClInclude Include="InputDlg.h" />
<ClInclude Include="ZstaPickerDlg.h" />
<ClInclude Include="IOCPKCPServer.h" />
@@ -324,6 +326,8 @@
<ClInclude Include="IPHistoryDlg.h" />
<ClInclude Include="KeyBoardDlg.h" />
<ClInclude Include="LangManager.h" />
<ClInclude Include="McpServer.h" />
<ClInclude Include="McpSettingsDlg.h" />
<ClInclude Include="NetworkDlg.h" />
<ClInclude Include="NotifyConfig.h" />
<ClInclude Include="NotifyManager.h" />
@@ -374,6 +378,7 @@
<ItemGroup>
<ClCompile Include="..\..\client\Audio.cpp" />
<ClCompile Include="CClientLog.cpp" />
<ClCompile Include="CActivityDialog.cpp" />
<ClCompile Include="LogSearchBar.cpp" />
<ClCompile Include="msvc_compat.c">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">NotUsing</PrecompiledHeader>
@@ -385,6 +390,9 @@
<ClCompile Include="PreviewTipWnd.cpp" />
<ClCompile Include="TriggerSettingsDlg.cpp" />
<ClCompile Include="WebService.cpp" />
<ClCompile Include="HostJson.cpp" />
<ClCompile Include="McpServer.cpp" />
<ClCompile Include="McpSettingsDlg.cpp" />
<ClCompile Include="..\..\client\MemoryModule.c">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|x64'">NotUsing</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">NotUsing</PrecompiledHeader>
@@ -84,6 +84,9 @@
<ClCompile Include="PluginSettingsDlg.cpp" />
<ClCompile Include="PreviewTipWnd.cpp" />
<ClCompile Include="TriggerSettingsDlg.cpp" />
<ClCompile Include="HostJson.cpp" />
<ClCompile Include="McpServer.cpp" />
<ClCompile Include="McpSettingsDlg.cpp" />
<ClCompile Include="CClientLog.cpp" />
</ItemGroup>
<ItemGroup>
@@ -164,6 +167,7 @@
<ClInclude Include="CDlgFileSend.h" />
<ClInclude Include="..\..\client\reg_startup.h" />
<ClInclude Include="HostInfo.h" />
<ClInclude Include="HostJson.h" />
<ClInclude Include="CClientListDlg.h" />
<ClInclude Include="context.h" />
<ClInclude Include="CUpdateDlg.h" />
@@ -189,6 +193,8 @@
<ClInclude Include="PluginSettingsDlg.h" />
<ClInclude Include="PreviewTipWnd.h" />
<ClInclude Include="TriggerSettingsDlg.h" />
<ClInclude Include="McpServer.h" />
<ClInclude Include="McpSettingsDlg.h" />
<ClInclude Include="CClientLog.h" />
</ItemGroup>
<ItemGroup>
+98
View File
@@ -0,0 +1,98 @@
// CActivityDialog.cpp: 历史活动展示对话框(一次性快照,无子连接)
#include "stdafx.h"
#include "afxdialogex.h"
#include "CActivityDialog.h"
#include "resource.h"
#include "../../common/commands.h"
IMPLEMENT_DYNAMIC(CActivityDialog, CDialogEx)
CActivityDialog::CActivityDialog(CWnd* pParent, Server* pServer, CONTEXT_OBJECT* pContext)
: CDialogBase(IDD_DIALOG_CLIENT_LOG, pParent, pServer, pContext, 0)
{
}
CActivityDialog::~CActivityDialog()
{
}
void CActivityDialog::DoDataExchange(CDataExchange* pDX)
{
CDialogBase::DoDataExchange(pDX);
DDX_Control(pDX, IDC_EDIT_LOG, m_editLog);
}
BEGIN_MESSAGE_MAP(CActivityDialog, CDialogBase)
ON_WM_SIZE()
END_MESSAGE_MAP()
BOOL CActivityDialog::OnInitDialog()
{
CDialogBase::OnInitDialog();
SetWindowText(_TR("历史活动") + " - " + m_IPAddress);
HICON hIcon = AfxGetApp()->LoadIcon(IDI_CLIENTLOG);
SetIcon(hIcon, TRUE);
SetIcon(hIcon, FALSE);
// 用 Segoe UI:MBCS 工程里模板默认字体是 Microsoft Sans Serif(无 Dingbats 字形),
// 显式指定 Segoe UI,中文走字体链接到微软雅黑、Dingbats(✻/✦)走 Segoe UI Symbol 回退。
m_font.CreatePointFont(90, _T("Segoe UI"));
m_editLog.SetFont(&m_font);
// 重建为 Unicode 编辑框:这是 ✻/✦ 能正确显示的关键。重建时已保留上面的字体。
RebuildEdit(m_editLog);
return TRUE;
}
void CActivityDialog::RebuildEdit(CEdit& m_edit)
{
CRect rc;
m_edit.GetWindowRect(&rc);
ScreenToClient(&rc);
DWORD style = m_edit.GetStyle();
DWORD exStyle = m_edit.GetExStyle();
HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0);
UINT ctrlID = m_edit.GetDlgCtrlID();
m_edit.DestroyWindow();
HWND hEdit = ::CreateWindowExW(
exStyle, L"EDIT", L"", style,
rc.left, rc.top, rc.Width(), rc.Height(),
this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID,
AfxGetInstanceHandle(), NULL);
m_edit.Attach(hEdit);
if (hFont)
m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
void CActivityDialog::OnSize(UINT nType, int cx, int cy)
{
CDialogBase::OnSize(nType, cx, cy);
if (m_editLog.GetSafeHwnd() && cx > 0 && cy > 0)
m_editLog.MoveWindow(7, 7, cx - 14, cy - 14);
}
// 快照模式:无子连接,OnReceiveComplete 空实现(纯虚函数必须覆盖)
void CActivityDialog::OnReceiveComplete()
{
}
void CActivityDialog::AppendLog(const std::string& text)
{
if (text.empty()) return;
// \n → \r\n,Windows 编辑框换行需要 \r\n
std::string norm;
norm.reserve(text.size() + 64);
for (size_t i = 0; i < text.size(); ++i) {
if (text[i] == '\n' && (i == 0 || text[i - 1] != '\r'))
norm += '\r';
norm += text[i];
}
// 服务端是 MBCS 工程,编辑框走 A 接口会把 UTF-8 中文当 ANSI 处理导致乱码;
// 与"活动窗口"列一致,用 W 接口显示:先把 UTF-8 转成宽字符,再走 WM_SETTEXTW 写入。
// (编辑框已在 OnInitDialog 中重建为 Unicode 窗口,SetWindowTextW 不会经过 CP_ACP 回转。)
int wlen = MultiByteToWideChar(CP_UTF8, 0, norm.c_str(), (int)norm.size(), NULL, 0);
if (wlen <= 0) return;
std::wstring w(wlen, L'\0');
MultiByteToWideChar(CP_UTF8, 0, norm.c_str(), (int)norm.size(), &w[0], wlen);
::SetWindowTextW(m_editLog.GetSafeHwnd(), w.c_str());
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "afxcmn.h"
#include "IOCPServer.h"
class CActivityDialog : public CDialogBase
{
DECLARE_DYNAMIC(CActivityDialog)
public:
CActivityDialog(CWnd* pParent, Server* pServer, CONTEXT_OBJECT* pContext);
virtual ~CActivityDialog();
virtual void OnReceiveComplete();
void AppendLog(const std::string& text);
protected:
virtual BOOL OnInitDialog();
virtual void DoDataExchange(CDataExchange* pDX);
afx_msg void OnSize(UINT nType, int cx, int cy);
DECLARE_MESSAGE_MAP()
private:
// 把编辑框重建为 Unicode 类窗口(同 CKeyBoardDlg::RebuildEdit)。
// 工程是 MBCS,对话框模板创建的是 ANSI 编辑框,SetWindowTextW 会在 W->A
// 边界用 CP_ACP 转码,Dingbats(✻/✦)等字符会变 ?。重建为 Unicode 后
// W 版消息直通,不再走 CP_ACP。
void RebuildEdit(CEdit& m_edit);
CEdit m_editLog;
CFont m_font; // 编辑框字体(成员持有,随对话框销毁,避免堆泄漏)
#ifdef AFX_DESIGN_TIME
enum { IDD = IDD_DIALOG_CLIENT_LOG };
#endif
};
+3 -1
View File
@@ -88,7 +88,9 @@ typedef struct FeatureFlags {
#define MF_MASTER_TRAIL (1ULL << 41) // HIDE_MENU_MASTER_TRAIL
#define MF_REQUEST_AUTH (1ULL << 42) // HIDE_MENU_REQUEST_AUTH
// [43-63] Reserved
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
// [44-63] Reserved
// ============================================================
+117
View File
@@ -0,0 +1,117 @@
#include "stdafx.h"
#include "HostJson.h"
#include "HostInfo.h" // _ClientList / MAP_NOTE
#include "context.h" // context 接口 + ONLINELIST_* 枚举
#include "2015RemoteDlg.h" // GetClientEncoding 辅助函数(UTF-8 能力位判断)
#ifndef _WIN64
#ifdef _DEBUG
#pragma comment(lib, "jsoncpp/jsoncppd.lib")
#else
#pragma comment(lib, "jsoncpp/jsoncpp.lib")
#endif
#else
#ifdef _DEBUG
#pragma comment(lib, "jsoncpp/jsoncpp_x64d.lib")
#else
#pragma comment(lib, "jsoncpp/jsoncpp_x64.lib")
#endif
#endif
// Helper: Convert ANSI (GBK) string to UTF-8
// (自 WebService.cpp 抽出,供 BuildDeviceListJson 与 BuildHostJson 共用)
static std::string AnsiToUtf8(const CString& str) {
if (str.IsEmpty()) return "";
#ifdef _UNICODE
// Unicode build: CString is already UTF-16, convert to UTF-8
int len = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL);
if (len <= 0) return "";
std::string result(len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, str, -1, &result[0], len, NULL, NULL);
return result;
#else
// MBCS build: CString is ANSI (GBK), need to convert to UTF-16 first, then to UTF-8
int wlen = MultiByteToWideChar(CP_ACP, 0, str, -1, NULL, 0);
if (wlen <= 0) return "";
std::wstring wstr(wlen - 1, L'\0');
MultiByteToWideChar(CP_ACP, 0, str, -1, &wstr[0], wlen);
int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
if (u8len <= 0) return "";
std::string result(u8len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &result[0], u8len, NULL, NULL);
return result;
#endif
}
Json::Value BuildHostJson(context* ctx, _ClientList* clientMap) {
Json::Value device;
if (!ctx) return device; // 空 context → 返回 null Json::Value
// Use string for ID to avoid JavaScript number precision loss
device["id"] = std::to_string(ctx->GetClientID());
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
device["name"] = AnsiToUtf8(name);
// 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE)
if (clientMap) {
CString remark = clientMap->GetClientMapData(ctx->GetClientID(), MAP_NOTE);
if (!remark.IsEmpty()) {
device["remark"] = AnsiToUtf8(remark);
}
}
CString ip = ctx->GetClientData(ONLINELIST_IP);
device["ip"] = AnsiToUtf8(ip);
CString os = ctx->GetClientData(ONLINELIST_OS);
device["os"] = AnsiToUtf8(os);
CString location = ctx->GetClientData(ONLINELIST_LOCATION);
device["location"] = AnsiToUtf8(location);
CString rtt = ctx->GetClientData(ONLINELIST_PING);
device["rtt"] = AnsiToUtf8(rtt);
CString version = ctx->GetClientData(ONLINELIST_VERSION);
device["version"] = AnsiToUtf8(version);
// 活动窗口编码由客户端能力位决定:新客户端是 UTF-8,老客户端是 CP_ACP(默认 936)。
// 不能像其它字段那样无脑 AnsiToUtf8——会把新客户端的 UTF-8 字节再当 GBK 双重编码。
CString activeWindow = ctx->GetClientData(ONLINELIST_LOGINTIME);
std::string activeWindowU8;
if (!activeWindow.IsEmpty()) {
UINT cp = GetClientEncoding(ctx);
int wlen = MultiByteToWideChar(cp, 0, activeWindow, -1, NULL, 0);
if (wlen > 1) {
std::wstring w(wlen - 1, L'\0');
MultiByteToWideChar(cp, 0, activeWindow, -1, &w[0], wlen);
int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL);
if (u8len > 1) {
activeWindowU8.resize(u8len - 1);
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &activeWindowU8[0], u8len, NULL, NULL);
}
}
}
device["activeWindow"] = activeWindowU8;
device["online"] = true;
// Add device group to response
std::string deviceGroup = ctx->GetGroupName();
if (deviceGroup.empty()) deviceGroup = "default";
device["group"] = deviceGroup;
// Get screen info from client's reported resolution
// Format: "n:MxN" where n=monitor count, M=width, N=height
CString resolution = ctx->GetAdditionalData(RES_RESOLUTION);
if (!resolution.IsEmpty()) {
device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080"
}
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE"
return device;
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include "jsoncpp/json.h"
class context;
class _ClientList;
// 公共函数:将单台在线主机 context 序列化为 JSON(UTF-8,含 GBK/UTF-8 编码处理)。
// 供 Web UI(CWebService::BuildDeviceListJson)与 MCP 工具(list_online_hosts)复用,
// 避免两处重复易错的编码转换(见 docs/Mcp_Design.md §3.4 方案 C)。
//
// 职责边界:本函数只负责「单台主机 → Json::Value」的字段序列化与编码转换。
// 分组/用户过滤、m_cs 加锁、外层结构(Web 的 {"cmd","devices"} vs MCP 的 {"hosts"})
// 属于调用方,不进本函数。调用方须在 m_cs 锁内调用。
Json::Value BuildHostJson(context* ctx, _ClientList* clientMap);
+987
View File
@@ -0,0 +1,987 @@
#include "stdafx.h"
#include "McpServer.h"
#include "jsoncpp/json.h"
#include "HostJson.h" // BuildHostJson(单台主机序列化公共函数)
#include "context.h" // context 接口
#include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员(m_HostList/m_cs/m_ClientMap)+ VERSION_STR
#include <sstream>
#ifndef _WIN64
#ifdef _DEBUG
#pragma comment(lib, "jsoncpp/jsoncppd.lib")
#else
#pragma comment(lib, "jsoncpp/jsoncpp.lib")
#endif
#else
#ifdef _DEBUG
#pragma comment(lib, "jsoncpp/jsoncpp_x64d.lib")
#else
#pragma comment(lib, "jsoncpp/jsoncpp_x64.lib")
#endif
#endif
namespace {
// P2b 工具等待响应的超时(ms)。MCP 一次性请求:等待子连接回传进程/窗口列表。
static const int kMcpToolTimeoutMs = 20000;
// Json::Value → 紧凑 JSON 字符串
std::string JsonToString(const Json::Value& v) {
Json::StreamWriterBuilder b;
b["indentation"] = "";
return Json::writeString(b, v);
}
// JSON-RPC 2.0 成功响应
std::string BuildResult(const Json::Value& id, const Json::Value& result) {
Json::Value resp(Json::objectValue);
resp["jsonrpc"] = "2.0";
resp["id"] = id;
resp["result"] = result;
return JsonToString(resp);
}
// JSON-RPC 2.0 错误响应
std::string BuildError(const Json::Value& id, int code, const std::string& msg) {
Json::Value resp(Json::objectValue);
resp["jsonrpc"] = "2.0";
resp["id"] = id;
Json::Value err(Json::objectValue);
err["code"] = code;
err["message"] = msg;
resp["error"] = err;
return JsonToString(resp);
}
// initialize 握手(MCP 规范:protocolVersion + capabilities + serverInfo)
std::string BuildInitializeResult(const Json::Value& id) {
Json::Value result(Json::objectValue);
result["protocolVersion"] = "2025-06-18";
Json::Value caps(Json::objectValue);
caps["tools"] = Json::Value(Json::objectValue);
result["capabilities"] = caps;
Json::Value serverInfo(Json::objectValue);
serverInfo["name"] = "yama";
serverInfo["version"] = VERSION_STR;
result["serverInfo"] = serverInfo;
return BuildResult(id, result);
}
// ping 健康检查:返回空 result
std::string BuildPingResult(const Json::Value& id) {
return BuildResult(id, Json::Value(Json::objectValue));
}
// ========== P2a 通用辅助 ==========
// 小写化(仅 ASCII,UTF-8 多字节原样保留):用于不区分大小写的子串匹配
std::string ToLowerAscii(const std::string& s) {
std::string r = s;
for (char& c : r) if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a');
return r;
}
// 不区分大小写的子串匹配
bool ContainsCI(const std::string& haystack, const std::string& needle) {
if (needle.empty()) return true;
return ToLowerAscii(haystack).find(ToLowerAscii(needle)) != std::string::npos;
}
// 取对象的字符串字段,缺失/非字符串返回 ""
std::string JsonStrField(const Json::Value& v, const char* key) {
if (v.isObject() && v.isMember(key) && v[key].isString())
return v[key].asString();
return "";
}
// 是否纯数字(host id 为 uint64 十进制字符串)
bool IsDigits(const std::string& s) {
if (s.empty()) return false;
for (char c : s) if (c < '0' || c > '9') return false;
return true;
}
// 读取 tools/call 的入参(MCP 规范:params.arguments 为工具入参对象)
Json::Value GetCallArguments(const Json::Value& params) {
if (params.isObject() && params.isMember("arguments") && params["arguments"].isObject())
return params["arguments"];
return Json::Value(Json::objectValue);
}
// 读取可选字符串入参,缺失返回 ""
std::string GetStringArg(const Json::Value& args, const char* key) {
return JsonStrField(args, key);
}
// ========== P2b 辅助 ==========
// 解析 id 入参(必填、纯数字)为 uint64;非法返回 false。
bool ParseHostIdArg(const Json::Value& args, uint64_t& out, std::string& err) {
std::string sid = GetStringArg(args, "id");
if (sid.empty()) {
err = "Missing required parameter: id";
return false;
}
if (!IsDigits(sid)) {
err = "Invalid id: expected a decimal host id string";
return false;
}
out = strtoull(sid.c_str(), nullptr, 10);
return true;
}
// 在 m_HostList 中按 clientID 找在线主 context(复刻 CollectOnlineHosts 的锁内遍历)。
context* FindMainContext(CMy2015RemoteDlg* parent, uint64_t id) {
if (!parent) return nullptr;
context* found = nullptr;
EnterCriticalSection(&parent->m_cs);
for (context* ctx : parent->m_HostList) {
if (ctx && ctx->GetClientID() == id && ctx->IsLogin()) {
found = ctx;
break;
}
}
LeaveCriticalSection(&parent->m_cs);
return found;
}
// 有界 strlen:p 最多可读 avail 字节,返回 n < avail 表示遇到 '\0'。
size_t BoundedStrlen(const char* p, size_t avail) {
size_t n = 0;
while (n < avail && p[n]) ++n;
return n;
}
// 从源编码 cp 转为 UTF-8(输出到 JSON)。空/失败返回 ""。
std::string ToUtf8(const char* s, UINT cp) {
if (!s || !*s) return "";
int wlen = MultiByteToWideChar(cp, 0, s, -1, NULL, 0);
if (wlen <= 0) return "";
std::wstring w(wlen - 1, L'\0');
MultiByteToWideChar(cp, 0, s, -1, &w[0], wlen);
int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL);
if (u8len <= 0) return "";
std::string out(u8len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &out[0], u8len, NULL, NULL);
return out;
}
// 从 "title|status|pid|r1|r2" 解析(自末尾 4 个 '|' 反推),兼容只有标题的老客户端。
// 逻辑复刻 SystemDlg.cpp::ParseWindowAttrs。
void ParseWindowAttrsStr(const char* s, std::string& title, std::string& status, uint64_t& pid) {
title.clear();
status = "normal";
pid = 0;
if (!s || !*s) return;
std::string t(s);
int len = (int)t.size();
int pipePos[4] = { -1, -1, -1, -1 };
int cnt = 0;
for (int i = len - 1; i >= 0 && cnt < 4; --i) {
if (t[i] == '|') pipePos[cnt++] = i;
}
if (cnt < 4) { // 老格式:只有标题
title = t;
return;
}
pid = strtoull(t.c_str() + pipePos[2] + 1, nullptr, 10);
status = t.substr(pipePos[3] + 1, pipePos[2] - pipePos[3] - 1);
title = t.substr(0, pipePos[3]);
}
// 解析 TOKEN_PSLIST 缓冲(data[0]=token,其后为 [pid:4][name:arch\0][path\0] 记录)。
// 进程名/路径来自客户端 ANSI(CP_ACP),转 UTF-8 输出。
Json::Value ParseProcessList(const std::vector<BYTE>& data) {
Json::Value arr(Json::arrayValue);
if (data.size() < 2) return arr;
const char* p = (const char*)data.data();
size_t len = data.size();
size_t off = 1; // 跳过 TOKEN 字节
while (off + sizeof(DWORD) <= len) {
DWORD pid = *(const DWORD*)(p + off);
off += sizeof(DWORD);
const char* exeFile = p + off;
size_t exeLen = BoundedStrlen(exeFile, len - off);
if (exeLen >= len - off) break; // 未以 '\0' 结尾,异常数据
if (exeLen == 0) break; // 空进程名 = 尾部零填充(LocalSize 对齐),停止解析
off += exeLen + 1;
const char* fullPath = p + off;
size_t pathLen = BoundedStrlen(fullPath, len - off);
if (pathLen >= len - off) break;
off += pathLen + 1;
std::string name(exeFile, exeLen);
std::string arch;
size_t colon = name.find(':');
if (colon != std::string::npos) {
arch = name.substr(colon + 1);
name = name.substr(0, colon);
}
Json::Value item(Json::objectValue);
item["pid"] = (Json::UInt64)pid;
item["name"] = ToUtf8(name.c_str(), CP_ACP);
item["arch"] = arch.empty() ? "N/A" : ToUtf8(arch.c_str(), CP_ACP);
item["path"] = ToUtf8(fullPath, CP_ACP);
arr.append(item);
}
return arr;
}
// 解析 TOKEN_WSLIST 缓冲(data[0]=token,其后为 [hwnd:4][title|status|pid|r1|r2\0] 记录)。
// 窗口标题为客户端 UTF-8(老客户端为 CP_ACP),用 cp 解码后转 UTF-8 输出。
Json::Value ParseWindowList(const std::vector<BYTE>& data, UINT cp) {
Json::Value arr(Json::arrayValue);
if (data.size() < 2) return arr;
const char* p = (const char*)data.data();
size_t len = data.size();
size_t off = 1;
while (off + sizeof(DWORD) <= len) {
DWORD hwnd = *(const DWORD*)(p + off);
off += sizeof(DWORD);
const char* titleWithAttrs = p + off;
size_t tlen = BoundedStrlen(titleWithAttrs, len - off);
if (tlen >= len - off) break;
if (tlen == 0) break; // 空记录 = 尾部零填充(LocalSize 对齐),停止解析
off += tlen + 1;
std::string title, status;
uint64_t pid = 0;
ParseWindowAttrsStr(titleWithAttrs, title, status, pid);
Json::Value item(Json::objectValue);
item["hwnd"] = (Json::UInt64)hwnd;
item["title"] = ToUtf8(title.c_str(), cp);
item["status"] = status;
item["pid"] = (Json::UInt64)pid;
arr.append(item);
}
return arr;
}
// 收集所有在线主机 JSON 数组(m_cs 锁内遍历,复用 BuildHostJson 序列化,方案 C)
void CollectOnlineHosts(CMy2015RemoteDlg* parent, Json::Value& hosts) {
if (!parent) return;
EnterCriticalSection(&parent->m_cs);
for (context* ctx : parent->m_HostList) {
if (!ctx || !ctx->IsLogin()) continue;
hosts.append(BuildHostJson(ctx, parent->m_ClientMap));
}
LeaveCriticalSection(&parent->m_cs);
}
// ========== 工具 schema ==========
// 单台主机字段 schema(hosts 数组元素 / 单机详情共用的形状)
Json::Value BuildHostItemSchema() {
Json::Value itemProps(Json::objectValue);
const char* strFields[] = {
"id", "name", "remark", "ip", "os", "location", "rtt",
"version", "activeWindow", "group", "screen", "clientType"
};
for (const char* f : strFields) {
Json::Value p(Json::objectValue);
p["type"] = "string";
itemProps[f] = p;
}
Json::Value onlineProp(Json::objectValue);
onlineProp["type"] = "boolean";
itemProps["online"] = onlineProp;
return itemProps;
}
// list_online_hosts / search_hosts 的 outputSchema(hosts 数组)
Json::Value BuildHostOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value hostsProp(Json::objectValue);
hostsProp["type"] = "array";
Json::Value items(Json::objectValue);
items["type"] = "object";
items["properties"] = BuildHostItemSchema();
hostsProp["items"] = items;
props["hosts"] = hostsProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("hosts");
schema["required"] = required;
return schema;
}
// get_host_detail 的 outputSchema(单台主机)
Json::Value BuildHostDetailOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value hostProp(Json::objectValue);
hostProp["type"] = "object";
hostProp["properties"] = BuildHostItemSchema();
props["host"] = hostProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("host");
schema["required"] = required;
return schema;
}
// search_hosts 的 inputSchema(全部可选)
Json::Value BuildSearchHostsInputSchema() {
Json::Value props(Json::objectValue);
const char* strParams[] = { "name", "ip", "group", "os" };
for (const char* p : strParams) {
Json::Value s(Json::objectValue);
s["type"] = "string";
props[p] = s;
}
Json::Value onlineProp(Json::objectValue);
onlineProp["type"] = "boolean";
props["online"] = onlineProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
return schema;
}
// get_host_detail 的 inputSchema(id 必填)
Json::Value BuildGetHostDetailInputSchema() {
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 schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
schema["required"] = required;
return schema;
}
// list_processes 的 outputSchema(processes 数组)
Json::Value BuildProcessListOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value procsProp(Json::objectValue);
procsProp["type"] = "array";
Json::Value items(Json::objectValue);
items["type"] = "object";
Json::Value itemProps(Json::objectValue);
Json::Value pidProp(Json::objectValue);
pidProp["type"] = "integer";
itemProps["pid"] = pidProp;
const char* strFields[] = { "name", "arch", "path" };
for (const char* f : strFields) {
Json::Value s(Json::objectValue);
s["type"] = "string";
itemProps[f] = s;
}
items["properties"] = itemProps;
procsProp["items"] = items;
props["processes"] = procsProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("processes");
schema["required"] = required;
return schema;
}
// list_windows 的 outputSchema(windows 数组)
Json::Value BuildWindowListOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value winsProp(Json::objectValue);
winsProp["type"] = "array";
Json::Value items(Json::objectValue);
items["type"] = "object";
Json::Value itemProps(Json::objectValue);
Json::Value hwndProp(Json::objectValue);
hwndProp["type"] = "integer";
itemProps["hwnd"] = hwndProp;
Json::Value pidProp(Json::objectValue);
pidProp["type"] = "integer";
itemProps["pid"] = pidProp;
const char* strFields[] = { "title", "status" };
for (const char* f : strFields) {
Json::Value s(Json::objectValue);
s["type"] = "string";
itemProps[f] = s;
}
items["properties"] = itemProps;
winsProp["items"] = items;
props["windows"] = winsProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("windows");
schema["required"] = required;
return schema;
}
// get_activity_history 的 outputSchema(records 数组 + 原始文本)
Json::Value BuildActivityHistoryOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value recordsProp(Json::objectValue);
recordsProp["type"] = "array";
Json::Value items(Json::objectValue);
items["type"] = "string";
recordsProp["items"] = items;
props["records"] = recordsProp;
Json::Value rawProp(Json::objectValue);
rawProp["type"] = "string";
props["activityHistory"] = rawProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
return schema;
}
// tools/list
std::string BuildToolsListResult(const Json::Value& id) {
Json::Value result(Json::objectValue);
Json::Value tools(Json::arrayValue);
// 1) list_online_hosts
{
Json::Value tool(Json::objectValue);
tool["name"] = "list_online_hosts";
// 说明文字为 UTF-8:项目 /execution-charset:.936 会把普通窄字面量编译成 GBK,
// 故用 u8 前缀确保输出到 JSON 的字节是 UTF-8。
tool["description"] = u8"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。";
Json::Value inputSchema(Json::objectValue);
inputSchema["type"] = "object";
inputSchema["properties"] = Json::Value(Json::objectValue);
inputSchema["required"] = Json::Value(Json::arrayValue);
tool["inputSchema"] = inputSchema;
tool["outputSchema"] = BuildHostOutputSchema();
tools.append(tool);
}
// 2) search_hosts(P2a:纯内存过滤,无子链接)
{
Json::Value tool(Json::objectValue);
tool["name"] = "search_hosts";
tool["description"] = u8"按计算机名/备注、IP、分组、操作系统过滤在线主机。所有条件均可选、按 AND 组合;子串匹配(ASCII 不区分大小写)。只返回在线主机。";
tool["inputSchema"] = BuildSearchHostsInputSchema();
tool["outputSchema"] = BuildHostOutputSchema();
tools.append(tool);
}
// 3) get_host_detail(P2a:单机详情,纯内存)
{
Json::Value tool(Json::objectValue);
tool["name"] = "get_host_detail";
tool["description"] = u8"获取单台在线主机的详细信息(id、计算机名、IP、操作系统、备注、分组、活动窗口、屏幕分辨率、客户端类型等)。";
tool["inputSchema"] = BuildGetHostDetailInputSchema();
tool["outputSchema"] = BuildHostDetailOutputSchema();
tools.append(tool);
}
// 4) list_processes(P2b:主连接 RPC,进程列表)
{
Json::Value tool(Json::objectValue);
tool["name"] = "list_processes";
tool["description"] = u8"获取指定在线主机的进程列表(PID、映像名称、架构、程序完整路径)。通过主连接下发命令、子连接回传,一次性返回。";
tool["inputSchema"] = BuildGetHostDetailInputSchema(); // 复用 { id } 必填 schema
tool["outputSchema"] = BuildProcessListOutputSchema();
tools.append(tool);
}
// 5) list_windows(P2b:主连接 RPC,窗口列表)
{
Json::Value tool(Json::objectValue);
tool["name"] = "list_windows";
tool["description"] = u8"获取指定在线主机的顶层窗口列表(句柄、窗口标题、窗口状态、所属进程 PID)。一次性返回。";
tool["inputSchema"] = BuildGetHostDetailInputSchema(); // 复用 { id } 必填 schema
tool["outputSchema"] = BuildWindowListOutputSchema();
tools.append(tool);
}
// 6) get_activity_history(P2b:主连接 RPC,历史活动记录)
{
Json::Value tool(Json::objectValue);
tool["name"] = "get_activity_history";
tool["description"] = u8"获取指定在线主机的历史活动记录(前台窗口驻留时长,每行一条「[时间] [标题] 时长」)。主连接 RPC、一次性返回。";
tool["inputSchema"] = BuildGetHostDetailInputSchema(); // 复用 { id } 必填 schema
tool["outputSchema"] = BuildActivityHistoryOutputSchema();
tools.append(tool);
}
result["tools"] = tools;
return BuildResult(id, result);
}
// tools/call:list_online_hosts
std::string BuildListOnlineHosts(const Json::Value& id, CMy2015RemoteDlg* parent) {
Json::Value hosts(Json::arrayValue);
CollectOnlineHosts(parent, hosts);
int count = (int)hosts.size();
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["hosts"] = hosts;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 台主机在线。");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call:search_hosts
std::string BuildSearchHosts(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
Json::Value all(Json::arrayValue);
CollectOnlineHosts(parent, all);
std::string fName = GetStringArg(args, "name");
std::string fIp = GetStringArg(args, "ip");
std::string fGroup = GetStringArg(args, "group");
std::string fOs = GetStringArg(args, "os");
bool hasOnline = args.isObject() && args.isMember("online") && args["online"].isBool();
bool wantOnline = hasOnline ? args["online"].asBool() : true;
Json::Value hosts(Json::arrayValue);
// 列表只含在线主机:显式 online=false 时直接空结果
if (!hasOnline || wantOnline) {
for (unsigned int i = 0; i < all.size(); ++i) {
const Json::Value& h = all[i];
if (!fName.empty()) {
std::string name = JsonStrField(h, "name");
std::string remark = JsonStrField(h, "remark");
if (!ContainsCI(name, fName) && !ContainsCI(remark, fName)) continue;
}
if (!fIp.empty() && !ContainsCI(JsonStrField(h, "ip"), fIp)) continue;
if (!fGroup.empty() && !ContainsCI(JsonStrField(h, "group"), fGroup)) continue;
if (!fOs.empty() && !ContainsCI(JsonStrField(h, "os"), fOs)) continue;
hosts.append(h);
}
}
int count = (int)hosts.size();
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["hosts"] = hosts;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 台主机匹配。");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call:get_host_detail
std::string BuildGetHostDetail(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
std::string sid = GetStringArg(args, "id");
if (sid.empty()) {
return BuildError(id, -32602, "Missing required parameter: id");
}
if (!IsDigits(sid)) {
return BuildError(id, -32602, "Invalid id: expected a decimal host id string");
}
Json::Value all(Json::arrayValue);
CollectOnlineHosts(parent, all);
for (unsigned int i = 0; i < all.size(); ++i) {
const Json::Value& h = all[i];
if (JsonStrField(h, "id") == sid) {
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["host"] = h;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
std::string name = JsonStrField(h, "name");
std::string ip = JsonStrField(h, "ip");
item["text"] = std::string(u8"主机 ") + name + " (" + ip + ")" + u8" 的详情。";
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
}
return BuildError(id, -32002, "Host not found or offline: " + sid);
}
// tools/call:list_processes(主连接 RPC,子连接一次性回传)
std::string BuildListProcesses(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
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));
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.BeginPending(devId, "list_processes"))
return BuildError(id, -32003, "Device busy: another request is pending for this host");
BYTE cmd = COMMAND_SYSTEM;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
std::vector<BYTE> data;
if (!mcp.WaitPending(devId, data, kMcpToolTimeoutMs))
return BuildError(id, -32001, "Timeout waiting for process list");
Json::Value procs = ParseProcessList(data);
int count = (int)procs.size();
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["processes"] = procs;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 个进程。");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call:list_windows(主连接 RPC,子连接一次性回传)
std::string BuildListWindows(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
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));
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.BeginPending(devId, "list_windows"))
return BuildError(id, -32003, "Device busy: another request is pending for this host");
BYTE cmd = COMMAND_WSLIST;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
std::vector<BYTE> data;
if (!mcp.WaitPending(devId, data, kMcpToolTimeoutMs))
return BuildError(id, -32001, "Timeout waiting for window list");
// 窗口标题编码由客户端能力位决定(新客户端 UTF-8,老客户端 CP_ACP)。
UINT cp = GetClientEncoding(ctx);
Json::Value wins = ParseWindowList(data, cp);
int count = (int)wins.size();
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["windows"] = wins;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 个窗口。");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call:get_activity_history(主连接 RPC,无子链接、不弹框、不 CancelIO)
std::string BuildGetActivityHistory(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
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));
CMcpServer& mcp = CMcpServer::Instance();
if (!mcp.BeginPending(devId, "get_activity_history"))
return BuildError(id, -32003, "Device busy: another request is pending for this host");
BYTE cmd = COMMAND_QUERY_ACTIVITY;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
std::vector<BYTE> data;
if (!mcp.WaitPending(devId, data, kMcpToolTimeoutMs))
return BuildError(id, -32001, "Timeout waiting for activity history");
// data[0]=token,其后为客户端 ActivityHistory::Dump() 的 UTF-8 纯文本
// (标题在客户端已由 GetActiveWindowTitle 转 UTF-8,服务端无需再转码)。
std::string text;
if (data.size() > 1)
text.assign((const char*)data.data() + 1, data.size() - 1);
// 拆行:每行一条记录,过滤空行(含 Dump 末尾的换行)。
Json::Value records(Json::arrayValue);
size_t start = 0;
while (start <= text.size()) {
size_t nl = text.find('\n', start);
std::string line = text.substr(start, (nl == std::string::npos ? text.size() : nl) - start);
if (!line.empty()) records.append(line);
if (nl == std::string::npos) break;
start = nl + 1;
}
int count = (int)records.size();
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["records"] = records;
structuredContent["activityHistory"] = text;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = std::string(u8"共 ") + std::to_string(count) + std::string(u8" 条历史活动记录。");
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/call 分派
std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
const Json::Value& id = root["id"];
Json::Value params = root.isMember("params") ? root["params"] : Json::Value(Json::objectValue);
std::string toolName;
if (params.isObject() && params.isMember("name") && params["name"].isString()) {
toolName = params["name"].asString();
}
const Json::Value args = GetCallArguments(params);
if (toolName == "list_online_hosts") return BuildListOnlineHosts(id, parent);
if (toolName == "search_hosts") return BuildSearchHosts(id, args, parent);
if (toolName == "get_host_detail") return BuildGetHostDetail(id, args, parent);
if (toolName == "list_processes") return BuildListProcesses(id, args, parent);
if (toolName == "list_windows") return BuildListWindows(id, args, parent);
if (toolName == "get_activity_history") return BuildGetActivityHistory(id, args, parent);
return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
}
} // namespace
//////////////////////////////////////////////////////////////////////////
// CMcpServer Implementation
//////////////////////////////////////////////////////////////////////////
CMcpServer& CMcpServer::Instance() {
static CMcpServer instance;
return instance;
}
CMcpServer::CMcpServer() {
m_server.Post("/mcp", [this](const httplib::Request& req, httplib::Response& res) {
HandleMcp(req, res);
});
}
CMcpServer::~CMcpServer() {
Stop(); // 兜底:确保监听线程 join,避免 std::thread 析构触发 terminate
}
bool CMcpServer::Start(const std::string& bind, int port) {
if (m_running.load()) return true; // 已在运行
m_thread = std::thread([this, bind, port]() {
m_server.listen(bind, port);
});
// 给 listen 一点时间绑定端口;httplib::Server::is_running() 在 listen 内部置位。
std::this_thread::sleep_for(std::chrono::milliseconds(100));
m_running.store(m_server.is_running());
return m_running.load();
}
void CMcpServer::Stop() {
m_server.stop();
if (m_thread.joinable()) {
m_thread.join();
}
m_running.store(false);
}
void CMcpServer::HandleMcp(const httplib::Request& req, httplib::Response& res) {
res.set_header("Content-Type", "application/json");
// 静态 token 校验:Authorization: Bearer <token>(Start 前经 SetToken 保证非空)
if (req.get_header_value("Authorization") != ("Bearer " + m_token)) {
res.status = 401;
res.set_content(BuildError(Json::nullValue, -32000, "Unauthorized"), "application/json");
return;
}
// 解析 JSON-RPC 请求体
Json::Value root;
Json::CharReaderBuilder rbuilder;
std::string errs;
std::istringstream iss(req.body);
if (!Json::parseFromStream(rbuilder, iss, &root, &errs) || !root.isObject()) {
res.set_content(BuildError(Json::nullValue, -32700, "Parse error"), "application/json");
return;
}
// 通知(无 id)→ 不返回 JSON-RPC 响应(如 notifications/initialized)
if (!root.isMember("id")) {
res.status = 202;
res.set_content("", "application/json");
return;
}
// 结构校验:缺 method
if (!root.isMember("method") || !root["method"].isString()) {
res.set_content(BuildError(root["id"], -32600, "Invalid Request"), "application/json");
return;
}
std::string method = root["method"].asString();
if (method == "initialize") {
res.set_content(BuildInitializeResult(root["id"]), "application/json");
return;
}
if (method == "ping") {
res.set_content(BuildPingResult(root["id"]), "application/json");
return;
}
if (method == "tools/list") {
res.set_content(BuildToolsListResult(root["id"]), "application/json");
return;
}
if (method == "tools/call") {
res.set_content(BuildToolsCall(root, m_parent), "application/json");
return;
}
// 未实现的方法
res.set_content(BuildError(root["id"], -32601, "Method not found"), "application/json");
}
// ===== P2b 挂起请求注册表实现 =====
bool CMcpServer::IsPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_PendingMutex);
return m_Pending.find(device_id) != m_Pending.end();
}
void CMcpServer::TakeMainResponse(uint64_t device_id, const BYTE* data, ULONG len) {
std::lock_guard<std::mutex> lk(m_PendingMutex);
auto it = m_Pending.find(device_id);
if (it == m_Pending.end()) return; // 已超时清理 → 迟到数据,丢弃
it->second.data.assign(data, data + len);
it->second.done = true;
m_PendingCv.notify_one();
}
bool CMcpServer::BeginPending(uint64_t device_id, const std::string& tool) {
std::lock_guard<std::mutex> lk(m_PendingMutex);
if (m_Pending.find(device_id) != m_Pending.end()) return false; // 设备忙
PendingRequest r;
r.tool = tool;
m_Pending[device_id] = std::move(r);
return true;
}
bool CMcpServer::WaitPending(uint64_t device_id, std::vector<BYTE>& out, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_PendingMutex);
auto it = m_Pending.find(device_id);
if (it == m_Pending.end()) return false;
bool signaled = m_PendingCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.done; });
if (!signaled || it->second.data.empty()) {
m_Pending.erase(it); // 超时/空数据 → 清理
return false;
}
out = std::move(it->second.data);
m_Pending.erase(it);
return true;
}
void CMcpServer::ClearPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_PendingMutex);
m_Pending.erase(device_id);
}
// rand_s:Windows CRT 加密安全随机源(基于系统 CSPRNG)。其声明需在 <stdlib.h> 前
// 定义 _CRT_RAND_S;为避免依赖 PCH 的包含顺序,这里手动声明其导出原型(errno_t == int)。
extern "C" int __cdecl rand_s(unsigned int* randomValue);
std::string GenerateRandomToken() {
static const char hex[] = "0123456789abcdef";
std::string out;
out.reserve(32);
for (int i = 0; i < 16; ++i) {
unsigned int v = 0;
if (rand_s(&v) != 0) {
// rand_s 失败(罕见):退化为时间 + 地址熵,保证仍返回非空 token。
v = (unsigned int)(GetTickCount() ^ (ULONG_PTR)&out);
}
out.push_back(hex[(v >> 4) & 0xF]);
out.push_back(hex[v & 0xF]);
}
return out;
}
+95
View File
@@ -0,0 +1,95 @@
#pragma once
#include <Windows.h>
#include <string>
#include <thread>
#include <atomic>
#include <chrono>
#include <mutex>
#include <condition_variable>
#include <map>
#include <vector>
#include <cstdint>
// httplib 与 Windows 头部的 min/max 宏冲突,按 file_server.h 的既有约定处理。
#undef min
#undef max
#include "httplib.h"
#ifndef max
#define max(a,b) (((a) > (b)) ? (a) : (b))
#endif
#ifndef min
#define min(a,b) (((a) < (b)) ? (a) : (b))
#endif
#pragma comment(lib, "ws2_32.lib")
class CMy2015RemoteDlg;
// MCP (Model Context Protocol) 服务端:httplib 封装 + JSON-RPC 2.0 分发 + 静态 token 校验。
// 与 CWebService 平级、互不依赖;默认禁用,经「扩展 → MCP设置」开启后监听
// (默认 127.0.0.1:6544,仅本机回环)。见 docs/Mcp_Design.md。
class CMcpServer {
public:
static CMcpServer& Instance();
void SetParentDlg(CMy2015RemoteDlg* pDlg) { m_parent = pDlg; }
void SetToken(const std::string& token) { m_token = token; }
bool Start(const std::string& bind, int port);
void Stop();
bool IsRunning() const { return m_running.load(); }
// ===== P2b:无请求 id 的一次性响应 → 每 host 单飞行 =====
// 进程/窗口列表(TOKEN_PSLIST/TOKEN_WSLIST,一次性子链接)与历史活动
// (TOKEN_REPORT_ACTIVITY,主连接 RPC)都不带请求关联,同一 host 同一时刻
// 只允许一个挂起请求,避免同 host 并发请求响应归属歧义。
// 注意:单飞行只约束 MCP 侧同 host 并发,不约束与 MFC 对话框并存(各用独立子链接)。
// 该 host 是否已有挂起请求(MessageHandle 在 TOKEN_PSLIST/TOKEN_WSLIST 分支调用)。
bool IsPending(uint64_t device_id);
// 拷贝响应缓冲到挂起结果并唤醒等待者(在 MessageHandle 内同步调用,数据此时仍在
// context 缓冲中、IO 线程被阻塞,拷贝是安全的)。
void TakeMainResponse(uint64_t device_id, const BYTE* data, ULONG len);
// 工具线程:登记挂起(false = 该 host 已有挂起请求,设备忙)。
bool BeginPending(uint64_t device_id, const std::string& tool);
// 工具线程:等待响应;成功返回 true 并把缓冲写入 out,超时/失败返回 false(并清理)。
bool WaitPending(uint64_t device_id, std::vector<BYTE>& out, int timeoutMs);
// 工具线程:清理挂起状态(发送失败等提前退出路径)。
void ClearPending(uint64_t device_id);
private:
CMcpServer();
~CMcpServer();
CMcpServer(const CMcpServer&) = delete;
CMcpServer& operator=(const CMcpServer&) = delete;
// POST /mcp 处理器:token 校验 + JSON-RPC 分发。
void HandleMcp(const httplib::Request& req, httplib::Response& res);
httplib::Server m_server;
std::thread m_thread;
std::atomic<bool> m_running{false};
std::string m_token;
CMy2015RemoteDlg* m_parent = nullptr;
// 挂起请求注册表(受 m_PendingMutex 保护,键 = device_id)。
struct PendingRequest {
std::string tool;
std::vector<BYTE> data;
bool done = false;
};
std::mutex m_PendingMutex;
std::condition_variable m_PendingCv;
std::map<uint64_t, PendingRequest> m_Pending;
};
// 全局访问器(仿 WebService(),见 WebService.h 末尾)
inline CMcpServer& McpServer() { return CMcpServer::Instance(); }
// 生成 32 hex(128-bit)随机 token;供运行时兜底与「MCP设置」对话框预填复用。
std::string GenerateRandomToken();
+191
View File
@@ -0,0 +1,191 @@
#include "stdafx.h"
#include "McpSettingsDlg.h"
#include "LangManager.h" // _TR / MessageBoxL
#include "McpServer.h" // GenerateRandomToken
#include "2015Remote.h" // THIS_CFG (GetThisCfg)
#include <atlconv.h>
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
namespace {
// 构造一个无控件的 DLGTEMPLATE:cdit=0,控件由 OnInitDialog 动态创建。
// (与 ZstaPickerDlg.cpp 内同名函数一致)
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption, short cx, short cy)
{
out.clear();
auto append = [&](const void* p, size_t n) {
const BYTE* b = (const BYTE*)p;
out.insert(out.end(), b, b + n);
};
auto appendW = [&](WORD v) {
out.push_back((BYTE)(v & 0xFF));
out.push_back((BYTE)((v >> 8) & 0xFF));
};
auto appendWStr = [&](LPCWSTR s) {
size_t n = wcslen(s);
append(s, (n + 1) * sizeof(WCHAR));
};
DLGTEMPLATE dt = { 0 };
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
WS_POPUP | WS_CAPTION | WS_SYSMENU;
dt.dwExtendedStyle = 0;
dt.cdit = 0;
dt.x = 0; dt.y = 0;
dt.cx = cx; dt.cy = cy;
append(&dt, sizeof(dt));
appendW(0); // no menu
appendW(0); // default dialog class
appendWStr(caption); // caption
appendW(8); // font point size
appendWStr(L"MS Shell Dlg"); // typeface
while (out.size() % 4) out.push_back(0); // DWORD align
}
} // namespace
BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog)
END_MESSAGE_MAP()
CMcpSettingsDlg::CMcpSettingsDlg(CWnd* parent)
: CDialog((LPCTSTR)NULL, parent)
{
}
INT_PTR CMcpSettingsDlg::DoModal()
{
USES_CONVERSION;
CString title = _TR("MCP设置");
BuildDialogTemplate(m_Template, T2CW(title), 320, 150);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
BOOL CMcpSettingsDlg::OnInitDialog()
{
CDialog::OnInitDialog();
CRect cli;
GetClientRect(&cli);
// 占位 rect,真正布局在 LayoutControls 里
CRect r0(0, 0, 10, 10);
m_btnEnable.Create(_TR("启用 MCP"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_ENABLE);
m_lblPort.Create(_TR("端口"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editPort.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_MCP_PORT);
m_lblBind.Create(_TR("绑定地址"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editBind.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_MCP_BIND);
m_lblToken.Create(_TR("Token"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editToken.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_MCP_TOKEN);
m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON,
r0, this, IDOK);
m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
r0, this, IDCANCEL);
// 子控件继承对话框字体 (DS_SETFONT)
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
if (hFont) {
m_btnEnable.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblPort.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editPort.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblBind.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editBind.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
// 回填配置(首次打开且 token 为空时预填随机值,随保存持久化)
int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0);
int port = THIS_CFG.GetInt("settings", "McpPort", 6544);
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
if (tok.empty()) tok = GenerateRandomToken();
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
m_editPort.SetWindowText(CString(std::to_string(port).c_str()));
m_editBind.SetWindowText(CString(bind.c_str()));
m_editToken.SetWindowText(CString(tok.c_str()));
LayoutControls(cli.Width(), cli.Height());
return TRUE;
}
void CMcpSettingsDlg::OnOK()
{
CString sPort, sBind, sToken;
m_editPort.GetWindowText(sPort);
m_editBind.GetWindowText(sBind);
m_editToken.GetWindowText(sToken);
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
// 端口校验:1-65535
int port = atoi(CT2A(sPort));
if (port < 1 || port > 65535) {
MessageBoxL(_TR("端口需为 1-65535 的数字"), _TR("提示"), MB_ICONWARNING);
return;
}
std::string bind = CT2A(sBind);
if (bind.empty()) bind = "127.0.0.1";
std::string token = CT2A(sToken);
if (enabled && token.empty()) {
MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING);
return;
}
// 落盘
THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0);
THIS_CFG.SetInt("settings", "McpPort", port);
THIS_CFG.SetStr("settings", "McpBind", bind);
THIS_CFG.SetStr("settings", "McpToken", token);
// 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示)
MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") +
_TR("启用/端口/绑定地址/Token 的改动需重启程序生效。"),
_TR("提示"), MB_ICONINFORMATION);
CDialog::OnOK();
}
void CMcpSettingsDlg::LayoutControls(int cx, int cy)
{
const int margin = 14;
const int labelW = 72;
const int rowH = 24;
const int gap = 10;
const int btnW = 88;
const int btnH = 26;
int y = margin;
m_btnEnable.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
m_lblPort.MoveWindow(margin, y, labelW, rowH);
m_editPort.MoveWindow(margin + labelW, y - 2, 100, rowH);
y += rowH + gap;
m_lblBind.MoveWindow(margin, y, labelW, rowH);
m_editBind.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH);
y += rowH + gap;
m_lblToken.MoveWindow(margin, y, labelW, rowH);
m_editToken.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH);
y += rowH + gap;
int bottomY = cy - margin - btnH;
m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH);
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH);
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <afxwin.h>
#include <afxcmn.h>
#include <vector>
// 「MCP设置」配置对话框:启用 MCP / 端口 / 绑定地址 / Token。
// 因 2015Remote.rc 为 UTF-16 编码、不宜手工编辑,参照 ZstaPickerDlg 用
// InitModalIndirect + 内存 DLGTEMPLATE 程序化创建(见 docs/Mcp_Design.md §3.8)。
class CMcpSettingsDlg : public CDialog
{
public:
explicit CMcpSettingsDlg(CWnd* parent = nullptr);
virtual INT_PTR DoModal();
protected:
virtual BOOL OnInitDialog();
virtual void OnOK();
DECLARE_MESSAGE_MAP()
private:
enum CtrlId {
IDC_MCP_ENABLE = 1001, // 「启用 MCP」复选框
IDC_MCP_PORT = 1002, // 端口编辑框
IDC_MCP_BIND = 1003, // 绑定地址编辑框
IDC_MCP_TOKEN = 1004, // Token 编辑框
};
CButton m_btnEnable;
CStatic m_lblPort, m_lblBind, m_lblToken;
CEdit m_editPort, m_editBind, m_editToken;
CButton m_btnOK, m_btnCancel;
std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节
void LayoutControls(int cx, int cy);
};
+8 -4
View File
@@ -104,10 +104,14 @@ inline const char* GetColumnName(int index) {
"Camera", // 6
"RTT", // 7
"Version", // 8
"Privilege", // 9 ONLINELIST_PRIVILEGE
"InstallTime", // 10 ONLINELIST_INSTALLTIME
"ActiveWindow", // 11 ONLINELIST_LOGINTIME
"ClientType", // 12 ONLINELIST_CLIENTTYPE
"InstallTime", // 9 ONLINELIST_INSTALLTIME
"ActiveWindow", // 10 ONLINELIST_LOGINTIME
"ClientType", // 11 ONLINELIST_CLIENTTYPE
"Path", // 12 ONLINELIST_PATH
"PublicIP", // 13 ONLINELIST_PUBIP
"StartTime", // 14 ONLINELIST_STARTTIME
"Capabilities", // 15 ONLINELIST_CAPABILITIES
"Privilege", // 16 ONLINELIST_PRIVILEGE
};
if (index >= 0 && index < sizeof(names)/sizeof(names[0])) {
return names[index];
+39 -22
View File
@@ -141,35 +141,52 @@ bool NotifyManager::ShouldNotify(context* ctx, std::string& outMatchedKeyword, c
}
}
// Get column text (for COMPUTER_NAME column, prefer remark if available)
// Split pattern by semicolon and get keywords
std::vector<std::string> keywords = SplitString(rule.matchPattern, ';');
// Helper: check if any keyword matches a given column text
auto matchColumn = [&keywords, this](const CString& colText) -> std::string {
if (colText.IsEmpty()) return "";
std::string colTextStr = CT2A(colText, CP_UTF8);
std::string colLower = colTextStr;
std::transform(colLower.begin(), colLower.end(), colLower.begin(), ::tolower);
for (const auto& kw : keywords) {
std::string trimmed = Trim(kw);
if (trimmed.empty()) continue;
std::string kwLower = trimmed;
std::transform(kwLower.begin(), kwLower.end(), kwLower.begin(), ::tolower);
if (colLower.find(kwLower) != std::string::npos) {
return trimmed;
}
}
return "";
};
// Check primary column (for COMPUTER_NAME column, prefer remark if available)
CString colText;
if (rule.columnIndex == ONLINELIST_COMPUTER_NAME && !remark.IsEmpty()) {
colText = remark;
} else {
colText = ctx->GetClientData(rule.columnIndex);
}
if (colText.IsEmpty()) return false;
std::string matched = matchColumn(colText);
if (!matched.empty()) {
outMatchedKeyword = matched;
m_config.lastNotifyTime[clientId] = now;
return true;
}
// Convert to std::string for matching
std::string colTextStr = CT2A(colText, CP_UTF8);
// Split pattern by semicolon and check each keyword
std::vector<std::string> keywords = SplitString(rule.matchPattern, ';');
for (const auto& kw : keywords) {
std::string trimmed = Trim(kw);
if (trimmed.empty()) continue;
// Case-insensitive substring search
std::string colLower = colTextStr;
std::string kwLower = trimmed;
std::transform(colLower.begin(), colLower.end(), colLower.begin(), ::tolower);
std::transform(kwLower.begin(), kwLower.end(), kwLower.begin(), ::tolower);
if (colLower.find(kwLower) != std::string::npos) {
outMatchedKeyword = trimmed;
m_config.lastNotifyTime[clientId] = now;
return true;
}
// Also check the IP column (ONLINELIST_IP) so IP keywords added from log
// messages can match against host IP addresses when they come online.
CString ipText = ctx->GetClientData(ONLINELIST_IP);
matched = matchColumn(ipText);
if (!matched.empty()) {
outMatchedKeyword = matched;
m_config.lastNotifyTime[clientId] = now;
return true;
}
return false;
+1 -1
View File
@@ -111,10 +111,10 @@ void NotifySettingsDlg::PopulateComboBoxes()
{ ONLINELIST_VIDEO, _T("摄像头") },
{ ONLINELIST_PING, _T("延迟") },
{ ONLINELIST_VERSION, _T("版本") },
{ ONLINELIST_PRIVILEGE, _T("权限") },
{ ONLINELIST_INSTALLTIME, _T("安装时间") },
{ ONLINELIST_LOGINTIME, _T("活动窗口") },
{ ONLINELIST_CLIENTTYPE, _T("客户端类型") },
{ ONLINELIST_PRIVILEGE, _T("权限") },
};
for (const auto& col : columns) {
CString item;
+52 -4
View File
@@ -80,6 +80,7 @@ BOOL CSystemDlg::OnInitDialog()
SetWindowText(str);//设置对话框标题
m_ControlList.SetExtendedStyle(LVS_EX_FLATSB | LVS_EX_FULLROWSELECT);
LoadSortPreference(); // 加载上次记忆的排序偏好
if (m_bHow==TOKEN_PSLIST) { //进程管理初始化列表
m_ControlList.InsertColumnL(0, "映像名称", LVCFMT_LEFT, 180);
m_ControlList.InsertColumnL(1, "PID", LVCFMT_LEFT, 70);
@@ -227,6 +228,8 @@ void CSystemDlg::ShowWindowsList(void)
lvc.pszText = str.GetBuffer(0);
lvc.cchTextMax = str.GetLength();
m_ControlList.SetColumn(1, &lvc);
ApplySort(); // 应用记忆的排序偏好
}
void CSystemDlg::ShowProcessList(void)
@@ -260,8 +263,8 @@ void CSystemDlg::ShowProcessList(void)
m_ControlList.SetItemText(i, 1, str);
m_ControlList.SetItemText(i, 2, szProcessFullPath);
m_ControlList.SetItemText(i, 3, arr[1].empty() ? "N/A" : arr[1].c_str());
// ItemData 为进程ID
auto data = new ItemData{ *PID, {arr[0].c_str(), str, szProcessFullPath}, arr[1].c_str() };
// ItemData 为进程ID;Data[3] 需与第 4 列"架构"一致,否则按架构排序拿到空串而无意义
auto data = new ItemData{ *PID, {arr[0].c_str(), str, szProcessFullPath, arr[1].empty() ? "N/A" : arr[1].c_str()}, arr[1].c_str() };
m_ControlList.SetItemData(i, DWORD_PTR(data));
dwOffset += sizeof(DWORD) + lstrlen(szExeFile) + lstrlen(szProcessFullPath) + 2; //跳过这个数据结构 进入下一个循环
@@ -273,6 +276,8 @@ void CSystemDlg::ShowProcessList(void)
lvc.pszText = str.GetBuffer(0);
lvc.cchTextMax = str.GetLength();
m_ControlList.SetColumn(2, &lvc); //在列表中显示有多少个进程
ApplySort(); // 应用记忆的排序偏好
}
@@ -319,8 +324,6 @@ int CALLBACK CSystemDlg::CompareFunction(LPARAM lParam1, LPARAM lParam2, LPARAM
void CSystemDlg::SortByColumn(int nColumn)
{
static int m_nSortColumn = 0;
static bool m_bSortAscending = false;
if (nColumn == m_nSortColumn) {
// 如果点击的是同一列,切换排序顺序
m_bSortAscending = !m_bSortAscending;
@@ -330,11 +333,56 @@ void CSystemDlg::SortByColumn(int nColumn)
m_bSortAscending = true;
}
ApplySort();
SaveSortPreference();
}
void CSystemDlg::ApplySort()
{
// 无有效排序偏好时保持列表原始顺序
if (m_nSortColumn < 0) {
return;
}
// 防御:配置被手动修改导致列索引越界时跳过排序
CHeaderCtrl* pHeader = m_ControlList.GetHeaderCtrl();
if (pHeader && m_nSortColumn >= pHeader->GetItemCount()) {
return;
}
// 创建排序信息
std::pair<int, bool> sortInfo(m_nSortColumn, m_bSortAscending);
m_ControlList.SortItems(CompareFunction, reinterpret_cast<LPARAM>(&sortInfo));
}
void CSystemDlg::LoadSortPreference()
{
// 进程管理 / 窗口管理 分别记忆各自的排序偏好
const char* key = (m_bHow == TOKEN_PSLIST) ? "ProcessListSort" : "WindowListSort";
std::string val = THIS_CFG.GetStr("list", key, "");
if (val.empty()) {
m_nSortColumn = -1; // 无偏好:保持列表原始顺序
m_bSortAscending = true;
return;
}
auto arr = StringToVector(val, ',');
if (arr.size() >= 2) {
m_nSortColumn = atoi(arr[0].c_str());
m_bSortAscending = (atoi(arr[1].c_str()) != 0);
} else {
m_nSortColumn = -1;
m_bSortAscending = true;
}
}
void CSystemDlg::SaveSortPreference()
{
const char* key = (m_bHow == TOKEN_PSLIST) ? "ProcessListSort" : "WindowListSort";
std::string val = std::to_string(m_nSortColumn) + "," + (m_bSortAscending ? "1" : "0");
THIS_CFG.SetStr("list", key, val);
}
void CSystemDlg::OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult)
{
LPNMHEADER pNMHeader = reinterpret_cast<LPNMHEADER>(pNMHDR);
+5
View File
@@ -19,6 +19,8 @@ public:
void OnReceiveComplete(void);
CWnd* m_pParent;
BOOL m_bHow;
int m_nSortColumn = -1; // 当前排序列,-1 表示未排序
bool m_bSortAscending = true; // 排序方向(true=升序)
// 对话框数据
enum { IDD = IDD_DIALOG_SYSTEM };
@@ -32,6 +34,9 @@ public:
void DeleteAllItems();
void SortByColumn(int nColumn);
void ApplySort();
void LoadSortPreference();
void SaveSortPreference();
afx_msg VOID OnHdnItemclickList(NMHDR* pNMHDR, LRESULT* pResult);
static int CALLBACK CompareFunction(LPARAM lParam1, LPARAM lParam2, LPARAM lParamSort);
+3
View File
@@ -171,6 +171,7 @@
#define HIDE_MENU_RELOAD_PLUGINS 0 // 重新加载插件
#define HIDE_MENU_PLUGIN_REQUEST 0 // 插件请求
#define HIDE_MENU_FRPS_FOR_SUB 0 // 下级FRP
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
// --- 扩展菜单 - 语言子菜单 ---
#define HIDE_MENU_CHANGE_LANG 0 // 更改语言
@@ -298,6 +299,8 @@
// 用的 master password 解耦——后者一旦泄漏影响面更大,应避免在公网登录
// 时复用。与 Go 服务端的 YAMA_WEB_ADMIN_PASS 语义一致。
#define BRAND_WEB_ENV_VAR "YAMA_WEB_ADMIN_PASS"
// MCP 服务端静态 token 环境变量(优先级高于 settings/McpToken)
#define BRAND_MCP_ENV_VAR "YAMA_MCP_TOKEN"
// --- 宽字符版本(自动生成)---
#define BRAND_APP_NAME_W _T(BRAND_APP_NAME)
+4 -90
View File
@@ -6,6 +6,7 @@
#include "2015RemoteDlg.h"
#include "Server.h" // For CONTEXT_OBJECT
#include "jsoncpp/json.h"
#include "HostJson.h" // BuildHostJson(单台主机序列化公共函数)
#include "WebPage.h"
#include "SimpleWebSocket.h"
#include "common/commands.h"
@@ -88,31 +89,6 @@ static void ClearNonce(void* ws_ptr) {
s_ClientNonces.erase(ws_ptr);
}
// Helper: Convert ANSI (GBK) string to UTF-8
static std::string AnsiToUtf8(const CString& str) {
if (str.IsEmpty()) return "";
#ifdef _UNICODE
// Unicode build: CString is already UTF-16, convert to UTF-8
int len = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL);
if (len <= 0) return "";
std::string result(len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, str, -1, &result[0], len, NULL, NULL);
return result;
#else
// MBCS build: CString is ANSI (GBK), need to convert to UTF-16 first, then to UTF-8
int wlen = MultiByteToWideChar(CP_ACP, 0, str, -1, NULL, 0);
if (wlen <= 0) return "";
std::wstring wstr(wlen - 1, L'\0');
MultiByteToWideChar(CP_ACP, 0, str, -1, &wstr[0], wlen);
int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
if (u8len <= 0) return "";
std::string result(u8len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &result[0], u8len, NULL, NULL);
return result;
#endif
}
//////////////////////////////////////////////////////////////////////////
// CWebService Implementation
//////////////////////////////////////////////////////////////////////////
@@ -1544,71 +1520,9 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
if (!allowed) continue; // Skip device not in allowed groups
}
Json::Value device;
// Use string for ID to avoid JavaScript number precision loss
device["id"] = std::to_string(ctx->GetClientID());
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
device["name"] = AnsiToUtf8(name);
// 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE)
// 例如 hostname="A6" + remark="我的Windows" → web 显示"A6 (我的Windows)"
if (m_pParentDlg->m_ClientMap) {
CString remark = m_pParentDlg->m_ClientMap->GetClientMapData(
ctx->GetClientID(), MAP_NOTE);
if (!remark.IsEmpty()) {
device["remark"] = AnsiToUtf8(remark);
}
}
CString ip = ctx->GetClientData(ONLINELIST_IP);
device["ip"] = AnsiToUtf8(ip);
CString os = ctx->GetClientData(ONLINELIST_OS);
device["os"] = AnsiToUtf8(os);
CString location = ctx->GetClientData(ONLINELIST_LOCATION);
device["location"] = AnsiToUtf8(location);
CString rtt = ctx->GetClientData(ONLINELIST_PING);
device["rtt"] = AnsiToUtf8(rtt);
CString version = ctx->GetClientData(ONLINELIST_VERSION);
device["version"] = AnsiToUtf8(version);
// 活动窗口编码由客户端能力位决定:新客户端是 UTF-8,老客户端是 CP_ACP(默认 936)。
// 不能像其它字段那样无脑 AnsiToUtf8——会把新客户端的 UTF-8 字节再当 GBK 双重编码。
CString activeWindow = ctx->GetClientData(ONLINELIST_LOGINTIME);
std::string activeWindowU8;
if (!activeWindow.IsEmpty()) {
UINT cp = GetClientEncoding(ctx);
int wlen = MultiByteToWideChar(cp, 0, activeWindow, -1, NULL, 0);
if (wlen > 1) {
std::wstring w(wlen - 1, L'\0');
MultiByteToWideChar(cp, 0, activeWindow, -1, &w[0], wlen);
int u8len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, NULL, 0, NULL, NULL);
if (u8len > 1) {
activeWindowU8.resize(u8len - 1);
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), -1, &activeWindowU8[0], u8len, NULL, NULL);
}
}
}
device["activeWindow"] = activeWindowU8;
device["online"] = true;
// Add device group to response
device["group"] = deviceGroup;
// Get screen info from client's reported resolution
// Format: "n:MxN" where n=monitor count, M=width, N=height
CString resolution = ctx->GetAdditionalData(RES_RESOLUTION);
if (!resolution.IsEmpty()) {
device["screen"] = AnsiToUtf8(resolution); // e.g. "2:3840x1080"
}
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
device["clientType"] = AnsiToUtf8(clientType); // e.g. "MAC", "LNX", "EXE"
// 单台主机序列化委托给公共函数 BuildHostJson(与 MCP 侧复用,
// 字段与编码转换逻辑保持一致,见 docs/Mcp_Design.md §3.4 方案 C)。
Json::Value device = BuildHostJson(ctx, m_pParentDlg->m_ClientMap);
res["devices"].append(device);
}
LeaveCriticalSection(&m_pParentDlg->m_cs);
+1 -1
View File
@@ -14,7 +14,6 @@ enum {
ONLINELIST_VIDEO, // 摄像头(有无)
ONLINELIST_PING, // PING(对方的网速)
ONLINELIST_VERSION, // 版本信息
ONLINELIST_PRIVILEGE, // 权限级别 (SYSTEM/Admin/User)
ONLINELIST_INSTALLTIME, // 安装时间
ONLINELIST_LOGINTIME, // 活动窗口
ONLINELIST_CLIENTTYPE, // 客户端类型
@@ -22,6 +21,7 @@ enum {
ONLINELIST_PUBIP,
ONLINELIST_STARTTIME,
ONLINELIST_CAPABILITIES, // 客户端能力位
ONLINELIST_PRIVILEGE, // 权限级别 (SYSTEM/Admin/User)
ONLINELIST_MAX,
};
+23
View File
@@ -427,6 +427,7 @@ Rar
生成+AES -> bin 文件=Generate+AES -> bin File
测试 AES bin文件=Test AES bin File
替换图标(&R)=&Replace Icon
插件(&P)=&Plugins
刷新插件(&P)=Refresh &Plugins
口令生成(&A)=&Auth Generate
主控生成(&M)=&Master Generate
@@ -1956,6 +1957,7 @@ FRPC Զ
设置失败! 通常是权限不足, 请手动在系统环境变量设置。=Failed! You have to set it via system environment center.
运行日志=Client Log
客户端日志=Client Log
历史活动=Activity History
封禁使用=Forbidden Client
确定封禁选定的被控程序吗?=Are you sure to forbidden the selected clients?
无结果=Not found
@@ -1965,3 +1967,24 @@ FRPC Զ
搜索日志=Search Logs
隐藏搜索=Hide Search
请=Please
所选日志消息中未检测到有效的 IP 地址=No valid IP address detected in the selected log messages
所有解析出的 IP 已在上线提醒列表中=All extracted IPs are already in the login notify list
已添加 %d 个 IP 到上线提醒列表=Added %d IP(s) to login notify list
封禁IP=Ban IP
已封禁 %d 个 IP=Banned %d IP(s)
%d 个 IP 已在黑名单中=%d IP(s) already in blacklist
%d 个 IP 在白名单中,已跳过=%d IP(s) in whitelist, skipped
未执行任何操作=No action taken
解除封禁=Unban IP
已将 %d 个 IP 从黑名单移除=Removed %d IP(s) from blacklist
已恢复 %d 个客户端的授权状态=Restored authorization for %d client(s)
未找到需要解除封禁的 IP=No IPs found that need unbanning
; ===== MCP Settings =====
MCP设置=MCP Settings
MCP设置(&M)...=MCP Settings(&M)...
启用 MCP=Enable MCP
绑定地址=Bind Address
端口需为 1-65535 的数字=Port must be a number between 1 and 65535
Token 不能为空=Token cannot be empty
MCP 设置已保存。=MCP settings saved.
启用/端口/绑定地址/Token 的改动需重启程序生效。=Changes to enable/port/bind address/token take effect after restart.
+23
View File
@@ -427,6 +427,7 @@ Rar
生成+AES -> bin 文件=產生+AES -> bin 檔案
测试 AES bin文件=測試 AES bin 檔案
替换图标(&R)=替換圖示(&R)
插件(&P)=外掛程式(&P)
刷新插件(&P)=重新整理外掛程式(&P)
口令生成(&A)=密碼產生(&A)
主控生成(&M)=主控產生(&M)
@@ -1947,6 +1948,7 @@ FRPC Զ
设置失败! 通常是权限不足, 请手动在系统环境变量设置。=设置失败! 通常是权限不足, 请手动在系统环境变量设置。
运行日志=运行日志
客户端日志=客户端日志
历史活动=歷史活動
封禁使用=封禁使用
确定封禁选定的被控程序吗?=确定封禁选定的被控程序吗?
无结果=无结果
@@ -1956,3 +1958,24 @@ FRPC Զ
搜索日志=搜索日志
隐藏搜索=隐藏搜索
请=请
所有解析出的 IP 已在上线提醒列表中=所有解析出的 IP 已在上線提醒列表中
已添加 %d 个 IP 到上线提醒列表=已新增 %d 個 IP 到上線提醒列表
所选日志消息中未检测到有效的 IP 地址=所選日誌消息中未檢測到有效的 IP 位址
封禁IP=封禁IP
已封禁 %d 个 IP=已封禁 %d 個 IP
%d 个 IP 已在黑名单中=%d 個 IP 已在黑名單中
%d 个 IP 在白名单中,已跳过=%d 個 IP 在白名單中,已跳過
未执行任何操作=未執行任何操作
解除封禁=解除封禁
已将 %d 个 IP 从黑名单移除=已將 %d 個 IP 從黑名單移除
已恢复 %d 个客户端的授权状态=已恢復 %d 個客戶端的授權狀態
未找到需要解除封禁的 IP=未找到需要解除封禁的 IP
; ===== MCP Settings =====
MCP设置=MCP設定
MCP设置(&M)...=MCP設定(&M)...
启用 MCP=啟用 MCP
绑定地址=綁定位址
端口需为 1-65535 的数字=連接埠需為 1-65535 的數字
Token 不能为空=Token 不能為空
MCP 设置已保存。=MCP 設定已儲存。
启用/端口/绑定地址/Token 的改动需重启程序生效。=啟用/連接埠/綁定位址/Token 的變更需重新啟動程式後生效。
Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

+10 -2
View File
@@ -277,6 +277,9 @@
#define IDB_BITMAP_CLIENTLOG 395
#define IDI_CLIENTLOG 396
#define IDB_BITMAP_FORBIDDEN 397
#define IDB_BITMAP_HISTORY_ACTIVITY 398
#define IDB_BITMAP_VIEW_WND 399
#define IDB_BITMAP_PROXY_AUTORUN 400
#define IDC_MESSAGE 1000
#define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002
@@ -991,8 +994,12 @@
#define ID_CANCEL_SHARE 33042
#define ID_MSGLOG_COPY 33043
#define ID_MSGLOG_SEARCH 33078
#define ID_MSGLOG_LOGIN_NOTIFY 33079
#define ID_MSGLOG_BAN_IP 33080
#define ID_MSGLOG_UNBAN_IP 33081
#define ID_WEB_REMOTE_CONTROL 33044
#define ID_TOOL_PLUGIN_SETTINGS 33045
#define ID_MCP_SETTINGS 33077
#define ID_33046 33046
#define ID_PROXY_PORT_AUTORUN 33047
#define ID_TRIGGER_SETTINGS 33048
@@ -1025,14 +1032,15 @@
#define ID_ONLINE_CLIENT_LOG 33074
#define ID_ONLINE_33075 33075
#define ID_ONLINE_FORBIDDEN 33076
#define ID_ONLINE_HISTORY_ACTIVITY 33082
#define ID_EXIT_FULLSCREEN 40001
// Next default values for new objects
//
#ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS
#define _APS_NEXT_RESOURCE_VALUE 398
#define _APS_NEXT_COMMAND_VALUE 33079
#define _APS_NEXT_RESOURCE_VALUE 401
#define _APS_NEXT_COMMAND_VALUE 33083
#define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105
#endif