Feature: Add exec_command MCP tool

Add a one-shot exec_command MCP tool that runs a command on a remote
Windows host and returns stdout plus exit code, reusing the Web terminal
link (main-connection COMMAND_SHELL, a shell sub-connection, and a
sentinel command line located via rfind to tolerate ConPTY echo).

The sentinel marker is embedded in the command line ConPTY echoes back,
so it is only treated as hit when it starts a line (preceded by a newline
or the buffer start); this keeps the echoed marker from being mistaken
for the real sentinel when the echo packet arrives before the output.

Execution is gated at Web remote-desktop sensitivity: a read-only mode
(McpReadonly, default on, hides the tool) and a command whitelist
(McpCmdWhitelist) with built-in read-only prefixes. Shell metacharacters
(& | < > ^) are rejected before whitelist matching, and each execution is
recorded in the server audit log.

Extend the MCP settings dialog with the read-only checkbox and a
multi-line whitelist box (commas and newlines both accepted, normalized
to a comma-separated list on save), and add English and Traditional
Chinese mappings for the new UI and audit-log strings.

Co-Authored-By: deepseek-v4-pro
This commit is contained in:
yuanyuanxiang
2026-08-23 23:23:20 +02:00
parent 8bd6f3b60a
commit 0ddbc1aced
7 changed files with 579 additions and 5 deletions

View File

@@ -2190,6 +2190,9 @@ BOOL CMy2015RemoteDlg::OnInitDialog()
McpServer().SetParentDlg(this); McpServer().SetParentDlg(this);
McpServer().SetToken(mcpToken); McpServer().SetToken(mcpToken);
// 安全配置:只读默认 1exec_command 默认禁用);白名单空则用内置只读前缀。
McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0);
McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", ""));
if (!McpServer().Start(mcpBind, mcpPort)) { if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
} else { } else {
@@ -5505,6 +5508,18 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
return; return;
} }
// ===== MCP exec_command 的 shell 子上下文:被 MCP 接管时,数据走 OnTerminalData =====
// 与上方 Web 终端同构MCP 接管的 shell 子上下文不开 MFC 对话框hDlg 为 NULL
// 每个数据包都落到 MessageHandle 顶部分支,转发给 McpServer 做哨兵检测。
if (McpServer().IsTerminalContext(ContextObject)) {
if (len == 1 && cmd == TOKEN_TERMINAL_CLOSE) {
McpServer().OnTerminalClosed(ContextObject);
} else {
McpServer().OnTerminalData(ContextObject, szBuffer, len);
}
return;
}
// 【L】主机上下线和授权 // 【L】主机上下线和授权
// 【x】对话框相关功能 // 【x】对话框相关功能
switch (cmd) { switch (cmd) {
@@ -6408,6 +6423,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
// hDlg 留 NULL后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支 // hDlg 留 NULL后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支
break; break;
} }
// MCP exec_command 触发的 shell 子上下文:交给 McpServer 接管(老 cmd 管道GBK
if (McpServer().IsTermPending(devId)) {
McpServer().RegisterTerminalContext(devId, ContextObject, /*isPty*/false);
break;
}
g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject); g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject);
break; break;
} }
@@ -6418,6 +6438,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true); WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
break; break;
} }
// MCP exec_command 触发的 PTY 子上下文:交给 McpServer 接管ConPTYUTF-8
if (McpServer().IsTermPending(devId)) {
McpServer().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
break;
}
// 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。 // 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。
// SYSTEM 场景WebView2 不支持 LocalSystem token会出现"窗口能弹但页面空白" // SYSTEM 场景WebView2 不支持 LocalSystem token会出现"窗口能弹但页面空白"

View File

