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
This commit is contained in:
1 parent
a522424491
commit
ca671d6453
3 files changed
+473
-15
No files matched your search
@@ -14,6 +14,8 @@
|
||||
#include <fstream>
|
||||
#include <shlobj.h>
|
||||
#include <set>
|
||||
#include <deque>
|
||||
#include <cstdio>
|
||||
|
||||
// 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<std::pair<std::string, std::string>> 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<AuditEntry> 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<std::mutex> 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<std::mutex> 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<AuditEntry> m_Entries;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// Challenge-response nonce storage (prevents replay attacks)
|
||||
static std::map<void*, std::string> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::pair<const char*, std::string>> 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<uint64_t> 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<std::mutex> 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<std::mutex> lk(m_TermMutex);
|
||||
m_TermSessions.erase(device_id);
|
||||
m_TermPending.erase(device_id);
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<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);
|
||||
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<std::mutex> 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<OfflineAudit> offline_audits;
|
||||
if (!offline.empty()) {
|
||||
std::lock_guard<std::mutex> 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
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <initializer_list>
|
||||
#include <utility>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
@@ -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<std::pair<const char*, std::string>> 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<uint64_t> 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
|
||||
|
||||
Reference in new issue
Block a user