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');