From ca671d64536ed71f41c75d6fc624565778afe2e7 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sun, 18 Oct 2026 19:16:17 +0200 Subject: [PATCH] Feature: In-memory web audit log for the MFC server Add a process-local security audit trail to the Windows server's web console, mirroring the Go server's event names and reply shape. The ring keeps the most recent 1000 events since process start and is never persisted. - New query_audit WebSocket command, admin-only, with time/event/actor/ IP/device/keyword/warn-only filters and pagination; the reply is the audit_result payload the existing Audit Log page already understands. The challenge advertises caps.auditLog so browsers enable the button. - Record logins (success, failure, throttling, salt probes), WebSocket connect/close, screen and terminal session start/stop/denials, clipboard transfers (byte counts only), RDP resets, and admin user mutations. - Repeated failed logins and username probes are de-duplicated per connection so scanners cannot flood the ring. - Audit writes never hold m_ClientsMutex: disconnect paths snapshot the client identity before the map entry is erased, and offline-device stops are audited after the clients lock is released. Co-Authored-By: deepseek-flash --- server/2015Remote/WebService.cpp | 461 ++++++++++++++++++++++++++++++- server/2015Remote/WebService.h | 23 +- server/web/index.html | 4 +- 3 files changed, 473 insertions(+), 15 deletions(-) diff --git a/server/2015Remote/WebService.cpp b/server/2015Remote/WebService.cpp index 8049b0c..ae022e9 100644 --- a/server/2015Remote/WebService.cpp +++ b/server/2015Remote/WebService.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include // Algorithm constants (same as ScreenSpyDlg.cpp) #define ALGORITHM_H264 2 @@ -66,6 +68,165 @@ static std::string ParseReqField(const Json::Value& root) { return ""; } +////////////////////////////////////////////////////////////////////////// +// Web audit trail (in-memory ring) +// +// Keeps the most recent kMaxAuditEntries security-relevant operations made +// through the web console: logins, screen/terminal sessions, clipboard and +// RDP actions, and admin mutations. The buffer starts empty when the server +// process starts and is deliberately not persisted — it is a bounded live +// view for spotting suspicious activity (failed logins, unexpected viewers). +// Passwords, tokens and clipboard contents are never recorded. +////////////////////////////////////////////////////////////////////////// + +namespace { + +constexpr size_t kMaxAuditEntries = 1000; + +struct AuditEntry { + int64_t ts = 0; // epoch seconds (UTC) + std::string time; // "2026-10-18T16:44:17.000Z" + std::string level; // "info" | "warn" + std::string event; + std::string actor; + std::string role; + std::string ip; + std::string device_id; + std::string conn; + std::vector> extra; +}; + +struct AuditQuery { + int64_t from = -1; // epoch seconds, -1 = unset + int64_t to = -1; + std::string event; + std::string actor; + std::string ip; + std::string device_id; + std::string keyword; + bool warn_only = false; + int limit = 100; + int offset = 0; +}; + +struct AuditQueryResult { + std::vector events; // newest first + int total = 0; // all matches in the ring +}; + +// Replace control characters and cap length so an attacker-controlled +// username / keyword cannot corrupt the record or bloat memory. +std::string SanitizeAuditString(const std::string& s, size_t maxBytes = 512) { + std::string out; + out.reserve(s.size() < maxBytes ? s.size() : maxBytes); + for (char ch : s) { + if (out.size() >= maxBytes) break; + unsigned char c = (unsigned char)ch; + out.push_back((c < 0x20 || c == 0x7f) ? ' ' : ch); + } + return out; +} + +std::string FormatAuditTimeUtc(int64_t ts) { + time_t t = (time_t)ts; + struct tm tmv; + if (gmtime_s(&tmv, &t) != 0) return std::string(); + char buf[40]; + snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.000Z", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, + tmv.tm_hour, tmv.tm_min, tmv.tm_sec); + return std::string(buf); +} + +// Accepts the ISO-8601 forms the browser sends (Date.toISOString() or a +// datetime-local converted in JS). Returns -1 when the value is unusable. +int64_t ParseAuditTimeUtc(const std::string& s) { + int y = 0, mo = 0, d = 0, h = 0, mi = 0, se = 0; + // Width-limited conversions: a hostile caller cannot overflow the ints. + if (sscanf(s.c_str(), "%4d-%2d-%2dT%2d:%2d:%2d", &y, &mo, &d, &h, &mi, &se) != 6) return -1; + if (y < 1970 || mo < 1 || mo > 12 || d < 1 || d > 31 || + h < 0 || h > 23 || mi < 0 || mi > 59 || se < 0 || se > 59) return -1; + struct tm tmv = {}; + tmv.tm_year = y - 1900; + tmv.tm_mon = mo - 1; + tmv.tm_mday = d; + tmv.tm_hour = h; + tmv.tm_min = mi; + tmv.tm_sec = se; + time_t t = _mkgmtime(&tmv); + return t == (time_t)-1 ? -1 : (int64_t)t; +} + +std::string ToLowerAscii(std::string s) { + for (char& c : s) { + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + } + return s; +} + +bool AuditEventMatches(const std::string& filter, const std::string& event) { + if (filter.empty()) return true; + // "web.*" and "web." are prefixes; anything else is an exact match. + if (filter.back() == '*') return event.rfind(filter.substr(0, filter.size() - 1), 0) == 0; + if (filter.back() == '.') return event.rfind(filter, 0) == 0; + return event == filter; +} + +bool AuditEntryMatches(const AuditEntry& e, const AuditQuery& q) { + if (q.from >= 0 && e.ts < q.from) return false; + if (q.to >= 0 && e.ts > q.to) return false; + if (!AuditEventMatches(q.event, e.event)) return false; + if (!q.actor.empty() && ToLowerAscii(e.actor) != ToLowerAscii(q.actor)) return false; + if (!q.ip.empty() && e.ip != q.ip) return false; + if (!q.device_id.empty() && e.device_id != q.device_id) return false; + if (q.warn_only && e.level != "warn") return false; + if (!q.keyword.empty()) { + std::string hay = e.event + " " + e.actor + " " + e.role + " " + e.ip + " " + + e.device_id + " " + e.conn; + for (const auto& kv : e.extra) hay += " " + kv.first + "=" + kv.second; + if (ToLowerAscii(hay).find(ToLowerAscii(q.keyword)) == std::string::npos) return false; + } + return true; +} + +class WebAuditRing { +public: + static WebAuditRing& Instance() { + static WebAuditRing ring; + return ring; + } + + void Add(AuditEntry e) { + e.time = FormatAuditTimeUtc(e.ts); + std::lock_guard lock(m_Mutex); + m_Entries.push_back(std::move(e)); + while (m_Entries.size() > kMaxAuditEntries) { + m_Entries.pop_front(); + } + } + + AuditQueryResult Query(const AuditQuery& q) { + AuditQueryResult res; + std::lock_guard lock(m_Mutex); + // Iterate newest-first; skip the first `offset` matches and collect + // up to `limit`, while still counting every match for the footer. + for (auto it = m_Entries.rbegin(); it != m_Entries.rend(); ++it) { + if (!AuditEntryMatches(*it, q)) continue; + if (res.total >= q.offset && (int)res.events.size() < q.limit) { + res.events.push_back(*it); + } + res.total++; + } + return res; + } + +private: + std::mutex m_Mutex; + std::deque m_Entries; +}; + +} // namespace + // Challenge-response nonce storage (prevents replay attacks) static std::map s_ClientNonces; static std::mutex s_NonceMutex; @@ -362,9 +523,12 @@ void CWebService::ServerThread(int port) { // 缺少这些字段的服务端(Go / 旧 C++ 二进制)会让新前端自动降级。 challenge["caps"]["multiWatch"] = true; challenge["caps"]["keyframeRequest"] = true; + challenge["caps"]["auditLog"] = true; Json::StreamWriterBuilder builder; builder["indentation"] = ""; SendText(ws_ptr, Json::writeString(builder, challenge)); + + AuditWeb("web.ws.connect", false, "", "", conn->clientIP(), ws_ptr, 0); }); // WebSocket disconnect @@ -443,6 +607,8 @@ void CWebService::ServerThread(int port) { HandleTermResize(ws_ptr, msg); } else if (cmd == "term_close") { HandleTermClose(ws_ptr, msg); + } else if (cmd == "query_audit") { + HandleQueryAudit(ws_ptr, msg); } } }); @@ -527,12 +693,27 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s // Verify nonce matches (prevents replay) std::string storedNonce = ConsumeNonce(ws_ptr); if (storedNonce.empty() || storedNonce != clientNonce) { + AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0, + {{"reason", "invalid_challenge"}}); SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid or expired challenge")); return; } // Check rate limit if (!CheckRateLimit(client_ip)) { + bool first = false; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end() && !it->second.login_throttle_logged) { + it->second.login_throttle_logged = true; + first = true; + } + } + if (first) { + AuditWeb("web.login.throttled", true, username, "", client_ip, ws_ptr, 0, + {{"reason", "rate_limited"}}); + } SendText(ws_ptr, BuildJsonResponse("login_result", false, "Too many failed attempts. Try again later.")); return; } @@ -554,6 +735,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s if (!user) { RecordFailedLogin(client_ip); + AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0, + {{"reason", "unknown_user"}}); SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials")); return; } @@ -562,6 +745,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s std::string expectedResponse = WSAuth::ComputeSHA256(user->password_hash + clientNonce); if (response != expectedResponse) { RecordFailedLogin(client_ip); + AuditWeb("web.login.failure", true, username, "", client_ip, ws_ptr, 0, + {{"reason", "bad_password"}}); SendText(ws_ptr, BuildJsonResponse("login_result", false, "Invalid credentials")); return; } @@ -581,6 +766,8 @@ void CWebService::HandleLogin(void* ws_ptr, const std::string& msg, const std::s } } + AuditWeb("web.login.success", false, username, user->role, client_ip, ws_ptr, 0); + // Build response Json::Value res; res["cmd"] = "login_result"; @@ -628,6 +815,24 @@ void CWebService::HandleGetSalt(void* ws_ptr, const std::string& msg) { if (!userFound) { // Generate deterministic fake salt from username (won't match any real password) salt = WSAuth::ComputeSHA256("fake_salt_prefix_" + username).substr(0, 16); + + // Username enumeration probe. Scanners can repeat this in a tight loop, + // so record at most one per connection. + bool first = false; + std::string ip; + { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + if (it != m_Clients.end() && !it->second.salt_probe_logged) { + it->second.salt_probe_logged = true; + first = true; + ip = it->second.client_ip; + } + } + if (first) { + AuditWeb("web.salt.unknown_user", true, "", "", ip, ws_ptr, 0, + {{"username", username}, {"reason", "unknown_user"}}); + } } Json::Value res; @@ -703,6 +908,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t } } if (!hasAccess) { + AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "group_permission"}}); send_error("Permission denied"); return; } @@ -713,6 +920,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t // remote_open, cleared on session teardown), so AI input cannot be injected into a // picture a human is watching/controlling. if (IsMcpTriggered(device_id)) { + AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "mcp_session_active"}}); send_error("Device busy: remote control session active"); return; } @@ -731,6 +940,8 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t // Check max clients per device int current_count = GetWebClientCount(device_id); if (!already && current_count >= m_nMaxClientsPerDevice) { + AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "too_many_viewers"}}); send_error("Too many viewers"); return; } @@ -760,12 +971,19 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t } } if (too_many_watch) { + AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "too_many_watches"}}); send_error("Too many devices on one connection"); return; } // StopRemoteDesktop 会再次锁 m_ClientsMutex,必须在锁外调用。 - for (uint64_t id : to_stop) { - StopRemoteDesktop(id); + if (!to_stop.empty()) { + std::string ip = ClientIP(ws_ptr); + for (uint64_t id : to_stop) { + StopRemoteDesktop(id); + AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, id, + {{"reason", "replaced"}}); + } } // Start remote desktop session if this is the first web viewer. 若之前已订阅 @@ -782,11 +1000,20 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t it->second.watch_device_ids.erase(device_id); } } + AuditWeb("remote.screen.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "start_failed"}}); send_error("Failed to start remote desktop"); return; } } + if (!already) { + AuditWeb("remote.screen.start", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"mode", need_start ? "started" : "attached"}, + {"viewers", std::to_string(current_count + 1)}, + {"multi_watch", multi_watch ? "true" : "false"}}); + } + // 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 事件兜底) @@ -891,6 +1118,13 @@ void CWebService::HandleDisconnect(void* ws_ptr, const std::string& token, uint6 for (uint64_t id : to_stop) { StopRemoteDesktop(id); } + if (!to_stop.empty()) { + std::string ip = ClientIP(ws_ptr); + for (uint64_t id : to_stop) { + AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, id, + {{"reason", "disconnect"}}); + } + } send_reply(true, ""); } @@ -1086,6 +1320,10 @@ void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) { WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr); ctx->Send2Client(szData.data(), (ULONG)szData.size()); + + // Never record clipboard contents -- only the transferred size. + AuditWeb("remote.clipboard.set", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"bytes", std::to_string((size_t)glen)}}); } void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { @@ -1108,6 +1346,8 @@ void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token, uin // 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包 BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD; ctx->Send2Client(&bToken, sizeof(BYTE)); + + AuditWeb("remote.clipboard.get", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id); } void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_t requested_device_id) { @@ -1136,8 +1376,12 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token, uint64_ BYTE bToken = CMD_RESTORE_CONSOLE; if (ctx->Send2Client(&bToken, 1)) { Mprintf("[WebService] Sent RDP reset command to device %llu\n", device_id); + AuditWeb("remote.rdp_reset", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"ok", "true"}}); SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", true)); } else { + AuditWeb("remote.rdp_reset", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"ok", "false"}}); SendText(ws_ptr, BuildJsonResponse("rdp_reset_result", false, "Failed to send command")); } } @@ -1257,6 +1501,8 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) { // Only admin can create users if (role != "admin") { + AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"operation", "create_user"}}); SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Permission denied")); return; } @@ -1281,7 +1527,12 @@ void CWebService::HandleCreateUser(void* ws_ptr, const std::string& msg) { return; } - if (CreateUser(newUsername, newPassword, newRole, allowedGroups)) { + bool created = CreateUser(newUsername, newPassword, newRole, allowedGroups); + AuditWeb("admin.user.create", !created, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"target_user", newUsername}, {"target_role", newRole}, + {"groups", std::to_string(allowedGroups.size())}, + {"ok", created ? "true" : "false"}}); + if (created) { SendText(ws_ptr, BuildJsonResponse("create_user_result", true)); } else { SendText(ws_ptr, BuildJsonResponse("create_user_result", false, "Failed to create user (may already exist)")); @@ -1305,13 +1556,18 @@ void CWebService::HandleDeleteUser(void* ws_ptr, const std::string& msg) { // Only admin can delete users if (role != "admin") { + AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"operation", "delete_user"}}); SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Permission denied")); return; } std::string targetUsername = root.get("username", "").asString(); - if (DeleteUser(targetUsername)) { + bool deleted = DeleteUser(targetUsername); + AuditWeb("admin.user.delete", !deleted, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"target_user", targetUsername}, {"ok", deleted ? "true" : "false"}}); + if (deleted) { SendText(ws_ptr, BuildJsonResponse("delete_user_result", true)); } else { SendText(ws_ptr, BuildJsonResponse("delete_user_result", false, "Failed to delete user")); @@ -1327,6 +1583,8 @@ void CWebService::HandleListUsers(void* ws_ptr, const std::string& token) { // Only admin can list users if (role != "admin") { + AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"operation", "list_users"}}); SendText(ws_ptr, BuildJsonResponse("list_users_result", false, "Permission denied")); return; } @@ -1370,6 +1628,8 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) { // Only admin can get groups list (for user management) if (role != "admin") { + AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"operation", "get_groups"}}); SendText(ws_ptr, BuildJsonResponse("groups", false, "Permission denied")); return; } @@ -1403,6 +1663,132 @@ void CWebService::HandleGetGroups(void* ws_ptr, const std::string& token) { SendText(ws_ptr, json); } +////////////////////////////////////////////////////////////////////////// +// Security Audit Trail +////////////////////////////////////////////////////////////////////////// + +std::string CWebService::ClientIP(void* ws_ptr) { + std::lock_guard lock(m_ClientsMutex); + auto it = m_Clients.find(ws_ptr); + return it != m_Clients.end() ? it->second.client_ip : std::string(); +} + +void CWebService::AuditWeb(const std::string& event, bool warn, + const std::string& actor, const std::string& role, + const std::string& ip, void* ws_ptr, uint64_t device_id, + std::initializer_list> extra) { + AuditEntry e; + e.ts = (int64_t)time(nullptr); + e.level = warn ? "warn" : "info"; + e.event = SanitizeAuditString(event, 96); + e.actor = SanitizeAuditString(actor, 128); + e.role = SanitizeAuditString(role, 32); + e.ip = SanitizeAuditString(ip, 64); + if (device_id != 0) e.device_id = std::to_string(device_id); + if (ws_ptr != nullptr) { + char buf[32]; + snprintf(buf, sizeof(buf), "%p", ws_ptr); + e.conn = buf; + } + for (const auto& kv : extra) { + std::string key = SanitizeAuditString(kv.first, 64); + if (key.empty()) continue; + // Never let a caller shadow the fixed schema fields. + if (key == "time" || key == "level" || key == "event" || key == "actor" || + key == "role" || key == "ip" || key == "device_id" || key == "conn") { + continue; + } + e.extra.emplace_back(std::move(key), SanitizeAuditString(kv.second)); + } + WebAuditRing::Instance().Add(std::move(e)); +} + +void CWebService::HandleQueryAudit(void* ws_ptr, const std::string& msg) { + static const char* replyCmd = "audit_result"; + + Json::Value root; + Json::Reader reader; + if (!reader.parse(msg, root)) { + SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Invalid JSON")); + return; + } + + std::string token = root["token"].isString() ? root["token"].asString() : ""; + std::string username, role; + if (!ValidateToken(token, username, role)) { + SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Invalid token")); + return; + } + if (role != "admin") { + AuditWeb("web.authz.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"operation", "query_audit"}, {"reason", "not_admin"}}); + SendText(ws_ptr, BuildJsonResponse(replyCmd, false, "Permission denied")); + return; + } + + AuditQuery q; + std::string from = root["from"].isString() ? root["from"].asString() : ""; + std::string to = root["to"].isString() ? root["to"].asString() : ""; + if (!from.empty()) q.from = ParseAuditTimeUtc(from); + if (!to.empty()) q.to = ParseAuditTimeUtc(to); + q.event = root["event"].isString() ? root["event"].asString() : ""; + q.actor = root["actor"].isString() ? root["actor"].asString() : ""; + q.ip = root["ip"].isString() ? root["ip"].asString() : ""; + q.device_id = root["device_id"].isString() ? root["device_id"].asString() : ""; + q.keyword = root["keyword"].isString() ? root["keyword"].asString() : ""; + q.warn_only = root["warn_only"].isBool() && root["warn_only"].asBool(); + + // Read as double first: asInt() on a huge JSON number can throw. + double limit_d = root["limit"].isNumeric() ? root["limit"].asDouble() : 100.0; + double offset_d = root["offset"].isNumeric() ? root["offset"].asDouble() : 0.0; + q.limit = limit_d <= 0.0 ? 100 : (int)(limit_d > 500.0 ? 500.0 : limit_d); + q.offset = offset_d < 0.0 ? 0 : (int)(offset_d > 10000.0 ? 10000.0 : offset_d); + + AuditQueryResult r = WebAuditRing::Instance().Query(q); + + Json::Value events(Json::arrayValue); + for (const auto& e : r.events) { + Json::Value ev; + ev["time"] = e.time; + ev["level"] = e.level; + ev["event"] = e.event; + if (!e.actor.empty()) ev["actor"] = e.actor; + if (!e.role.empty()) ev["role"] = e.role; + if (!e.ip.empty()) ev["ip"] = e.ip; + if (!e.device_id.empty()) ev["device_id"] = e.device_id; + if (!e.conn.empty()) ev["conn"] = e.conn; + for (const auto& kv : e.extra) ev[kv.first] = kv.second; + events.append(ev); + } + + // Reading the trail is itself an auditable action. This record is added + // after the snapshot, so it does not appear in this reply. + AuditWeb("admin.audit.query", false, username, role, ClientIP(ws_ptr), ws_ptr, 0, + {{"event_filter", q.event}, + {"actor_filter", q.actor}, + {"ip_filter", q.ip}, + {"device_filter", q.device_id}, + {"keyword", q.keyword}, + {"warn_only", q.warn_only ? "true" : "false"}, + {"matches", std::to_string(r.total)}, + {"returned", std::to_string(r.events.size())}, + {"offset", std::to_string(q.offset)}}); + + Json::Value res; + res["cmd"] = replyCmd; + res["ok"] = true; + res["events"] = events; + res["total"] = r.total; + res["offset"] = q.offset; + res["limit"] = q.limit; + res["file"] = "memory (last 1000 events since server startup)"; + res["file_exists"] = true; + res["truncated"] = false; + Json::StreamWriterBuilder builder; + builder["indentation"] = ""; + SendText(ws_ptr, Json::writeString(builder, res)); +} + ////////////////////////////////////////////////////////////////////////// // Token Management (delegated to WebServiceAuth module) ////////////////////////////////////////////////////////////////////////// @@ -1430,6 +1816,8 @@ void CWebService::RegisterClient(void* ws_ptr, const std::string& client_ip) { void CWebService::UnregisterClient(void* ws_ptr) { std::set watched; + std::string username, role, ip; + uint64_t connected_at = 0; // Copy the watch set and remove the client under the lock. { @@ -1437,6 +1825,10 @@ void CWebService::UnregisterClient(void* ws_ptr) { auto it = m_Clients.find(ws_ptr); if (it != m_Clients.end()) { watched = std::move(it->second.watch_device_ids); + username = it->second.username; + role = it->second.role; + ip = it->second.client_ip; + connected_at = it->second.connected_at; m_Clients.erase(it); } } @@ -1445,6 +1837,8 @@ void CWebService::UnregisterClient(void* ws_ptr) { // StopRemoteDesktop re-locks m_ClientsMutex, so this must run outside the lock. for (uint64_t device_id : watched) { StopRemoteDesktop(device_id); + AuditWeb("remote.screen.stop", false, username, role, ip, ws_ptr, device_id, + {{"reason", "ws_close"}}); } // 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描) @@ -1454,7 +1848,17 @@ void CWebService::UnregisterClient(void* ws_ptr) { 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); + for (uint64_t did : to_close) CloseTermSessionLocked(did, "ws_close", username, role, ip); + } + + // Anonymous connections and rejected scanners are noisy; only authenticated + // sessions get a close record (mirrors the Go server's policy). Written last + // so the trail reads connect -> ... -> stops -> close for one connection. + if (!username.empty()) { + uint64_t now = (uint64_t)time(nullptr); + AuditWeb("web.ws.close", false, username, role, ip, ws_ptr, 0, + {{"duration_ms", std::to_string((now > connected_at ? now - connected_at : 0) * 1000)}, + {"screens", std::to_string(watched.size())}}); } } @@ -2123,6 +2527,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) { } context* ctx = m_pParentDlg->FindHost(device_id); if (!ctx) { + AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "device_offline"}}); SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline")); return; } @@ -2139,6 +2545,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) { } } if (!ok) { + AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "group_permission"}}); SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied")); return; } @@ -2149,6 +2557,8 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) { std::lock_guard lk(m_TermMutex); if (m_TermSessions.find(device_id) != m_TermSessions.end() || m_TermPending.find(device_id) != m_TermPending.end()) { + AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "terminal_busy"}}); SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Terminal already open by another viewer")); return; @@ -2162,13 +2572,18 @@ void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) { // 触发客户端:发 COMMAND_SHELL BYTE cmd = COMMAND_SHELL; if (!ctx->Send2Client(&cmd, 1)) { - std::lock_guard lk(m_TermMutex); - m_TermSessions.erase(device_id); - m_TermPending.erase(device_id); + { + std::lock_guard lk(m_TermMutex); + m_TermSessions.erase(device_id); + m_TermPending.erase(device_id); + } + AuditWeb("remote.terminal.denied", true, username, role, ClientIP(ws_ptr), ws_ptr, device_id, + {{"reason", "send_failed"}}); SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed")); return; } Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str()); + AuditWeb("remote.terminal.start", false, username, role, ClientIP(ws_ptr), ws_ptr, device_id); } void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) { @@ -2233,10 +2648,12 @@ void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) { std::lock_guard 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); + CloseTermSessionLocked(device_id, "user"); } -void CWebService::CloseTermSessionLocked(uint64_t device_id) { +void CWebService::CloseTermSessionLocked(uint64_t device_id, const char* reason, + const std::string& actor, const std::string& role, + const std::string& ip) { auto it = m_TermSessions.find(device_id); if (it == m_TermSessions.end()) return; @@ -2253,6 +2670,20 @@ void CWebService::CloseTermSessionLocked(uint64_t device_id) { // 通知前端 SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed")); Mprintf("[WebService] term_closed device=%llu\n", device_id); + + // 身份可能已随 ws 断开被删除(UnregisterClient 路径),此时调用方会传入。 + std::string username = actor, role_name = role, peer_ip = ip; + if (username.empty()) { + std::lock_guard lk(m_ClientsMutex); + auto cit = m_Clients.find(ws_ptr); + if (cit != m_Clients.end()) { + username = cit->second.username; + role_name = cit->second.role; + peer_ip = cit->second.client_ip; + } + } + AuditWeb("remote.terminal.stop", false, username, role_name, peer_ip, ws_ptr, device_id, + {{"reason", reason ? reason : ""}}); } bool CWebService::IsTermPending(uint64_t device_id) { @@ -2329,7 +2760,7 @@ void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) { auto it = m_TermContextToDevice.find(ctx); if (it == m_TermContextToDevice.end()) return; uint64_t device_id = it->second; - CloseTermSessionLocked(device_id); + CloseTermSessionLocked(device_id, "remote_closed"); } ////////////////////////////////////////////////////////////////////////// @@ -2392,6 +2823,8 @@ void CWebService::FlushDeviceChanges() { } // Notify clients watching offline devices + struct OfflineAudit { void* ws; std::string actor, role, ip; uint64_t id; }; + std::vector offline_audits; if (!offline.empty()) { std::lock_guard lock(m_ClientsMutex); for (auto& [ws_ptr, client] : m_Clients) { @@ -2409,9 +2842,15 @@ void CWebService::FlushDeviceChanges() { SendText(ws_ptr, Json::writeString(builder, res)); client.watch_device_ids.erase(id); client.keyframe_req_at.erase(id); + offline_audits.push_back({ws_ptr, client.username, client.role, client.client_ip, id}); } } } + // AuditWeb 自身会锁 m_ClientsMutex,必须在释放后调用。 + for (const auto& a : offline_audits) { + AuditWeb("remote.screen.stop", false, a.actor, a.role, a.ip, a.ws, a.id, + {{"reason", "device_offline"}}); + } // Clear offline devices from cache { diff --git a/server/2015Remote/WebService.h b/server/2015Remote/WebService.h index c1fe072..b9a9f7a 100644 --- a/server/2015Remote/WebService.h +++ b/server/2015Remote/WebService.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include #include @@ -76,6 +78,10 @@ struct WebClient { uint64_t connected_at; uint64_t last_activity; // Last message/pong received (for heartbeat timeout) std::string client_ip; + // Per-connection audit de-duplication: probes an attacker can repeat in a + // tight loop are recorded once per connection instead of flooding the ring. + bool salt_probe_logged = false; + bool login_throttle_logged = false; WebClient() : connected_at(0), last_activity(0) {} }; @@ -239,6 +245,15 @@ private: void HandleListUsers(void* ws_ptr, const std::string& token); void HandleGetGroups(void* ws_ptr, const std::string& token); + // Security audit trail: in-memory ring of the last 1000 events since + // startup (see WebService.cpp). query_audit is admin-only. + void HandleQueryAudit(void* ws_ptr, const std::string& msg); + std::string ClientIP(void* ws_ptr); + void AuditWeb(const std::string& event, bool warn, + const std::string& actor, const std::string& role, + const std::string& ip, void* ws_ptr, uint64_t device_id, + std::initializer_list> extra = {}); + // Send to WebSocket void SendText(void* ws_ptr, const std::string& text); void SendBinary(void* ws_ptr, const uint8_t* data, size_t len); @@ -388,8 +403,12 @@ private: std::set m_TermPending; // 已发 COMMAND_SHELL 待响应 std::mutex m_TermMutex; - // 内部清理(已持锁版本) - void CloseTermSessionLocked(uint64_t device_id); + // 内部清理(已持锁版本)。reason/actor/role/ip 仅用于审计日志;actor 为空时 + // 尝试从 m_Clients 反查(ws 已注销时需由调用方传入)。 + void CloseTermSessionLocked(uint64_t device_id, const char* reason = "ws_close", + const std::string& actor = std::string(), + const std::string& role = std::string(), + const std::string& ip = std::string()); }; // Global accessor diff --git a/server/web/index.html b/server/web/index.html index 0aac27d..11891ec 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -4532,8 +4532,8 @@ // Show the admin-only toolbar buttons (Users / Audit Log). Called after // login, SSO restore, logout and capability negotiation. The audit - // button also requires the server capability: the C++ server does not - // implement query_audit. + // button also requires the server capability (auditLog); servers that + // do not implement query_audit simply never advertise it. function updateAdminButtons() { const admin = currentUserRole === 'admin'; const usersBtn = document.getElementById('users-btn');