@@ -4,6 +4,7 @@
#include "HostJson.h" // BuildHostJson单台主机序列化公共函数 #include "HostJson.h" // BuildHostJson单台主机序列化公共函数
#include "context.h" // context 接口 #include "context.h" // context 接口
#include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员m_HostList/m_cs/m_ClientMap+ VERSION_STR #include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员m_HostList/m_cs/m_ClientMap+ VERSION_STR
#include "LangManager.h" // _TR审计日志标题语言映射
#include <sstream> #include <sstream>
@@ -889,6 +890,59 @@ Json::Value BuildListRegistryOutputSchema() {
return schema; return schema;
} }
// exec_command 的 inputSchemaid 必填、command 必填、timeout_ms 可选)
Json::Value BuildExecCommandInputSchema() {
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 cmdProp(Json::objectValue);
cmdProp["type"] = "string";
cmdProp["description"] = u8"要执行的命令(受只读模式与命令白名单约束,仅允许只读命令前缀)";
props["command"] = cmdProp;
Json::Value timeoutProp(Json::objectValue);
timeoutProp["type"] = "integer";
timeoutProp["description"] = u8"等待输出完成的超时毫秒数(可选,默认 20000上限 600000";
props["timeout_ms"] = timeoutProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("id");
required.append("command");
schema["required"] = required;
return schema;
}
// exec_command 的 outputSchemastdout 文本 / exit_code
Json::Value BuildExecCommandOutputSchema() {
Json::Value props(Json::objectValue);
Json::Value stdoutProp(Json::objectValue);
stdoutProp["type"] = "string";
stdoutProp["description"] = u8"命令输出(已剥哨兵与 ANSI 转义)";
props["stdout"] = stdoutProp;
Json::Value exitProp(Json::objectValue);
exitProp["type"] = "integer";
exitProp["description"] = u8"退出码0=成功、1=非零退出、-1=未知(进程异常退出)";
props["exit_code"] = exitProp;
Json::Value schema(Json::objectValue);
schema["type"] = "object";
schema["properties"] = props;
Json::Value required(Json::arrayValue);
required.append("stdout");
required.append("exit_code");
schema["required"] = required;
return schema;
}
// get_client_log 的 outputSchema原始日志文本 // get_client_log 的 outputSchema原始日志文本
Json::Value BuildClientLogOutputSchema() { Json::Value BuildClientLogOutputSchema() {
Json::Value props(Json::objectValue); Json::Value props(Json::objectValue);
@@ -1221,6 +1275,19 @@ std::string BuildToolsListResult(const Json::Value& id) {
tools.append(tool); tools.append(tool);
} }
// 13) exec_commandP3一次性远程命令仅 Windows安全门只读默认 + 白名单 + 审计)
// 只读模式下 tools/list 直接隐藏该工具即便被绕过BuildExecCommand 也会再次校验只读。
if (!CMcpServer::Instance().IsReadonly()) {
Json::Value tool(Json::objectValue);
tool["name"] = "exec_command";
tool["description"] = u8"在指定在线 Windows 主机上执行一条命令并返回 stdout 与退出码(受只读模式与命令白名单约束,默认仅允许只读命令前缀,见 McpReadonly / McpCmdWhitelist";
tool["inputSchema"] = BuildExecCommandInputSchema();
tool["outputSchema"] = BuildExecCommandOutputSchema();
tools.append(tool);
}
result["tools"] = tools; result["tools"] = tools;
return BuildResult(id, result); return BuildResult(id, result);
} }
@@ -1759,6 +1826,187 @@ std::string BuildListRegistry(const Json::Value& id, const Json::Value& args, CM
return BuildResult(id, result); return BuildResult(id, result);
} }
// ========== P3exec_command ==========
// exec_command 默认白名单:只读命令前缀(逗号分隔,前缀匹配,大小写不敏感)。
// 只放查询/只读类命令写命令del/copy/move/reg add/sc stop/...)一律不在列。
static const char* kDefaultCmdWhitelist =
"dir,type,cd,chdir,ver,hostname,whoami,where,tasklist,systeminfo,ipconfig,netstat,"
"path,set,find,findstr,reg query,sc query,"
"ping,tracert,nslookup,getmac,driverquery,query,gpresult,"
"arp -a,route print,schtasks /query";
// 去首尾空白(空格/制表符/回车/换行)。
static std::string Trim(const std::string& s) {
size_t b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return "";
return s.substr(b, s.find_last_not_of(" \t\r\n") - b + 1);
}
// 去尾部空白(含尾部 CR/LF
static std::string TrimRight(const std::string& s) {
size_t e = s.find_last_not_of(" \t\r\n");
return (e == std::string::npos) ? "" : s.substr(0, e + 1);
}
// 命令白名单校验命令trim + ASCII 小写)以某白名单项为前缀,且其后字符为空/空格/制表符,
// 防止 "dirx" 前缀误配 "dir"。多词前缀sc query / reg query也按完整前缀匹配。
static bool IsCommandAllowed(const std::string& command, const std::string& whitelist) {
std::string c = ToLowerAscii(Trim(command));
if (c.empty()) return false;
size_t start = 0;
while (start < whitelist.size()) {
size_t comma = whitelist.find(',', start);
std::string item = Trim(whitelist.substr(
start, comma == std::string::npos ? std::string::npos : comma - start));
if (!item.empty()) {
std::string p = ToLowerAscii(item);
if (c.size() >= p.size() && c.compare(0, p.size(), p) == 0 &&
(c.size() == p.size() || c[p.size()] == ' ' || c[p.size()] == '\t')) {
return true;
}
}
if (comma == std::string::npos) break;
start = comma + 1;
}
return false;
}
// 剥 ANSI 转义序列CSI/OSC/单字节 ESC避免终端彩色码污染返回文本。
static std::string StripAnsi(const std::string& s) {
std::string out;
out.reserve(s.size());
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] != '\x1b') { out += s[i]; continue; }
++i; // 吞 ESC
if (i < s.size() && s[i] == '[') { // CSI ... 结尾 @~
while (++i < s.size() && !(s[i] >= '@' && s[i] <= '~')) {}
} else if (i < s.size() && s[i] == ']') { // OSC ... 结尾 BEL/ESC
while (++i < s.size() && s[i] != '\x07' && s[i] != '\x1b') {}
}
// 其它单字节 ESC 序列吞掉当前字节即可(循环 ++i 会跳过它)
}
return out;
}
// tools/callexec_commandWindows 一次性远程命令:主连接 COMMAND_SHELL → 子连接终端 →
// 哨兵命令行 → 收集 stdout + exit_code → 关子链接)。安全门:只读默认 + 白名单 + 审计。
std::string BuildExecCommand(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));
// 终端仅 Windows 客户端实现ConPTY / 老 cmd 管道LNX/MAC 远期再考虑。
CString clientType = ctx->GetAdditionalData(RES_CLIENT_TYPE);
if (clientType == "LNX" || clientType == "MAC")
return BuildError(id, -32005, "exec_command is only supported on Windows hosts");
std::string command = Trim(GetStringArg(args, "command"));
if (command.empty())
return BuildError(id, -32602, "Missing required parameter: command");
CMcpServer& mcp = CMcpServer::Instance();
// 安全门 1只读模式直接拒绝默认只读exec_command 需显式关只读才可用)。
if (mcp.IsReadonly())
return BuildError(id, -32006, "exec_command is disabled: MCP is in read-only mode (set McpReadonly=0 to enable)");
// 安全门 2拒绝 shell 元字符,防止 "dir && del ..." 之类绕过白名单的注入。
if (command.find_first_of("&|<>^") != std::string::npos)
return BuildError(id, -32008, "Command contains forbidden shell characters: " + command);
// 安全门 3命令白名单前缀校验。
std::string whitelist = mcp.GetCmdWhitelist();
if (whitelist.empty()) whitelist = kDefaultCmdWhitelist;
if (!IsCommandAllowed(command, whitelist))
return BuildError(id, -32007, "Command not allowed by whitelist: " + command);
int timeoutMs = kMcpToolTimeoutMs;
std::string t = GetStringArg(args, "timeout_ms");
if (!t.empty() && IsDigits(t)) {
int v = atoi(t.c_str());
if (v > 0 && v <= 600000) timeoutMs = v;
}
std::string nonce = GenerateRandomToken().substr(0, 8);
if (!mcp.BeginTermPending(devId, command, nonce))
return BuildError(id, -32003, "Device busy: a terminal session is already active for this host");
// 主连接下发 COMMAND_SHELL客户端建立 shell 子连接并回 TOKEN_*_START。
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
mcp.ClearTermPending(devId);
return BuildError(id, -32004, "Failed to send command to host");
}
context* subCtx = nullptr;
bool isPty = false;
if (!mcp.WaitTerminalReady(devId, subCtx, isPty, timeoutMs))
return BuildError(id, -32001, "Timeout waiting for shell to start");
// 编码ConPTY(UTF-8) / 老 cmd 管道(GBK)。哨兵为纯 ASCII两种编码下字节一致。
UINT cp = isPty ? CP_UTF8 : 936;
// 哨兵命令行:@echo off 仅 ConPTY 需要(抑制回显噪声);老 ShellManager 已自行跳过回显,
// 加 @echo off 反而破坏其回显跳过逻辑。&&/|| 取命令真实退出码(%errorlevel% 在复合句中
// 是解析期展开、已过期,故用控制操作符)。哨兵为随机串,命令输出不可能恰好包含。
std::string line;
if (isPty) line += "@echo off & ";
line += command;
line += " 2>&1 && echo __MCP_DONE_" + nonce + "__0 || echo __MCP_DONE_" + nonce + "__1";
std::string wireLine = ToAnsi(line, cp) + "\r\n";
subCtx->Send2Client((BYTE*)wireLine.data(), (ULONG)wireLine.size());
std::vector<BYTE> raw;
int exitCode = -1;
bool closed = false;
if (!mcp.WaitTerminalDone(devId, raw, exitCode, closed, timeoutMs)) {
subCtx->CancelIO();
return BuildError(id, -32001, "Timeout waiting for command output");
}
subCtx->CancelIO(); // 关子链接,结束 shell 进程
// 清洗raw → UTF-8 → 剥 ANSI → CRLF 归一。raw 已截断到哨兵前。
std::string stdoutStr;
if (!raw.empty()) {
std::string rawStr((const char*)raw.data(), raw.size());
stdoutStr = StripAnsi(ToUtf8(rawStr.c_str(), cp));
size_t p = 0;
while ((p = stdoutStr.find("\r\n", p)) != std::string::npos)
stdoutStr.erase(p, 1);
stdoutStr = TrimRight(stdoutStr);
}
// 审计:命令执行落服务端消息/审计日志(不可关闭)。
if (parent) {
std::string text = "host " + std::to_string(devId) + " exec: " + command;
parent->PostMessageA(WM_SHOWERRORMSG,
(WPARAM)new CString(ToAnsi(text, 936).c_str()),
(LPARAM)new CString(_TR("MCP命令执行"))); // 标题走语言映射GBK随语言切换
}
Json::Value result(Json::objectValue);
Json::Value structuredContent(Json::objectValue);
structuredContent["stdout"] = stdoutStr;
structuredContent["exit_code"] = exitCode;
result["structuredContent"] = structuredContent;
Json::Value content(Json::arrayValue);
Json::Value item(Json::objectValue);
item["type"] = "text";
item["text"] = stdoutStr.empty() ? std::string(u8"(无输出)") : stdoutStr;
content.append(item);
result["content"] = content;
result["isError"] = false;
return BuildResult(id, result);
}
// tools/callget_client_log主连接下发 COMMAND_QUERY_LOG子连接回传 TOKEN_REPORT_LOG // tools/callget_client_log主连接下发 COMMAND_QUERY_LOG子连接回传 TOKEN_REPORT_LOG
std::string BuildGetClientLog(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { std::string BuildGetClientLog(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) {
uint64_t devId = 0; uint64_t devId = 0;
@@ -1873,6 +2121,7 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
if (toolName == "get_client_log") return BuildGetClientLog(id, args, parent); if (toolName == "get_client_log") return BuildGetClientLog(id, args, parent);
if (toolName == "get_audit_log") return BuildGetAuditLog(id, parent); if (toolName == "get_audit_log") return BuildGetAuditLog(id, parent);
if (toolName == "list_registry") return BuildListRegistry(id, args, parent); if (toolName == "list_registry") return BuildListRegistry(id, args, parent);
if (toolName == "exec_command") return BuildExecCommand(id, args, parent);
return BuildError(id, -32602, return BuildError(id, -32602,
"Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName));
@@ -1880,6 +2129,158 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) {
} // namespace } // namespace
// ===== P3exec_command 哨兵检测与终端会话 =====
// 在原始 shell 输出字节流中定位哨兵(纯 ASCII编码无关。真实哨兵由 echo 单独输出、独占
// 一行(前面是 \n 或缓冲区开头);而 ConPTY 会把整条命令行回显进输出流,哨兵字样嵌在
// "&& echo ... || echo ..." 里、前面是空格。据此从后往前找「行首命中」,排除回显行误判:
// 否则回显包先于命令输出单独到达时rfind 会命中回显里的 __1 提前结束、截断真实输出。
// 老 ShellManager 已自行跳过回显,输出里只有真实哨兵(仍为行首)。命中 __0 → exit 0__1 → exit 1。
static bool FindSentinel(const std::vector<BYTE>& buf, const std::string& nonce,
size_t& pos, int& exitCode) {
std::string marker = "__MCP_DONE_" + nonce + "__";
if (marker.size() + 1 > buf.size()) return false;
std::string s((const char*)buf.data(), buf.size());
size_t from = std::string::npos;
while (true) {
size_t p = s.rfind(marker, from);
if (p == std::string::npos) return false;
bool lineStart = (p == 0) || (s[p - 1] == '\n');
size_t digitPos = p + marker.size();
if (lineStart && digitPos < s.size() &&
(s[digitPos] == '0' || s[digitPos] == '1')) {
exitCode = (s[digitPos] == '0') ? 0 : 1;
pos = p;
return true;
}
if (p == 0) return false;
from = p - 1;
}
}
bool CMcpServer::IsTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
return it != m_TermSessions.end() && !it->second.started;
}
void CMcpServer::RegisterTerminalContext(uint64_t device_id, context* subCtx, bool isPty) {
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.started) return; // 没有等它的会话
TermSession& s = it->second;
s.started = true;
s.subCtx = subCtx;
s.isPty = isPty;
m_TermContextToDevice[subCtx] = device_id;
}
// 关键步骤:告知客户端「启动 shell 输出回流」。客户端读线程靠 COMMAND_NEXT 才启动,
// 漏发会导致 shell 在跑但输出永不送回。PTY 还要先告知初始 80x24否则 TUI 尺寸错乱。
if (isPty) {
BYTE resizeBuf[5];
resizeBuf[0] = CMD_TERMINAL_RESIZE;
*(short*)(resizeBuf + 1) = (short)80;
*(short*)(resizeBuf + 3) = (short)24;
subCtx->Send2Client(resizeBuf, 5);
}
BYTE startCmd = COMMAND_NEXT;
subCtx->Send2Client(&startCmd, 1);
m_TermCv.notify_all();
}
bool CMcpServer::IsTerminalContext(context* subCtx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermContextToDevice.find(subCtx) != m_TermContextToDevice.end();
}
void CMcpServer::OnTerminalData(context* subCtx, const BYTE* data, ULONG len) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(subCtx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
TermSession& s = sit->second;
if (s.done || s.closed) return; // 已结束,忽略迟到数据
s.data.insert(s.data.end(), data, data + len);
if (FindSentinel(s.data, s.nonce, s.sentPos, s.exitCode)) {
s.done = true;
m_TermCv.notify_all();
}
}
void CMcpServer::OnTerminalClosed(context* subCtx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(subCtx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
sit->second.closed = true;
m_TermCv.notify_all();
}
bool CMcpServer::BeginTermPending(uint64_t device_id, const std::string& command, const std::string& nonce) {
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end()) return false; // 已有会话
TermSession s;
s.command = command;
s.nonce = nonce;
m_TermSessions[device_id] = std::move(s);
return true;
}
bool CMcpServer::WaitTerminalReady(uint64_t device_id, context*& subCtx, bool& isPty, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
bool signaled = m_TermCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.started; });
if (!signaled) {
m_TermSessions.erase(it); // 超时 → 清理
return false;
}
subCtx = it->second.subCtx;
isPty = it->second.isPty;
return true;
}
bool CMcpServer::WaitTerminalDone(uint64_t device_id, std::vector<BYTE>& out,
int& exitCode, bool& closed, int timeoutMs) {
std::unique_lock<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return false;
bool signaled = m_TermCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[&] { return it->second.done || it->second.closed; });
if (!signaled) { // 超时 → 清理(含路由表)
m_TermContextToDevice.erase(it->second.subCtx);
m_TermSessions.erase(it);
return false;
}
TermSession s = it->second; // 拷贝出,避免擦除后悬空
m_TermContextToDevice.erase(s.subCtx);
m_TermSessions.erase(it);
closed = s.closed;
exitCode = s.exitCode;
if (s.done && s.sentPos <= s.data.size())
out.assign(s.data.begin(), s.data.begin() + s.sentPos); // 截断到哨兵前
else
out = s.data; // 进程退出无哨兵:返回已收集的原始输出
return true;
}
void CMcpServer::ClearTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it != m_TermSessions.end()) m_TermSessions.erase(it);
}
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////
// CMcpServer Implementation // CMcpServer Implementation
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////

