diff --git a/server/2015Remote/2015RemoteDlg.cpp b/server/2015Remote/2015RemoteDlg.cpp index d3a2695..61424b5 100644 --- a/server/2015Remote/2015RemoteDlg.cpp +++ b/server/2015Remote/2015RemoteDlg.cpp @@ -2190,6 +2190,9 @@ BOOL CMy2015RemoteDlg::OnInitDialog() McpServer().SetParentDlg(this); McpServer().SetToken(mcpToken); + // 安全配置:只读默认 1(exec_command 默认禁用);白名单空则用内置只读前缀。 + McpServer().SetReadonly(THIS_CFG.GetInt("settings", "McpReadonly", 1) != 0); + McpServer().SetCmdWhitelist(THIS_CFG.GetStr("settings", "McpCmdWhitelist", "")); if (!McpServer().Start(mcpBind, mcpPort)) { Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort); } else { @@ -5505,6 +5508,18 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) 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】:主机上下线和授权 // 【x】:对话框相关功能 switch (cmd) { @@ -6408,6 +6423,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) // hDlg 留 NULL:后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支 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); break; } @@ -6418,6 +6438,11 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject) WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true); break; } + // MCP exec_command 触发的 PTY 子上下文:交给 McpServer 接管(ConPTY,UTF-8)。 + if (McpServer().IsTermPending(devId)) { + McpServer().RegisterTerminalContext(devId, ContextObject, /*isPty*/true); + break; + } // 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。 // SYSTEM 场景:WebView2 不支持 LocalSystem token,会出现"窗口能弹但页面空白", diff --git a/server/2015Remote/McpServer.cpp b/server/2015Remote/McpServer.cpp index 05fee98..5a30090 100644 --- a/server/2015Remote/McpServer.cpp +++ b/server/2015Remote/McpServer.cpp @@ -4,6 +4,7 @@ #include "HostJson.h" // BuildHostJson(单台主机序列化公共函数) #include "context.h" // context 接口 #include "2015RemoteDlg.h" // CMy2015RemoteDlg 成员(m_HostList/m_cs/m_ClientMap)+ VERSION_STR +#include "LangManager.h" // _TR(审计日志标题语言映射) #include @@ -889,6 +890,59 @@ Json::Value BuildListRegistryOutputSchema() { return schema; } +// exec_command 的 inputSchema(id 必填、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 的 outputSchema(stdout 文本 / 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(原始日志文本) Json::Value BuildClientLogOutputSchema() { Json::Value props(Json::objectValue); @@ -1221,6 +1275,19 @@ std::string BuildToolsListResult(const Json::Value& id) { tools.append(tool); } + // 13) exec_command(P3:一次性远程命令,仅 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; return BuildResult(id, result); } @@ -1759,6 +1826,187 @@ std::string BuildListRegistry(const Json::Value& id, const Json::Value& args, CM return BuildResult(id, result); } +// ========== P3:exec_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/call:exec_command(Windows 一次性远程命令:主连接 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 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/call:get_client_log(主连接下发 COMMAND_QUERY_LOG,子连接回传 TOKEN_REPORT_LOG) std::string BuildGetClientLog(const Json::Value& id, const Json::Value& args, CMy2015RemoteDlg* parent) { 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_audit_log") return BuildGetAuditLog(id, parent); if (toolName == "list_registry") return BuildListRegistry(id, args, parent); + if (toolName == "exec_command") return BuildExecCommand(id, args, parent); return BuildError(id, -32602, "Unknown tool: " + (toolName.empty() ? std::string("(empty)") : toolName)); @@ -1880,6 +2129,158 @@ std::string BuildToolsCall(const Json::Value& root, CMy2015RemoteDlg* parent) { } // namespace +// ===== P3:exec_command 哨兵检测与终端会话 ===== + +// 在原始 shell 输出字节流中定位哨兵(纯 ASCII,编码无关)。真实哨兵由 echo 单独输出、独占 +// 一行(前面是 \n 或缓冲区开头);而 ConPTY 会把整条命令行回显进输出流,哨兵字样嵌在 +// "&& echo ... || echo ..." 里、前面是空格。据此从后往前找「行首命中」,排除回显行误判: +// 否则回显包先于命令输出单独到达时,rfind 会命中回显里的 __1 提前结束、截断真实输出。 +// 老 ShellManager 已自行跳过回显,输出里只有真实哨兵(仍为行首)。命中 __0 → exit 0;__1 → exit 1。 +static bool FindSentinel(const std::vector& 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 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 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 lk(m_TermMutex); + return m_TermContextToDevice.find(subCtx) != m_TermContextToDevice.end(); +} + +void CMcpServer::OnTerminalData(context* subCtx, const BYTE* data, ULONG len) { + std::lock_guard 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 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 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 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& out, + int& exitCode, bool& closed, int timeoutMs) { + std::unique_lock 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 lk(m_TermMutex); + auto it = m_TermSessions.find(device_id); + if (it != m_TermSessions.end()) m_TermSessions.erase(it); +} + ////////////////////////////////////////////////////////////////////////// // CMcpServer Implementation ////////////////////////////////////////////////////////////////////////// diff --git a/server/2015Remote/McpServer.h b/server/2015Remote/McpServer.h index 4d67734..f960711 100644 --- a/server/2015Remote/McpServer.h +++ b/server/2015Remote/McpServer.h @@ -98,6 +98,47 @@ public: bool WaitPendingRegistry(uint64_t device_id, std::vector& subkeys, std::vector& values, int timeoutMs); + // ===== P3:exec_command(Windows 一次性远程命令,复刻 Web 终端链路)===== + // 流程:工具线程 BeginTermPending → 主连接下发 COMMAND_SHELL → 客户端 shell 子连接回 + // TOKEN_TERMINAL_START / TOKEN_SHELL_START → RegisterTerminalContext 接管 → 发 + // COMMAND_NEXT(+PTY resize) 启动输出回流 → 工具线程发哨兵命令行 → OnTerminalData 收集 + // 并做哨兵检测 → WaitTerminalDone 取 stdout + exit_code → CancelIO 关子链接。 + // 单设备单终端(MVP):BeginTermPending 命中已有会话即拒绝,避免同设备并发归属歧义。 + + // 该 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); + + // 工具线程:等待输出收集完成(哨兵命中 / 进程退出 / 超时)。 + // 成功 true:out=截断到哨兵前的原始字节、exitCode=0/1(进程退出无哨兵时 -1)、closed=是否进程退出。 + bool WaitTerminalDone(uint64_t device_id, std::vector& 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: CMcpServer(); ~CMcpServer(); @@ -131,6 +172,27 @@ private: // get_screenshot 的 reqId 发生器(16 位,跳过 0)。与 MFC 预览的 m_PreviewReqId 各自独立计数。 std::atomic m_PreviewReqId{1}; + + // ===== P3:exec_command 终端会话(受 m_TermMutex 保护)===== + struct TermSession { + bool started = false; // false=已发 COMMAND_SHELL 待 START;true=已接管 + context* subCtx = nullptr; // shell 子连接上下文 + bool isPty = false; // true=ConPTY(UTF-8);false=老 cmd 管道(GBK) + std::string command; // 原始 UTF-8 命令(审计用) + std::string nonce; // 哨兵随机串 + std::vector 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 m_TermSessions; // device_id → 会话 + std::map m_TermContextToDevice; // subCtx → device_id(顶部路由) + + bool m_readonly = true; + std::string m_cmdWhitelist; }; // 全局访问器(仿 WebService(),见 WebService.h 末尾) diff --git a/server/2015Remote/McpSettingsDlg.cpp b/server/2015Remote/McpSettingsDlg.cpp index 1d45fc5..57fbcee 100644 --- a/server/2015Remote/McpSettingsDlg.cpp +++ b/server/2015Remote/McpSettingsDlg.cpp @@ -46,6 +46,52 @@ void BuildDialogTemplate(std::vector& out, LPCWSTR caption, short cx, shor 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 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 BEGIN_MESSAGE_MAP(CMcpSettingsDlg, CDialog) @@ -60,7 +106,7 @@ INT_PTR CMcpSettingsDlg::DoModal() { USES_CONVERSION; CString title = _TR("MCP设置"); - BuildDialogTemplate(m_Template, T2CW(title), 320, 150); + BuildDialogTemplate(m_Template, T2CW(title), 320, 320); InitModalIndirect((LPCDLGTEMPLATE)m_Template.data()); return CDialog::DoModal(); } @@ -87,6 +133,13 @@ BOOL CMcpSettingsDlg::OnInitDialog() 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_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, r0, this, IDOK); 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_lblToken.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_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 tok = THIS_CFG.GetStr("settings", "McpToken", ""); 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_editPort.SetWindowText(CString(std::to_string(port).c_str())); m_editBind.SetWindowText(CString(bind.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()); return TRUE; @@ -124,11 +185,13 @@ BOOL CMcpSettingsDlg::OnInitDialog() void CMcpSettingsDlg::OnOK() { - CString sPort, sBind, sToken; + CString sPort, sBind, sToken, sWhitelist; m_editPort.GetWindowText(sPort); m_editBind.GetWindowText(sBind); m_editToken.GetWindowText(sToken); + m_editWhitelist.GetWindowText(sWhitelist); bool enabled = (m_btnEnable.GetCheck() == BST_CHECKED); + bool readonly = (m_btnReadonly.GetCheck() == BST_CHECKED); // 端口校验:1-65535 int port = atoi(CT2A(sPort)); @@ -151,10 +214,14 @@ void CMcpSettingsDlg::OnOK() THIS_CFG.SetInt("settings", "McpPort", port); THIS_CFG.SetStr("settings", "McpBind", bind); 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 中表示) MessageBox(_TR("MCP 设置已保存。") + _T("\r\n") + - _TR("启用/端口/绑定地址/Token 的改动需重启程序生效。"), + _TR("启用/端口/绑定地址/Token/只读/白名单的改动需重启程序生效。"), _TR("提示"), MB_ICONINFORMATION); 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); 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; m_btnCancel.MoveWindow(cx - margin - btnW, bottomY, btnW, btnH); m_btnOK.MoveWindow(cx - margin - btnW * 2 - gap, bottomY, btnW, btnH); diff --git a/server/2015Remote/McpSettingsDlg.h b/server/2015Remote/McpSettingsDlg.h index dc4fce3..2566b28 100644 --- a/server/2015Remote/McpSettingsDlg.h +++ b/server/2015Remote/McpSettingsDlg.h @@ -26,11 +26,16 @@ private: IDC_MCP_PORT = 1002, // 端口编辑框 IDC_MCP_BIND = 1003, // 绑定地址编辑框 IDC_MCP_TOKEN = 1004, // Token 编辑框 + IDC_MCP_READONLY = 1005, // 「只读模式」复选框(默认勾选,禁 exec_command) + IDC_MCP_WHITELIST = 1006, // 命令白名单编辑框(多行,逗号/换行分隔,空 = 内置只读前缀) }; CButton m_btnEnable; CStatic m_lblPort, m_lblBind, m_lblToken; CEdit m_editPort, m_editBind, m_editToken; + CButton m_btnReadonly; + CStatic m_lblWhitelist; + CEdit m_editWhitelist; CButton m_btnOK, m_btnCancel; std::vector m_Template; // 内存 DLGTEMPLATE 字节 diff --git a/server/2015Remote/lang/en_US.ini b/server/2015Remote/lang/en_US.ini index 55e8dc8..5173136 100644 --- a/server/2015Remote/lang/en_US.ini +++ b/server/2015Remote/lang/en_US.ini @@ -1987,4 +1987,7 @@ MCP ˿Ϊ 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. +/˿/󶨵ַ/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 diff --git a/server/2015Remote/lang/zh_TW.ini b/server/2015Remote/lang/zh_TW.ini index d630c66..21909bb 100644 --- a/server/2015Remote/lang/zh_TW.ini +++ b/server/2015Remote/lang/zh_TW.ini @@ -1978,4 +1978,7 @@ MCP ˿Ϊ 1-65535 =BӲ 1-65535 Ĕ Token Ϊ=Token ܞ MCP ѱ档=MCP Oу档 -/˿/󶨵ַ/Token ĸĶЧ=/BӲ/λַ/Token ׃†ӳʽЧ +/˿/󶨵ַ/Token/ֻ/ĸĶЧ=/BӲ/λַ/Token/Ψx/ε׃†ӳʽЧ +ֻģʽִУ=ΨxģʽУ += +MCPִ=MCP