2261 lines
79 KiB
C++
2261 lines
79 KiB
C++
#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"
|
||
#include "Server.h" // For CONTEXT_OBJECT
|
||
#include "jsoncpp/json.h"
|
||
#include "WebPage.h"
|
||
#include "SimpleWebSocket.h"
|
||
#include "common/commands.h"
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <shlobj.h>
|
||
#include <set>
|
||
|
||
// Algorithm constants (same as ScreenSpyDlg.cpp)
|
||
#define ALGORITHM_H264 2
|
||
|
||
#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;
|
||
static std::atomic<bool> s_bShuttingDown{false}; // Prevents access during static destruction
|
||
|
||
// Generate random salt (16 hex chars) - thread-safe
|
||
static std::string GenerateSalt() {
|
||
static std::random_device rd;
|
||
static std::mt19937_64 gen(rd());
|
||
std::uniform_int_distribution<uint64_t> dis;
|
||
char buf[17];
|
||
snprintf(buf, sizeof(buf), "%016llX", dis(gen));
|
||
return std::string(buf);
|
||
}
|
||
|
||
// Generate random nonce (32 hex chars) - thread-safe
|
||
static std::string GenerateNonce() {
|
||
if (s_bShuttingDown) return "";
|
||
static std::random_device rd;
|
||
static std::mt19937_64 gen(rd());
|
||
std::uniform_int_distribution<uint64_t> dis;
|
||
char buf[33];
|
||
{
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
sprintf_s(buf, "%016llx%016llx", dis(gen), dis(gen));
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
// Store nonce for client
|
||
static void StoreNonce(void* ws_ptr, const std::string& nonce) {
|
||
if (s_bShuttingDown) return;
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
s_ClientNonces[ws_ptr] = nonce;
|
||
}
|
||
|
||
// Get and clear nonce for client (one-time use)
|
||
static std::string ConsumeNonce(void* ws_ptr) {
|
||
if (s_bShuttingDown) return "";
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
auto it = s_ClientNonces.find(ws_ptr);
|
||
if (it == s_ClientNonces.end()) return "";
|
||
std::string nonce = it->second;
|
||
s_ClientNonces.erase(it);
|
||
return nonce;
|
||
}
|
||
|
||
// Clear nonce when client disconnects
|
||
static void ClearNonce(void* ws_ptr) {
|
||
if (s_bShuttingDown) return;
|
||
std::lock_guard<std::mutex> lock(s_NonceMutex);
|
||
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
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
CWebService& CWebService::Instance() {
|
||
static CWebService instance;
|
||
return instance;
|
||
}
|
||
|
||
CWebService::CWebService()
|
||
: m_bRunning(false)
|
||
, m_bStopping(false)
|
||
, m_pServer(nullptr)
|
||
, m_pParentDlg(nullptr)
|
||
, m_nMaxClientsPerDevice(10)
|
||
, m_nTokenExpireSeconds(86400) // 24 hours
|
||
, m_bHideWebSessions(true) // Hide web-triggered dialogs by default
|
||
{
|
||
// Secret key will be initialized in Start() via WSAuth::Init()
|
||
// Admin user will be configured via SetAdminPassword()
|
||
|
||
// Initialize payloads directory
|
||
char exe_path[MAX_PATH];
|
||
GetModuleFileNameA(NULL, exe_path, MAX_PATH);
|
||
std::filesystem::path exeDir = std::filesystem::path(exe_path).parent_path();
|
||
m_PayloadsDir = (exeDir / "Payloads").string();
|
||
std::error_code ec;
|
||
std::filesystem::create_directories(m_PayloadsDir, ec);
|
||
|
||
// Initialize config directory (same as YAMA.db location)
|
||
char appdata_path[MAX_PATH];
|
||
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata_path))) {
|
||
m_ConfigDir = std::string(appdata_path) + "\\" BRAND_DATA_FOLDER "\\";
|
||
} else {
|
||
m_ConfigDir = ".\\";
|
||
}
|
||
std::filesystem::create_directories(m_ConfigDir, ec);
|
||
}
|
||
|
||
void CWebService::SetAdminPassword(const std::string& password) {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
m_Users.clear();
|
||
|
||
// Admin user is built-in, always first
|
||
WebUser admin;
|
||
admin.username = "admin";
|
||
admin.salt = ""; // Not used with challenge-response auth
|
||
admin.password_hash = WSAuth::ComputeSHA256(password); // Simple SHA256
|
||
admin.role = "admin";
|
||
m_Users.push_back(admin);
|
||
|
||
Mprintf("[WebService] Admin password configured\n");
|
||
|
||
// Load additional users from file (non-admin users)
|
||
LoadUsers();
|
||
}
|
||
|
||
CWebService::~CWebService() {
|
||
Stop();
|
||
}
|
||
|
||
bool CWebService::Start(int port) {
|
||
if (m_bRunning) return true;
|
||
|
||
// Initialize authorization and get secret key
|
||
// Pass raw authorization string, verified internally by private library
|
||
WSAuthContext authCtx;
|
||
std::string authStr = THIS_CFG.GetStr("settings", "Authorization", "");
|
||
if (!WSAuth::Init(authCtx, authStr)) {
|
||
Mprintf("[WebService] Authorization check failed, service disabled\n");
|
||
return false;
|
||
}
|
||
|
||
m_SecretKey = authCtx.secretKey;
|
||
m_nTokenExpireSeconds = authCtx.tokenExpireSec;
|
||
m_nMaxClientsPerDevice = authCtx.maxClientsPerDevice;
|
||
|
||
m_bStopping = false;
|
||
m_ServerThread = std::thread(&CWebService::ServerThread, this, port);
|
||
|
||
// Wait for server to start
|
||
for (int i = 0; i < 50 && !m_bRunning; i++) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
|
||
// Start heartbeat thread
|
||
if (m_bRunning) {
|
||
m_HeartbeatThread = std::thread(&CWebService::HeartbeatThread, this);
|
||
}
|
||
|
||
return m_bRunning;
|
||
}
|
||
|
||
void CWebService::Stop() {
|
||
if (!m_bRunning) return;
|
||
|
||
// Set flags FIRST to prevent new operations from starting
|
||
m_bRunning = false;
|
||
m_bStopping = true;
|
||
s_bShuttingDown = true; // Prevent access to static variables
|
||
|
||
// Stop heartbeat thread first
|
||
if (m_HeartbeatThread.joinable()) {
|
||
m_HeartbeatThread.join();
|
||
}
|
||
|
||
if (m_ServerThread.joinable()) {
|
||
m_ServerThread.join();
|
||
}
|
||
|
||
// Clear all clients after server stopped
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
m_Clients.clear();
|
||
}
|
||
|
||
// Clear screen contexts to prevent dangling pointers
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts.clear();
|
||
}
|
||
|
||
// Clear device cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
m_DeviceCache.clear();
|
||
}
|
||
|
||
m_pServer = nullptr;
|
||
m_pParentDlg = nullptr; // Clear to prevent access to destroyed dialog
|
||
}
|
||
|
||
bool CWebService::IsRunning() const {
|
||
return m_bRunning;
|
||
}
|
||
|
||
void CWebService::ServerThread(int port) {
|
||
ws::Server wsServer;
|
||
m_pServer = &wsServer;
|
||
|
||
// Serve static HTML page and file downloads
|
||
static std::string cachedHtml = GetWebPageHTML();
|
||
// Log loaded size; zero bytes means the RC BINARY resource is missing or
|
||
// server/web/index.html was not embedded — check 2015Remote.rc and rebuild.
|
||
Mprintf("[WebService] index.html loaded: %zu bytes\n", cachedHtml.size());
|
||
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
|
||
|
||
// 静态资源缓存: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") {
|
||
// PWA manifest for iOS standalone app support
|
||
static const char* manifest = R"({
|
||
"name": "SimpleRemoter",
|
||
"short_name": "Remoter",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"orientation": "any",
|
||
"background_color": "#1a1a2e",
|
||
"theme_color": "#1a1a2e"
|
||
})";
|
||
return ws::HttpResponse::OK(manifest, "application/manifest+json");
|
||
} else if (path.rfind("/payloads/", 0) == 0) {
|
||
// File download: /payloads/filename
|
||
std::string filename = path.substr(10); // Remove "/payloads/"
|
||
|
||
// Security check: no path traversal or absolute paths
|
||
if (filename.empty() ||
|
||
filename.find("..") != std::string::npos || // Path traversal
|
||
filename[0] == '/' || filename[0] == '\\' || // Absolute path (Unix/Windows)
|
||
(filename.length() > 1 && filename[1] == ':')) // Windows drive letter (C:)
|
||
{
|
||
return ws::HttpResponse::Forbidden();
|
||
}
|
||
|
||
std::filesystem::path filepath = std::filesystem::path(payloadsDir) / filename;
|
||
std::error_code ec;
|
||
if (!std::filesystem::exists(filepath, ec) || !std::filesystem::is_regular_file(filepath, ec)) {
|
||
return ws::HttpResponse::NotFound();
|
||
}
|
||
|
||
return ws::HttpResponse::File(filepath.string(), filename);
|
||
}
|
||
return ws::HttpResponse::NotFound();
|
||
});
|
||
|
||
// WebSocket connect
|
||
wsServer.onConnect([this](std::shared_ptr<ws::Connection> conn) {
|
||
// Skip if server is stopping
|
||
if (m_bStopping) return;
|
||
|
||
void* ws_ptr = conn.get();
|
||
RegisterClient(ws_ptr, conn->clientIP());
|
||
|
||
// Generate and send challenge nonce
|
||
std::string nonce = GenerateNonce();
|
||
if (nonce.empty()) return; // Shutting down
|
||
StoreNonce(ws_ptr, nonce);
|
||
|
||
Json::Value challenge;
|
||
challenge["cmd"] = "challenge";
|
||
challenge["nonce"] = nonce;
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, challenge));
|
||
});
|
||
|
||
// WebSocket disconnect
|
||
wsServer.onDisconnect([this](std::shared_ptr<ws::Connection> conn) {
|
||
// Skip cleanup if server is stopping (destructor will clean up)
|
||
if (m_bStopping) return;
|
||
void* ws_ptr = conn.get();
|
||
ClearNonce(ws_ptr);
|
||
UnregisterClient(ws_ptr);
|
||
});
|
||
|
||
// WebSocket message
|
||
wsServer.onMessage([this](std::shared_ptr<ws::Connection> conn, const std::string& msg) {
|
||
// Skip if server is stopping
|
||
if (m_bStopping) return;
|
||
|
||
void* ws_ptr = conn.get();
|
||
|
||
// Update last activity time for heartbeat
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
it->second.last_activity = (uint64_t)time(nullptr);
|
||
}
|
||
}
|
||
|
||
// Parse JSON signaling
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (reader.parse(msg, root)) {
|
||
std::string cmd = root.get("cmd", "").asString();
|
||
std::string token = root.get("token", "").asString();
|
||
|
||
if (cmd == "login") {
|
||
HandleLogin(ws_ptr, msg, conn->clientIP());
|
||
} else if (cmd == "get_devices") {
|
||
HandleGetDevices(ws_ptr, token);
|
||
} else if (cmd == "connect") {
|
||
// Support both string and number ID formats
|
||
std::string id_str = root.get("id", "").asString();
|
||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||
HandleConnect(ws_ptr, token, device_id);
|
||
} else if (cmd == "disconnect") {
|
||
std::string disc_id_str = root.get("id", "").asString();
|
||
uint64_t disc_device_id = disc_id_str.empty() ? 0 : strtoull(disc_id_str.c_str(), nullptr, 10);
|
||
HandleDisconnect(ws_ptr, token, disc_device_id);
|
||
} else if (cmd == "ping") {
|
||
HandlePing(ws_ptr, token);
|
||
} else if (cmd == "mouse") {
|
||
HandleMouse(ws_ptr, msg);
|
||
} else if (cmd == "key") {
|
||
HandleKey(ws_ptr, msg);
|
||
} else if (cmd == "rdp_reset") {
|
||
HandleRdpReset(ws_ptr, token);
|
||
} else if (cmd == "audio_toggle") {
|
||
HandleAudioToggle(ws_ptr, token);
|
||
} else if (cmd == "get_salt") {
|
||
HandleGetSalt(ws_ptr, msg);
|
||
} else if (cmd == "create_user") {
|
||
HandleCreateUser(ws_ptr, msg);
|
||
} else if (cmd == "delete_user") {
|
||
HandleDeleteUser(ws_ptr, msg);
|
||
} else if (cmd == "list_users") {
|
||
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);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Start listening
|
||
if (wsServer.start(port)) {
|
||
m_bRunning = true;
|
||
// Wait until stop is requested
|
||
while (!m_bStopping && wsServer.isRunning()) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
wsServer.stop();
|
||
}
|
||
m_bRunning = false;
|
||
}
|
||
|
||
void CWebService::HeartbeatThread() {
|
||
Mprintf("[WebService] Heartbeat thread started (interval=%ds, timeout=%ds)\n",
|
||
HEARTBEAT_INTERVAL_SEC, HEARTBEAT_TIMEOUT_SEC);
|
||
|
||
while (!m_bStopping) {
|
||
// Sleep in small increments to respond quickly to stop
|
||
for (int i = 0; i < HEARTBEAT_INTERVAL_SEC * 10 && !m_bStopping; i++) {
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
}
|
||
if (m_bStopping) break;
|
||
|
||
// Send ping to all WebSocket connections
|
||
// Copy pointer first to avoid race with Stop()
|
||
void* pServer = m_pServer;
|
||
if (pServer && !m_bStopping) {
|
||
auto* wsServer = static_cast<ws::Server*>(pServer);
|
||
if (wsServer->isRunning()) {
|
||
wsServer->pingAll();
|
||
}
|
||
}
|
||
|
||
// Check for timed out clients
|
||
uint64_t now = (uint64_t)time(nullptr);
|
||
std::vector<void*> timedOutClients;
|
||
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
// Only check clients that are watching a device (streaming video)
|
||
if (client.watch_device_id > 0 && client.last_activity > 0) {
|
||
uint64_t elapsed = now - client.last_activity;
|
||
if (elapsed > HEARTBEAT_TIMEOUT_SEC) {
|
||
timedOutClients.push_back(ws_ptr);
|
||
Mprintf("[WebService] Client %s timed out (no activity for %llu seconds)\n",
|
||
client.client_ip.c_str(), elapsed);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Disconnect timed out clients (outside lock to avoid deadlock)
|
||
for (void* ws_ptr : timedOutClients) {
|
||
UnregisterClient(ws_ptr);
|
||
}
|
||
}
|
||
|
||
Mprintf("[WebService] Heartbeat thread stopped\n");
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Signaling Handlers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::string& client_ip) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string username = root.get("username", "").asString();
|
||
std::string response = root.get("response", "").asString();
|
||
std::string clientNonce = root.get("nonce", "").asString();
|
||
|
||
// Verify nonce matches (prevents replay)
|
||
std::string storedNonce = ConsumeNonce(ws_ptr);
|
||
if (storedNonce.empty() || storedNonce != clientNonce) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid or expired challenge"));
|
||
return;
|
||
}
|
||
|
||
// Check rate limit
|
||
if (!CheckRateLimit(client_ip)) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Too many failed attempts. Try again later."));
|
||
return;
|
||
}
|
||
|
||
// Check if password is configured
|
||
if (m_Users.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Server password not configured"));
|
||
return;
|
||
}
|
||
|
||
// Find user
|
||
WebUser* user = nullptr;
|
||
for (auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
user = &u;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!user) {
|
||
RecordFailedLogin(client_ip);
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||
return;
|
||
}
|
||
|
||
// Verify challenge response: response = SHA256(passwordHash + nonce)
|
||
std::string expectedResponse = WSAuth::ComputeSHA256(user->password_hash + clientNonce);
|
||
if (response != expectedResponse) {
|
||
RecordFailedLogin(client_ip);
|
||
SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials"));
|
||
return;
|
||
}
|
||
|
||
// Success - generate token
|
||
RecordSuccessLogin(client_ip);
|
||
std::string token = GenerateToken(username, user->role);
|
||
|
||
// Update client state
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
it->second.token = token;
|
||
it->second.username = username;
|
||
it->second.role = user->role;
|
||
}
|
||
}
|
||
|
||
// Build response
|
||
Json::Value res;
|
||
res["cmd"] = "login_result";
|
||
res["ok"] = true;
|
||
res["token"] = token;
|
||
res["role"] = user->role;
|
||
res["expires"] = m_nTokenExpireSeconds;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
}
|
||
|
||
void CWebService::HandleGetSalt(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("salt", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string username = root.get("username", "").asString();
|
||
if (username.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("salt", false, "Username required"));
|
||
return;
|
||
}
|
||
|
||
// Find user and get salt
|
||
std::string salt = "";
|
||
bool userFound = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
salt = u.salt;
|
||
userFound = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// For security: if user doesn't exist, generate a fake deterministic salt
|
||
// This prevents username enumeration attacks
|
||
// Note: Admin has empty salt, so we must check userFound, not salt.empty()
|
||
if (!userFound) {
|
||
// Generate deterministic fake salt from username (won't match any real password)
|
||
salt = WSAuth::ComputeSHA256("fake_salt_prefix_" + username).substr(0, 16);
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "salt";
|
||
res["ok"] = true;
|
||
res["salt"] = salt;
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
}
|
||
|
||
void CWebService::HandleGetDevices(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("device_list", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
SendText(ws_ptr, BuildDeviceListJson(username));
|
||
}
|
||
|
||
void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t device_id) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Check device exists and is online
|
||
if (!m_pParentDlg) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Service not ready"));
|
||
return;
|
||
}
|
||
|
||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||
if (!ctx) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Device not online"));
|
||
return;
|
||
}
|
||
|
||
// Check group permission (admin can access all devices)
|
||
if (username != "admin") {
|
||
std::string deviceGroup = ctx->GetGroupName();
|
||
if (deviceGroup.empty()) deviceGroup = "default";
|
||
|
||
bool hasAccess = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
for (const auto& g : u.allowed_groups) {
|
||
if (g == deviceGroup) {
|
||
hasAccess = true;
|
||
break;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!hasAccess) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Check max clients per device
|
||
int current_count = GetWebClientCount(device_id);
|
||
if (current_count >= m_nMaxClientsPerDevice) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Too many viewers"));
|
||
return;
|
||
}
|
||
|
||
// Update client state
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
it->second.watch_device_id = device_id;
|
||
}
|
||
}
|
||
|
||
// Start remote desktop session if this is the first web viewer
|
||
if (current_count == 0) {
|
||
if (!StartRemoteDesktop(device_id)) {
|
||
SendText(ws_ptr, BuildJsonResponse("connect_result", false, "Failed to start remote desktop"));
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Get screen dimensions + audio state from device info cache (may not be ready)
|
||
int width = 0, height = 0;
|
||
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it != m_DeviceCache.end()) {
|
||
width = it->second->screen_width;
|
||
height = it->second->screen_height;
|
||
audio_enabled = it->second->audio_enabled;
|
||
}
|
||
}
|
||
|
||
// Build success response
|
||
Json::Value res;
|
||
res["cmd"] = "connect_result";
|
||
res["ok"] = true;
|
||
// Only include dimensions if we have valid cached values
|
||
// Otherwise, client will wait for resolution_changed message
|
||
if (width > 0 && height > 0) {
|
||
res["width"] = width;
|
||
res["height"] = height;
|
||
}
|
||
if (audio_enabled >= 0) {
|
||
res["audio_enabled"] = (audio_enabled != 0);
|
||
}
|
||
res["algorithm"] = "h264";
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
|
||
// Send cached keyframe if available
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it != m_DeviceCache.end() && !it->second->keyframe_cache.empty()) {
|
||
std::lock_guard<std::mutex> cache_lock(it->second->cache_mutex);
|
||
auto packet = BuildFramePacket(device_id, true,
|
||
it->second->keyframe_cache.data(),
|
||
it->second->keyframe_cache.size());
|
||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint64_t requested_device_id) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("disconnect_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Get the device_id before clearing watch state
|
||
uint64_t device_id = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
// Only disconnect if no specific device requested, or if it matches current device
|
||
// This prevents race condition when quickly switching devices
|
||
if (requested_device_id == 0 || it->second.watch_device_id == requested_device_id) {
|
||
device_id = it->second.watch_device_id;
|
||
it->second.watch_device_id = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Close remote desktop if this was the last viewer
|
||
if (device_id > 0) {
|
||
StopRemoteDesktop(device_id);
|
||
}
|
||
|
||
SendText(ws_ptr, BuildJsonResponse("disconnect_result", true));
|
||
}
|
||
|
||
void CWebService::HandlePing(void* ws_ptr, const std::string& token) {
|
||
Json::Value res;
|
||
res["cmd"] = "pong";
|
||
res["time"] = (Json::Int64)time(nullptr);
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
}
|
||
|
||
void CWebService::HandleMouse(void* ws_ptr, const std::string& msg) {
|
||
// Parse JSON
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string type = root.get("type", "").asString();
|
||
int x = root.get("x", 0).asInt();
|
||
int y = root.get("y", 0).asInt();
|
||
int button = root.get("button", 0).asInt();
|
||
int delta = root.get("delta", 0).asInt();
|
||
|
||
// Validate token
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
return;
|
||
}
|
||
|
||
// Get device being watched
|
||
uint64_t device_id = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
device_id = it->second.watch_device_id;
|
||
it->second.last_activity = (uint64_t)time(nullptr);
|
||
}
|
||
}
|
||
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context (not main context!)
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// Build MSG64 structure
|
||
MSG64 msg64;
|
||
memset(&msg64, 0, sizeof(MSG64));
|
||
msg64.pt.x = x;
|
||
msg64.pt.y = y;
|
||
msg64.lParam = MAKELPARAM(x, y);
|
||
msg64.time = GetTickCount();
|
||
|
||
// Map type and button to Windows message
|
||
if (type == "down") {
|
||
if (button == 0) {
|
||
msg64.message = WM_LBUTTONDOWN;
|
||
msg64.wParam = MK_LBUTTON;
|
||
} else if (button == 1) {
|
||
msg64.message = WM_MBUTTONDOWN;
|
||
msg64.wParam = MK_MBUTTON;
|
||
} else if (button == 2) {
|
||
msg64.message = WM_RBUTTONDOWN;
|
||
msg64.wParam = MK_RBUTTON;
|
||
}
|
||
} else if (type == "up") {
|
||
if (button == 0) {
|
||
msg64.message = WM_LBUTTONUP;
|
||
} else if (button == 1) {
|
||
msg64.message = WM_MBUTTONUP;
|
||
} else if (button == 2) {
|
||
msg64.message = WM_RBUTTONUP;
|
||
}
|
||
} else if (type == "move") {
|
||
msg64.message = WM_MOUSEMOVE;
|
||
} else if (type == "wheel") {
|
||
msg64.message = WM_MOUSEWHEEL;
|
||
// WM_MOUSEWHEEL: HIWORD(wParam) = wheel delta, LOWORD(wParam) = key flags
|
||
// Normalize: browser delta is usually ±100+, Windows expects ±120
|
||
short wheelDelta = (short)(delta > 0 ? -120 : (delta < 0 ? 120 : 0));
|
||
msg64.wParam = MAKEWPARAM(0, wheelDelta);
|
||
} else if (type == "dblclick") {
|
||
// dblclick is only needed for macOS clients
|
||
// Windows detects double-click from rapid mousedown/mouseup sequence
|
||
context* mainCtx = m_pParentDlg->FindHost(device_id);
|
||
if (!mainCtx) return;
|
||
CString clientType = mainCtx->GetAdditionalData(RES_CLIENT_TYPE);
|
||
// Check for both "MAC" (new) and "macOS" (legacy) for compatibility
|
||
if (clientType != GetClientType(CLIENT_TYPE_MACOS) && clientType != "macOS") {
|
||
return; // Skip dblclick for non-macOS clients
|
||
}
|
||
if (button == 0) {
|
||
msg64.message = WM_LBUTTONDBLCLK;
|
||
msg64.wParam = MK_LBUTTON;
|
||
} else if (button == 2) {
|
||
msg64.message = WM_RBUTTONDBLCLK;
|
||
msg64.wParam = MK_RBUTTON;
|
||
}
|
||
} else {
|
||
return; // Unknown type
|
||
}
|
||
|
||
// Send command to device
|
||
const int length = sizeof(MSG64) + 1;
|
||
BYTE szData[length + 4];
|
||
szData[0] = COMMAND_SCREEN_CONTROL;
|
||
memcpy(szData + 1, &msg64, sizeof(MSG64));
|
||
ctx->Send2Client(szData, length);
|
||
}
|
||
|
||
void CWebService::HandleKey(void* ws_ptr, const std::string& msg) {
|
||
// Parse JSON
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
int keyCode = root.get("keyCode", 0).asInt();
|
||
bool isDown = root.get("down", false).asBool();
|
||
bool altKey = root.get("alt", false).asBool();
|
||
|
||
// Filter Windows keys (same as MFC version)
|
||
if (keyCode == VK_LWIN || keyCode == VK_RWIN) {
|
||
return;
|
||
}
|
||
|
||
// Validate token
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
return;
|
||
}
|
||
|
||
// Get device being watched
|
||
uint64_t device_id = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
device_id = it->second.watch_device_id;
|
||
it->second.last_activity = (uint64_t)time(nullptr);
|
||
}
|
||
}
|
||
|
||
if (device_id == 0) return;
|
||
|
||
// Get screen context
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
return;
|
||
}
|
||
|
||
// Build MSG64 structure
|
||
MSG64 msg64;
|
||
memset(&msg64, 0, sizeof(MSG64));
|
||
|
||
// Use WM_SYSKEYDOWN/UP for Alt combinations (same as MFC version)
|
||
if (altKey) {
|
||
msg64.message = isDown ? WM_SYSKEYDOWN : WM_SYSKEYUP;
|
||
} else {
|
||
msg64.message = isDown ? WM_KEYDOWN : WM_KEYUP;
|
||
}
|
||
msg64.wParam = keyCode;
|
||
msg64.time = GetTickCount();
|
||
|
||
// Build lParam for keyboard message:
|
||
// bits 0-15: repeat count (1)
|
||
// bits 16-23: scan code
|
||
// bit 24: extended key flag
|
||
// bit 29: context code (1 if Alt is pressed)
|
||
// bit 30: previous key state (1 for keyup)
|
||
// bit 31: transition state (1 for keyup)
|
||
UINT scanCode = MapVirtualKey(keyCode, MAPVK_VK_TO_VSC);
|
||
|
||
// Extended keys: arrows, insert, delete, home, end, page up/down, numpad enter, etc.
|
||
bool isExtended = (keyCode >= VK_PRIOR && keyCode <= VK_DOWN) || // Page Up/Down, End, Home, Arrows
|
||
keyCode == VK_INSERT || keyCode == VK_DELETE ||
|
||
keyCode == VK_NUMLOCK || keyCode == VK_RCONTROL || keyCode == VK_RMENU ||
|
||
keyCode == VK_APPS;
|
||
|
||
LPARAM lParam = 1; // repeat count = 1
|
||
lParam |= (scanCode & 0xFF) << 16;
|
||
if (isExtended) lParam |= (1 << 24);
|
||
if (altKey) lParam |= (1 << 29); // context code for Alt
|
||
if (!isDown) lParam |= (3UL << 30); // bit 30 and 31 set for key up
|
||
|
||
msg64.lParam = lParam;
|
||
|
||
// Send command to device
|
||
const int length = sizeof(MSG64) + 1;
|
||
BYTE szData[length + 4];
|
||
szData[0] = COMMAND_SCREEN_CONTROL;
|
||
memcpy(szData + 1, &msg64, sizeof(MSG64));
|
||
ctx->Send2Client(szData, length);
|
||
}
|
||
|
||
void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Get the device being watched by this client
|
||
uint64_t device_id = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
device_id = it->second.watch_device_id;
|
||
}
|
||
}
|
||
|
||
if (device_id == 0) {
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "No device connected"));
|
||
return;
|
||
}
|
||
|
||
// Get screen context (not main context!)
|
||
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||
if (!ctx) {
|
||
Mprintf("[WebService] HandleRdpReset: No screen context for device %llu\n", device_id);
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "No active screen session"));
|
||
return;
|
||
}
|
||
|
||
// Send CMD_RESTORE_CONSOLE command to client
|
||
BYTE bToken = CMD_RESTORE_CONSOLE;
|
||
if (ctx->Send2Client(&bToken, 1)) {
|
||
Mprintf("[WebService] Sent RDP reset command to device %llu\n", device_id);
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Failed to send command"));
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
uint64_t device_id = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) device_id = it->second.watch_device_id;
|
||
}
|
||
if (device_id == 0) {
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected"));
|
||
return;
|
||
}
|
||
|
||
// 投递到 ScreenSpyDlg 的 UI 线程;那边会调用 Enable/DisableAudio 并通过
|
||
// NotifyAudioState 把新状态广播给所有 watching 的 web 客户端
|
||
if (!m_pParentDlg || !m_pParentDlg->PostWebAudioToggle(device_id)) {
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No active screen session"));
|
||
return;
|
||
}
|
||
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true));
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// User Management Handlers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can create users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
std::string newUsername = root.get("username", "").asString();
|
||
std::string newPassword = root.get("password", "").asString();
|
||
std::string newRole = root.get("role", "viewer").asString();
|
||
|
||
// Parse allowed_groups array
|
||
std::vector<std::string> allowedGroups;
|
||
const Json::Value& groups = root["allowed_groups"];
|
||
if (groups.isArray()) {
|
||
for (const auto& g : groups) {
|
||
if (g.isString() && !g.asString().empty()) {
|
||
allowedGroups.push_back(g.asString());
|
||
}
|
||
}
|
||
}
|
||
|
||
if (newUsername.empty() || newPassword.empty()) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Username and password required"));
|
||
return;
|
||
}
|
||
|
||
if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)"));
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleDeleteUser(void* ws_ptr, const std::string& msg) {
|
||
Json::Value root;
|
||
Json::Reader reader;
|
||
if (!reader.parse(msg, root)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Invalid JSON"));
|
||
return;
|
||
}
|
||
|
||
std::string token = root.get("token", "").asString();
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can delete users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
std::string targetUsername = root.get("username", "").asString();
|
||
|
||
if (DeleteUser(targetUsername)) {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", true));
|
||
} else {
|
||
SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Failed to delete user"));
|
||
}
|
||
}
|
||
|
||
void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can list users
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "list_users_result";
|
||
res["ok"] = true;
|
||
|
||
Json::Value usersArray(Json::arrayValue);
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
Json::Value user;
|
||
user["username"] = u.username;
|
||
user["role"] = u.role;
|
||
|
||
// Include allowed_groups
|
||
Json::Value groups(Json::arrayValue);
|
||
for (const auto& g : u.allowed_groups) {
|
||
groups.append(g);
|
||
}
|
||
user["allowed_groups"] = groups;
|
||
|
||
usersArray.append(user);
|
||
}
|
||
}
|
||
res["users"] = usersArray;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
SendText(ws_ptr, json);
|
||
}
|
||
|
||
void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) {
|
||
std::string username, role;
|
||
if (!ValidateToken(token, username, role)) {
|
||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Invalid token"));
|
||
return;
|
||
}
|
||
|
||
// Only admin can get groups list (for user management)
|
||
if (role != "admin") {
|
||
SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied"));
|
||
return;
|
||
}
|
||
|
||
// Collect all unique groups from online devices
|
||
std::set<std::string> groups;
|
||
groups.insert("default"); // Always include default group
|
||
|
||
if (m_pParentDlg) {
|
||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||
if (!ctx || !ctx->IsLogin()) continue;
|
||
std::string g = ctx->GetGroupName();
|
||
groups.insert(g.empty() ? "default" : g);
|
||
}
|
||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||
}
|
||
|
||
// Build response
|
||
Json::Value res;
|
||
res["cmd"] = "groups";
|
||
res["ok"] = true;
|
||
res["groups"] = Json::Value(Json::arrayValue);
|
||
for (const auto& g : groups) {
|
||
res["groups"].append(g);
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
SendText(ws_ptr, json);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Token Management (delegated to WebServiceAuth module)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::GenerateToken(const std::string& username, const std::string& role) {
|
||
return WSAuth::GenerateToken(username, role, m_nTokenExpireSeconds);
|
||
}
|
||
|
||
bool CWebService::ValidateToken(const std::string& token, std::string& username, std::string& role) {
|
||
return WSAuth::ValidateToken(token, username, role);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Client Management
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) {
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
WebClient client;
|
||
client.client_ip = client_ip;
|
||
client.connected_at = (uint64_t)time(nullptr);
|
||
client.last_activity = client.connected_at; // Initialize for heartbeat
|
||
m_Clients[ws_ptr] = client;
|
||
}
|
||
|
||
void CWebService::UnregisterClient(void* ws_ptr) {
|
||
uint64_t device_id = 0;
|
||
|
||
// Get device_id and remove client
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
auto it = m_Clients.find(ws_ptr);
|
||
if (it != m_Clients.end()) {
|
||
device_id = it->second.watch_device_id;
|
||
m_Clients.erase(it);
|
||
}
|
||
}
|
||
|
||
// Close remote desktop if this was the last viewer
|
||
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) {
|
||
auto it = m_Clients.find(ws_ptr);
|
||
return (it != m_Clients.end()) ? &it->second : nullptr;
|
||
}
|
||
|
||
int CWebService::GetWebClientCount(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
int count = 0;
|
||
for (const auto& [ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
count++;
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Rate Limiting
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
bool CWebService::CheckRateLimit(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
auto it = m_LoginAttempts.find(ip);
|
||
if (it == m_LoginAttempts.end()) return true;
|
||
return time(nullptr) >= it->second.locked_until;
|
||
}
|
||
|
||
void CWebService::RecordFailedLogin(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
auto& attempt = m_LoginAttempts[ip];
|
||
attempt.failed_count++;
|
||
if (attempt.failed_count >= 3) {
|
||
attempt.locked_until = time(nullptr) + 3600; // Lock for 1 hour
|
||
attempt.failed_count = 0;
|
||
Mprintf("[WebService] IP %s locked for 1 hour due to failed login attempts\n", ip.c_str());
|
||
}
|
||
}
|
||
|
||
void CWebService::RecordSuccessLogin(const std::string& ip) {
|
||
std::lock_guard<std::mutex> lock(m_LoginMutex);
|
||
m_LoginAttempts.erase(ip);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Password Verification (delegated to WebServiceAuth module)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
bool CWebService::VerifyPassword(const std::string& input, const WebUser& user) {
|
||
return WSAuth::VerifyPassword(input, user.password_hash, user.salt);
|
||
}
|
||
|
||
std::string CWebService::ComputeHash(const std::string& input) {
|
||
return WSAuth::ComputeSHA256(input);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// User Management
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::GetUsersFilePath() {
|
||
return m_ConfigDir + "users.json";
|
||
}
|
||
|
||
void CWebService::LoadUsers() {
|
||
// Note: m_UsersMutex should already be held by caller (SetAdminPassword)
|
||
// Load additional users from users.json (admin user is already in m_Users)
|
||
|
||
std::string path = GetUsersFilePath();
|
||
std::ifstream file(path);
|
||
if (!file.is_open()) {
|
||
Mprintf("[WebService] No users.json found, using admin only\n");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
Json::Value root;
|
||
Json::CharReaderBuilder builder;
|
||
std::string errors;
|
||
if (!Json::parseFromStream(builder, file, &root, &errors)) {
|
||
Mprintf("[WebService] Failed to parse users.json: %s\n", errors.c_str());
|
||
return;
|
||
}
|
||
|
||
const Json::Value& users = root["users"];
|
||
int loaded = 0;
|
||
for (const auto& u : users) {
|
||
std::string username = u.get("username", "").asString();
|
||
// Skip admin user (it's built-in with master password)
|
||
if (username.empty() || username == "admin") continue;
|
||
|
||
WebUser user;
|
||
user.username = username;
|
||
user.password_hash = u.get("password_hash", "").asString();
|
||
user.salt = u.get("salt", "").asString();
|
||
user.role = u.get("role", "viewer").asString();
|
||
|
||
// Load allowed_groups
|
||
const Json::Value& groups = u["allowed_groups"];
|
||
if (groups.isArray()) {
|
||
for (const auto& g : groups) {
|
||
user.allowed_groups.push_back(g.asString());
|
||
}
|
||
}
|
||
|
||
if (!user.password_hash.empty()) {
|
||
m_Users.push_back(user);
|
||
loaded++;
|
||
}
|
||
}
|
||
Mprintf("[WebService] Loaded %d additional users from users.json\n", loaded);
|
||
} catch (const std::exception& e) {
|
||
Mprintf("[WebService] Error loading users.json: %s\n", e.what());
|
||
}
|
||
}
|
||
|
||
void CWebService::SaveUsers() {
|
||
// Save non-admin users to users.json
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
Json::Value root;
|
||
Json::Value users(Json::arrayValue);
|
||
|
||
for (const auto& u : m_Users) {
|
||
// Skip admin user (it uses master password, not stored in file)
|
||
if (u.username == "admin") continue;
|
||
|
||
Json::Value user;
|
||
user["username"] = u.username;
|
||
user["password_hash"] = u.password_hash;
|
||
user["salt"] = u.salt;
|
||
user["role"] = u.role;
|
||
|
||
// Save allowed_groups
|
||
Json::Value groups(Json::arrayValue);
|
||
for (const auto& g : u.allowed_groups) {
|
||
groups.append(g);
|
||
}
|
||
user["allowed_groups"] = groups;
|
||
|
||
users.append(user);
|
||
}
|
||
|
||
root["users"] = users;
|
||
|
||
std::string path = GetUsersFilePath();
|
||
std::ofstream file(path);
|
||
if (!file.is_open()) {
|
||
Mprintf("[WebService] Failed to open users.json for writing\n");
|
||
return;
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = " ";
|
||
std::unique_ptr<Json::StreamWriter> writer(builder.newStreamWriter());
|
||
writer->write(root, &file);
|
||
|
||
Mprintf("[WebService] Saved %d users to users.json\n", (int)users.size());
|
||
}
|
||
|
||
bool CWebService::CreateUser(const std::string& username, const std::string& password, const std::string& role,
|
||
const std::vector<std::string>& allowed_groups) {
|
||
if (username.empty() || password.empty()) return false;
|
||
if (username == "admin") return false; // Cannot create user named "admin"
|
||
if (role != "admin" && role != "viewer") return false;
|
||
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
// Check if user already exists
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) return false;
|
||
}
|
||
|
||
// Generate salt and hash password with salt
|
||
WebUser user;
|
||
user.username = username;
|
||
user.salt = GenerateSalt();
|
||
user.password_hash = WSAuth::ComputeSHA256(password + user.salt);
|
||
user.role = role;
|
||
user.allowed_groups = allowed_groups;
|
||
|
||
m_Users.push_back(user);
|
||
Mprintf("[WebService] Created user: %s (role: %s, groups: %d)\n",
|
||
username.c_str(), role.c_str(), (int)allowed_groups.size());
|
||
}
|
||
|
||
// Save to file (outside lock scope since SaveUsers acquires its own lock)
|
||
SaveUsers();
|
||
return true;
|
||
}
|
||
|
||
bool CWebService::DeleteUser(const std::string& username) {
|
||
if (username.empty() || username == "admin") return false;
|
||
|
||
bool deleted = false;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
|
||
for (auto it = m_Users.begin(); it != m_Users.end(); ++it) {
|
||
if (it->username == username) {
|
||
m_Users.erase(it);
|
||
Mprintf("[WebService] Deleted user: %s\n", username.c_str());
|
||
deleted = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (deleted) {
|
||
SaveUsers();
|
||
}
|
||
return deleted;
|
||
}
|
||
|
||
std::vector<std::pair<std::string, std::string>> CWebService::ListUsers() {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
std::vector<std::pair<std::string, std::string>> result;
|
||
for (const auto& u : m_Users) {
|
||
result.push_back({u.username, u.role});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// JSON Helpers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::string CWebService::BuildJsonResponse(const std::string& cmd, bool ok, const std::string& msg) {
|
||
Json::Value res;
|
||
res["cmd"] = cmd;
|
||
res["ok"] = ok;
|
||
if (!msg.empty()) {
|
||
res["msg"] = msg;
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
return Json::writeString(builder, res);
|
||
}
|
||
|
||
std::string CWebService::BuildDeviceListJson(const std::string& username) {
|
||
Json::Value res;
|
||
res["cmd"] = "device_list";
|
||
res["devices"] = Json::Value(Json::arrayValue);
|
||
|
||
// Get user's allowed groups for filtering (skip for admin or empty username)
|
||
std::vector<std::string> allowedGroups;
|
||
bool filterByGroup = false;
|
||
if (!username.empty() && username != "admin") {
|
||
std::lock_guard<std::mutex> lock(m_UsersMutex);
|
||
for (const auto& u : m_Users) {
|
||
if (u.username == username) {
|
||
allowedGroups = u.allowed_groups;
|
||
filterByGroup = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (m_pParentDlg) {
|
||
// Access device list with lock
|
||
EnterCriticalSection(&m_pParentDlg->m_cs);
|
||
for (context* ctx : m_pParentDlg->m_HostList) {
|
||
if (!ctx || !ctx->IsLogin()) continue;
|
||
|
||
// Get device group (empty = "default")
|
||
std::string deviceGroup = ctx->GetGroupName();
|
||
if (deviceGroup.empty()) deviceGroup = "default";
|
||
|
||
// Filter by allowed groups if user is not admin
|
||
if (filterByGroup) {
|
||
bool allowed = false;
|
||
for (const auto& g : allowedGroups) {
|
||
if (g == deviceGroup) {
|
||
allowed = true;
|
||
break;
|
||
}
|
||
}
|
||
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"
|
||
}
|
||
|
||
res["devices"].append(device);
|
||
}
|
||
LeaveCriticalSection(&m_pParentDlg->m_cs);
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
return Json::writeString(builder, res);
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// WebSocket Send Helpers
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::SendText(void* ws_ptr, const std::string& text) {
|
||
if (!ws_ptr || m_bStopping) return;
|
||
ws::Connection* conn = (ws::Connection*)ws_ptr;
|
||
if (!conn->isClosed()) {
|
||
conn->send(text);
|
||
}
|
||
}
|
||
|
||
void CWebService::SendBinary(void* ws_ptr, const uint8_t* data, size_t len) {
|
||
if (!ws_ptr || !data || len == 0 || m_bStopping) return;
|
||
ws::Connection* conn = (ws::Connection*)ws_ptr;
|
||
if (!conn->isClosed()) {
|
||
conn->sendBinary(data, len);
|
||
}
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Frame Broadcasting
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
std::vector<uint8_t> CWebService::BuildFramePacket(uint64_t device_id, bool is_keyframe,
|
||
const uint8_t* data, size_t len) {
|
||
// Packet format: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
|
||
std::vector<uint8_t> packet;
|
||
packet.reserve(9 + len);
|
||
|
||
// DeviceID (4 bytes, little-endian, truncated to 32-bit for JS compatibility)
|
||
uint32_t id32 = (uint32_t)(device_id & 0xFFFFFFFF);
|
||
packet.push_back((id32 >> 0) & 0xFF);
|
||
packet.push_back((id32 >> 8) & 0xFF);
|
||
packet.push_back((id32 >> 16) & 0xFF);
|
||
packet.push_back((id32 >> 24) & 0xFF);
|
||
|
||
// FrameType (1 byte): 0=P, 1=IDR
|
||
packet.push_back(is_keyframe ? 1 : 0);
|
||
|
||
// DataLen (4 bytes, little-endian)
|
||
uint32_t data_len = (uint32_t)len;
|
||
packet.push_back((data_len >> 0) & 0xFF);
|
||
packet.push_back((data_len >> 8) & 0xFF);
|
||
packet.push_back((data_len >> 16) & 0xFF);
|
||
packet.push_back((data_len >> 24) & 0xFF);
|
||
|
||
// H264 Data
|
||
packet.insert(packet.end(), data, data + len);
|
||
|
||
return packet;
|
||
}
|
||
|
||
void CWebService::BroadcastFrame(uint64_t device_id, const uint8_t* data, size_t len, bool is_keyframe) {
|
||
if (!data || len == 0 || m_bStopping) return;
|
||
|
||
// Build packet once
|
||
auto packet = BuildFramePacket(device_id, is_keyframe, data, len);
|
||
|
||
// Broadcast to all watching clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::CacheKeyframe(uint64_t device_id, const uint8_t* data, size_t len) {
|
||
if (!data || len == 0 || m_bStopping) return;
|
||
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
|
||
std::lock_guard<std::mutex> cache_lock(it->second->cache_mutex);
|
||
it->second->keyframe_cache.assign(data, data + len);
|
||
}
|
||
|
||
void CWebService::BroadcastH264Frame(uint64_t device_id, const uint8_t* data, size_t len) {
|
||
// The data is already a complete packet: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
|
||
if (!data || len < 9 || m_bStopping) return;
|
||
|
||
// Broadcast to all watching clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
SendBinary(ws_ptr, data, len);
|
||
}
|
||
}
|
||
// Cache keyframe (check FrameType byte at offset 4)
|
||
if (data[4] == 1) { // IDR frame
|
||
CacheKeyframe(device_id, data, len);
|
||
}
|
||
}
|
||
|
||
void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int height) {
|
||
if (m_bStopping) return;
|
||
|
||
// Update cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
it->second->screen_width = width;
|
||
it->second->screen_height = height;
|
||
}
|
||
|
||
// Notify watching clients
|
||
Json::Value res;
|
||
res["cmd"] = "resolution_changed";
|
||
res["id"] = device_id;
|
||
res["width"] = width;
|
||
res["height"] = height;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
|
||
if (m_bStopping) return;
|
||
|
||
// 缓存最新状态,新加入的 web 客户端通过 connect_result 取到初值
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
auto it = m_DeviceCache.find(device_id);
|
||
if (it == m_DeviceCache.end()) {
|
||
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||
it = m_DeviceCache.find(device_id);
|
||
}
|
||
it->second->audio_enabled = enabled ? 1 : 0;
|
||
}
|
||
|
||
Json::Value res;
|
||
res["cmd"] = "audio_state";
|
||
res["id"] = device_id;
|
||
res["enabled"] = enabled;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
|
||
if (m_bStopping) return;
|
||
|
||
// Build JSON message
|
||
Json::Value res;
|
||
res["cmd"] = "cursor";
|
||
res["index"] = cursor_index;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, res);
|
||
|
||
// Send to all watching clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
bool CWebService::StartRemoteDesktop(uint64_t device_id) {
|
||
if (!m_pParentDlg) return false;
|
||
|
||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||
if (!ctx) return false;
|
||
|
||
// Check if there's already a Web session for this device
|
||
// Only reuse if Web has already triggered AND a Web dialog exists
|
||
// This ensures MFC and Web have independent dialogs
|
||
if (IsWebTriggered(device_id) && HasActiveSession(device_id)) {
|
||
Mprintf("[WebService] Reusing existing Web session for device %llu\n", device_id);
|
||
return true; // Web session exists, new web user joins watching
|
||
}
|
||
|
||
// Mark as web-triggered (dialog should be hidden)
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
m_WebTriggeredDevices.insert(device_id);
|
||
}
|
||
|
||
// Send COMMAND_SCREEN_SPY with H264 algorithm
|
||
// If client is already capturing (MFC opened first), it will re-send TOKEN_BITMAPINFO
|
||
// This creates a new hidden Web dialog while MFC dialog remains visible
|
||
BYTE bToken[32] = { 0 };
|
||
bToken[0] = COMMAND_SCREEN_SPY;
|
||
bToken[1] = 0; // DXGI mode: 0=GDI
|
||
bToken[2] = ALGORITHM_H264; // H264 algorithm
|
||
bToken[3] = 1; // Multi-screen: true
|
||
|
||
return ctx->Send2Client(bToken, sizeof(bToken)) != FALSE;
|
||
}
|
||
|
||
void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
||
if (!m_pParentDlg) return;
|
||
|
||
// Check if any other web clients are watching this device
|
||
int watchingCount = 0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (client.watch_device_id == device_id) {
|
||
watchingCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// If no more web clients watching, close only the Web session dialog
|
||
// MFC dialogs remain open
|
||
if (watchingCount == 0) {
|
||
ClearWebTriggered(device_id);
|
||
m_pParentDlg->CloseWebRemoteDesktopByClientID(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)
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::RegisterScreenContext(uint64_t device_id, CONTEXT_OBJECT* ctx) {
|
||
if (!m_bRunning) return;
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts[device_id] = ctx;
|
||
Mprintf("[WebService] Registered screen context for device %llu\n", device_id);
|
||
}
|
||
|
||
void CWebService::UnregisterScreenContext(uint64_t device_id) {
|
||
// Always clean up, even if WebService is stopping
|
||
// This prevents stale pointers in m_ScreenContexts
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
m_ScreenContexts.erase(device_id);
|
||
if (m_bRunning) {
|
||
Mprintf("[WebService] Unregistered screen context for device %llu\n", device_id);
|
||
}
|
||
}
|
||
|
||
CONTEXT_OBJECT* CWebService::GetScreenContext(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
auto it = m_ScreenContexts.find(device_id);
|
||
return (it != m_ScreenContexts.end()) ? it->second : nullptr;
|
||
}
|
||
|
||
//////////////////////////////////////////////////////////////////////////
|
||
// Device Events
|
||
//////////////////////////////////////////////////////////////////////////
|
||
|
||
void CWebService::MarkDeviceOnline(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
m_OnlineDevices.insert(device_id);
|
||
m_OfflineDevices.erase(device_id); // Cancel pending offline if any
|
||
}
|
||
|
||
void CWebService::MarkDeviceOffline(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
m_OfflineDevices.insert(device_id);
|
||
m_OnlineDevices.erase(device_id); // Cancel pending online if any
|
||
}
|
||
|
||
void CWebService::FlushDeviceChanges() {
|
||
if (!m_bRunning || m_bStopping) return;
|
||
|
||
// Collect changes under lock
|
||
std::set<uint64_t> online, offline;
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DirtyDevicesMutex);
|
||
if (m_OnlineDevices.empty() && m_OfflineDevices.empty()) {
|
||
return; // No changes
|
||
}
|
||
online = std::move(m_OnlineDevices);
|
||
offline = std::move(m_OfflineDevices);
|
||
m_OnlineDevices.clear();
|
||
m_OfflineDevices.clear();
|
||
}
|
||
|
||
// Notify clients watching offline devices
|
||
if (!offline.empty()) {
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (auto& [ws_ptr, client] : m_Clients) {
|
||
if (offline.count(client.watch_device_id)) {
|
||
Json::Value res;
|
||
res["cmd"] = "device_offline";
|
||
res["id"] = std::to_string(client.watch_device_id);
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
SendText(ws_ptr, Json::writeString(builder, res));
|
||
client.watch_device_id = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clear offline devices from cache
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||
for (uint64_t id : offline) {
|
||
m_DeviceCache.erase(id);
|
||
}
|
||
}
|
||
|
||
// Build and broadcast devices_changed message
|
||
Json::Value msg;
|
||
msg["cmd"] = "devices_changed";
|
||
msg["online"] = Json::Value(Json::arrayValue);
|
||
msg["offline"] = Json::Value(Json::arrayValue);
|
||
for (uint64_t id : online) {
|
||
msg["online"].append(std::to_string(id));
|
||
}
|
||
for (uint64_t id : offline) {
|
||
msg["offline"].append(std::to_string(id));
|
||
}
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json = Json::writeString(builder, msg);
|
||
|
||
// Broadcast to all authenticated clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (!client.token.empty()) {
|
||
SendText(ws_ptr, json);
|
||
}
|
||
}
|
||
}
|
||
|
||
bool CWebService::IsWebTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
return m_WebTriggeredDevices.find(device_id) != m_WebTriggeredDevices.end();
|
||
}
|
||
|
||
void CWebService::ClearWebTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_WebTriggeredMutex);
|
||
m_WebTriggeredDevices.erase(device_id);
|
||
}
|
||
|
||
void CWebService::SetMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
m_MfcTriggeredDevices.insert(device_id);
|
||
}
|
||
|
||
bool CWebService::IsMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
return m_MfcTriggeredDevices.find(device_id) != m_MfcTriggeredDevices.end();
|
||
}
|
||
|
||
void CWebService::ClearMfcTriggered(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_MfcTriggeredMutex);
|
||
m_MfcTriggeredDevices.erase(device_id);
|
||
}
|
||
|
||
bool CWebService::HasActiveSession(uint64_t device_id) {
|
||
std::lock_guard<std::mutex> lock(m_ScreenContextsMutex);
|
||
return m_ScreenContexts.find(device_id) != m_ScreenContexts.end();
|
||
}
|
||
|
||
void CWebService::NotifyDeviceUpdate(uint64_t device_id, const std::string& rtt, const std::string& activeWindow) {
|
||
if (!m_bRunning || m_bStopping) return;
|
||
|
||
// Build update message
|
||
Json::Value msg;
|
||
msg["cmd"] = "device_update";
|
||
msg["id"] = std::to_string(device_id);
|
||
msg["rtt"] = rtt;
|
||
msg["activeWindow"] = activeWindow;
|
||
|
||
Json::StreamWriterBuilder builder;
|
||
builder["indentation"] = "";
|
||
std::string json_str = Json::writeString(builder, msg);
|
||
|
||
// Broadcast to all authenticated clients
|
||
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||
for (const auto& [ws_ptr, client] : m_Clients) {
|
||
if (!client.token.empty()) {
|
||
SendText(ws_ptr, json_str);
|
||
}
|
||
}
|
||
}
|