View File

@@ -98,6 +98,47 @@ public:
bool WaitPendingRegistry(uint64_t device_id, std::vector<BYTE>& subkeys, bool WaitPendingRegistry(uint64_t device_id, std::vector<BYTE>& subkeys,
std::vector<BYTE>& values, int timeoutMs); std::vector<BYTE>& values, int timeoutMs);
// ===== P3exec_commandWindows 一次性远程命令,复刻 Web 终端链路)=====
// 流程:工具线程 BeginTermPending → 主连接下发 COMMAND_SHELL → 客户端 shell 子连接回
// TOKEN_TERMINAL_START / TOKEN_SHELL_START → RegisterTerminalContext 接管 → 发
// COMMAND_NEXT(+PTY resize) 启动输出回流 → 工具线程发哨兵命令行 → OnTerminalData 收集
// 并做哨兵检测 → WaitTerminalDone 取 stdout + exit_code → CancelIO 关子链接。
// 单设备单终端MVPBeginTermPending 命中已有会话即拒绝,避免同设备并发归属歧义。
// 该 host 是否已有待接管的终端会话MessageHandle TOKEN_*_START 分支调用)。
bool IsTermPending(uint64_t device_id);
// 终端子连接就绪:接管(登记 subCtx/isPty、清 pending、发 COMMAND_NEXT 唤醒回流)。
void RegisterTerminalContext(uint64_t device_id, context* subCtx, bool isPty);
// 终端子连接是否已被 MCP 接管MessageHandle 顶部据此把 shell 输出路由到 OnTerminalData
bool IsTerminalContext(context* subCtx);
// 追加 shell 输出并做哨兵检测(命中置 done 并唤醒等待者)。
void OnTerminalData(context* subCtx, const BYTE* data, ULONG len);
// shell 进程退出TOKEN_TERMINAL_CLOSE
void OnTerminalClosed(context* subCtx);
// 工具线程登记终端挂起false = 该 host 已有终端会话)。
bool BeginTermPending(uint64_t device_id, const std::string& command, const std::string& nonce);
// 工具线程:等待子连接回 START 并接管true = 已接管,输出 subCtx/isPty
bool WaitTerminalReady(uint64_t device_id, context*& subCtx, bool& isPty, int timeoutMs);
// 工具线程:等待输出收集完成(哨兵命中 / 进程退出 / 超时)。
// 成功 trueout=截断到哨兵前的原始字节、exitCode=0/1进程退出无哨兵时 -1、closed=是否进程退出。
bool WaitTerminalDone(uint64_t device_id, std::vector<BYTE>& out, int& exitCode, bool& closed, int timeoutMs);
// 工具线程:清理终端挂起(发送失败等提前退出路径)。
void ClearTermPending(uint64_t device_id);
// 安全配置(启动时由 CMy2015RemoteDlg 读 THIS_CFG 后设置)。
void SetReadonly(bool readonly) { m_readonly = readonly; }
void SetCmdWhitelist(const std::string& whitelist) { m_cmdWhitelist = whitelist; }
bool IsReadonly() const { return m_readonly; }
const std::string& GetCmdWhitelist() const { return m_cmdWhitelist; }
private: private:
CMcpServer(); CMcpServer();
~CMcpServer(); ~CMcpServer();
@@ -131,6 +172,27 @@ private:
// get_screenshot 的 reqId 发生器16 位,跳过 0。与 MFC 预览的 m_PreviewReqId 各自独立计数。 // get_screenshot 的 reqId 发生器16 位,跳过 0。与 MFC 预览的 m_PreviewReqId 各自独立计数。
std::atomic<uint16_t> m_PreviewReqId{1}; std::atomic<uint16_t> m_PreviewReqId{1};
// ===== P3exec_command 终端会话(受 m_TermMutex 保护)=====
struct TermSession {
bool started = false; // false=已发 COMMAND_SHELL 待 STARTtrue=已接管
context* subCtx = nullptr; // shell 子连接上下文
bool isPty = false; // true=ConPTY(UTF-8)false=老 cmd 管道(GBK)
std::string command; // 原始 UTF-8 命令(审计用)
std::string nonce; // 哨兵随机串
std::vector<BYTE> data; // 收集的原始 shell 输出
size_t sentPos = 0; // 哨兵在 data 中的位置
int exitCode = -1;
bool done = false; // 哨兵命中
bool closed = false; // TOKEN_TERMINAL_CLOSE进程退出
};
std::mutex m_TermMutex;
std::condition_variable m_TermCv;
std::map<uint64_t, TermSession> m_TermSessions; // device_id → 会话
std::map<context*, uint64_t> m_TermContextToDevice; // subCtx → device_id顶部路由
bool m_readonly = true;
std::string m_cmdWhitelist;
}; };
// 全局访问器(仿 WebService(),见 WebService.h 末尾) // 全局访问器(仿 WebService(),见 WebService.h 末尾)

