Feature: Web remote terminal (xterm.js + mobile UX polish)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
#include "stdafx.h"
|
||||
#include "2015Remote.h"
|
||||
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
|
||||
#include "WebService.h"
|
||||
#include "WebServiceAuth.h"
|
||||
#include "2015RemoteDlg.h"
|
||||
@@ -18,6 +19,21 @@
|
||||
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
|
||||
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
|
||||
// Returns empty string on failure. The std::string is OK to hold binary data
|
||||
// (we only treat it as bytes; size is from .length()).
|
||||
static std::string LoadBinaryResourceAsString(int resourceId) {
|
||||
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
|
||||
if (!hRes) return {};
|
||||
DWORD size = SizeofResource(NULL, hRes);
|
||||
if (!size) return {};
|
||||
HGLOBAL hData = LoadResource(NULL, hRes);
|
||||
if (!hData) return {};
|
||||
LPVOID p = LockResource(hData);
|
||||
if (!p) return {};
|
||||
return std::string(static_cast<const char*>(p), size);
|
||||
}
|
||||
|
||||
// Challenge-response nonce storage (prevents replay attacks)
|
||||
static std::map<void*, std::string> s_ClientNonces;
|
||||
static std::mutex s_NonceMutex;
|
||||
@@ -241,9 +257,30 @@ void CWebService::ServerThread(int port) {
|
||||
static std::string cachedHtml = GetWebPageHTML();
|
||||
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
|
||||
|
||||
wsServer.onHttp([payloadsDir](const std::string& path) -> ws::HttpResponse {
|
||||
// 静态资源缓存:xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
|
||||
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
|
||||
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
|
||||
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
|
||||
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
|
||||
|
||||
auto buildStatic = [](const std::string& body, const std::string& mime) {
|
||||
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
|
||||
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
|
||||
return r;
|
||||
};
|
||||
|
||||
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
|
||||
if (path == "/" || path == "/index.html") {
|
||||
return ws::HttpResponse::OK(cachedHtml);
|
||||
} else if (path == "/static/xterm.js") {
|
||||
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/static/xterm.css") {
|
||||
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
|
||||
} else if (path == "/static/xterm-fit.js") {
|
||||
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/health") {
|
||||
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
|
||||
} else if (path == "/manifest.json") {
|
||||
@@ -366,6 +403,14 @@ void CWebService::ServerThread(int port) {
|
||||
HandleListUsers(ws_ptr, token);
|
||||
} else if (cmd == "get_groups") {
|
||||
HandleGetGroups(ws_ptr, token);
|
||||
} else if (cmd == "term_open") {
|
||||
HandleTermOpen(ws_ptr, msg);
|
||||
} else if (cmd == "term_input") {
|
||||
HandleTermInput(ws_ptr, msg);
|
||||
} else if (cmd == "term_resize") {
|
||||
HandleTermResize(ws_ptr, msg);
|
||||
} else if (cmd == "term_close") {
|
||||
HandleTermClose(ws_ptr, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1163,6 +1208,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
|
||||
if (device_id > 0) {
|
||||
StopRemoteDesktop(device_id);
|
||||
}
|
||||
|
||||
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
std::vector<uint64_t> to_close;
|
||||
for (auto& kv : m_TermSessions) {
|
||||
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
|
||||
}
|
||||
for (uint64_t did : to_close) CloseTermSessionLocked(did);
|
||||
}
|
||||
}
|
||||
|
||||
WebClient* CWebService::FindClient(void* ws_ptr) {
|
||||
@@ -1716,6 +1771,255 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Web Terminal Session
|
||||
//
|
||||
// 数据流向:
|
||||
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
|
||||
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
|
||||
// ── RegisterTerminalContext ──► WebService
|
||||
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
|
||||
// ──► term_output 给浏览器
|
||||
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
|
||||
//
|
||||
// 一台主机最多一个 web 终端会话(MVP)。
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
|
||||
Json::Value v;
|
||||
v["cmd"] = cmd;
|
||||
for (auto& p : kv) v[p.first] = p.second;
|
||||
Json::StreamWriterBuilder b; b["indentation"] = "";
|
||||
return Json::writeString(b, v);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
|
||||
return;
|
||||
}
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id || !m_pParentDlg) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
|
||||
return;
|
||||
}
|
||||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||||
if (!ctx) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group permission check (admin 全部可见)
|
||||
if (username != "admin") {
|
||||
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
|
||||
bool ok = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_UsersMutex);
|
||||
for (auto& u : m_Users) if (u.username == username) {
|
||||
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 占用:MVP 阶段单设备单 web 终端
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
||||
m_TermPending.find(device_id) != m_TermPending.end()) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
|
||||
"Terminal already open by another viewer"));
|
||||
return;
|
||||
}
|
||||
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
|
||||
s.shell_ctx = nullptr; s.is_pty = false;
|
||||
m_TermSessions[device_id] = s;
|
||||
m_TermPending.insert(device_id);
|
||||
}
|
||||
|
||||
// 触发客户端:发 COMMAND_SHELL
|
||||
BYTE cmd = COMMAND_SHELL;
|
||||
if (!ctx->Send2Client(&cmd, 1)) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
m_TermSessions.erase(device_id);
|
||||
m_TermPending.erase(device_id);
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
|
||||
return;
|
||||
}
|
||||
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
std::string data = root.get("data", "").asString();
|
||||
if (!device_id || data.empty()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
}
|
||||
if (!shellCtx) return; // shell 子上下文还没就绪
|
||||
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
int cols = root.get("cols", 0).asInt();
|
||||
int rows = root.get("rows", 0).asInt();
|
||||
if (!device_id || cols <= 0 || rows <= 0) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
bool isPty = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
isPty = it->second.is_pty;
|
||||
}
|
||||
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
|
||||
|
||||
BYTE buf[5];
|
||||
buf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(buf + 1) = (short)cols;
|
||||
*(short*)(buf + 3) = (short)rows;
|
||||
shellCtx->Send2Client(buf, 5);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id) return;
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
|
||||
void* ws_ptr = it->second.ws_ptr;
|
||||
m_TermSessions.erase(it);
|
||||
m_TermPending.erase(device_id);
|
||||
if (shellCtx) {
|
||||
m_TermContextToDevice.erase(shellCtx);
|
||||
// 触发客户端 shell 退出:直接断该子上下文
|
||||
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
|
||||
shellCtx->CancelIO();
|
||||
}
|
||||
// 通知前端
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
|
||||
Mprintf("[WebService] term_closed device=%llu\n", device_id);
|
||||
}
|
||||
|
||||
bool CWebService::IsTermPending(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermPending.find(device_id) != m_TermPending.end();
|
||||
}
|
||||
|
||||
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
|
||||
void* ws_ptr_to_notify = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
|
||||
it->second.shell_ctx = ctx;
|
||||
it->second.is_pty = isPty;
|
||||
m_TermContextToDevice[ctx] = device_id;
|
||||
m_TermPending.erase(device_id);
|
||||
ws_ptr_to_notify = it->second.ws_ptr;
|
||||
}
|
||||
|
||||
// 关键步骤:告知客户端"启动 shell 输出回流"。
|
||||
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
|
||||
// (参考 TerminalDlg::OnTerminalReady)。漏发会导致 shell 在跑但输出永远不送回。
|
||||
// PTY 还要先告知初始 cols/rows(默认 80x24),否则 shell 会按 PTY 默认尺寸渲染,
|
||||
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
|
||||
if (isPty) {
|
||||
BYTE resizeBuf[5];
|
||||
resizeBuf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(resizeBuf + 1) = (short)80;
|
||||
*(short*)(resizeBuf + 3) = (short)24;
|
||||
ctx->Send2Client(resizeBuf, 5);
|
||||
}
|
||||
BYTE startCmd = COMMAND_NEXT;
|
||||
ctx->Send2Client(&startCmd, 1);
|
||||
|
||||
// 通知前端 ready,告知模式(pty / legacy)
|
||||
if (ws_ptr_to_notify) {
|
||||
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
|
||||
}
|
||||
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
|
||||
device_id, isPty ? "pty" : "legacy");
|
||||
}
|
||||
|
||||
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
|
||||
void* ws_ptr = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
auto sit = m_TermSessions.find(it->second);
|
||||
if (sit == m_TermSessions.end()) return;
|
||||
ws_ptr = sit->second.ws_ptr;
|
||||
}
|
||||
if (!ws_ptr || !data || !len) return;
|
||||
|
||||
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
|
||||
// 帧格式:[4B magic 'TRM1'][N=payload]
|
||||
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceID(uint32 LE),
|
||||
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
|
||||
std::vector<uint8_t> packet;
|
||||
packet.reserve(len + 4);
|
||||
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
|
||||
packet.insert(packet.end(), data, data + len);
|
||||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
uint64_t device_id = it->second;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Screen Context Registry (for mouse/keyboard control)
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
Reference in New Issue
Block a user