View File

@@ -46,6 +46,52 @@ void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption, short cx, shor
while (out.size() % 4) out.push_back(0); // DWORD align while (out.size() % 4) out.push_back(0); // DWORD align
} }
// 把白名单文本规范化为「逗号分隔、逐项去首尾空白、去空项」的存储格式。
// 换行视作分隔符(与逗号等价),便于多行输入。
std::string NormalizeWhitelist(const std::string& raw)
{
std::string norm;
norm.reserve(raw.size());
for (char ch : raw)
norm += (ch == '\r' || ch == '\n') ? ',' : ch;
std::vector<std::string> items;
size_t start = 0;
while (start < norm.size()) {
size_t comma = norm.find(',', start);
std::string item = norm.substr(start, comma == std::string::npos ? std::string::npos : comma - start);
size_t b = item.find_first_not_of(" \t\r\n");
if (b != std::string::npos) {
size_t e = item.find_last_not_of(" \t\r\n");
item = item.substr(b, e - b + 1);
} else {
item.clear();
}
if (!item.empty()) items.push_back(item);
if (comma == std::string::npos) break;
start = comma + 1;
}
std::string out;
for (size_t i = 0; i < items.size(); ++i) {
if (i) out += ",";
out += items[i];
}
return out;
}
// 存储格式(逗号分隔)→ 多行展示(每行一条)。
std::string WhitelistForDisplay(const std::string& stored)
{
std::string disp;
disp.reserve(stored.size());
for (char ch : stored) {
if (ch == ',') disp += "\r\n";
else disp += ch;
}
return disp;
}
} // namespace } // namespace
BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog) BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog)
@@ -60,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal()
{ {
USES_CONVERSION; USES_CONVERSION;
CString title = _TR("MCP设置"); CString title = _TR("MCP设置");
BuildDialogTemplate(m_Template, T2CW(title), 320, 150); BuildDialogTemplate(m_Template, T2CW(title), 320, 320);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal(); return CDialog::DoModal();
} }
@@ -87,6 +133,13 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_lblToken.Create(_TR("Token"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1); 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, m_editToken.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP | ES_AUTOHSCROLL,
r0, this, IDC_MCP_TOKEN); r0, this, IDC_MCP_TOKEN);
m_btnReadonly.Create(_TR("只读模式(禁命令执行)"),
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_AUTOCHECKBOX,
r0, this, IDC_MCP_READONLY);
m_lblWhitelist.Create(_TR("命令白名单"), WS_CHILD | WS_VISIBLE, r0, this, (UINT)-1);
m_editWhitelist.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
ES_MULTILINE | ES_AUTOVSCROLL | ES_WANTRETURN | WS_VSCROLL,
r0, this, IDC_MCP_WHITELIST);
m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON, m_btnOK.Create(_TR("确定"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_DEFPUSHBUTTON,
r0, this, IDOK); r0, this, IDOK);
m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON, m_btnCancel.Create(_TR("取消"), WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON,
@@ -102,6 +155,9 @@ BOOL CMcpSettingsDlg::OnInitDialog()
m_editBind.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_lblToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_editToken.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnReadonly.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_lblWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_editWhitelist.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0)); m_btnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
} }
@@ -112,11 +168,16 @@ BOOL CMcpSettingsDlg::OnInitDialog()
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1"); std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
std::string tok = THIS_CFG.GetStr("settings", "McpToken", ""); std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
if (tok.empty()) tok = GenerateRandomToken(); if (tok.empty()) tok = GenerateRandomToken();
int readonly = THIS_CFG.GetInt("settings", "McpReadonly", 1);
std::string whitelist = THIS_CFG.GetStr("settings", "McpCmdWhitelist", "");
m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED); m_btnEnable.SetCheck(enabled ? BST_CHECKED : BST_UNCHECKED);
m_editPort.SetWindowText(CString(std::to_string(port).c_str())); m_editPort.SetWindowText(CString(std::to_string(port).c_str()));
m_editBind.SetWindowText(CString(bind.c_str())); m_editBind.SetWindowText(CString(bind.c_str()));
m_editToken.SetWindowText(CString(tok.c_str())); m_editToken.SetWindowText(CString(tok.c_str()));
m_btnReadonly.SetCheck(readonly ? BST_CHECKED : BST_UNCHECKED);
// 白名单存储为逗号分隔,展示为每行一条。
m_editWhitelist.SetWindowText(CString(WhitelistForDisplay(whitelist).c_str()));
LayoutControls(cli.Width(), cli.Height()); LayoutControls(cli.Width(), cli.Height());
return TRUE; return TRUE;
@@ -124,11 +185,13 @@ BOOL CMcpSettingsDlg::OnInitDialog()
void CMcpSettingsDlg::OnOK() void CMcpSettingsDlg::OnOK()
{ {
CString sPort, sBind, sToken; CString sPort, sBind, sToken, sWhitelist;
m_editPort.GetWindowText(sPort); m_editPort.GetWindowText(sPort);
m_editBind.GetWindowText(sBind); m_editBind.GetWindowText(sBind);
m_editToken.GetWindowText(sToken); m_editToken.GetWindowText(sToken);
m_editWhitelist.GetWindowText(sWhitelist);
bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED); bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED);
bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED);
// 端口校验1-65535 // 端口校验1-65535
int port = atoi(CT2A(sPort)); int port = atoi(CT2A(sPort));
@@ -151,10 +214,14 @@ void CMcpSettingsDlg::OnOK()
THIS_CFG.SetInt("settings", "McpPort", port); THIS_CFG.SetInt("settings", "McpPort", port);
THIS_CFG.SetStr("settings", "McpBind", bind); THIS_CFG.SetStr("settings", "McpBind", bind);
THIS_CFG.SetStr("settings", "McpToken", token); THIS_CFG.SetStr("settings", "McpToken", token);
THIS_CFG.SetInt("settings", "McpReadonly", readonly ? 1 : 0);
std::string whitelist = CT2A(sWhitelist);
whitelist = NormalizeWhitelist(whitelist);
THIS_CFG.SetStr("settings", "McpCmdWhitelist", whitelist);
// 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示) // 拆成两段可翻译的单行键,中间用 \r\n 连接(多行键无法在 INI 中表示)
MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") +
_TR("启用/端口/绑定地址/Token 的改动需重启程序生效。"), _TR("启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。"),
_TR("提示"), MB_ICONINFORMATION); _TR("提示"), MB_ICONINFORMATION);
CDialog::OnOK(); CDialog::OnOK();
@@ -185,6 +252,14 @@ void CMcpSettingsDlg::LayoutControls(int cx, int cy)
m_editToken.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH); m_editToken.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, rowH);
y += rowH + gap; y += rowH + gap;
m_btnReadonly.MoveWindow(margin, y, cx - margin * 2, 22);
y += 30;
const int whitelistH = 90;
m_lblWhitelist.MoveWindow(margin, y, labelW, rowH);
m_editWhitelist.MoveWindow(margin + labelW, y - 2, cx - margin * 2 - labelW, whitelistH);
y += whitelistH + gap;
int bottomY = cy - margin - btnH; int bottomY = cy - margin - btnH;
m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH); m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH);
m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH); m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH);

View File

@@ -26,11 +26,16 @@ private:
IDC_MCP_PORT = 1002, // 端口编辑框 IDC_MCP_PORT = 1002, // 端口编辑框
IDC_MCP_BIND = 1003, // 绑定地址编辑框 IDC_MCP_BIND = 1003, // 绑定地址编辑框
IDC_MCP_TOKEN = 1004, // Token 编辑框 IDC_MCP_TOKEN = 1004, // Token 编辑框
IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command
IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀)
}; };
CButton m_btnEnable; CButton m_btnEnable;
CStatic m_lblPort, m_lblBind, m_lblToken; CStatic m_lblPort, m_lblBind, m_lblToken;
CEdit m_editPort, m_editBind, m_editToken; CEdit m_editPort, m_editBind, m_editToken;
CButton m_btnReadonly;
CStatic m_lblWhitelist;
CEdit m_editWhitelist;
CButton m_btnOK, m_btnCancel; CButton m_btnOK, m_btnCancel;
std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节 std::vector<BYTE> m_Template; // 内存 DLGTEMPLATE 字节

View File

@@ -1987,4 +1987,7 @@ MCP
端口需为 1-65535 的数字=Port must be a number between 1 and 65535 端口需为 1-65535 的数字=Port must be a number between 1 and 65535
Token 不能为空=Token cannot be empty Token 不能为空=Token cannot be empty
MCP 设置已保存。=MCP settings saved. MCP 设置已保存。=MCP settings saved.
启用/端口/绑定地址/Token 的改动需重启程序生效。=Changes to enable/port/bind address/token take effect after restart. 启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。=Changes to enable/port/bind/token/read-only/whitelist take effect after restart.
只读模式(禁命令执行)=Read-only mode (disable command execution)
命令白名单=Command whitelist
MCP命令执行=MCP command execution

View File

@@ -1978,4 +1978,7 @@ MCP
端口需为 1-65535 的数字=連接埠需為 1-65535 的數字 端口需为 1-65535 的数字=連接埠需為 1-65535 的數字
Token 不能为空=Token 不能為空 Token 不能为空=Token 不能為空
MCP 设置已保存。=MCP 設定已儲存。 MCP 设置已保存。=MCP 設定已儲存。
启用/端口/绑定地址/Token 的改动需重启程序生效。=啟用/連接埠/綁定位址/Token 的變更需重新啟動程式後生效。 启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。=啟用/連接埠/綁定位址/Token/唯讀/白名單的變更需重新啟動程式後生效。
只读模式(禁命令执行)=唯讀模式(禁命令執行)
命令白名单=命令白名單
MCP命令执行=MCP命令執行